diff --git a/.agents/skills/test-t3-app/references/sqlite-fixtures.md b/.agents/skills/test-t3-app/references/sqlite-fixtures.md index 8eca543cf979..9a1225ce3d91 100644 --- a/.agents/skills/test-t3-app/references/sqlite-fixtures.md +++ b/.agents/skills/test-t3-app/references/sqlite-fixtures.md @@ -4,7 +4,9 @@ Load this reference only when inspecting or seeding local T3 state directly. ## Select the correct database -When `--base-dir` or `--home-dir` is explicit, runtime state lives under `/userdata` and the database path is `/userdata/state.sqlite`. The `/dev` state directory is only the fallback for an implicit development home, preventing an ordinary `vp run dev` from touching production state. +When `--base-dir` or `--home-dir` is explicit, runtime state lives under `/userdata` and the database path is `/userdata/statev2.sqlite`. The `/dev` state directory is only the fallback for an implicit development home, preventing an ordinary `vp run dev` from touching production state. + +The server copies the V1 `state.sqlite` into `statev2.sqlite` only when `statev2.sqlite` is missing. After the first start, edits to `state.sqlite` change nothing. Start the target runtime once before seeding so all migrations have run. Use an isolated base directory. Stop the server before writes to avoid racing application state or an active projection. @@ -23,7 +25,7 @@ Inspect current columns before writing a fixture: ```bash node apps/server/scripts/t3-sqlite-state.ts query \ --base-dir \ - --sql "PRAGMA table_info(projection_threads)" + --sql "PRAGMA table_info(orchestration_v2_projection_threads)" ``` Apply a SQL fixture from a file: @@ -38,21 +40,10 @@ Use one statement per invocation for both `query` and `exec`; the helper wraps w ## Seed projection data carefully -The web UI primarily reads these projection tables: - -- `projection_projects` -- `projection_threads` -- `projection_thread_messages` -- `projection_thread_activities` -- `projection_thread_sessions` -- `projection_turns` -- `projection_pending_approvals` -- `projection_thread_proposed_plans` - -Inspect `PRAGMA table_info()` and the current migrations under `apps/server/src/persistence/Migrations/` before constructing inserts. Keep identifiers unique, timestamps as ISO strings, JSON columns valid, and related project/thread/turn IDs consistent. +Clients read projects from `projection_projects` and everything else from the `orchestration_v2_projection_*` tables: threads, runs, messages, turn items, runtime requests, plans, and provider sessions. The older `projection_thread*` tables hold V1 history, which the server imports once per thread at startup; later edits there do not reach the UI. -For a substantial current example, inspect `seedDatabase` in `scripts/mobile-showcase-environment.ts`. Adapt its column set to the target database instead of assuming copied SQL remains current. +Most V2 rows carry a `payload_json` that must decode against the schemas in `packages/contracts/src/orchestrationV2.ts`. The safest start is a row the app wrote itself: create a thread through the UI, copy its rows, and edit them. Keep identifiers unique, timestamps as ISO strings, and related project, thread, and run IDs consistent. -Direct projection writes are appropriate for ephemeral visual states, edge-case counts, long titles, activity lists, and similar UI fixtures. They do not create a coherent orchestration event history. Do not modify `orchestration_events` unless the test specifically exercises projector internals, and do not use direct projection writes to claim backend business behavior works. +Direct projection writes are appropriate for ephemeral visual states, edge-case counts, long titles, long timelines, and similar UI fixtures. They do not create a coherent event history. Leave the event log (`orchestration_events`) unchanged, and do not use direct projection writes to claim backend business behavior works. Use the app's commands or APIs for behavior tests. Use `node apps/server/src/bin.ts auth ...` for auth state rather than editing `auth_pairing_links` or `auth_sessions`. diff --git a/.github/TRIAGE_EXEMPTIONS.td b/.github/TRIAGE_EXEMPTIONS.td index 6a432bd65678..484b9f76ecce 100644 --- a/.github/TRIAGE_EXEMPTIONS.td +++ b/.github/TRIAGE_EXEMPTIONS.td @@ -1,6 +1,9 @@ # Explicit contribution-triage exemptions; independent of VOUCHED.td. # Syntax: github:username (one entry per line; no denouncements). # Keep entries sorted alphabetically. Only the trusted upstream main copy applies. +github:bmdavis419 github:juliusmarminge github:maria-rcks +github:markflorkowski github:t3dotgg +github:Yash-Singh1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 856314c5789f..2bb2005e58a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -487,6 +487,7 @@ jobs: test, test_web, test_server, + transfer-report, rust, mobile_native_changes, mobile_native_static_analysis, diff --git a/.github/workflows/mobile-eas-production.yml b/.github/workflows/mobile-eas-production.yml index 393ca7b3edbf..93c4b64a26d5 100644 --- a/.github/workflows/mobile-eas-production.yml +++ b/.github/workflows/mobile-eas-production.yml @@ -18,8 +18,11 @@ name: Mobile EAS Production # Store stays a manual App Store Connect step. # 2. OTA: publish a production-channel update for each platform where at # least one finished production build matches the current native -# fingerprint. Old-version binaries with a matching fingerprint receive -# it too. When native drift means no binary could install the update, +# fingerprint. Older binaries of the same major version with a matching +# fingerprint receive it too; the major version is part of the +# fingerprint (apps/mobile/fingerprint.config.js), so a new major never +# reaches the previous major's store binaries over the air. When native +# drift means no binary could install the update, # it is skipped and flagged in the job summary instead of published # into the void. # workflow_dispatch remains as a manual override for both modes (e.g. to diff --git a/AGENTS.md b/AGENTS.md index 16229e099f11..9ba77da386d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,17 +88,8 @@ The most common defect in this repo is a change that works on the path you teste An empty database is a bad test. Seed your worktree's `.t3` with a copy of real data instead of pointing at live state: -- Copy from `~/.t3/userdata` (the developer's real data, the most realistic test set) or `~/.t3/dev`. Worktree state lives at `/.t3/userdata`. -- Snapshot the database with `VACUUM INTO`, which is safe even while a server has the source open and yields one consistent file: - - ```bash - mkdir -p .t3/userdata - rm -f .t3/userdata/state.sqlite* # VACUUM INTO refuses to overwrite - bun -e "new (require('bun:sqlite').Database)(process.env.HOME + '/.t3/userdata/state.sqlite', { readonly: true }).run(\"VACUUM INTO '.t3/userdata/state.sqlite'\")" - ``` - - A plain `cp` is only safe when no server has the source open, and must bring the `-wal` and `-shm` siblings along. A live file copy is a corrupt copy. - +- Run `vp run migrate-dev-db` with your dev server stopped. It rebuilds `/.t3/userdata/statev2.sqlite` from a read-only snapshot of `~/.t3/userdata/statev2.sqlite`, the developer's real data. It keeps recent projects and their stopped threads, and drops scheduled tasks, pending work, and auth sessions, so your dev server never runs the developer's agents. Raise `--projects` and `--threads-per-project` for more data. +- Refresh `statev2.sqlite`, not `state.sqlite`. The server copies the V1 `state.sqlite` only when `statev2.sqlite` is missing. - Bring `secrets` and `settings.json` only if the flow under test needs them. - Copy in, never symlink. Data flows one way: into your sandbox, never back out. @@ -108,7 +99,7 @@ An empty database is a bad test. Seed your worktree's `.t3` with a copy of real - Test meaningful logic or observable behavior. Do not render components to static markup to assert props or attributes, or add tests that merely assert callback wiring or mirror the implementation. - **Do not run repo-wide checks.** No `vp check`, no `vp run -r test`, no `vp run -r typecheck` unless I ask. CI owns the full suite. - Backend behavior changes ship with focused tests for that behavior. -- The server is event-sourced and its async flows emit typed receipts. Wait on receipts and worker drains, never on sleeps or polling. A test that needs a timeout to pass is wrong. +- The server is event-sourced, and side effects run after the command commits. In tests, drain the effect worker (`OrchestrationEffectWorkerV2.drain`) or await the specific persisted event or `Deferred` that marks the milestone. Never wait on sleeps or polling. A test that needs a timeout to pass is wrong. - Upon request, user-visible frontend changes should get one integrated pass in a real client: `test-t3-app` for web, `test-t3-mobile` for mobile. The primary agent does this once after integrating. Subagents do not launch their own dev servers. Ask permission before doing computer use or spinning up browsers. For authorized mobile verification, a missing or outdated native client is a build step, not a blocker. Run `node scripts/mobile-native-client.ts ensure ` on the simulator host before starting Metro. It checks the local Expo fingerprint and builds/installs when needed. See `test-t3-mobile` for the full workflow. @@ -121,7 +112,7 @@ For authorized mobile verification, a missing or outdated native client is a bui - Body: the problem in a sentence or two, then how you fixed it. - UI changes need before/after images. Motion or timing needs a short video. - Upload PR evidence to GitHub. Never commit PR-only screenshots or assets such as `.github/pr-assets/`. -- One concern per PR. If the description says "also", split it. +- One request is one PR. Split it only when the maintainer asks. Outside contributions follow the stricter [one problem per PR](CONTRIBUTING.md#one-problem) rule. - When babysitting: poll checks and comments newer than the last push, verify each bot finding against the source, fix real ones, dismiss false positives with a written reason. Stay quiet when nothing is new. Stop when the bots are green on the latest commit. ## Documentation @@ -144,9 +135,9 @@ Most code changes do not need an internal documentation change. Agents can read ## How it works -Clients send typed WebSocket requests. The server turns them into _commands_, a pure _decider_ turns commands into persisted _events_, and a _projector_ derives the read model the UI renders. Provider CLIs run as subprocesses; per-provider _adapters_ translate their native protocols into orchestration events. Side effects run in queue-backed _reactors_ that emit _receipts_ when milestones land. Each turn ends with a _checkpoint_, a hidden git ref, so the app can diff and restore. +Clients send typed WebSocket requests. The server turns them into _commands_. The _orchestrator_ (`apps/server/src/orchestration-v2/Orchestrator.ts`) serializes commands and decides _events_ without doing any I/O. The _event sink_ commits those events, the _projections_ the UI reads, the _command receipt_, and _outbox_ effects in one transaction. The _effect worker_ then runs the effects, such as starting a provider turn or capturing a checkpoint, and feeds results back as commands. Provider CLIs run as subprocesses; per-provider _adapters_ translate their native protocols into orchestration events. Each turn ends with a _checkpoint_, a hidden git ref, so the app can diff and restore. -Full glossary with file links: `docs/internals/glossary.md` +Architecture and its constraints: `docs/internals/overview.md`. Glossary: `docs/internals/glossary.md` ## Where code lives diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 511e4b094bc1..ad5fa9d879d5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -70,6 +70,9 @@ Explain their relationship when it is not obvious. An adjacent cleanup, refactor its own PR unless it is necessary to solve the same problem. A large diff alone does not establish that the PR contains unrelated work. +This rule is for outside contributions. Maintainers, the logins in +[.github/TRIAGE_EXEMPTIONS.td](.github/TRIAGE_EXEMPTIONS.td), may batch related fixes in one PR. + Follow the [documentation rules](AGENTS.md#documentation). Keep internal docs for decisions and hard-to-discover constraints. Update user guides when how to use a feature changes; skip descriptions of obvious controls and cosmetic changes. diff --git a/apps/desktop/scripts/browser-secret-native.test.mjs b/apps/desktop/scripts/browser-secret-native.test.mjs index 754a91f1342c..bba047636ba6 100644 --- a/apps/desktop/scripts/browser-secret-native.test.mjs +++ b/apps/desktop/scripts/browser-secret-native.test.mjs @@ -78,7 +78,7 @@ describe.skipIf(hostPlatform !== "linux")("bundled libsecret helper", () => { expect(result.stderr.length).toBe(0); }); - for (const [scenario, code] of [ + it.each([ ["missing", 2], ["empty", 2], ["locked", 3], @@ -86,13 +86,11 @@ describe.skipIf(hostPlatform !== "linux")("bundled libsecret helper", () => { ["denied", 3], ["unavailable", 4], ["unloaded", 4], - ]) { - it(`reports ${scenario} without emitting a secret`, () => { - const result = run([scenario]); - expect(result.status).toBe(code); - expect(result.stdout.length).toBe(0); - }); - } + ])("reports %s without emitting a secret", (scenario, code) => { + const result = run([scenario]); + expect(result.status).toBe(code); + expect(result.stdout.length).toBe(0); + }); it("rejects invalid arguments before accessing the keyring", () => { for (const args of [[], [""], ["chrome", "extra"]]) { const result = run(args); diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index 339675bd65a6..b629301b1691 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -303,8 +303,9 @@ it.effect( }, ); -for (const entry of ["startup", "open-url"] as const) { - it.effect(`receives hosted web sign-in through the desktop ${entry} handler`, () => +it.effect.each(["startup", "open-url"] as const)( + "receives hosted web sign-in through the desktop %s handler", + (entry) => Effect.gen(function* () { storageMock.mockReturnValue(storageAdapter); createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: true }); @@ -382,5 +383,4 @@ for (const entry of ["startup", "open-url"] as const) { ), ); }).pipe(Effect.scoped), - ); -} +); diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index 33c74f5a8b9b..9e24f9db3e05 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -101,8 +101,9 @@ function makeDesktopWindowLayer( } describe("DesktopLifecycle", () => { - for (const platform of ["darwin", "win32", "linux"] satisfies ReadonlyArray) { - it.effect(`lets the updater's quit event proceed on ${platform}`, () => { + it.effect.each(["darwin", "win32", "linux"] satisfies ReadonlyArray)( + "lets the updater's quit event proceed on %s", + (platform) => { const appListeners = new Map void>(); let windowsDestroyed = false; const environmentLayer = Layer.succeed(DesktopEnvironment.DesktopEnvironment, { @@ -152,8 +153,8 @@ describe("DesktopLifecycle", () => { assert.isTrue(yield* Ref.get(state.quitting)); }), ).pipe(Effect.provide(layer)); - }); - } + }, + ); it.effect("destroys windows before waiting for backend shutdown", () => Effect.gen(function* () { diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts index 7e859aaf981a..a2d4483e6d2b 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts @@ -81,54 +81,52 @@ describe("DesktopPreReadyPlatform", () => { ); }); - for (const previousEntry of [undefined, 'Exec="/Applications/deleted-previous.AppImage" %U']) { - it.effect( - `prepares a ${previousEntry ? "stale" : "missing"} Linux desktop entry before startup yields`, - () => { - vi.stubEnv("VITE_DEV_SERVER_URL", ""); - vi.stubEnv("XDG_DATA_HOME", "/xdg"); - vi.stubEnv("APPIMAGE", "/Applications/current.AppImage"); - getSwitchValueMock.mockReturnValue(""); - let desktopName = "t3code.desktop"; - let desktopEntry = previousEntry; - let iconInstalled = false; - copyFileSyncMock.mockImplementation((_source: string, destination: string) => { - iconInstalled = destination === "/xdg/icons/com.t3tools.T3Code.desktop.png"; - }); - setDesktopNameMock.mockImplementation((name: string) => { - desktopName = name; - }); - writeFileSyncMock.mockImplementation((path: string, contents: string) => { - if (path === "/xdg/applications/com.t3tools.T3Code.desktop") desktopEntry = contents; - }); + it.effect.each([ + { previousEntry: undefined, label: "missing" }, + { previousEntry: 'Exec="/Applications/deleted-previous.AppImage" %U', label: "stale" }, + ])("prepares a $label Linux desktop entry before startup yields", ({ previousEntry }) => { + vi.stubEnv("VITE_DEV_SERVER_URL", ""); + vi.stubEnv("XDG_DATA_HOME", "/xdg"); + vi.stubEnv("APPIMAGE", "/Applications/current.AppImage"); + getSwitchValueMock.mockReturnValue(""); + let desktopName = "t3code.desktop"; + let desktopEntry = previousEntry; + let iconInstalled = false; + copyFileSyncMock.mockImplementation((_source: string, destination: string) => { + iconInstalled = destination === "/xdg/icons/com.t3tools.T3Code.desktop.png"; + }); + setDesktopNameMock.mockImplementation((name: string) => { + desktopName = name; + }); + writeFileSyncMock.mockImplementation((path: string, contents: string) => { + if (path === "/xdg/applications/com.t3tools.T3Code.desktop") desktopEntry = contents; + }); - return Effect.scoped( - Effect.gen(function* () { - const portalIdentity = Promise.resolve().then(() => ({ - desktopName, - desktopEntry, - iconInstalled, - })); - yield* Layer.build( - DesktopPreReadyPlatform.layer.pipe( - Layer.provide(Layer.succeed(HostProcessPlatform, "linux")), - ), - ); - const identity = yield* Effect.promise(() => portalIdentity); - assert.equal(identity.desktopName, "com.t3tools.T3Code.desktop"); - assert.include(identity.desktopEntry ?? "", 'Exec="/Applications/current.AppImage" %U'); - assert.include(identity.desktopEntry ?? "", "Name=T3 Code (Alpha)"); - assert.include(identity.desktopEntry ?? "", "MimeType=x-scheme-handler/t3code;"); - assert.include( - identity.desktopEntry ?? "", - "Icon=/xdg/icons/com.t3tools.T3Code.desktop.png", - ); - assert.isTrue(identity.iconInstalled); - }), - ).pipe(Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs()))); - }, - ); - } + return Effect.scoped( + Effect.gen(function* () { + const portalIdentity = Promise.resolve().then(() => ({ + desktopName, + desktopEntry, + iconInstalled, + })); + yield* Layer.build( + DesktopPreReadyPlatform.layer.pipe( + Layer.provide(Layer.succeed(HostProcessPlatform, "linux")), + ), + ); + const identity = yield* Effect.promise(() => portalIdentity); + assert.equal(identity.desktopName, "com.t3tools.T3Code.desktop"); + assert.include(identity.desktopEntry ?? "", 'Exec="/Applications/current.AppImage" %U'); + assert.include(identity.desktopEntry ?? "", "Name=T3 Code (Alpha)"); + assert.include(identity.desktopEntry ?? "", "MimeType=x-scheme-handler/t3code;"); + assert.include( + identity.desktopEntry ?? "", + "Icon=/xdg/icons/com.t3tools.T3Code.desktop.png", + ); + assert.isTrue(identity.iconInstalled); + }), + ).pipe(Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs()))); + }); it.effect("keeps startup available when the early desktop entry cannot be written", () => { getSwitchValueMock.mockReturnValue(""); diff --git a/apps/desktop/src/app/DesktopUserData.test.ts b/apps/desktop/src/app/DesktopUserData.test.ts index 374948ec921d..41e497463a0d 100644 --- a/apps/desktop/src/app/DesktopUserData.test.ts +++ b/apps/desktop/src/app/DesktopUserData.test.ts @@ -37,39 +37,37 @@ it.effect("identifies a failed source read and preserves its cause", () => { ); }); -for (const sourceName of ["t3code", "T3 Code (Alpha)"]) { - it.effect( - `preserves Windows credential keys from ${sourceName} without copying browser databases`, - () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-v2-profile-" }); - const source = path.join(directory, sourceName); - const destination = path.join(directory, "t3code-v2"); - const state = '{"os_crypt":{"encrypted_key":"test-encrypted-key"}}'; - yield* fs.makeDirectory(path.join(directory, "T3 Code (Alpha)"), { recursive: true }); - yield* fs.makeDirectory(path.join(source, "IndexedDB"), { recursive: true }); - yield* fs.writeFileString(path.join(source, "Local State"), state); - yield* fs.writeFileString(path.join(source, "IndexedDB", "LOCK"), "V1 owns this database"); - yield* resolveUserDataPath({ - appDataDirectory: directory, - isDevelopment: false, - platform: "win32", - }); - assert.equal(yield* fs.readFileString(path.join(destination, "Local State")), state); - assert.equal(yield* fs.readFileString(path.join(source, "Local State")), state); - assert.isFalse(yield* fs.exists(path.join(destination, "IndexedDB"))); - yield* fs.writeFileString(path.join(destination, "Local State"), "existing V2 state"); - yield* resolveUserDataPath({ - appDataDirectory: directory, - isDevelopment: false, - platform: "win32", - }); - assert.equal( - yield* fs.readFileString(path.join(destination, "Local State")), - "existing V2 state", - ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); -} +it.effect.each(["t3code", "T3 Code (Alpha)"])( + "preserves Windows credential keys from %s without copying browser databases", + (sourceName) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-v2-profile-" }); + const source = path.join(directory, sourceName); + const destination = path.join(directory, "t3code-v2"); + const state = '{"os_crypt":{"encrypted_key":"test-encrypted-key"}}'; + yield* fs.makeDirectory(path.join(directory, "T3 Code (Alpha)"), { recursive: true }); + yield* fs.makeDirectory(path.join(source, "IndexedDB"), { recursive: true }); + yield* fs.writeFileString(path.join(source, "Local State"), state); + yield* fs.writeFileString(path.join(source, "IndexedDB", "LOCK"), "V1 owns this database"); + yield* resolveUserDataPath({ + appDataDirectory: directory, + isDevelopment: false, + platform: "win32", + }); + assert.equal(yield* fs.readFileString(path.join(destination, "Local State")), state); + assert.equal(yield* fs.readFileString(path.join(source, "Local State")), state); + assert.isFalse(yield* fs.exists(path.join(destination, "IndexedDB"))); + yield* fs.writeFileString(path.join(destination, "Local State"), "existing V2 state"); + yield* resolveUserDataPath({ + appDataDirectory: directory, + isDevelopment: false, + platform: "win32", + }); + assert.equal( + yield* fs.readFileString(path.join(destination, "Local State")), + "existing V2 state", + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index c2012299802d..d1590d1a433d 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -644,7 +644,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl // The packaged sidecar is a Windows executable and cannot run inside the // Linux WSL backend. Keep the field absent instead of passing an unusable // `/mnt/.../*.exe` path; WSL resource telemetry is reported unavailable. - // See docs/architecture/resource-telemetry.md. + // See docs/internals/resource-telemetry.md. ...buildObservabilityFragment(input.observabilitySettings), }; diff --git a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts index 84e7678cce4a..17f9ff9b9867 100644 --- a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts +++ b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts @@ -386,45 +386,43 @@ describe("parseFirefoxProfiles", () => { }), ); - for (const [platform, root] of [ - ["Linux", "/home/user/.mozilla/firefox"], - ["macOS", "/Users/user/Library/Application Support/Firefox"], - ] as const) { - it.effect(`validates relative and absolute ${platform} profile paths`, () => - Effect.gen(function* () { - const parsed = yield* parsePosixFirefoxProfiles( - [ - "[Profile0]", - "Name=Relative", - "IsRelative=1", - "Path=Profiles/relative.default", - "[Profile1]", - "Name=Custom", - "IsRelative=0", - "Path=/mnt/custom/firefox-profile", - "[Profile2]", - "IsRelative=1", - "Path=../../escape", - "[Profile3]", - "IsRelative=1", - "Path=/absolute-marked-relative", - "[Profile4]", - "IsRelative=0", - "Path=relative-marked-absolute", - "[Profile5]", - "IsRelative=1", - "Path=Profiles/nul\u0000escape", - ].join("\n"), - root, - ); + it.effect.each([ + { platform: "Linux", root: "/home/user/.mozilla/firefox" }, + { platform: "macOS", root: "/Users/user/Library/Application Support/Firefox" }, + ])("validates relative and absolute $platform profile paths", ({ root }) => + Effect.gen(function* () { + const parsed = yield* parsePosixFirefoxProfiles( + [ + "[Profile0]", + "Name=Relative", + "IsRelative=1", + "Path=Profiles/relative.default", + "[Profile1]", + "Name=Custom", + "IsRelative=0", + "Path=/mnt/custom/firefox-profile", + "[Profile2]", + "IsRelative=1", + "Path=../../escape", + "[Profile3]", + "IsRelative=1", + "Path=/absolute-marked-relative", + "[Profile4]", + "IsRelative=0", + "Path=relative-marked-absolute", + "[Profile5]", + "IsRelative=1", + "Path=Profiles/nul\u0000escape", + ].join("\n"), + root, + ); - expect(parsed).toEqual([ - { directory: "Profiles/relative.default", name: "Relative" }, - { directory: "/mnt/custom/firefox-profile", name: "Custom" }, - ]); - }), - ); - } + expect(parsed).toEqual([ + { directory: "Profiles/relative.default", name: "Relative" }, + { directory: "/mnt/custom/firefox-profile", name: "Custom" }, + ]); + }), + ); it.effect("uses Windows path rules for relative and absolute profiles", () => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/BrowserImport/Sources.test.ts b/apps/desktop/src/preview/BrowserImport/Sources.test.ts index a867f78497b4..df27c4f96f1a 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.test.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.test.ts @@ -763,8 +763,9 @@ describe("listSourceProfiles Firefox fallback", () => { { platform: "win32" as const, profileDirectory: NodePath.join("Profiles", "windows.default") }, ]; - for (const { platform, profileDirectory } of cases) { - it.effect(`scans the ${platform} profile location and excludes stale entries`, () => + it.effect.each(cases)( + "scans the $platform profile location and excludes stale entries", + ({ platform, profileDirectory }) => run( Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -800,8 +801,7 @@ describe("listSourceProfiles Firefox fallback", () => { ]); }), ), - ); - } + ); it.effect("scans for profiles when profiles.ini declares only ones without cookies", () => run( @@ -1166,8 +1166,9 @@ describe("Safari profiles", () => { ), ); - for (const metadataState of ["missing", "corrupt"] as const) { - it.effect(`recovers separate cookie stores when metadata is ${metadataState}`, () => + it.effect.each(["missing", "corrupt"] as const)( + "recovers separate cookie stores when metadata is %s", + (metadataState) => run( Effect.gen(function* () { const { context, store, metadata } = yield* fixture(); @@ -1181,8 +1182,7 @@ describe("Safari profiles", () => { assert.isTrue(yield* isSourceInstalled(safari, context)); }), ), - ); - } + ); it.effect("keeps Safari without profiles available", () => run( diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 95197d8916a0..9adaeaaa448b 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -1691,6 +1691,123 @@ describe("PreviewManager", () => { ), ); + const makeAttachingGuest = (id: number) => { + const listeners = new Map void>(); + const attach = vi.fn(); + const detach = vi.fn(); + const sendCommand = vi.fn<(method: string, params?: unknown) => Promise>( + async () => undefined, + ); + let destroyed = false; + const wc = { + id, + isDestroyed: () => destroyed, + isDevToolsOpened: () => { + // Electron throws from native methods once a WebContents is destroyed. + if (destroyed) throw new TypeError("Object has been destroyed"); + return false; + }, + getType: () => "webview", + getURL: () => "http://localhost:5173/README.md", + getTitle: () => "README.md", + isLoading: () => true, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + setAudioMuted: vi.fn(), + isCurrentlyAudible: () => false, + on: vi.fn(), + off: vi.fn(), + once: (event: string, listener: () => void) => { + listeners.set(event, listener); + }, + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => attach.mock.calls.length > detach.mock.calls.length, + attach, + detach, + sendCommand, + on: vi.fn(), + off: vi.fn(), + }, + }; + return { + wc: wc as unknown as Electron.WebContents, + attach, + detach, + sendCommand, + destroy: () => { + destroyed = true; + listeners.get("destroyed")?.(); + }, + }; + }; + + effectIt.effect("sets the opaque base as soon as a guest attaches", () => + withManager((manager) => + Effect.gen(function* () { + // The tab's first document can paint before the renderer registers the + // guest, so the opaque base has to be the first command on attach. + const claimed = makeAttachingGuest(44); + fromId.mockReturnValue(claimed.wc); + yield* manager.prepareWebview(claimed.wc); + expect(claimed.attach).toHaveBeenCalledTimes(1); + expect(claimed.sendCommand.mock.calls[0]).toEqual([ + "Emulation.setDefaultBackgroundColorOverride", + { color: { r: 255, g: 255, b: 255, a: 1 } }, + ]); + + yield* manager.createTab("tab_early"); + yield* manager.registerWebview("tab_early", 44); + yield* manager.setColorScheme("tab_early", "dark"); + expect(claimed.attach).toHaveBeenCalledTimes(1); + expect(claimed.sendCommand).toHaveBeenCalledWith("Emulation.setEmulatedMedia", { + features: [{ name: "prefers-color-scheme", value: "dark" }], + }); + + // A guest destroyed before any tab claims it releases its session. + const unclaimed = makeAttachingGuest(45); + yield* manager.prepareWebview(unclaimed.wc); + expect(unclaimed.attach).toHaveBeenCalledTimes(1); + unclaimed.destroy(); + yield* Effect.yieldNow; + expect(unclaimed.detach).toHaveBeenCalledTimes(1); + expect(claimed.detach).not.toHaveBeenCalled(); + }), + ), + ); + + effectIt.effect("skips a guest destroyed while another guest's session opens", () => + withManager((manager) => + Effect.gen(function* () { + const slow = makeAttachingGuest(46); + let releaseSlow = () => {}; + const slowCommand = new Promise((resolve) => { + releaseSlow = resolve; + }); + slow.sendCommand.mockImplementation(() => slowCommand); + const queued = makeAttachingGuest(47); + + const slowFiber = yield* manager + .prepareWebview(slow.wc) + .pipe(Effect.forkChild({ startImmediately: true })); + const queuedFiber = yield* manager + .prepareWebview(queued.wc) + .pipe(Effect.forkChild({ startImmediately: true })); + queued.destroy(); + releaseSlow(); + + expect(Exit.isSuccess(yield* Fiber.await(slowFiber))).toBe(true); + expect(Exit.isSuccess(yield* Fiber.await(queuedFiber))).toBe(true); + expect(slow.attach).toHaveBeenCalledTimes(1); + expect(queued.attach).not.toHaveBeenCalled(); + }), + ), + ); + const makeAudioWebContents = (id: number) => { const listeners = new Map void>(); const setAudioMuted = vi.fn(); diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 4195bdffff6a..f7b5046a7fed 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -1303,6 +1303,17 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function > => { const existing = sessions.get(wc.id); if (existing) return Effect.succeed([existing, sessions] as const); + // A guest can be destroyed while it waits for this lock, and its native + // methods throw once it is. + if (wc.isDestroyed()) { + return Effect.fail( + new PreviewOperationError({ + operation: "ensureControlSession", + webContentsId: wc.id, + cause: new Error("WebContents was destroyed"), + }), + ); + } if (wc.isDevToolsOpened()) { return Effect.fail( new PreviewAutomationDevToolsOpenError({ @@ -1409,21 +1420,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wcDebugger.on("message", onMessage); wcDebugger.attach("1.3"); }); - yield* Effect.forEach( - ["Runtime.enable", "Accessibility.enable", "Network.enable", "Log.enable"], - (method) => - attemptPromise( - { operation: `initializeDebugger.${method}`, webContentsId: wc.id }, - () => wcDebugger.sendCommand(method), - ), - { concurrency: "unbounded", discard: true }, - ); // Electron gives `` guests a transparent base background, and // Chromium only paints a dark canvas for dark color-scheme pages over an // opaque base. Without this, dark-scheme pages with no background of // their own (text/plain, e.g. .md files) render white text on white. // White matches the webview's existing white backing, so light pages look // the same; Chromium still swaps in its dark canvas for dark-scheme pages. + // Sent first because a document that paints before it arrives keeps the + // transparent base until its next load. yield* attemptPromise( { operation: "initializeDebugger.defaultBackground", webContentsId: wc.id }, () => @@ -1431,6 +1435,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function color: { r: 255, g: 255, b: 255, a: 1 }, }), ); + yield* Effect.forEach( + ["Runtime.enable", "Accessibility.enable", "Network.enable", "Log.enable"], + (method) => + attemptPromise( + { operation: `initializeDebugger.${method}`, webContentsId: wc.id }, + () => wcDebugger.sendCommand(method), + ), + { concurrency: "unbounded", discard: true }, + ); return [ control, replaceMap(sessions, (copy) => { @@ -2415,6 +2428,30 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }); + // Called when a guest attaches to the window, before its first document + // paints. A tab opened straight to a URL often paints before the renderer + // gets to registerWebview, which would leave that page on the transparent + // base. registerWebview reuses the session opened here. + const prepareWebview = Effect.fn("PreviewManager.prepareWebview")(function* ( + wc: Electron.WebContents, + ) { + const webContentsId = wc.id; + // A guest destroyed before any tab claims it has no other cleanup path. + wc.once("destroyed", () => { + runFork(detachControlSession(webContentsId)); + }); + // Runs detached from the attach event, so nothing may escape. registerWebview + // opens the session again if this one did not. + yield* ensureControlSession(wc).pipe( + Effect.catchCause((cause) => + Effect.logDebug("Preview webview control session was not opened on attach.", { + webContentsId, + cause, + }), + ), + ); + }); + const navigate = Effect.fn("PreviewManager.navigate")(function* (tabId: string, rawUrl: string) { const url = yield* attempt({ operation: "navigate.normalizeUrl", tabId }, () => normalizePreviewUrl(rawUrl), @@ -4675,6 +4712,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function openPictureInPicture, openDevTools, pickElement, + prepareWebview, reapplyZoom, refresh, registerWebview, @@ -5011,6 +5049,7 @@ export class PreviewManager extends Context.Service< tabId: string, webContentsId: number, ) => Effect.Effect; + readonly prepareWebview: (webContents: Electron.WebContents) => Effect.Effect; readonly navigate: (tabId: string, url: string) => Effect.Effect; readonly goBack: (tabId: string) => Effect.Effect; readonly goForward: (tabId: string) => Effect.Effect; @@ -5137,6 +5176,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { createTab: operations.createTab, closeTab: operations.closeTab, registerWebview: operations.registerWebview, + prepareWebview: operations.prepareWebview, navigate: operations.navigate, goBack: operations.goBack, goForward: operations.goForward, diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index 343a498d26ef..efd9fbfbfd3b 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -131,6 +131,7 @@ const settingsChange = (settings: DesktopSettings, changed: boolean): DesktopSet const DesktopSettingsWriteOperation = Schema.Literals([ "create-temporary-file-name", + "resolve-symlink", "encode-document", "create-directory", "write-temporary-file", @@ -425,6 +426,14 @@ const writeSettings = Effect.fn("desktop.settings.writeSettings")(function* (inp const targetPath = yield* resolveSymlinkTarget(input.settingsPath).pipe( Effect.provideService(FileSystem.FileSystem, input.fileSystem), Effect.provideService(Path.Path, input.path), + Effect.mapError( + (cause) => + new DesktopSettingsWriteError({ + operation: "resolve-symlink", + path: input.settingsPath, + cause, + }), + ), ); const directory = input.path.dirname(targetPath); const tempPath = `${targetPath}.${process.pid}.${input.suffix}.tmp`; diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 91b64da41dd1..2d88fb80a670 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -177,58 +177,56 @@ describe("DesktopClientSettings", () => { ), ); - for (const failure of [ + it.effect.each([ { label: "permission", reason: "PermissionDenied" }, { label: "I/O", reason: "Unknown" }, - ] as const) { - it.effect(`preserves saved preferences across ${failure.label} read failures and retries`, () => - withClientSettings( - Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const fileSystem = yield* FileSystem.FileSystem; - const settings = yield* DesktopClientSettings.DesktopClientSettings; - const savedSettings = { - ...clientSettings, - onboardingCompletedAt: "2026-09-05T12:00:00.000Z", - }; - yield* settings.set(savedSettings); - const savedContents = yield* fileSystem.readFileString(environment.clientSettingsPath); - const cause = PlatformError.systemError({ - _tag: failure.reason, - module: "FileSystem", - method: "readFileString", - pathOrDescriptor: environment.clientSettingsPath, - }); - let failRead = true; - const retryableSettings = yield* DesktopClientSettings.make.pipe( - Effect.provideService( - FileSystem.FileSystem, - FileSystem.FileSystem.of({ - ...fileSystem, - readFileString: (path) => - Effect.suspend(() => - failRead ? Effect.fail(cause) : fileSystem.readFileString(path), - ), - }), - ), - ); + ] as const)("preserves saved preferences across $label read failures and retries", (failure) => + withClientSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopClientSettings.DesktopClientSettings; + const savedSettings = { + ...clientSettings, + onboardingCompletedAt: "2026-09-05T12:00:00.000Z", + }; + yield* settings.set(savedSettings); + const savedContents = yield* fileSystem.readFileString(environment.clientSettingsPath); + const cause = PlatformError.systemError({ + _tag: failure.reason, + module: "FileSystem", + method: "readFileString", + pathOrDescriptor: environment.clientSettingsPath, + }); + let failRead = true; + const retryableSettings = yield* DesktopClientSettings.make.pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.FileSystem.of({ + ...fileSystem, + readFileString: (path) => + Effect.suspend(() => + failRead ? Effect.fail(cause) : fileSystem.readFileString(path), + ), + }), + ), + ); - const error = yield* retryableSettings.get.pipe(Effect.flip); - assert.instanceOf(error, DesktopClientSettings.DesktopClientSettingsReadError); - assert.equal(error.operation, "read-file"); - assert.equal(error.path, environment.clientSettingsPath); - assert.strictEqual(error.cause, cause); - assert.equal( - yield* fileSystem.readFileString(environment.clientSettingsPath), - savedContents, - ); + const error = yield* retryableSettings.get.pipe(Effect.flip); + assert.instanceOf(error, DesktopClientSettings.DesktopClientSettingsReadError); + assert.equal(error.operation, "read-file"); + assert.equal(error.path, environment.clientSettingsPath); + assert.strictEqual(error.cause, cause); + assert.equal( + yield* fileSystem.readFileString(environment.clientSettingsPath), + savedContents, + ); - failRead = false; - assert.deepEqual(yield* retryableSettings.get, Option.some(savedSettings)); - }), - ), - ); - } + failRead = false; + assert.deepEqual(yield* retryableSettings.get, Option.some(savedSettings)); + }), + ), + ); it.effect("reports the failed client settings write operation and path", () => withClientSettings( @@ -316,31 +314,29 @@ describe("DesktopClientSettings", () => { ), ); - for (const document of [ + it.effect.each([ { label: "malformed JSON", contents: "{not-json" }, { label: "invalid direct settings", contents: '{"fontSizeCode":"large"}' }, { label: "invalid legacy settings", contents: '{"settings":{"fontSizeCode":"large"}}' }, - ]) { - it.effect(`reports ${document.label} without treating the settings file as absent`, () => - withClientSettings( - Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const fileSystem = yield* FileSystem.FileSystem; - const settings = yield* DesktopClientSettings.DesktopClientSettings; - yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); - yield* fileSystem.writeFileString(environment.clientSettingsPath, document.contents); + ])("reports $label without treating the settings file as absent", (document) => + withClientSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopClientSettings.DesktopClientSettings; + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString(environment.clientSettingsPath, document.contents); - const error = yield* settings.get.pipe(Effect.flip); - assert.instanceOf(error, DesktopClientSettings.DesktopClientSettingsReadError); - assert.equal(error.operation, "decode-document"); - assert.equal(error.path, environment.clientSettingsPath); - assert.instanceOf(error.cause, Schema.SchemaError); - assert.equal( - yield* fileSystem.readFileString(environment.clientSettingsPath), - document.contents, - ); - }), - ), - ); - } + const error = yield* settings.get.pipe(Effect.flip); + assert.instanceOf(error, DesktopClientSettings.DesktopClientSettingsReadError); + assert.equal(error.operation, "decode-document"); + assert.equal(error.path, environment.clientSettingsPath); + assert.instanceOf(error.cause, Schema.SchemaError); + assert.equal( + yield* fileSystem.readFileString(environment.clientSettingsPath), + document.contents, + ); + }), + ), + ); }); diff --git a/apps/desktop/src/settings/DesktopClientSettings.ts b/apps/desktop/src/settings/DesktopClientSettings.ts index bbac7b4ff3d0..50b1e09aacee 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.ts @@ -42,6 +42,7 @@ export class DesktopClientSettingsReadError extends Schema.TaggedError + new DesktopClientSettingsWriteError({ + operation: "resolve-symlink", + path: input.settingsPath, + cause, + }), + ), ); const directory = input.path.dirname(targetPath); const tempPath = `${targetPath}.${process.pid}.${input.suffix}.tmp`; diff --git a/apps/desktop/src/snapShot/DesktopSnapShot.test.ts b/apps/desktop/src/snapShot/DesktopSnapShot.test.ts index 205a72a55265..b5ba16d614ca 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.test.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.test.ts @@ -3873,8 +3873,9 @@ it.effect("waits to apply settings while permissions are pending", () => { ).pipe(Effect.provide(layer)); }); -for (const fails of [false, true]) { - it.effect(`tests macOS capture without publishing it and cleans up, failure=${fails}`, () => { +it.effect.each([false, true])( + "tests macOS capture without publishing it and cleans up, failure=%s", + (fails) => { const active = { platform: "macos", id: 42, @@ -3915,8 +3916,8 @@ for (const fails of [false, true]) { }), ), ); - }); -} + }, +); it.effect("rejects macOS test capture on other platforms", () => Effect.scoped( diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 5df400b90c44..1345c37bf687 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -314,6 +314,7 @@ function makeTestLayer(input: { Layer.mock(PreviewManager.PreviewManager)({ getBrowserSession: () => Effect.succeed({} as Electron.Session), setMainWindow: () => Effect.void, + prepareWebview: () => Effect.void, isBrowserPartition: (partition) => partition.startsWith("persist:t3code-preview-"), getBrowserPartition: () => Effect.succeed("persist:t3code-preview-test"), reapplyZoom: () => diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 754de3caa727..aa5aa085a3ce 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -600,6 +600,7 @@ export const make = Effect.gen(function* () { installContextMenu(window, window.webContents); window.webContents.on("did-attach-webview", (_event, contents) => { installContextMenu(window, contents); + void runPromise(previewManager.prepareWebview(contents)); }); window.webContents.setWindowOpenHandler(({ url }) => { diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 24940e480ecd..cf009b3bc37a 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -418,7 +418,16 @@ const config: ExpoConfig = { minSdkVersion: 24, // kotlinx-io uses Kotlin 2.3's return-value checker annotation, while // SDK 58 builds with Kotlin 2.2. It has no runtime behavior. - extraProguardRules: "-dontwarn kotlin.MustUseReturnValues", + // + // WorkManager 2.9 keeps InputMerger classes but not their constructors, + // and R8 full mode no longer keeps a default constructor implicitly. + // Without it no work request can start, so the Glance session behind + // the widget never renders and it stays on "Loading widget". WorkManager + // 2.10 ships this rule itself; drop it once the resolved version gets there. + extraProguardRules: [ + "-dontwarn kotlin.MustUseReturnValues", + "-keep class * extends androidx.work.InputMerger { (); }", + ].join("\n"), }, ios: { deploymentTarget: "18.0", diff --git a/apps/mobile/fingerprint.config.js b/apps/mobile/fingerprint.config.js new file mode 100644 index 000000000000..e6d4d2d218d6 --- /dev/null +++ b/apps/mobile/fingerprint.config.js @@ -0,0 +1,18 @@ +// @ts-check +const fs = require("node:fs"); +const path = require("node:path"); + +// Expo's fingerprint ignores the app version, so binaries of different majors +// share a runtime version whenever native code is unchanged, and a production +// OTA from main would reach every older store binary. Hashing the major +// version keeps each major's OTAs on its own binaries: a new major reaches +// users only once its store build is promoted. +const appConfig = fs.readFileSync(path.join(__dirname, "app.config.ts"), "utf8"); +const majorVersion = appConfig.match(/^ {2}version: "(\d+)\./m)?.[1]; +if (!majorVersion) { + throw new Error("fingerprint.config.js could not read the app version from app.config.ts"); +} + +module.exports = { + extraSources: [{ type: "contents", id: "appMajorVersion", contents: majorVersion }], +}; diff --git a/apps/mobile/package.json b/apps/mobile/package.json index d1bd5c810894..b5f0d49d2bc1 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -114,7 +114,7 @@ "react-native": "0.88.0-rc.3", "react-native-gesture-handler": "~3.2.1", "react-native-image-viewing": "^0.2.2", - "react-native-keyboard-controller": "1.22.4", + "react-native-keyboard-controller": "1.22.6", "react-native-nitro-markdown": "^0.5.0", "react-native-nitro-modules": "0.35.9", "react-native-reanimated": "4.7.0", diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index 994f8852e173..836e529e5965 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -949,59 +949,57 @@ describe("makeRelayDeviceRegistrationRequest", () => { await new Promise((resolve) => setTimeout(resolve, 0)); expect(widgetMocks.start).toHaveBeenCalledTimes(1); }); - for (const os of ["ios", "android"] as const) { - it.effect( - `does not enable ${os} notifications when a token rotates after permission is revoked`, - () => { - vi.spyOn(Platform, "OS", "get").mockReturnValue(os); - vi.spyOn(Platform, "Version", "get").mockReturnValue(os === "ios" ? 18 : 36); - vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({ - type: os, - data: "initial", + it.effect.each(["ios", "android"] as const)( + "does not enable %s notifications when a token rotates after permission is revoked", + (os) => { + vi.spyOn(Platform, "OS", "get").mockReturnValue(os); + vi.spyOn(Platform, "Version", "get").mockReturnValue(os === "ios" ? 18 : 36); + vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({ + type: os, + data: "initial", + }); + const registrations: unknown[] = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init); + if (request.url.endsWith("/v1/client/dpop-token")) { + return Response.json({ + access_token: "dpop", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "DPoP", + expires_in: 300, + scope: "mobile:registration", + }); + } + registrations.push(await request.json()); + return Response.json({ ok: true }); + }); + Constants.expoConfig!.extra = { relay: { url: "https://permission-relay.example.test" } }; + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk"), "user-a"); + return Effect.gen(function* () { + yield* runBackgroundOperations(); + expect(registrations.at(-1)).toMatchObject({ + preferences: { notificationsEnabled: true }, }); - const registrations: unknown[] = []; - vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { - const request = new Request(input, init); - if (request.url.endsWith("/v1/client/dpop-token")) { - return Response.json({ - access_token: "dpop", - issued_token_type: "urn:ietf:params:oauth:token-type:access_token", - token_type: "DPoP", - expires_in: 300, - scope: "mobile:registration", - }); - } - registrations.push(await request.json()); - return Response.json({ ok: true }); + vi.mocked(Notifications.getPermissionsAsync).mockResolvedValueOnce({ + granted: false, + } as Awaited>); + const listener = vi.mocked(Notifications.addPushTokenListener).mock.calls.at(-1)![0]; + listener({ type: os, data: "rotated" }); + yield* runBackgroundOperations(); + expect(registrations.at(-1)).toMatchObject({ + preferences: { notificationsEnabled: false }, }); - Constants.expoConfig!.extra = { relay: { url: "https://permission-relay.example.test" } }; - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk"), "user-a"); - return Effect.gen(function* () { - yield* runBackgroundOperations(); - expect(registrations.at(-1)).toMatchObject({ - preferences: { notificationsEnabled: true }, - }); - vi.mocked(Notifications.getPermissionsAsync).mockResolvedValueOnce({ - granted: false, - } as Awaited>); - const listener = vi.mocked(Notifications.addPushTokenListener).mock.calls.at(-1)![0]; - listener({ type: os, data: "rotated" }); - yield* runBackgroundOperations(); - expect(registrations.at(-1)).toMatchObject({ - preferences: { notificationsEnabled: false }, - }); - expect(registrations.at(-1)).not.toHaveProperty("pushToken"); - }).pipe( - Effect.provideService(FetchHttpClient.Fetch, globalThis.fetch), - Effect.provide( - managedRelayClientLayer("https://permission-relay.example.test").pipe( - Layer.provide(Layer.mergeAll(FetchHttpClient.layer, cryptoLayer)), - ), + expect(registrations.at(-1)).not.toHaveProperty("pushToken"); + }).pipe( + Effect.provideService(FetchHttpClient.Fetch, globalThis.fetch), + Effect.provide( + managedRelayClientLayer("https://permission-relay.example.test").pipe( + Layer.provide(Layer.mergeAll(FetchHttpClient.layer, cryptoLayer)), ), - ); - }, - ); - } + ), + ); + }, + ); it.effect("preserves relay rejection errors with React Native response headers", () => { vi.spyOn(Platform, "OS", "get").mockReturnValue("android"); vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({ diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index aab366710176..6538697da297 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -44,6 +44,7 @@ import { ThreadListV2SettledShelfHeader, ThreadListV2ShowMoreRow, ThreadListV2SnoozedShelfHeader, + ThreadListV2WorkingShelfHeader, } from "../threads/thread-list-v2-items"; import { useThreadRowProviderInstanceResolver } from "../threads/thread-provider-instance"; import { @@ -51,6 +52,7 @@ import { getThreadListV2OrderedSection, buildThreadListV2ListItems, threadListV2ListItemsAreEqual, + threadListInboxReturns, THREAD_LIST_V2_SETTLED_INITIAL_COUNT, THREAD_LIST_V2_SETTLED_PAGE_COUNT, type ThreadListV2ListItem, @@ -466,8 +468,11 @@ export function HomeScreen(props: HomeScreenProps) { loaded: shelfPreferencesLoaded, settledShelfExpanded, snoozedShelfExpanded, + workingShelfEnabled, + workingShelfExpanded, toggleSettledShelf, toggleSnoozedShelf, + toggleWorkingShelf, } = useThreadListV2ShelfPreferences(); // The queued-start and snooze helpers need a clock while the list stays open. const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); @@ -520,8 +525,13 @@ export function HomeScreen(props: HomeScreenProps) { queuedThreadKeys, }), }); - return new Map([...sectionAvailability("pinned"), ...sectionAvailability("active")]); + // The Working beta orders the inbox by time, so only pins can move. + return new Map([ + ...sectionAvailability("pinned"), + ...(workingShelfEnabled ? [] : sectionAvailability("active")), + ]); }, [ + workingShelfEnabled, pinReorderEnvironmentIds, activeReorderEnvironmentIds, props.threads, @@ -533,6 +543,7 @@ export function HomeScreen(props: HomeScreenProps) { snoozeWakeTick, ]); const threadListV2Layout = useMemo(() => { + threadListInboxReturns.observe(workingShelfEnabled ? props.threads : null); // Settled threads are live shells; archived threads keep their original // "hidden from lists" meaning. return buildThreadListV2Items({ @@ -547,11 +558,16 @@ export function HomeScreen(props: HomeScreenProps) { queuedThreadKeys, settledLimit: settledVisibleCount, now: new Date().toISOString(), + workingShelfEnabled, + workingShelfExpanded, + inboxReturnAt: threadListInboxReturns.returnedAt, snoozedShelfExpanded, settledShelfExpanded, selectedThreadKey: null, }); }, [ + workingShelfEnabled, + workingShelfExpanded, pendingOrder, queuedThreadKeys, nowMinute, @@ -606,6 +622,9 @@ export function HomeScreen(props: HomeScreenProps) { buildThreadListV2ListItems({ items: threadListV2Layout.items, pendingTasks: v2PendingTasks, + workingCount: threadListV2Layout.workingCount, + workingShelfExpanded, + workingShelfHeaderIndex: threadListV2Layout.workingShelfHeaderIndex, snoozedCount: threadListV2Layout.snoozedCount, snoozedShelfExpanded, snoozedShelfHeaderIndex: threadListV2Layout.snoozedShelfHeaderIndex, @@ -628,6 +647,7 @@ export function HomeScreen(props: HomeScreenProps) { snoozeEnvironmentIds, threadListV2Layout, v2PendingTasks, + workingShelfExpanded, ], ); @@ -662,6 +682,16 @@ export function HomeScreen(props: HomeScreenProps) { /> ); } + if (item.type === "v2-working-shelf") { + return ( + + ); + } if (item.type === "v2-snoozed-shelf") { return ( item.key, []); @@ -800,6 +832,8 @@ export function HomeScreen(props: HomeScreenProps) { savedConnectionsById: props.savedConnectionsById, searchQuery: props.searchQuery, threadSearchMatchByKey, + // Rows read it for their reorder menu items. + workingShelfEnabled, }), [ projectByKey, @@ -808,6 +842,7 @@ export function HomeScreen(props: HomeScreenProps) { listEnvironments, threadSearchMatchByKey, v2ProjectTitleByProjectKey, + workingShelfEnabled, ], ); diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx index 08888dd469d2..20c87b496ab3 100644 --- a/apps/mobile/src/features/review/ReviewSheet.tsx +++ b/apps/mobile/src/features/review/ReviewSheet.tsx @@ -126,8 +126,8 @@ function ReviewHeader( id: "sections", inline: true, items: [ - sectionAction(props.sectionMenu.workingTree, "Working tree"), - sectionAction(props.sectionMenu.branchChanges, "Branch changes"), + sectionAction(props.sectionMenu.branchChanges, "Changes"), + sectionAction(props.sectionMenu.workingTree, "Uncommitted"), sectionAction(props.sectionMenu.latestTurn, "Latest turn"), ], }, diff --git a/apps/mobile/src/features/review/reviewModel.test.ts b/apps/mobile/src/features/review/reviewModel.test.ts index 2af0fb4ba063..9c2cc9b567c9 100644 --- a/apps/mobile/src/features/review/reviewModel.test.ts +++ b/apps/mobile/src/features/review/reviewModel.test.ts @@ -62,7 +62,7 @@ describe("buildReviewSectionItems", () => { { id: "working-tree", kind: "working-tree", - title: "Dirty worktree", + title: "Uncommitted", baseRef: "HEAD", headRef: null, diff: "diff --git a/a.ts b/a.ts", @@ -72,7 +72,7 @@ describe("buildReviewSectionItems", () => { { id: "branch-range", kind: "branch-range", - title: "Against main", + title: "Changes vs main", baseRef: "main", headRef: "feature", diff: "diff --git a/a.ts b/a.ts", @@ -105,10 +105,28 @@ describe("buildReviewSectionItems", () => { isLoading: false, diff: expect.stringContaining("loaded.ts"), }); - expect(getDefaultReviewSectionId(items)).toBe("turn:2"); + expect(getDefaultReviewSectionId(items)).toBe("git:branch-range"); }); - it("shows dirty worktree while git preview is loading", () => { + it("falls back to the first turn without git sections", () => { + const items = buildReviewSectionItems({ + checkpoints: [ + makeCheckpoint({ + runId: RunId.make("run-1"), + checkpointTurnCount: 1, + completedAt: "2026-04-01T00:00:00.000Z", + }), + ], + gitSections: [], + turnDiffById: {}, + loadingTurnIds: {}, + loadingGitSections: false, + }); + + expect(getDefaultReviewSectionId(items)).toBe("turn:1"); + }); + + it("holds the Changes place while git preview is loading", () => { const items = buildReviewSectionItems({ checkpoints: [], gitSections: [], @@ -119,15 +137,14 @@ describe("buildReviewSectionItems", () => { expect(items).toEqual([ expect.objectContaining({ - id: "git:working-tree", - kind: "working-tree", - title: "Dirty worktree", - subtitle: "Tracked, staged, and untracked worktree changes", + id: "git:branch-range", + kind: "branch-range", + title: "Changes", diff: null, isLoading: true, }), ]); - expect(getDefaultReviewSectionId(items)).toBe("git:working-tree"); + expect(getDefaultReviewSectionId(items)).toBe("git:branch-range"); }); }); diff --git a/apps/mobile/src/features/review/reviewModel.ts b/apps/mobile/src/features/review/reviewModel.ts index aa935bd28710..86ded9a6264e 100644 --- a/apps/mobile/src/features/review/reviewModel.ts +++ b/apps/mobile/src/features/review/reviewModel.ts @@ -9,9 +9,9 @@ import * as Order from "effect/Order"; export type ReviewSectionKind = "turn" | "working-tree" | "branch-range"; -const DIRTY_WORKTREE_SECTION_ID = "git:working-tree"; -const DIRTY_WORKTREE_TITLE = "Dirty worktree"; -const DIRTY_WORKTREE_SUBTITLE = "Tracked, staged, and untracked worktree changes"; +const CHANGES_SECTION_ID = "git:branch-range"; +const CHANGES_TITLE = "Changes"; +const UNCOMMITTED_SUBTITLE = "Staged, unstaged, and untracked files"; export interface ReviewSectionItem { readonly id: string; @@ -124,7 +124,7 @@ const readyCheckpointOrder = Order.make( function gitSubtitle(section: ReviewDiffPreviewSource): string | null { if (section.kind === "working-tree") { - return DIRTY_WORKTREE_SUBTITLE; + return UNCOMMITTED_SUBTITLE; } if (section.baseRef) { return `${section.baseRef} ... ${section.headRef ?? "HEAD"}`; @@ -443,15 +443,16 @@ export function buildReviewSectionItems(input: { truncated: section.truncated, isLoading: false, })); - const hasDirtyWorktreeItem = gitItems.some((item) => item.id === DIRTY_WORKTREE_SECTION_ID); + // Changes is the default section, so it holds the place while git sources load. + const hasChangesItem = gitItems.some((item) => item.id === CHANGES_SECTION_ID); const visibleGitItems = - input.loadingGitSections && !hasDirtyWorktreeItem + input.loadingGitSections && !hasChangesItem ? [ { - id: DIRTY_WORKTREE_SECTION_ID, - kind: "working-tree", - title: DIRTY_WORKTREE_TITLE, - subtitle: DIRTY_WORKTREE_SUBTITLE, + id: CHANGES_SECTION_ID, + kind: "branch-range", + title: CHANGES_TITLE, + subtitle: null, diff: null, isLoading: true, } satisfies ReviewSectionItem, @@ -462,10 +463,11 @@ export function buildReviewSectionItems(input: { return [...turnItems, ...visibleGitItems]; } +/** Prefers Changes, then the first section (a turn when the project is not a git repo). */ export function getDefaultReviewSectionId( sections: ReadonlyArray, ): string | null { - return sections[0]?.id ?? null; + return (sections.find((section) => section.id === CHANGES_SECTION_ID) ?? sections[0])?.id ?? null; } export function buildReviewParsedDiff( diff --git a/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx index 5ff83dea728f..dab724e236a3 100644 --- a/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx @@ -45,6 +45,7 @@ export function SettingsThreadsRouteScreen() { contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }} > + @@ -239,6 +240,35 @@ function AutoSettleSettingsRows() { ); } +/** + * Device-local beta toggles, the counterpart of web's Working section (beta) + * in Settings → General. + */ +function BetaSettingsSection() { + const savePreferences = useAtomSet(updateMobilePreferencesAtom); + const preferences = useAtomValue(mobilePreferencesAtom); + const workingShelfEnabled = + AsyncResult.isSuccess(preferences) && preferences.value.workingShelfEnabled === true; + + return ( + + + savePreferences({ workingShelfEnabled: value })} + /> + + + Fold working and monitoring threads into a Working section. They return to the top of the + list when they need you. While this is on, active threads are ordered by time and cannot be + moved. + + + ); +} + /** * Device-local legacy toggles. Mobile has no client-settings sync, so this is * the counterpart of web's Settings → General → Legacy features backed by diff --git a/apps/mobile/src/features/threads/SubagentRow.tsx b/apps/mobile/src/features/threads/SubagentRow.tsx new file mode 100644 index 000000000000..bef52c4b7c75 --- /dev/null +++ b/apps/mobile/src/features/threads/SubagentRow.tsx @@ -0,0 +1,154 @@ +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { resolveSubagentMetadata } from "@t3tools/client-runtime/state/subagent-display"; +import type { EnvironmentId, OrchestrationV2Subagent } from "@t3tools/contracts"; +import type { ReactNode } from "react"; +import { View } from "react-native"; + +import { AppText as Text } from "../../components/AppText"; +import { SymbolView } from "../../components/AppSymbol"; +import { ProviderIcon } from "../../components/ProviderIcon"; +import { cn } from "../../lib/cn"; +import { useEnvironmentServerConfig, useProject, useThreadShell } from "../../state/entities"; +import { subagentCardDetail } from "./subagent-card-presentation"; +import { SUBAGENT_TONE_TEXT_CLASS, SubagentStatusDot } from "./SubagentStatusDot"; +import { resolveSubagentRowPresentation } from "./threadAgentsPresentation"; + +type SubagentRowSubagent = Pick< + OrchestrationV2Subagent, + | "threadId" + | "childThreadId" + | "model" + | "driver" + | "providerInstanceId" + | "title" + | "prompt" + | "status" + | "progress" + | "result" +>; + +/** + * One agent as shown in the Agents sheet and transcript cards: status, model, + * workspace changes, and recent progress or result. Callers own the press + * target and pass their own elapsed timer so ticking stays isolated. + */ +export function SubagentRow(props: { + readonly environmentId: EnvironmentId; + readonly subagent: SubagentRowSubagent; + readonly elapsed: ReactNode; +}) { + const presentation = resolveSubagentRowPresentation(props.subagent); + const detail = subagentCardDetail(presentation.detail); + return ( + + + + + + + + + {presentation.title} + + + · + + + {presentation.statusLabel} + + + {props.elapsed} + {presentation.canOpenThread ? ( + + ) : null} + + + {detail ? ( + + {detail} + + ) : null} + + + ); +} + +/** Uses shell data already held by the client, without loading child transcripts. */ +function SubagentMetadata(props: { + readonly environmentId: EnvironmentId; + readonly subagent: SubagentRowSubagent; +}) { + const { environmentId, subagent } = props; + const config = useEnvironmentServerConfig(environmentId); + const provider = config?.providers.find( + (candidate) => candidate.instanceId === subagent.providerInstanceId, + ); + const parent = useThreadShell(scopeThreadRef(environmentId, subagent.threadId))?.source; + const child = useThreadShell( + subagent.childThreadId === null ? null : scopeThreadRef(environmentId, subagent.childThreadId), + )?.source; + const parentProject = useProject( + parent ? scopeProjectRef(environmentId, parent.projectId) : null, + ); + const childProject = useProject(child ? scopeProjectRef(environmentId, child.projectId) : null); + const { modelLabel, workspace } = resolveSubagentMetadata({ + model: subagent.model, + provider, + parentThread: parent, + childThread: child, + parentProject, + childProject, + }); + return ( + + + + {provider?.displayName ? `${provider.displayName} · ` : ""} + {modelLabel} + + {workspace.map(({ label, value }) => ( + // The row reads this label in place of the icon. collapsable keeps the + // view (and label) from being flattened away without making it a + // separate accessibility stop. + + · + + + {value} + + + ))} + + ); +} diff --git a/apps/mobile/src/features/threads/SubagentStatusDot.tsx b/apps/mobile/src/features/threads/SubagentStatusDot.tsx index a08355effb50..348f6e534b6c 100644 --- a/apps/mobile/src/features/threads/SubagentStatusDot.tsx +++ b/apps/mobile/src/features/threads/SubagentStatusDot.tsx @@ -10,19 +10,25 @@ const TONE_CLASS = { stopped: "bg-foreground-muted", } as const satisfies Record; +export const SUBAGENT_TONE_TEXT_CLASS = { + working: "text-adaptive-sky-600-400", + completed: "text-adaptive-emerald-600-400", + failed: "text-adaptive-rose-600-400", + stopped: "text-foreground-muted", +} as const satisfies Record; + export function SubagentStatusDot({ tone, placement = "inline", }: { readonly tone: SubagentRowTone; - readonly placement?: "inline" | "provider" | "sheet"; + readonly placement?: "inline" | "sheet"; }) { return ( diff --git a/apps/mobile/src/features/threads/ThreadAgentsSheet.tsx b/apps/mobile/src/features/threads/ThreadAgentsSheet.tsx index 7d7c825aaa08..1d082515172d 100644 --- a/apps/mobile/src/features/threads/ThreadAgentsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadAgentsSheet.tsx @@ -17,13 +17,10 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AndroidSheetHeader } from "../../components/AndroidScreenHeader"; import { AppText as Text } from "../../components/AppText"; -import { SymbolView } from "../../components/AppSymbol"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { environmentThreadDetails } from "../../state/threads"; import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; -import { resolveSubagentRowPresentation } from "./threadAgentsPresentation"; - -import { SubagentStatusDot } from "./SubagentStatusDot"; +import { SubagentRow } from "./SubagentRow"; const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); @@ -72,6 +69,7 @@ export function ThreadAgentsSheet({ route }: StaticScreenProps) { @@ -121,32 +119,21 @@ export function ThreadAgentsSheet({ route }: StaticScreenProps) { } function AgentRow(props: { + readonly environmentId: EnvironmentId; readonly subagent: OrchestrationV2Subagent; readonly tickSeconds: boolean; readonly onOpen: (childThreadId: ThreadId) => void; }) { const { subagent } = props; - const presentation = resolveSubagentRowPresentation(subagent); const childThreadId = subagent.childThreadId; - const elapsed = useSubagentElapsed(subagent, props.tickSeconds); const row = ( - - - - - {presentation.title} - - - {presentation.detail ?? presentation.statusLabel} - - - {elapsed === null ? null : ( - {elapsed} - )} - {presentation.canOpenThread ? ( - - ) : null} + + } + /> ); @@ -154,7 +141,6 @@ function AgentRow(props: { return ( {row} @@ -165,7 +151,6 @@ function AgentRow(props: { return ( props.onOpen(childThreadId)} className="active:opacity-70" @@ -175,9 +160,19 @@ function AgentRow(props: { ); } +function AgentElapsed(props: { + readonly subagent: OrchestrationV2Subagent; + readonly tickSeconds: boolean; +}) { + const elapsed = useSubagentElapsed(props.subagent, props.tickSeconds); + return elapsed === null ? null : ( + {elapsed} + ); +} + /** - * Elapsed time for one agent. Only a roster with live work subscribes to the - * shared second tick, so a settled sheet never repaints. + * Elapsed time for one agent. Only live work ticks, inside AgentElapsed, + * so the timer never repaints the metadata or a settled sheet. */ function useSubagentElapsed( subagent: Pick, diff --git a/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx b/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx index cb25aa67af78..97dc6cf85eca 100644 --- a/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx @@ -2,6 +2,7 @@ import { appAtomRegistry } from "../../state/atom-registry"; import { useAtomValue } from "@effect/atom-react"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; +import { sortInboxThreadsByReturn } from "@t3tools/client-runtime/state/thread-inbox"; import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; import { Animated, FlatList, Modal, Pressable, View } from "react-native"; import { Gesture, GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler"; @@ -26,7 +27,8 @@ import { threadDragAction, type ThreadMoveDestination, } from "./threadOrder"; -import { getThreadListV2OrderedSection } from "./threadListV2"; +import { getThreadListV2OrderedSection, threadListInboxReturns } from "./threadListV2"; +import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences"; const ROW_HEIGHT = 56; const HEADER_HEIGHT = 48; @@ -154,6 +156,7 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) { const pendingOrder = useAtomValue(pendingThreadOrderAtom); const dropBusy = useAtomValue(threadDropBusyAtom); const { moveThread } = useThreadListActions(); + const { workingShelfEnabled } = useThreadListV2ShelfPreferences(); const [now, setNow] = useState(() => new Date().toISOString()); const [expanded, setExpanded] = useState({ snoozed: false, settled: false }); useEffect(() => { @@ -195,23 +198,28 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) { ); return { pinned, - active, + // The Working beta orders the inbox by time; show that order here too. + active: workingShelfEnabled + ? sortInboxThreadsByReturn(active, threadListInboxReturns.returnedAt) + : active, snoozed: parked.filter((thread) => effectiveSnoozed(thread, { now })), settled: parked.filter((thread) => !effectiveSnoozed(thread, { now })), }; - }, [threads, configs, now, queuedThreadKeys, pendingOrder]); + }, [threads, configs, now, queuedThreadKeys, pendingOrder, workingShelfEnabled]); const planners = useMemo(() => { const planner = (section: "pinned" | "active") => createThreadMovePlanner({ ordered: sections[section], allThreads: threads, section, + // A time-ordered inbox has no slots, so Active takes no drops while + // the Working beta is on. The saved arrangement stays untouched. reorderableEnvironmentIds: new Set( [...configs].flatMap(([id, config]) => ( section === "pinned" ? config.environment.capabilities.threadPinReorder - : config.environment.capabilities.threadActiveReorder + : !workingShelfEnabled && config.environment.capabilities.threadActiveReorder ) ? [id] : [], @@ -219,7 +227,7 @@ export function ThreadArrangementSheet(props: { onClose: () => void }) { ), }); return { pinned: planner("pinned"), active: planner("active") }; - }, [sections, threads, configs]); + }, [sections, threads, configs, workingShelfEnabled]); const rows = useMemo(() => { const result: Row[] = []; let offset = 0; diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index a0eaaaf85548..16b09e7c1b1a 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -150,6 +150,7 @@ export interface ThreadComposerProps { */ readonly threadSyncPhase?: "loading" | "syncing" | null; readonly selectedThread: EnvironmentThreadShell; + readonly reportedModelSelection?: ModelSelection | null; readonly hasCompactableConversation: boolean; readonly serverConfig: T3ServerConfig | null; readonly queueCount: number; @@ -667,6 +668,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer providerInstanceId: currentModelSelection.instanceId, providerGroups: threadProviderGroups, selectedModel: currentModelSelection, + reportedModelSelection: props.reportedModelSelection, onSelectModel: (option) => props.onUpdateModelSelection(withRememberedModelOptions(option.selection)), optionDescriptors: providerOptionDescriptors, @@ -683,6 +685,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer }), [ currentModelSelection, + props.reportedModelSelection, currentRuntimeMode, props.onUpdateModelSelection, props.onUpdateRuntimeMode, diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index d1470315a566..8b9e061f39a2 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -1,3 +1,4 @@ +import { useThreadReportedModelSelection } from "../../state/entities"; import { UsageLimitRecoveryCard } from "./UsageLimitRecoveryCard"; import { useNavigation } from "@react-navigation/native"; import type { WorktreeSetupCardProps } from "./worktree-setup-card"; @@ -35,7 +36,7 @@ import { formatModelSelectionEffort, type ProviderSubagentStatus, } from "@t3tools/client-runtime/state/thread-execution"; -import { formatModelSlugName } from "@t3tools/shared/model"; +import { formatModelSlugName, resolveSelectableModel } from "@t3tools/shared/model"; import { isProviderNativeSubagentThread } from "@t3tools/contracts"; import type { QueuedRunEdit } from "../../state/queued-run-edit"; import type { FollowUpBehavior } from "../../lib/followUpBehavior"; @@ -303,6 +304,10 @@ const USER_INPUT_TOGGLE_TIMING = { export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) { const navigation = useNavigation(); + const reportedModelSelection = useThreadReportedModelSelection({ + environmentId: props.environmentId, + threadId: props.selectedThread.id, + }); const deviceState = useEnvironmentQuery( deviceEnvironment.state({ environmentId: props.environmentId, input: {} }), ); @@ -469,11 +474,12 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread } if (pendingBackgroundWork !== null && contentPresentationKind === "ready") { return { - kind: "waiting", + kind: "background", label: pendingBackgroundWork.title, accessibilityLabel: `${pendingBackgroundWork.title}: ${pendingBackgroundWork.items .map((item) => item.label) .join(", ")}`, + waiting: pendingBackgroundWork.waiting, }; } return null; @@ -760,8 +766,16 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const providerSubagentProvider = props.serverConfig?.providers.find( (provider) => provider.instanceId === props.selectedThread.modelSelection.instanceId, ); + // Providers can report a dated id or alias (claude-haiku-4-5-20251001). + const providerSubagentModelSlug = providerSubagentProvider + ? resolveSelectableModel( + providerSubagentProvider.driver, + props.selectedThread.modelSelection.model, + providerSubagentProvider.models, + ) + : null; const providerSubagentCatalogModel = providerSubagentProvider?.models.find( - (model) => model.slug === props.selectedThread.modelSelection.model, + (model) => model.slug === providerSubagentModelSlug, ); const workspaceContentWidth = useWorkspaceContentWidth(); // Clearing animated width can retain the unfolded width after Android resumes folded. @@ -1269,6 +1283,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread effortLabel={formatModelSelectionEffort( props.selectedThread.modelSelection, providerSubagentProvider?.models, + reportedModelSelection, )} status={props.providerSubagentStatus ?? null} onOpenParent={ @@ -1285,6 +1300,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread ) : ( <> new Date().toISOString().slice(0, 16)); @@ -343,8 +348,13 @@ function ThreadNavigationSidebarPane( queuedThreadKeys, }), }); - return new Map([...sectionAvailability("pinned"), ...sectionAvailability("active")]); + // The Working beta orders the inbox by time, so only pins can move. + return new Map([ + ...sectionAvailability("pinned"), + ...(workingShelfEnabled ? [] : sectionAvailability("active")), + ]); }, [ + workingShelfEnabled, pinReorderEnvironmentIds, activeReorderEnvironmentIds, threads, @@ -356,6 +366,7 @@ function ThreadNavigationSidebarPane( snoozeWakeTick, ]); const threadListV2Layout = useMemo(() => { + threadListInboxReturns.observe(workingShelfEnabled ? threads : null); return buildThreadListV2Items({ pendingOrder, threads: threads.filter((thread) => thread.archivedAt === null), @@ -368,11 +379,16 @@ function ThreadNavigationSidebarPane( queuedThreadKeys, settledLimit: settledVisibleCount, now: new Date().toISOString(), + workingShelfEnabled, + workingShelfExpanded, + inboxReturnAt: threadListInboxReturns.returnedAt, snoozedShelfExpanded, settledShelfExpanded, selectedThreadKey: props.selectedThreadKey ?? null, }); }, [ + workingShelfEnabled, + workingShelfExpanded, pendingOrder, queuedThreadKeys, nowMinute, @@ -424,6 +440,9 @@ function ThreadNavigationSidebarPane( const items: SidebarListItem[] = buildThreadListV2ListItems({ items: threadListV2Layout.items, pendingTasks: v2PendingTasks, + workingCount: threadListV2Layout.workingCount, + workingShelfExpanded, + workingShelfHeaderIndex: threadListV2Layout.workingShelfHeaderIndex, snoozedCount: threadListV2Layout.snoozedCount, snoozedShelfExpanded, snoozedShelfHeaderIndex: threadListV2Layout.snoozedShelfHeaderIndex, @@ -457,6 +476,7 @@ function ThreadNavigationSidebarPane( snoozedShelfExpanded, snoozeEnvironmentIds, threadListV2Layout, + workingShelfExpanded, ]); const listMenuActions = useMemo( () => [ @@ -587,6 +607,8 @@ function ThreadNavigationSidebarPane( savedConnectionsById, listEnvironments, threadSearchMatchByKey, + // Rows read it for their reorder menu items. + workingShelfEnabled, }), [ props.selectedThreadKey, @@ -595,6 +617,7 @@ function ThreadNavigationSidebarPane( savedConnectionsById, listEnvironments, threadSearchMatchByKey, + workingShelfEnabled, ], ); useThreadJumpShortcuts(listItems, handleSelectThread); @@ -712,7 +735,7 @@ function ThreadNavigationSidebarPane( reorderSupported={ item.item.pinned ? pinReorderEnvironmentIds.has(thread.environmentId) - : activeReorderEnvironmentIds.has(thread.environmentId) + : !workingShelfEnabled && activeReorderEnvironmentIds.has(thread.environmentId) } canMoveUp={item.canMoveUp} canMoveDown={item.canMoveDown} @@ -729,6 +752,16 @@ function ThreadNavigationSidebarPane( /> ); } + case "v2-working-shelf": + return ( + + ); case "v2-snoozed-shelf": return ( ; readonly selectedModel: ModelSelection | null; + readonly reportedModelSelection?: ModelSelection | null; readonly onSelectModel: (option: ModelOption) => void; readonly optionDescriptors: ReadonlyArray; readonly onUpdateOptionSelections: (selections: ReadonlyArray) => void; @@ -304,6 +305,8 @@ type ThreadSettingsSessionValue = { readonly runtimeModeChoices: ReturnType; readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void; readonly displayedDescriptors: ReadonlyArray; + readonly displayedModelSelection: ModelSelection | null; + readonly reportedModelSelection: ModelSelection | null; readonly providerExpansionOverrides: ReadonlySet; readonly hasLegacyModels: boolean; readonly pendingModel: ModelOption | null; @@ -476,6 +479,8 @@ function ThreadSettingsSessionProvider( runtimeModeChoices, onUpdateRuntimeMode: props.onUpdateRuntimeMode, displayedDescriptors, + displayedModelSelection: pendingModel?.selection ?? props.selectedModel, + reportedModelSelection: pendingModel ? null : (props.reportedModelSelection ?? null), favoriteKeys, favoritesLoaded, providerExpansionOverrides, @@ -507,6 +512,8 @@ function ThreadSettingsSessionProvider( isApplied, isDisplayed, props.environmentId, + props.selectedModel, + props.reportedModelSelection, props.providerInstanceId, pendingModel, pressModel, @@ -743,7 +750,11 @@ function ThreadSettingsOptionsItem(props: { > props.onOpenSubmenu({ kind: "descriptor", id: descriptor.id })} /> @@ -997,7 +1008,13 @@ function ThreadSettingsChoiceContent(props: { id: choice.id, label: choice.label, description: undefined, - selected: choice.id === getProviderOptionCurrentValue(activeDescriptor), + selected: + choice.id === + getProviderOptionCurrentValue( + activeDescriptor, + session.displayedModelSelection, + session.reportedModelSelection, + ), onPress: () => { void Haptics.selectionAsync(); session.applyOptionChange(activeDescriptor.id, choice.id); diff --git a/apps/mobile/src/features/threads/floating-working-control.tsx b/apps/mobile/src/features/threads/floating-working-control.tsx index 3538949c9d2d..95ea8ca5534a 100644 --- a/apps/mobile/src/features/threads/floating-working-control.tsx +++ b/apps/mobile/src/features/threads/floating-working-control.tsx @@ -385,16 +385,18 @@ function FloatingStatusLabel(props: { ); } - if (props.status.kind === "waiting") { + if (props.status.kind === "background") { return ( + {/* A dev server can run for hours after the agent is done, so only work + that will wake the agent gets the bolt. */} { const params = { environmentId, threadId }; @@ -339,7 +339,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { { void tryOpenExternalUrl(link.url, "pull-request").then((opened) => { if (!opened) diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index e37f2da7c131..d8b0e8791b73 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -191,10 +191,12 @@ type ThreadListV2ShelfHeaderProps = { readonly pane?: "screen" | "sidebar"; }; +const SHELF_LABEL = { working: "Working", snoozed: "Snoozed", settled: "Settled" } as const; + function ThreadListV2ShelfHeader( - props: ThreadListV2ShelfHeaderProps & { readonly kind: "snoozed" | "settled" }, + props: ThreadListV2ShelfHeaderProps & { readonly kind: keyof typeof SHELF_LABEL }, ) { - const label = props.kind === "snoozed" ? "Snoozed" : "Settled"; + const label = SHELF_LABEL[props.kind]; return ( ; +}); + export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedShelfHeader( props: ThreadListV2ShelfHeaderProps, ) { diff --git a/apps/mobile/src/features/threads/thread-subagent-group.tsx b/apps/mobile/src/features/threads/thread-subagent-group.tsx index 0c51c6195b75..c0f1adf4736e 100644 --- a/apps/mobile/src/features/threads/thread-subagent-group.tsx +++ b/apps/mobile/src/features/threads/thread-subagent-group.tsx @@ -2,10 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; import { useIsFocused, useNavigation } from "@react-navigation/native"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { summarizeSubagentStatuses } from "@t3tools/client-runtime/state/subagent-display"; -import { - isActiveSubagentStatus, - isTerminalSubagentStatus, -} from "@t3tools/client-runtime/state/subagentRuntime"; +import { isActiveSubagentStatus } from "@t3tools/client-runtime/state/subagentRuntime"; import type { EnvironmentId, OrchestrationV2Subagent, @@ -21,9 +18,8 @@ import { cn } from "../../lib/cn"; import type { ThreadFeedActivity } from "../../lib/threadActivity"; import { serverEnvironment } from "../../state/server"; import { environmentThreadDetails } from "../../state/threads"; -import { SubagentStatusDot } from "./SubagentStatusDot"; -import { subagentCardDetail, subagentCardElapsed } from "./subagent-card-presentation"; -import { resolveSubagentRowPresentation } from "./threadAgentsPresentation"; +import { subagentCardElapsed } from "./subagent-card-presentation"; +import { SubagentRow } from "./SubagentRow"; import { WorkLogBlock } from "./work-log-layout"; type SubagentItem = Extract; @@ -47,27 +43,20 @@ function SubagentElapsed({ agents }: { readonly agents: ReadonlyArray{elapsed} + {elapsed} ) : null; } function SubagentAvatar(props: { readonly item: SubagentItem; readonly iconUrl?: string | null | undefined; - readonly status?: OrchestrationV2Subagent["status"]; }) { return ( - {props.status ? ( - - ) : null} ); } @@ -99,6 +88,7 @@ export function ThreadSubagentGroup(props: { completedAt: live?.completedAt ?? item.completedAt, result: live?.result ?? item.result, progress: live?.progress ?? item.progress, + model: live?.model ?? null, }; }); const grouped = agents.length > 1; @@ -157,19 +147,12 @@ export function ThreadSubagentGroup(props: { {!grouped || expanded ? ( {agents.map((agent) => { - const presentation = resolveSubagentRowPresentation(agent); - const detail = subagentCardDetail( - isTerminalSubagentStatus(agent.status) - ? agent.result?.trim() || agent.progress || null - : agent.progress?.trim() || agent.result, - ); const threadId = agent.childThreadId; return ( - } /> - - - - {presentation.title} - - {detail && agent.status !== "completed" ? ( - - {presentation.statusLabel} - - ) : null} - - - {detail ?? presentation.statusLabel} - - - - {threadId !== null ? ( - - ) : null} ); })} diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index 826b9205ac75..b9ca6d4eaa56 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -1009,7 +1009,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( entering={WORK_LOG_DETAIL_ENTER_TRANSITION} exiting={WORK_LOG_DETAIL_EXIT_TRANSITION} layout={WORK_LOG_LAYOUT_TRANSITION} - className={reasoning ? "ml-7 py-1" : "ml-7 border-l border-border pb-1 pl-3 pt-0.5"} + className={reasoning ? "ml-7 py-1" : "pb-1 pt-0.5"} > {row.workEntry.questionAnswer ? ( { expect(row.detail).toBeNull(); expect(row.statusLabel).toBe("Working"); }); + + it("bounds long result previews without dropping the agent's status", () => { + const row = resolveSubagentRowPresentation({ + ...base, + status: "failed", + result: "x".repeat(400), + }); + expect(row.detail).toBe(`${"x".repeat(280)}…`); + expect(row.statusLabel).toBe("Failed"); + }); }); diff --git a/apps/mobile/src/features/threads/threadAgentsPresentation.ts b/apps/mobile/src/features/threads/threadAgentsPresentation.ts index d1b85b896470..9605891143b4 100644 --- a/apps/mobile/src/features/threads/threadAgentsPresentation.ts +++ b/apps/mobile/src/features/threads/threadAgentsPresentation.ts @@ -1,8 +1,8 @@ -import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display"; import { - isActiveSubagentStatus, - isTerminalSubagentStatus, -} from "@t3tools/client-runtime/state/subagentRuntime"; + formatSubagentDisplayTitle, + subagentDetailPreview, +} from "@t3tools/client-runtime/state/subagent-display"; +import { isActiveSubagentStatus } from "@t3tools/client-runtime/state/subagentRuntime"; import type { OrchestrationV2Subagent } from "@t3tools/contracts"; const PROMPT_TITLE_LIMIT = 80; @@ -64,13 +64,9 @@ export function resolveSubagentRowPresentation( >, ): SubagentRowPresentation { const live = isActiveSubagentStatus(subagent.status); - const progress = subagent.progress?.trim() ?? ""; - const result = subagent.result?.trim() ?? ""; - const settled = isTerminalSubagentStatus(subagent.status); - const detail = settled ? result || progress : progress || result; return { title: rowTitle(subagent), - detail: detail.length > 0 ? detail.replace(/\s+/gu, " ") : null, + detail: subagentDetailPreview(subagent), statusLabel: rowStatusLabel(subagent.status), tone: rowTone(subagent.status), live, diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 166baa9d2e46..7459b17672f9 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -1,3 +1,5 @@ +import { presentThreadShell } from "@t3tools/client-runtime/state/models"; +import * as DateTime from "effect/DateTime"; import { planPinnedMove } from "@t3tools/client-runtime/state/thread-sort"; import { createPendingThreadOrder, @@ -23,7 +25,7 @@ import { import { describe, expect, it, vi } from "vite-plus/test"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; -import { makeThreadShellFixture } from "../../test-fixtures"; +import { makeRawThreadShell, makeThreadShellFixture } from "../../test-fixtures"; import { threadJumpTarget } from "../keyboard/threadKeyboardShortcuts"; import { buildThreadListV2Items, @@ -36,6 +38,7 @@ import { resolveThreadListV2SwipeActions, sortThreadsForListV2, threadListV2ListItemsAreEqual, + threadHasUnseenCompletion, type ThreadListV2ListItem, } from "./threadListV2"; @@ -160,9 +163,7 @@ describe("resolveThreadListV2Status", () => { makeThread({ id: ThreadId.make("t"), title: "t", - pendingBackgroundTasks: [ - { taskId: "bg-1", description: "Run Codex review", kind: "command" }, - ], + pendingBackgroundTasks: [{ taskId: "bg-1", description: "Watch build", kind: "monitor" }], runtime: { status: "idle", activeRunId: null, @@ -176,6 +177,28 @@ describe("resolveThreadListV2Status", () => { ).toBe("waiting"); }); + it.each([ + { kind: "command", status: "ready" }, + { kind: "monitor", status: "waiting" }, + ] as const)( + "presents an unseen completion with a $kind roster as $status", + ({ kind, status }) => { + const thread = presentThreadShell( + environmentId, + makeRawThreadShell({ + latestRunId: RunId.make("run-background-completion"), + status: "completed", + latestRunCompletedAt: DateTime.makeUnsafe(NOW), + lastVisitedAt: DateTime.makeUnsafe("2026-06-01T23:59:00.000Z"), + pendingBackgroundTasks: [{ taskId: "background-work", kind }], + }), + ); + + expect(resolveThreadListV2Status(thread)).toBe(status); + expect(threadHasUnseenCompletion(thread)).toBe(true); + }, + ); + it("resolves ready for quiescent threads", () => { expect(resolveThreadListV2Status(makeThread({ id: ThreadId.make("t"), title: "t" }))).toBe( "ready", @@ -2135,3 +2158,147 @@ describe("buildThreadListV2ListItems row-state stamps", () => { expect(threadListV2ListItemsAreEqual(shelfLoading, shelfLoaded)).toBe(false); }); }); + +describe("Working section beta", () => { + const running = { + status: "running" as const, + activeRunId: null, + providerInstanceId: ProviderInstanceId.make("codex"), + providerName: "Codex", + lastError: null, + updatedAt: NOW, + }; + const finishedAt = (id: string, completedAt: string) => + makeThread({ + id: ThreadId.make(id), + title: id, + createdAt: "2026-06-01T00:00:00.000Z", + latestRun: { + runId: RunId.make(`run-${id}`), + status: "completed", + requestedAt: "2026-06-01T00:00:00.000Z", + startedAt: "2026-06-01T00:00:00.000Z", + completedAt, + assistantMessageId: null, + }, + }); + const threads = [ + finishedAt("finished-early", "2026-06-01T01:00:00.000Z"), + finishedAt("finished-late", "2026-06-01T03:00:00.000Z"), + makeThread({ id: ThreadId.make("working"), title: "working", runtime: running }), + makeThread({ + id: ThreadId.make("asks-approval"), + title: "asks-approval", + createdAt: "2026-06-01T02:00:00.000Z", + runtime: running, + hasPendingApprovals: true, + }), + makeThread({ + id: ThreadId.make("pinned-working"), + title: "pinned-working", + runtime: running, + pinnedAt: "2026-06-01T00:00:00.000Z", + }), + ]; + const build = (input: Partial[0]> = {}) => + buildThreadListV2Items({ + threads, + environmentId: null, + searchQuery: "", + now: NOW, + workingShelfEnabled: true, + ...input, + }); + const ids = (layout: ReturnType) => + layout.items.map((item) => item.thread.id); + + it("folds unpinned working threads into a collapsed shelf", () => { + const layout = build(); + expect(ids(layout)).toEqual([ + "pinned-working", + "finished-late", + "asks-approval", + "finished-early", + ]); + expect(layout.workingCount).toBe(1); + expect(layout.workingShelfHeaderIndex).toBe(4); + + const off = build({ workingShelfEnabled: false }); + expect(ids(off)).toContain("working"); + expect(off.workingCount).toBe(0); + }); + + it("shows working rows as cards when expanded, or only the selected one when collapsed", () => { + expect(build({ workingShelfExpanded: true }).items.at(-1)).toMatchObject({ + thread: { id: "working" }, + variant: "card", + }); + expect(ids(build({ selectedThreadKey: `${environmentId}:working` })).at(-1)).toBe("working"); + }); + + it("orders the inbox by the latest return this device observed", () => { + const layout = build({ + inboxReturnAt: (thread) => + thread.id === "finished-early" ? Date.parse("2026-06-01T04:00:00.000Z") : undefined, + }); + expect(ids(layout).slice(1)).toEqual(["finished-early", "finished-late", "asks-approval"]); + }); + + it("places the shelf after queued tasks and before snoozed and settled threads", () => { + const layout = buildThreadListV2Items({ + threads: [ + makeThread({ id: ThreadId.make("active"), title: "active" }), + makeThread({ id: ThreadId.make("working"), title: "working", runtime: running }), + makeThread({ + id: ThreadId.make("snoozed"), + title: "snoozed", + runtime: running, + snoozedUntil: "2026-06-03T09:00:00.000Z", + snoozedAt: "2026-06-01T12:00:00.000Z", + }), + makeThread({ + id: ThreadId.make("settled"), + title: "settled", + settledOverride: "settled", + settledAt: NOW, + }), + ], + environmentId: null, + searchQuery: "", + now: NOW, + workingShelfEnabled: true, + workingShelfExpanded: true, + snoozedShelfExpanded: true, + }); + const items = buildThreadListV2ListItems({ + items: layout.items, + pendingTasks: [makePendingTask("queued")], + workingCount: layout.workingCount, + workingShelfExpanded: true, + workingShelfHeaderIndex: layout.workingShelfHeaderIndex, + snoozedCount: layout.snoozedCount, + snoozedShelfExpanded: true, + snoozedShelfHeaderIndex: layout.snoozedShelfHeaderIndex, + settledCount: layout.settledCount, + settledShelfHeaderIndex: layout.settledShelfHeaderIndex, + }); + expect( + items.map((item) => + item.type === "v2-thread" + ? item.item.thread.id + : item.type === "v2-pending" + ? item.pendingTask.title + : item.type, + ), + ).toEqual([ + "active", + "queued", + "v2-working-shelf", + "working", + "v2-snoozed-shelf", + "snoozed", + "v2-settled-shelf", + "settled", + ]); + }); +}); diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index f0b2459301ae..f06f649793b1 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -11,6 +11,11 @@ import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled" import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { resolveThreadProviderStack } from "@t3tools/client-runtime/state/models"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; +import { + createInboxReturnTracker, + isThreadWorking, + sortInboxThreadsByReturn, +} from "@t3tools/client-runtime/state/thread-inbox"; import { sortActiveThreadsByOrderKey, resolveSettledThreadTimestamp, @@ -33,6 +38,11 @@ import { export { snoozeWakeLabel }; +/** Working section beta: when this device saw each thread leave the Working + shelf. One instance for Home and the iPad sidebar, so both lists order the + inbox the same way and the order survives screens that unmount. */ +export const threadListInboxReturns = createInboxReturnTracker(); + /** * Provider drivers for a row's trailing icon stack, back to front. Instances * missing from the environment's config are skipped, and an unresolved @@ -62,7 +72,8 @@ export function resolveThreadListV2ProviderDrivers( * failures. Ready is the unlabeled resting state; waiting (runtime status "idle") is the agent * parked on open background tasks, grey like working rather than a false Done. * The orchestrator v2 presentation bridge parks runtime at idle when the - * post-settlement background roster is nonempty. + * post-settlement background roster holds the run's completion (subagents, + * monitors); commands left running, such as a dev server, read as ready. */ export type ThreadListV2Status = | "approval" @@ -268,6 +279,10 @@ export interface ThreadListV2Layout { readonly items: ThreadListV2Item[]; /** Settled threads beyond the render limit (behind "Show more"). */ readonly hiddenSettledCount: number; + /** Working threads folded away by the Working section beta. */ + readonly workingCount: number; + /** Index in `items` where the Working shelf header belongs. */ + readonly workingShelfHeaderIndex: number | null; /** Snoozed threads matching the current filters. */ readonly snoozedCount: number; /** Index in `items` where the Snoozed shelf header belongs. The header is @@ -324,6 +339,15 @@ export interface ThreadListV2PendingListItem { readonly showTrailingDivider: boolean; } +export interface ThreadListV2WorkingShelfListItem { + readonly type: "v2-working-shelf"; + readonly key: "v2-working-shelf"; + readonly count: number; + readonly expanded: boolean; + /** See the snoozed shelf header's field. */ + readonly disabled: boolean; +} + export interface ThreadListV2SnoozedShelfListItem { readonly type: "v2-snoozed-shelf"; readonly key: "v2-snoozed-shelf"; @@ -348,6 +372,7 @@ export interface ThreadListV2SettledShelfListItem { export type ThreadListV2ListItem = | ThreadListV2ThreadListItem | ThreadListV2PendingListItem + | ThreadListV2WorkingShelfListItem | ThreadListV2SnoozedShelfListItem | ThreadListV2SettledShelfListItem; @@ -359,6 +384,7 @@ export function isThreadListV2ListItem(value: { return ( value.type === "v2-thread" || value.type === "v2-pending" || + value.type === "v2-working-shelf" || value.type === "v2-snoozed-shelf" || value.type === "v2-settled-shelf" ); @@ -400,6 +426,13 @@ export function threadListV2ListItemsAreEqual( previous.showPendingDivider === item.showPendingDivider && previous.showTrailingDivider === item.showTrailingDivider ); + case "v2-working-shelf": + return ( + previous.type === "v2-working-shelf" && + previous.count === item.count && + previous.expanded === item.expanded && + previous.disabled === item.disabled + ); case "v2-snoozed-shelf": return ( previous.type === "v2-snoozed-shelf" && @@ -440,13 +473,17 @@ function resolveThreadListV2ItemTimeLabel( } /** - * Builds the shared mobile order: active → pending → snoozed shelf → settled. - * Pending tasks are waiting rather than asking, and parked work remains - * reachable without competing with either the inbox or settled history. + * Builds the shared mobile order: active → pending → working shelf (beta) → + * snoozed shelf → settled. Pending tasks are waiting rather than asking, and + * busy or parked work remains reachable without competing with either the + * inbox or settled history. */ export function buildThreadListV2ListItems(input: { readonly items: ReadonlyArray; readonly pendingTasks: ReadonlyArray; + readonly workingCount?: number; + readonly workingShelfExpanded?: boolean; + readonly workingShelfHeaderIndex?: number | null; readonly snoozedCount?: number; readonly snoozedShelfExpanded?: boolean; readonly snoozedShelfHeaderIndex?: number | null; @@ -513,14 +550,27 @@ export function buildThreadListV2ListItems(input: { showPendingDivider: index === 0, showTrailingDivider: false, })); + const workingCount = input.workingCount ?? 0; + const workingShelfHeaderIndex = input.workingShelfHeaderIndex ?? null; const snoozedCount = input.snoozedCount ?? 0; const snoozedShelfHeaderIndex = input.snoozedShelfHeaderIndex ?? null; const settledCount = input.settledCount ?? 0; const settledShelfHeaderIndex = input.settledShelfHeaderIndex ?? null; - const activeEnd = snoozedShelfHeaderIndex ?? settledShelfHeaderIndex ?? threadItems.length; const snoozedEnd = settledShelfHeaderIndex ?? threadItems.length; + const workingEnd = snoozedShelfHeaderIndex ?? snoozedEnd; + const activeEnd = workingShelfHeaderIndex ?? workingEnd; const result: ThreadListV2ListItem[] = [...threadItems.slice(0, activeEnd), ...pendingItems]; const shelfDisabled = input.shelfPreferencesLoading === true; + if (workingShelfHeaderIndex !== null && workingCount > 0) { + result.push({ + type: "v2-working-shelf", + key: "v2-working-shelf", + count: workingCount, + expanded: input.workingShelfExpanded === true, + disabled: shelfDisabled, + }); + result.push(...threadItems.slice(workingShelfHeaderIndex, workingEnd)); + } if (snoozedShelfHeaderIndex !== null && snoozedCount > 0) { result.push({ type: "v2-snoozed-shelf", @@ -579,6 +629,15 @@ export function buildThreadListV2Items(input: { readonly settledLimit?: number; /** Second-precise clock used for time-based classification. */ readonly now: string; + /** Working section beta: unpinned working threads fold into the Working + shelf, and the inbox orders by when each thread came back to the user + instead of the saved arrangement. */ + readonly workingShelfEnabled?: boolean; + /** Expands the Working shelf into rows. Collapsed is the default. */ + readonly workingShelfExpanded?: boolean; + /** Returns this device observed but the server does not stamp, such as an + approval request mid-turn. Only read while the beta is on. */ + readonly inboxReturnAt?: (thread: EnvironmentThreadShell) => number | undefined; /** Expands the snoozed shelf into rows. Collapsed is the default. */ readonly snoozedShelfExpanded?: boolean; /** Expands the settled shelf into rows. Expanded is the default. */ @@ -608,8 +667,10 @@ export function buildThreadListV2Items(input: { ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`)) : null; + const workingShelfEnabled = input.workingShelfEnabled === true; const pinned: EnvironmentThreadShell[] = []; const active: EnvironmentThreadShell[] = []; + const working: EnvironmentThreadShell[] = []; const settled: EnvironmentThreadShell[] = []; const snoozed: EnvironmentThreadShell[] = []; let nextSnoozeWakeAt: string | null = null; @@ -655,17 +716,31 @@ export function buildThreadListV2Items(input: { settled.push(thread); } else if (thread.pinnedAt != null) { pinned.push(thread); + } else if (workingShelfEnabled && isThreadWorking(thread)) { + working.push(thread); } else { active.push(thread); } } - const orderedActive = applyPendingThreadOrder(sortThreadsForListV2(active), "active", pending); + // The beta inbox is time-ordered, so the saved arrangement (and any move in + // flight) is kept but not applied until the beta is off again. + const orderedActive = workingShelfEnabled + ? sortInboxThreadsByReturn(active, input.inboxReturnAt) + : applyPendingThreadOrder(sortThreadsForListV2(active), "active", pending); + // Newest work first, by the same clock as the inbox. + const orderedWorking = sortInboxThreadsByReturn(working); const orderedSnoozed = [...snoozed].sort( (left, right) => parseTimestampMs(left.snoozedUntil ?? "") - parseTimestampMs(right.snoozedUntil ?? ""), ); const selectedThreadKey = input.selectedThreadKey ?? null; + const visibleWorking = + input.workingShelfExpanded === true + ? orderedWorking + : orderedWorking.filter( + (thread) => `${thread.environmentId}:${thread.id}` === selectedThreadKey, + ); const visibleSnoozed = input.snoozedShelfExpanded === true ? orderedSnoozed @@ -710,6 +785,16 @@ export function buildThreadListV2Items(input: { isLast: false, }); } + const workingShelfHeaderIndex = orderedWorking.length > 0 ? items.length : null; + for (const thread of visibleWorking) { + items.push({ + thread, + variant: "card", + snoozed: false, + pinned: false, + isLast: false, + }); + } const snoozedShelfHeaderIndex = orderedSnoozed.length > 0 ? items.length : null; for (const thread of visibleSnoozed) { items.push({ @@ -737,6 +822,8 @@ export function buildThreadListV2Items(input: { return { items, hiddenSettledCount: orderedSettled.length - pagedSettled.length, + workingCount: orderedWorking.length, + workingShelfHeaderIndex, snoozedCount: orderedSnoozed.length, snoozedShelfHeaderIndex, settledCount: orderedSettled.length, diff --git a/apps/mobile/src/features/threads/use-thread-list-v2-shelf-preferences.ts b/apps/mobile/src/features/threads/use-thread-list-v2-shelf-preferences.ts index 8ffab4e045b4..b32d9ad40043 100644 --- a/apps/mobile/src/features/threads/use-thread-list-v2-shelf-preferences.ts +++ b/apps/mobile/src/features/threads/use-thread-list-v2-shelf-preferences.ts @@ -17,10 +17,16 @@ export function useThreadListV2ShelfPreferences() { loaded && preferencesResult.value.threadListSnoozedShelfExpanded === true; const settledShelfExpanded = loaded && preferencesResult.value.threadListSettledShelfExpanded === true; + // Working section beta: off until the preference loads and is enabled. + const workingShelfEnabled = loaded && preferencesResult.value.workingShelfEnabled === true; + const workingShelfExpanded = + loaded && preferencesResult.value.threadListWorkingShelfExpanded === true; const snoozedShelfExpandedRef = useRef(snoozedShelfExpanded); const settledShelfExpandedRef = useRef(settledShelfExpanded); + const workingShelfExpandedRef = useRef(workingShelfExpanded); snoozedShelfExpandedRef.current = snoozedShelfExpanded; settledShelfExpandedRef.current = settledShelfExpanded; + workingShelfExpandedRef.current = workingShelfExpanded; const toggleSnoozedShelf = useCallback(() => { if (!loaded) return; @@ -34,12 +40,21 @@ export function useThreadListV2ShelfPreferences() { settledShelfExpandedRef.current = expanded; savePreferences({ threadListSettledShelfExpanded: expanded }); }, [loaded, savePreferences]); + const toggleWorkingShelf = useCallback(() => { + if (!loaded) return; + const expanded = !workingShelfExpandedRef.current; + workingShelfExpandedRef.current = expanded; + savePreferences({ threadListWorkingShelfExpanded: expanded }); + }, [loaded, savePreferences]); return { loaded, settledShelfExpanded, snoozedShelfExpanded, + workingShelfEnabled, + workingShelfExpanded, toggleSettledShelf, toggleSnoozedShelf, + toggleWorkingShelf, } as const; } diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 5bb2713d6c8d..5a8a4b1a15d4 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -42,7 +42,7 @@ import { UsageLimitsSection } from "./UsageLimitsPooled"; import { ControlPillMenu } from "../../components/ControlPill"; import { SymbolView } from "../../components/AppSymbol"; import type { UsageChartMetric } from "./usageChartData"; -import { PROVIDER_LABEL, useProviderColors } from "./usageProviders"; +import { PROVIDER_LABEL, useProviderColors, useUsageMixColors } from "./usageProviders"; type UsageTab = "usage" | "limits"; const TAB_OPTIONS = [ @@ -360,7 +360,8 @@ export function UsageRouteScreen() { onCursorEnabled={refreshAfterCursorEnable} /> - + + )} @@ -688,6 +689,84 @@ function TotalsSection(props: { readonly merged: MergedUsage; readonly isPast24H ); } +function CostSection(props: { readonly merged: MergedUsage }) { + const { categoryCost, speedCost } = props.merged; + const colors = useUsageMixColors(); + const byType = [ + { label: "Input", value: categoryCost.input, color: colors.input }, + { label: "Cache read", value: categoryCost.cacheRead, color: colors.cacheRead }, + { label: "Cache write", value: categoryCost.cacheWrite, color: colors.cacheWrite }, + { label: "Output", value: categoryCost.output, color: colors.output }, + // Reported cost with no rates to split it, or from older servers. Below a + // cent it is rounding, not usage. + { + label: "Other", + value: categoryCost.unsplit >= 0.005 ? categoryCost.unsplit : 0, + color: colors.other, + }, + ]; + const bySpeed = [ + { label: "Standard", value: speedCost.standard, color: colors.standard }, + { label: "Fast", value: speedCost.fast, color: colors.fast }, + { label: "Ultrafast", value: speedCost.ultrafast, color: colors.ultrafast }, + ]; + if (props.merged.costUsd <= 0) return null; + + return ( + + + {speedCost.fast + speedCost.ultrafast > 0 ? ( + + + + ) : null} + + ); +} + +/** One part-to-whole cost bar with its legend. Empty segments are left out. */ +function ShareBar(props: { + readonly label: string; + readonly segments: readonly { label: string; value: number; color: string }[]; + readonly aside?: string; +}) { + const visible = props.segments.filter((segment) => segment.value > 0); + if (visible.length === 0) return null; + + return ( + + + {props.label} + {props.aside ? ( + {props.aside} + ) : null} + + + {visible.map((segment) => ( + + ))} + + + {visible.map((segment) => ( + + + {segment.label} + {formatUsd(segment.value)} + + ))} + + + ); +} + function MetricCell(props: { readonly label: string; readonly value: string; @@ -702,14 +781,22 @@ function MetricCell(props: { ); } -function ModelsSection(props: { readonly merged: MergedUsage }) { - const { merged } = props; +function ModelsSection(props: { readonly merged: MergedUsage; readonly metric: UsageChartMetric }) { + const { merged, metric } = props; const colors = useProviderColors(); if (merged.models.length === 0) return null; + // Ranked like the provider rows. .sort() on a copy, not .toSorted(): Hermes + // doesn't ship the ES2023 method. + const ordered = [...merged.models].sort((a, b) => + metric === "cost" + ? b.costUsd - a.costUsd || b.totalTokens - a.totalTokens + : b.totalTokens - a.totalTokens || b.costUsd - a.costUsd, + ); + return ( - {merged.models.map((model, index) => ( + {ordered.map((model, index) => ( - {isModelCostUnknown(model) - ? `no known rates · ${formatTokens(model.totalTokens)} tokens` - : `${formatPercent(model.costShare)} of cost · ${formatTokens(model.totalTokens)} tokens`} + {metric === "tokens" + ? `${formatPercent(model.tokenShare)} of tokens · ${ + isModelCostUnknown(model) ? "no known rates" : formatUsd(model.costUsd) + }` + : isModelCostUnknown(model) + ? `no known rates · ${formatTokens(model.totalTokens)} tokens` + : `${formatPercent(model.costShare)} of cost · ${formatTokens(model.totalTokens)} tokens`} - {isModelCostUnknown(model) ? "Unpriced" : formatUsd(model.costUsd)} + {metric === "tokens" + ? formatTokens(model.totalTokens) + : isModelCostUnknown(model) + ? "Unpriced" + : formatUsd(model.costUsd)} ))} diff --git a/apps/mobile/src/features/usage/usageProviders.ts b/apps/mobile/src/features/usage/usageProviders.ts index 45a94ddae1f7..5742cbca1043 100644 --- a/apps/mobile/src/features/usage/usageProviders.ts +++ b/apps/mobile/src/features/usage/usageProviders.ts @@ -38,3 +38,23 @@ export function useProviderColors(): Record { antigravity: "#8c7bd1", }; } + +/** + * Neutral steps for cost and token mixes, so they never borrow a provider's + * color. Matches the web steps: oklab mixes of the codex ink into the + * background, above the 15 ΔE separation floor for adjacent segments. + */ +export function useUsageMixColors() { + const { themeAppearance: scheme } = useAppearancePreferences(); + const dark = scheme === "dark"; + return { + input: dark ? "#737373" : "#848484", + cacheRead: dark ? "#282828" : "#c0c0c0", + cacheWrite: dark ? "#949494" : "#6d6d6d", + output: dark ? "#e6e6e6" : "#3c3c43", + other: dark ? "#494949" : "#a3a3a3", + standard: dark ? "#313131" : "#b8b8b8", + fast: dark ? "#838383" : "#797979", + ultrafast: dark ? "#e6e6e6" : "#3c3c43", + }; +} diff --git a/apps/mobile/src/lib/storage.test.ts b/apps/mobile/src/lib/storage.test.ts index 78a165c5ff1d..f56c3a7eff0e 100644 --- a/apps/mobile/src/lib/storage.test.ts +++ b/apps/mobile/src/lib/storage.test.ts @@ -232,24 +232,32 @@ describe("mobile connection storage", () => { expect(fallback.updatedAt).toEqual(expect.any(Number)); }); - it("persists thread list shelf expansion preferences", async () => { + it("persists thread list shelf preferences", async () => { await expect( savePreferencesPatch({ threadListSettledShelfExpanded: false, threadListSnoozedShelfExpanded: true, + threadListWorkingShelfExpanded: true, + workingShelfEnabled: true, }), ).resolves.toEqual({ threadListSettledShelfExpanded: false, threadListSnoozedShelfExpanded: true, + threadListWorkingShelfExpanded: true, + workingShelfEnabled: true, }); await expect(loadPreferences()).resolves.toEqual({ threadListSettledShelfExpanded: false, threadListSnoozedShelfExpanded: true, + threadListWorkingShelfExpanded: true, + workingShelfEnabled: true, }); expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({ threadListSettledShelfExpanded: false, threadListSnoozedShelfExpanded: true, + threadListWorkingShelfExpanded: true, + workingShelfEnabled: true, }); }); diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 4f0c140ac229..16fd407f537b 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -1085,6 +1085,62 @@ describe("buildThreadFeed", () => { ]); }); + it("keeps imported V1 turns folded once the thread's first V2 run starts", () => { + const imported = (item: T, id: string) => ({ + ...item, + id: TurnItemId.make(id), + runId: null, + }); + const presented = (start: OrchestrationV2TurnItem) => + deriveThreadFeedPresentation( + buildThreadFeed( + [ + imported(userMessage("2026-06-20T00:00:00.000Z"), "imported-prompt"), + imported( + { + ...assistantMessage("2026-06-20T00:00:02.000Z"), + messageId: MessageId.make("update"), + }, + "imported-update", + ), + imported(command("2026-06-20T00:00:04.000Z"), "imported-ls"), + imported( + { + ...assistantMessage("2026-06-20T00:00:08.000Z"), + messageId: MessageId.make("answer"), + }, + "imported-answer", + ), + start, + ].map((item, position) => projected(item, position)), + ), + { runId, status: "running", startedAt: "2026-06-20T00:01:00.000Z", completedAt: null }, + new Set(), + new Set(), + "2026-06-20T00:01:00.000Z", + ) + .slice(0, 4) + .map((entry) => (entry.type === "message" ? entry.message.role : entry.type)); + + // A sent prompt and an automatic wake both start V2 work below the import. + expect( + presented({ + ...userMessage("2026-06-20T00:01:00.000Z"), + id: TurnItemId.make("new-prompt"), + messageId: MessageId.make("new-prompt"), + }), + ).toEqual(["user", "assistant", "run-fold", "assistant"]); + expect( + presented({ + ...base("wake", "2026-06-20T00:01:00.000Z", 4), + type: "notification", + source: { kind: "background_task" }, + outcome: "completed", + summary: "Background task finished", + }), + ).toEqual(["user", "assistant", "run-fold", "assistant"]); + }); + it("keeps a provider-native subagent's runless tool call live while it works", () => { const startedAt = "2026-06-20T00:00:01.000Z"; const { exitCode: _exitCode, ...completedCommand } = command(); @@ -1116,9 +1172,10 @@ describe("buildThreadFeed", () => { expect(presented.some((entry) => entry.type === "thinking")).toBe(false); }); - it("keeps a runless tail settled while a normal thread waits for its sent run", () => { + it("keeps a runless tail folded while a normal thread waits for its sent run", () => { // Right after a send the local clock runs before the server creates the - // run, and the latest run may still be queued: neither is runless work. + // run, and the latest run may still be queued: neither is runless work, + // so the settled tail must not reopen and shift the feed. const startedAt = "2026-06-20T00:00:05.000Z"; const feed = buildThreadFeed([ projected({ ...userMessage(), runId: null }, 0), @@ -1135,9 +1192,7 @@ describe("buildThreadFeed", () => { new Set(), startedAt, ); - const toggle = presented.find((entry) => entry.type === "work-toggle"); - expect(toggle).toMatchObject({ live: false, shimmer: false }); - expect(presented.at(-1)?.type).toBe("thinking"); + expect(presented.map((entry) => entry.type)).toEqual(["message", "run-fold", "thinking"]); } }); diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index c453ebf2312d..3a77324a7ac8 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -48,7 +48,7 @@ import { RunId, ThreadId } from "@t3tools/contracts"; import { classifyToolActivity, collectToolFilePaths, - computerUseToolTitle, + dynamicToolTitle, formatReadToolLabel, formatSearchToolLabel, } from "@t3tools/shared/toolActivity"; @@ -540,7 +540,7 @@ function itemSummary( if (item.type === "system_notice") return item.message; if (item.type === "compaction") return contextCompactionLabel(item); const title = - (item.type === "dynamic_tool" ? computerUseToolTitle(item.toolName, item.input) : undefined) ?? + (item.type === "dynamic_tool" ? dynamicToolTitle(item.toolName, item.input) : undefined) ?? item.title?.trim(); if (item.type === "subagent") return formatSubagentDisplayTitle(title || "Subagent"); if (title) return toolPresentation?.displayName ?? capitalizePhrase(title); @@ -972,13 +972,14 @@ export function failedFeedRunIds( } /** - * A thread without runs (a provider-native subagent) folds each prompt's - * response like a run; `isWorking` keeps its latest response open. + * A prompt without a run (a provider-native subagent, or a turn imported from + * V1) folds its response like a run. `runlessWorkActive` keeps the latest + * runless response open; V2 work must not reopen imported turns. */ function deriveThreadFeedRunFolds( feed: ReadonlyArray, latestRun: ThreadFeedLatestRun | null, - isWorking: boolean, + runlessWorkActive: boolean, ): ReadonlyMap { const firstAssistantMessageIdByRun = new Map(); const terminalAssistantMessageIdByRun = new Map(); @@ -988,14 +989,15 @@ function deriveThreadFeedRunFolds( RunId, { entries: ThreadFeedEntry[]; startBoundary: string | null } >(); - // Fold state is keyed by run, so each prompt of a runless thread lends its - // response a stable key of its own. + // Fold state is keyed by run, so each runless prompt lends its response a + // stable key of its own. Decide per prompt, not per thread: a V1 thread's + // first V2 run must not unfold every imported turn above it. let runlessKey: RunId | null = null; let pendingUserBoundary: string | null = null; for (const entry of feed) { if (entry.type === "message" && entry.message.role === "user") { pendingUserBoundary = entry.message.createdAt; - runlessKey = latestRun === null ? RunId.make(`runless:${entry.id}`) : null; + runlessKey = entry.message.runId == null ? RunId.make(`runless:${entry.id}`) : null; continue; } const runId = @@ -1038,7 +1040,7 @@ function deriveThreadFeedRunFolds( for (const [runId, group] of groupsByRunId) { if ( runId === activeRunId || - (isWorking && runId === runlessKey) || + (runlessWorkActive && runId === runlessKey) || interruptedRunIds.has(runId) || failedRunIds.has(runId) || group.entries.some((entry) => entry.type === "message" && entry.message.streaming) @@ -1161,7 +1163,11 @@ export function deriveThreadFeedPresentation( const activeTailGroup = sourceFeed.at(-1); const activeRunId = unsettledRunId(latestRun); const isWorking = activeWorkStartedAt !== null && latestRun?.status !== "preparing"; - const foldsByAnchorId = deriveThreadFeedRunFolds(sourceFeed, latestRun, isWorking); + const foldsByAnchorId = deriveThreadFeedRunFolds( + sourceFeed, + latestRun, + isWorking && runlessWorkActive, + ); const collapsedEntryIds = new Set(); for (const fold of foldsByAnchorId.values()) { if (!expandedRunIds.has(fold.runId)) { diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 7868d53f9b93..ee96bce2d5ce 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -41,6 +41,8 @@ export interface Preferences { readonly projectGroupingMode?: SidebarProjectGroupingMode; /** Device-local counterpart of desktop's `planModeEnabled` legacy flag. */ readonly planModeEnabled?: boolean; + /** Device-local counterpart of web's `sidebarWorkingShelfEnabled` beta. */ + readonly workingShelfEnabled?: boolean; /** Model favorites belong to this device, like the web client setting. */ readonly modelFavorites?: ReadonlyArray<{ readonly provider: ProviderInstanceId; @@ -49,6 +51,7 @@ export interface Preferences { /** Fresh keys reset both shelves to collapsed when users update. */ readonly threadListSettledShelfExpanded?: boolean; readonly threadListSnoozedShelfExpanded?: boolean; + readonly threadListWorkingShelfExpanded?: boolean; } export class MobilePreferencesLoadError extends Schema.TaggedError()( @@ -107,9 +110,11 @@ function sanitizePreferences(parsed: Preferences): Preferences { projectGroupingEnabled?: boolean; projectGroupingMode?: SidebarProjectGroupingMode; planModeEnabled?: boolean; + workingShelfEnabled?: boolean; modelFavorites?: Preferences["modelFavorites"]; threadListSettledShelfExpanded?: boolean; threadListSnoozedShelfExpanded?: boolean; + threadListWorkingShelfExpanded?: boolean; } = {}; if (typeof parsed.liveActivitiesEnabled === "boolean") { @@ -180,6 +185,9 @@ function sanitizePreferences(parsed: Preferences): Preferences { if (typeof parsed.planModeEnabled === "boolean") { preferences.planModeEnabled = parsed.planModeEnabled; } + if (typeof parsed.workingShelfEnabled === "boolean") { + preferences.workingShelfEnabled = parsed.workingShelfEnabled; + } if (Array.isArray(parsed.modelFavorites)) { preferences.modelFavorites = parsed.modelFavorites.filter( (favorite) => @@ -197,6 +205,9 @@ function sanitizePreferences(parsed: Preferences): Preferences { if (typeof parsed.threadListSnoozedShelfExpanded === "boolean") { preferences.threadListSnoozedShelfExpanded = parsed.threadListSnoozedShelfExpanded; } + if (typeof parsed.threadListWorkingShelfExpanded === "boolean") { + preferences.threadListWorkingShelfExpanded = parsed.threadListWorkingShelfExpanded; + } return preferences; } diff --git a/apps/mobile/src/state/entities.ts b/apps/mobile/src/state/entities.ts index 649545b1f927..3bc9066a399e 100644 --- a/apps/mobile/src/state/entities.ts +++ b/apps/mobile/src/state/entities.ts @@ -1,8 +1,10 @@ import { useAtomValue } from "@effect/atom-react"; +import { deriveReportedModelSelection } from "@t3tools/client-runtime/state/thread-execution"; import { appAtomRegistry } from "./atom-registry"; import type { EnvironmentProject, + EnvironmentThread, EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; import type { @@ -15,7 +17,7 @@ import { Atom } from "effect/unstable/reactivity"; import { environmentProjects } from "./projects"; import { environmentServerConfigsAtom, serverEnvironment } from "./server"; -import { environmentThreadShells } from "./threads"; +import { environmentThreadDetails, environmentThreadShells } from "./threads"; const EMPTY_PROJECT_ATOM = Atom.make(null).pipe( Atom.withLabel("mobile-project:empty"), @@ -87,3 +89,10 @@ export function useEnvironmentServerConfig( export function useServerConfigs(): ReadonlyMap { return useAtomValue(environmentServerConfigsAtom); } + +const selectReportedModelSelection = (thread: EnvironmentThread | null) => + thread === null ? null : deriveReportedModelSelection(thread.projection); + +export function useThreadReportedModelSelection(ref: ScopedThreadRef) { + return useAtomValue(environmentThreadDetails.threadAtom(ref), selectReportedModelSelection); +} diff --git a/apps/server/scripts/migrate-dev-db.test.ts b/apps/server/scripts/migrate-dev-db.test.ts index a3343b0b2e48..694af7bbf305 100644 --- a/apps/server/scripts/migrate-dev-db.test.ts +++ b/apps/server/scripts/migrate-dev-db.test.ts @@ -14,24 +14,21 @@ const withDatabase = ( effect: Effect.Effect, ) => effect.pipe(Effect.provide(NodeSqliteClient.layer({ filename: databasePath }))); -/** A migrated source db with one thread per lifecycle state. Only - * `stopped-thread` qualifies for the clone. */ +/** A migrated source db with one V2 thread per lifecycle state. Only + * `stopped-thread` and its fork qualify for the clone. */ const createFixtureSource = Effect.fn("createMigrateDevDbFixtureSource")(function* ( baseDir: string, ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const stateDir = path.join(baseDir, "userdata"); - const databasePath = path.join(stateDir, "state.sqlite"); + const databasePath = path.join(stateDir, "statev2.sqlite"); yield* fs.makeDirectory(stateDir, { recursive: true }); yield* withDatabase( databasePath, Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; yield* runMigrations(); - // The real shared db carries this column from a branch build without a - // matching migration; reproduce that drift so the filter is exercised. - yield* sql`ALTER TABLE projection_threads ADD COLUMN monitor_json TEXT`; yield* sql`INSERT INTO projection_projects (project_id, title, workspace_root, scripts_json, created_at, updated_at, deleted_at) @@ -39,23 +36,56 @@ const createFixtureSource = Effect.fn("createMigrateDevDbFixtureSource")(functio ('project-kept', 'Kept', '/tmp/kept', '[]', '2026-08-01', '2026-08-01', NULL), ('project-deleted', 'Deleted', '/tmp/deleted', '[]', '2026-08-01', '2026-08-02', '2026-08-02')`; + const forkPayload = + '{"lineage":{"parentThreadId":"stopped-thread","relationshipToParent":"fork","rootThreadId":"stopped-thread"}}'; + const subagentPayload = + '{"lineage":{"parentThreadId":"subagent-parent","relationshipToParent":"subagent","rootThreadId":"subagent-parent"},"forkedFrom":{"type":"node","nodeId":"node-1"}}'; + // Excluded threads are newer than the kept family, so only the filters + // can keep them out of a one-family-per-project clone. const threads = [ - ["stopped-thread", "project-kept", "stopped", null, null], - ["running-thread", "project-kept", "running", null, null], - ["settled-thread", "project-kept", "stopped", "2026-08-01", null], - ["monitored-thread", "project-kept", "stopped", null, '{"kind":"pr"}'], - ["deleted-project-thread", "project-deleted", "stopped", null, null], + ["stopped-thread", "project-kept", "completed", "{}", "2026-08-01"], + ["fork-thread", "project-kept", "completed", forkPayload, "2026-08-02"], + ["running-thread", "project-kept", "running", "{}", "2026-08-05"], + ["settled-thread", "project-kept", "completed", '{"settledAt":"2026-08-01"}', "2026-08-05"], + [ + "limit-thread", + "project-kept", + "completed", + '{"limitRecovery":{"autoResume":true}}', + "2026-08-05", + ], + // Its result never reached the parent, so startup would deliver it. + ["subagent-parent", "project-kept", "completed", "{}", "2026-08-05"], + ["subagent-child", "project-kept", "completed", subagentPayload, "2026-08-05"], + ["deleted-project-thread", "project-deleted", "completed", "{}", "2026-08-05"], ] as const; - for (const [threadId, projectId, status, settledAt, monitorJson] of threads) { - yield* sql`INSERT INTO projection_threads - (thread_id, project_id, title, created_at, updated_at, settled_at, monitor_json) - VALUES (${threadId}, ${projectId}, ${threadId}, '2026-08-01', '2026-08-01', ${settledAt}, ${monitorJson})`; - yield* sql`INSERT INTO projection_thread_sessions (thread_id, status, updated_at) - VALUES (${threadId}, ${status}, '2026-08-01')`; + for (const [threadId, projectId, runStatus, payload, updatedAt] of threads) { + yield* sql`INSERT INTO orchestration_v2_projection_threads + (thread_id, project_id, title, default_provider, runtime_mode, interaction_mode, created_at, updated_at, payload_json) + VALUES (${threadId}, ${projectId}, ${threadId}, 'codex', 'full-access', 'default', '2026-08-01', ${updatedAt}, ${payload})`; + yield* sql`INSERT INTO orchestration_v2_projection_runs + (run_id, thread_id, ordinal, provider, status, requested_at, payload_json) + VALUES (${`run-${threadId}`}, ${threadId}, 1, 'codex', ${runStatus}, '2026-08-01', '{}')`; yield* sql`INSERT INTO orchestration_events (event_id, aggregate_kind, stream_id, stream_version, event_type, occurred_at, actor_kind, payload_json, metadata_json) VALUES (${`event-${threadId}`}, 'thread', ${threadId}, 0, 'thread.created', '2026-08-01', 'user', '{}', '{}')`; } + // A provider session shared by two threads names its latest writer. + yield* sql`INSERT INTO orchestration_v2_projection_provider_sessions + (provider_session_id, thread_id, provider, status, updated_at, payload_json) + VALUES ('session-shared', 'running-thread', 'codex', 'stopped', '2026-08-01', '{}')`; + yield* sql`INSERT INTO orchestration_v2_projection_provider_session_bindings + (provider_session_id, thread_id) + VALUES ('session-shared', 'running-thread'), ('session-shared', 'stopped-thread')`; + yield* sql`INSERT INTO orchestration_v2_projection_context_transfers + (context_transfer_id, source_thread_id, target_thread_id, type, status, updated_at, payload_json) + VALUES ('transfer-1', 'settled-thread', 'stopped-thread', 'provider_handoff', 'completed', '2026-08-01', '{}')`; + yield* sql`INSERT INTO scheduled_tasks + (task_id, title, prompt, enabled, schedule_json, project_id, workspace_strategy_json, + model_selection_json, runtime_mode, interaction_mode, created_by, creation_source, + created_at, updated_at, last_run_status, run_count) + VALUES ('task-1', 'Nightly', 'Run it', 1, '{}', 'project-kept', '{}', '{}', + 'full-access', 'default', 'user', 'user', '2026-08-01', '2026-08-01', 'never', 0)`; yield* sql`INSERT INTO auth_sessions (session_id, subject, scopes, method, issued_at, expires_at) VALUES ('session-1', 'user', '[]', 'pairing', '2026-08-01', '2027-08-01')`; }), @@ -64,7 +94,7 @@ const createFixtureSource = Effect.fn("createMigrateDevDbFixtureSource")(functio }); it.layer(NodeServices.layer)("migrate-dev-db", (it) => { - it.effect("keeps only stopped threads from live projects and clears auth state", () => + it.effect("keeps stopped thread families from live projects and clears pending work", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -73,7 +103,7 @@ it.layer(NodeServices.layer)("migrate-dev-db", (it) => { const source = yield* createFixtureSource(sourceDir); const result = yield* runMigrateDevDb( - { baseDir: destDir, source, projects: 5, threadsPerProject: 10 }, + { baseDir: destDir, source, projects: 5, threadsPerProject: 1 }, { sharedHome: sourceDir }, ); @@ -83,23 +113,32 @@ it.layer(NodeServices.layer)("migrate-dev-db", (it) => { Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; const threads = yield* sql<{ thread_id: string }>` - SELECT thread_id FROM projection_threads ORDER BY thread_id`; + SELECT thread_id FROM orchestration_v2_projection_threads ORDER BY thread_id`; const events = yield* sql<{ stream_id: string }>` - SELECT stream_id FROM orchestration_events`; - const [auth] = yield* sql<{ count: number }>` - SELECT COUNT(*) AS count FROM auth_sessions`; - return { threads, events, authCount: auth?.count ?? 0 }; + SELECT stream_id FROM orchestration_events ORDER BY stream_id`; + const sessions = yield* sql<{ provider_session_id: string }>` + SELECT provider_session_id FROM orchestration_v2_projection_provider_sessions`; + const [leftovers] = yield* sql<{ auth: number; tasks: number; transfers: number }>` + SELECT + (SELECT COUNT(*) FROM auth_sessions) AS auth, + (SELECT COUNT(*) FROM scheduled_tasks) AS tasks, + (SELECT COUNT(*) FROM orchestration_v2_projection_context_transfers) AS transfers`; + return { threads, events, sessions, leftovers }; }), ); assert.deepStrictEqual( kept.threads.map((row) => row.thread_id), - ["stopped-thread"], + ["fork-thread", "stopped-thread"], ); assert.deepStrictEqual( kept.events.map((row) => row.stream_id), - ["stopped-thread"], + ["fork-thread", "stopped-thread"], + ); + assert.deepStrictEqual( + kept.sessions.map((row) => row.provider_session_id), + ["session-shared"], ); - assert.equal(kept.authCount, 0); + assert.deepStrictEqual(kept.leftovers, { auth: 0, tasks: 0, transfers: 0 }); }), ); diff --git a/apps/server/scripts/migrate-dev-db.ts b/apps/server/scripts/migrate-dev-db.ts index b9ec9a875354..c0982f731e77 100644 --- a/apps/server/scripts/migrate-dev-db.ts +++ b/apps/server/scripts/migrate-dev-db.ts @@ -6,10 +6,12 @@ * * `vp run migrate-dev-db` from a worktree: * 1. Nukes `/.t3/userdata/statev2.sqlite`. - * 2. Snapshots the real db (read-only VACUUM INTO) and prunes it to the - * most recently updated projects and, per project, the most recent - * threads that have fully stopped. Working, settled, and monitored - * threads are skipped so the dev server never adopts live work. + * 2. Snapshots the real db (`~/.t3/userdata/statev2.sqlite`, read-only + * VACUUM INTO) and prunes it to the most recently updated projects and, + * per project, the most recent threads that have fully stopped, with + * their forks and subagents. Working, settled, and archived threads, and + * threads with pending recovery, are skipped, and scheduled tasks and + * queued effects are dropped, so the dev server never adopts live work. * Auth sessions, pairing links, command receipts, and provider * runtime rows are dropped — pair a fresh browser against dev. * 3. Runs migrations on the result. Because the clone carries the real @@ -19,9 +21,8 @@ * `Migrations/NNN_` id (the second one's CREATE TABLE is skipped). * * The event log (`orchestration_events`) is pruned per stream while - * `sqlite_sequence` and `projection_state` carry over untouched, so new - * events keep appending after the old high-water mark and projection - * cursors never rewind. + * `sqlite_sequence` and the projection cursors carry over untouched, so new events keep appending + * after the old high-water mark and projection cursors never rewind. */ import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; @@ -31,12 +32,14 @@ import { resolveWorktreeT3Home } from "@t3tools/shared/devHome"; 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 Schema from "effect/Schema"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { Command, Flag } from "effect/unstable/cli"; +import * as ProjectionStore from "../src/orchestration-v2/ProjectionStore.ts"; import { migrationManifest, runMigrations } from "../src/persistence/Migrations.ts"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; @@ -143,7 +146,7 @@ export class MigrateDevDbPhaseError extends Schema.TaggedError/.t3` of the cwd. */ readonly baseDir?: string | undefined; - /** Source database. Defaults to `~/.t3/userdata/state.sqlite`. */ + /** Source database. Defaults to `~/.t3/userdata/statev2.sqlite`. */ readonly source?: string | undefined; readonly projects: number; readonly threadsPerProject: number; @@ -232,31 +235,56 @@ const ensureNotInUse = Effect.fn("ensureDevDbNotInUse")(function* (databasePath: } }); +const RECOVERY_KINDS: ReadonlyArray = [ + "queued-runs", + "runtime", + "subagent-results", + "delegated-completions", +]; + const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigrateDevDbInput) { const sql = yield* SqlClient.SqlClient; - // The shared db can carry monitor_json from a branch build even though no - // migration in this checkout creates it, so filter it only when present. - const threadColumns = yield* sql<{ name: string }>` - SELECT name FROM pragma_table_info('projection_threads')`; - const monitorFilter = threadColumns.some((column) => column.name === "monitor_json") - ? "AND t.monitor_json IS NULL" - : ""; - - // "Stopped" is the persisted subset of the UI's thread status: the session - // reached status 'stopped' and nothing marks the thread settled or - // monitored. The in-memory working/monitoring liveness never persists, so - // filtering the session status is sufficient. - yield* sql.unsafe(`CREATE TEMP TABLE stopped_threads AS - SELECT t.thread_id, t.project_id, t.updated_at - FROM projection_threads t - JOIN projection_thread_sessions s ON s.thread_id = t.thread_id - WHERE t.deleted_at IS NULL - AND t.archived_at IS NULL - AND t.settled_at IS NULL - AND (t.settled_override IS NULL OR t.settled_override <> 'settled') - ${monitorFilter} - AND s.status = 'stopped'`).unprepared; + // Threads the server would resume or start work on by itself. Its own + // recovery queries find most of them; usage-limit recovery also depends on + // settings, so any thread whose latest run failed or that has a recovery + // choice counts as live too. + const projections = yield* ProjectionStore.ProjectionStoreV2; + const recoveryThreadIds = yield* Effect.forEach(RECOVERY_KINDS, (kind) => + projections.getRecoveryThreadIds(kind), + ); + yield* sql`CREATE TEMP TABLE live_threads (thread_id TEXT PRIMARY KEY)`; + for (const threadId of new Set(recoveryThreadIds.flat())) { + yield* sql`INSERT INTO live_threads (thread_id) VALUES (${threadId})`; + } + yield* sql`INSERT OR IGNORE INTO live_threads (thread_id) + SELECT r.thread_id FROM orchestration_v2_projection_runs r + WHERE r.status = 'failed' AND r.ordinal = ( + SELECT MAX(latest.ordinal) FROM orchestration_v2_projection_runs latest + WHERE latest.thread_id = r.thread_id) + UNION + SELECT thread_id FROM orchestration_v2_projection_threads + WHERE json_extract(payload_json, '$.limitRecovery') IS NOT NULL`; + + // Forks and subagents read history and results through their lineage, so + // a thread family is cloned or dropped as a whole. A family is stopped when + // its root is visible and unsettled and none of its threads is live. + yield* sql`CREATE TEMP TABLE thread_families AS + SELECT thread_id, updated_at, + COALESCE(json_extract(payload_json, '$.lineage.rootThreadId'), thread_id) AS root_id + FROM orchestration_v2_projection_threads`; + + yield* sql`CREATE TEMP TABLE stopped_families AS + SELECT f.root_id, root.project_id, MAX(f.updated_at) AS updated_at + FROM thread_families f + JOIN orchestration_v2_projection_threads root ON root.thread_id = f.root_id + WHERE root.deleted_at IS NULL + AND json_extract(root.payload_json, '$.deletedAt') IS NULL + AND json_extract(root.payload_json, '$.archivedAt') IS NULL + AND json_extract(root.payload_json, '$.settledAt') IS NULL + AND json_extract(root.payload_json, '$.settledOverride') IS NOT 'settled' + GROUP BY f.root_id, root.project_id + HAVING SUM(f.thread_id IN (SELECT thread_id FROM live_threads)) = 0`; // Projects with clonable threads outrank empty-but-recent ones: the point // of the exercise is thread data, not the project list. @@ -265,7 +293,7 @@ const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigra FROM projection_projects p LEFT JOIN ( SELECT project_id, MAX(updated_at) AS last_stopped_at - FROM stopped_threads + FROM stopped_families GROUP BY project_id ) q ON q.project_id = p.project_id WHERE p.deleted_at IS NULL @@ -274,42 +302,56 @@ const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigra LIMIT ${input.projects}`; yield* sql`CREATE TEMP TABLE kept_threads AS - SELECT thread_id FROM ( - SELECT - st.thread_id, - ROW_NUMBER() OVER ( - PARTITION BY st.project_id - ORDER BY st.updated_at DESC - ) AS recency_rank - FROM stopped_threads st - JOIN kept_projects kp ON kp.project_id = st.project_id - ) - WHERE recency_rank <= ${input.threadsPerProject}`; + SELECT f.thread_id FROM thread_families f + WHERE f.root_id IN ( + SELECT root_id FROM ( + SELECT + sf.root_id, + ROW_NUMBER() OVER ( + PARTITION BY sf.project_id + ORDER BY sf.updated_at DESC + ) AS recency_rank + FROM stopped_families sf + JOIN kept_projects kp ON kp.project_id = sf.project_id + ) + WHERE recency_rank <= ${input.threadsPerProject} + )`; + + // Every V2 table and every V1 table the lazy importer reads is keyed by + // thread_id, so one sweep covers both and new tables need no change here. + // Provider sessions are shared between threads and pruned by binding below. + const threadTables = yield* sql<{ name: string }>` + SELECT m.name FROM sqlite_master m, pragma_table_info(m.name) c + WHERE m.type = 'table' AND c.name = 'thread_id' + AND m.name <> 'orchestration_v2_projection_provider_sessions'`; yield* sql.withTransaction( Effect.gen(function* () { yield* sql`DELETE FROM projection_projects WHERE project_id NOT IN (SELECT project_id FROM kept_projects)`; - yield* sql`DELETE FROM projection_threads - WHERE thread_id NOT IN (SELECT thread_id FROM kept_threads)`; - for (const table of [ - "projection_thread_messages", - "projection_thread_activities", - "projection_thread_sessions", - "projection_turns", - "projection_pending_approvals", - "projection_thread_proposed_plans", - "checkpoint_diff_blobs", - ]) { + yield* sql`DELETE FROM orchestration_v2_projection_provider_sessions + WHERE COALESCE(thread_id, '') NOT IN (SELECT thread_id FROM kept_threads) + AND provider_session_id NOT IN ( + SELECT provider_session_id FROM orchestration_v2_projection_provider_session_bindings + WHERE thread_id IN (SELECT thread_id FROM kept_threads) + )`; + for (const { name } of threadTables) { yield* sql.unsafe( - `DELETE FROM ${table} WHERE thread_id NOT IN (SELECT thread_id FROM kept_threads)`, + `DELETE FROM "${name}" WHERE thread_id NOT IN (SELECT thread_id FROM kept_threads)`, ).unprepared; } + yield* sql`DELETE FROM orchestration_v2_projection_context_transfers + WHERE source_thread_id NOT IN (SELECT thread_id FROM kept_threads) + OR target_thread_id NOT IN (SELECT thread_id FROM kept_threads)`; yield* sql`DELETE FROM orchestration_events WHERE (aggregate_kind = 'thread' AND stream_id NOT IN (SELECT thread_id FROM kept_threads)) OR (aggregate_kind = 'project' AND stream_id NOT IN (SELECT project_id FROM kept_projects))`; + // Pending work the dev server would otherwise pick up and run. + yield* sql`DELETE FROM scheduled_tasks`; + yield* sql`DELETE FROM orchestration_v2_effect_outbox`; + yield* sql`DELETE FROM orchestration_v2_thread_launch_workflows`; yield* sql`DELETE FROM orchestration_command_receipts`; yield* sql`DELETE FROM provider_session_runtime`; yield* sql`DELETE FROM auth_sessions`; @@ -320,7 +362,8 @@ const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigra const keptProjects = yield* sql<{ title: string; threads: number }>` SELECT p.title, - (SELECT COUNT(*) FROM projection_threads t WHERE t.project_id = p.project_id) AS threads + (SELECT COUNT(*) FROM orchestration_v2_projection_threads t + WHERE t.project_id = p.project_id) AS threads FROM projection_projects p ORDER BY p.updated_at DESC`; const [events] = yield* sql<{ count: number }>` @@ -362,7 +405,7 @@ export const runMigrateDevDb = Effect.fn("runMigrateDevDb")(function* ( const sharedHome = path.resolve(options.sharedHome ?? path.join(NodeOS.homedir(), ".t3")); const sourcePath = path.resolve( - input.source ?? path.join(sharedHome, "userdata", "state.sqlite"), + input.source ?? path.join(sharedHome, "userdata", "statev2.sqlite"), ); const baseDir = @@ -458,7 +501,11 @@ export const runMigrateDevDb = Effect.fn("runMigrateDevDb")(function* ( `Pruning to ${input.projects} projects, ${input.threadsPerProject} stopped threads each...`, ); const result = yield* pruneSnapshot(input).pipe( - Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath })), + Effect.provide( + ProjectionStore.layer.pipe( + Layer.provideMerge(NodeSqliteClient.layer({ filename: snapshotPath })), + ), + ), wrapPhase("prune", snapshotPath), ); @@ -512,7 +559,9 @@ export const migrateDevDbCommand = Command.make( ), threadsPerProject: Flag.Int("threads-per-project").pipe( Flag.withDefault(10), - Flag.withDescription("How many recent stopped threads to keep per project."), + Flag.withDescription( + "How many recent stopped threads, with their forks and subagents, to keep per project.", + ), ), baseDir: Flag.String("base-dir").pipe( Flag.optional, @@ -520,7 +569,7 @@ export const migrateDevDbCommand = Command.make( ), source: Flag.String("source").pipe( Flag.optional, - Flag.withDescription("Source database. Defaults to ~/.t3/userdata/state.sqlite."), + Flag.withDescription("Source database. Defaults to ~/.t3/userdata/statev2.sqlite."), ), }, ({ projects, threadsPerProject, baseDir, source }) => diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 71a2c769ef4e..a596ca55c32a 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -713,6 +713,49 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(testLayer)), ); + it.effect("previews workspace files with literal fragment characters in their paths", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-preview-literal-" }); + const directory = path.join(root, "assets#archive"); + yield* fileSystem.makeDirectory(directory); + + for (const name of ["icon#v2.png", "report#draft.pdf"]) { + const filePath = path.join(directory, name); + yield* fileSystem.writeFileString(filePath, "preview fixture"); + const canonicalFile = yield* fileSystem.realPath(filePath); + const result = yield* issueAssetUrl({ + resource: { + _tag: "workspace-file", + threadId: ThreadId.make("thread-1"), + path: filePath, + }, + workspaceRoot: root, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const token = suffix.slice(0, suffix.indexOf("/")); + + expect(yield* resolveAsset(token, name)).toEqual({ kind: "file", path: canonicalFile }); + if (name.endsWith(".png")) { + expect(yield* resolveAsset(token, "other.png")).toBeNull(); + } + } + + const disguisedPath = path.join(root, "image.png#notes.txt"); + yield* fileSystem.writeFileString(disguisedPath, "not an image"); + const error = yield* issueAssetUrl({ + resource: { + _tag: "workspace-file", + threadId: ThreadId.make("thread-1"), + path: disguisedPath, + }, + workspaceRoot: root, + }).pipe(Effect.flip); + expect(error).toBeInstanceOf(AssetPreviewTypeValidationError); + }).pipe(Effect.provide(testLayer)), + ); + it.effect("issues exact attachment capabilities by attachment id", () => Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; diff --git a/apps/server/src/atomicWrite.test.ts b/apps/server/src/atomicWrite.test.ts index d3c18283c26f..172c9703a304 100644 --- a/apps/server/src/atomicWrite.test.ts +++ b/apps/server/src/atomicWrite.test.ts @@ -1,8 +1,11 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import { writeFileStringAtomically } from "./atomicWrite.ts"; @@ -43,6 +46,49 @@ it.layer(NodeServices.layer)("writeFileStringAtomically", (it) => { }), ); + it.effect("fails on a symlink cycle without replacing either link", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" }); + const first = path.join(root, "first.json"); + const second = path.join(root, "second.json"); + yield* fs.symlink(second, first); + yield* fs.symlink(first, second); + + const result = yield* Effect.exit( + writeFileStringAtomically({ filePath: first, contents: "after" }), + ); + + assert.isTrue(Exit.isFailure(result)); + assert.strictEqual(yield* fs.readLink(first), second); + assert.strictEqual(yield* fs.readLink(second), first); + }), + ); + + it.effect("resolves a relative link through a symlinked parent directory", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" }); + const destination = path.join(root, "dotfiles", "config", "settings.json"); + const linkedState = path.join(root, "dotfiles", "state"); + const home = path.join(root, "home"); + const link = path.join(home, "state", "settings.json"); + yield* fs.makeDirectory(path.dirname(destination), { recursive: true }); + yield* fs.makeDirectory(linkedState, { recursive: true }); + yield* fs.makeDirectory(home, { recursive: true }); + yield* fs.symlink(linkedState, path.join(home, "state")); + yield* fs.writeFileString(destination, "before"); + yield* fs.symlink("../config/settings.json", link); + + yield* writeFileStringAtomically({ filePath: link, contents: "after" }); + + assert.strictEqual(yield* fs.readLink(link), "../config/settings.json"); + assert.strictEqual(yield* fs.readFileString(destination), "after"); + }), + ); + it.effect("creates a missing file and its directory", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -56,3 +102,38 @@ it.layer(NodeServices.layer)("writeFileStringAtomically", (it) => { }), ); }); + +it.effect("surfaces an unreadable link instead of writing over it", () => + Effect.gen(function* () { + const readLinkFailure = PlatformError.systemError({ + _tag: "Unknown", + module: "FileSystem", + method: "readLink", + pathOrDescriptor: "/home/settings.json", + }); + + const result = yield* Effect.exit( + writeFileStringAtomically({ filePath: "/home/settings.json", contents: "after" }), + ); + + assert.deepStrictEqual(result, Exit.fail(readLinkFailure)); + }).pipe( + Effect.provide( + Layer.mergeAll( + Path.layer, + FileSystem.layerNoop({ + readLink: () => + Effect.fail( + PlatformError.systemError({ + _tag: "Unknown", + module: "FileSystem", + method: "readLink", + pathOrDescriptor: "/home/settings.json", + }), + ), + rename: () => Effect.die("an unreadable link must not be replaced"), + }), + ), + ), + ), +); diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index d5b165865ab2..fc355f66a9eb 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -7,11 +7,15 @@ import { WsRpcGroup, } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as RpcTest from "effect/unstable/rpc/RpcTest"; import { RPC_REQUIRED_SCOPES, requiredScopeForRpcMethod, requiredScopeForDeviceList, + rpcScopeAuthorizationLayer, } from "./RpcAuthorization.ts"; describe("RPC authorization scopes", () => { @@ -116,3 +120,39 @@ it("requires operate permission for tool updates even alongside a read-only chec ); expect(requiredScopeForDeviceList({ updateTool: "hub" })).toBe(AuthOrchestrationOperateScope); }); + +describe("RPC scope middleware", () => { + const tested = [WS_METHODS.serverProbe, WS_METHODS.serverRetryResourceTelemetry] as const; + const group = WsRpcGroup.omit( + ...[...WsRpcGroup.requests.keys()].filter( + (tag): tag is Exclude => + !(tested as ReadonlyArray).includes(tag), + ), + ); + + it.effect("checks each RPC's declared scope before its handler runs", () => + Effect.gen(function* () { + const handled: Array = []; + const client = yield* RpcTest.makeClient(group).pipe( + Effect.provide( + Layer.mergeAll( + group.toLayerHandler(WS_METHODS.serverProbe, () => Effect.succeed({})), + group.toLayerHandler(WS_METHODS.serverRetryResourceTelemetry, () => + Effect.sync(() => handled.push("retry")).pipe(Effect.andThen(Effect.never)), + ), + rpcScopeAuthorizationLayer([AuthOrchestrationReadScope]), + ), + ), + ); + + expect(yield* client[WS_METHODS.serverProbe]({})).toEqual({}); + expect( + yield* client[WS_METHODS.serverRetryResourceTelemetry]({}).pipe(Effect.flip), + ).toMatchObject({ + _tag: "EnvironmentAuthorizationError", + requiredScope: AuthOrchestrationOperateScope, + }); + expect(handled).toEqual([]); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index b482a2e9c549..ae8f50d44809 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -9,9 +9,13 @@ import { AuthTerminalOperateScope, ORCHESTRATION_V2_WS_METHODS, type AuthEnvironmentScope, + EnvironmentAuthorizationError, + RpcScopeAuthorization, WS_METHODS, WsRpcGroup, } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import type * as RpcGroup from "effect/unstable/rpc/RpcGroup"; type WsRpcMethod = RpcGroup.Rpcs["_tag"]; @@ -213,6 +217,21 @@ export function requiredScopeForRpcMethod(method: string): AuthEnvironmentScope return requiredScope; } +export const rpcAuthorizationError = (requiredScope: AuthEnvironmentScope) => + new EnvironmentAuthorizationError({ + message: `The authenticated token is missing required scope: ${requiredScope}.`, + requiredScope, + }); + +/** Authorizes every RPC on one connection against that connection's session scopes. */ +export const rpcScopeAuthorizationLayer = (scopes: ReadonlyArray) => + Layer.succeed(RpcScopeAuthorization)((effect, { rpc }) => { + const requiredScope = requiredScopeForRpcMethod(rpc._tag); + return scopes.includes(requiredScope) + ? effect + : Effect.fail(rpcAuthorizationError(requiredScope)); + }); + /** Retrying can install or restart tools even though ordinary listing is readable. */ export const requiredScopeForDeviceList = (input: DeviceListInput): AuthEnvironmentScope => input.retryHostId || input.updateTool diff --git a/apps/server/src/cli/app.test.ts b/apps/server/src/cli/app.test.ts index fe5feadaf619..e478f66f0731 100644 --- a/apps/server/src/cli/app.test.ts +++ b/apps/server/src/cli/app.test.ts @@ -256,8 +256,9 @@ describe("t3 app", () => { ), ); - for (const responseKind of ["failure", "invalid"] as const) { - it.effect(`never falls back after the default desktop sends a ${responseKind} response`, () => + it.effect.each(["failure", "invalid"] as const)( + "never falls back after the default desktop sends a %s response", + (responseKind) => withTempDirectory("t3-app-response-test-", (root) => Effect.gen(function* () { vi.mocked(NodeOS.homedir).mockReturnValue(root); @@ -302,6 +303,5 @@ describe("t3 app", () => { } }).pipe(Effect.scoped), ), - ); - } + ); }); diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index 83a9d2f7e15b..9a9663533259 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -361,29 +361,39 @@ describe("device discovery after server restart", () => { ); }); -for (const [diagnostic, reason, message] of [ - ["Insufficient disk space at /private/user/path", "disk_space", "not enough free disk space"], - ["Timed out spawning /private/user/command", "timeout", "did not become ready in time"], - ["Unexpected failure: secret-token", "launch_failed", "could not start"], -] as const) { - it.effect(`normalizes boot failure: ${reason}`, () => - Effect.gen(function* () { - const { service } = yield* fixture(Effect.void, diagnostic); - yield* service.configure({ enabled: true }); - const error = yield* service - .open({ - threadId: ThreadId.make("boot-failure"), - deviceId: "Pixel_API_35", - platform: "android", - }) - .pipe(Effect.flip); - expect(error._tag).toBe("DeviceBootError"); - expect(error.message).toContain(message); - expect(error.message).not.toContain(diagnostic); - expect((yield* service.state).bootingDevices).toEqual([]); - }).pipe(Effect.scoped), - ); -} +it.effect.each([ + { + diagnostic: "Insufficient disk space at /private/user/path", + reason: "disk_space", + message: "not enough free disk space", + }, + { + diagnostic: "Timed out spawning /private/user/command", + reason: "timeout", + message: "did not become ready in time", + }, + { + diagnostic: "Unexpected failure: secret-token", + reason: "launch_failed", + message: "could not start", + }, +] as const)("normalizes boot failure: $reason", ({ diagnostic, message }) => + Effect.gen(function* () { + const { service } = yield* fixture(Effect.void, diagnostic); + yield* service.configure({ enabled: true }); + const error = yield* service + .open({ + threadId: ThreadId.make("boot-failure"), + deviceId: "Pixel_API_35", + platform: "android", + }) + .pipe(Effect.flip); + expect(error._tag).toBe("DeviceBootError"); + expect(error.message).toContain(message); + expect(error.message).not.toContain(diagnostic); + expect((yield* service.state).bootingDevices).toEqual([]); + }).pipe(Effect.scoped), +); it.effect("keeps shutdown successful when subsequent discovery fails", () => Effect.gen(function* () { diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 91ab19b55e01..16a4a93b3f20 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -233,6 +233,7 @@ export const make = Effect.gen(function* () { environmentThemes: true, usageLimitSources: true, usagePriceOverrides: true, + usageModelAliases: true, threadPinning: true, threadPinReorder: true, threadActiveReorder: true, @@ -240,6 +241,7 @@ export const make = Effect.gen(function* () { threadTitleRegeneration: true, threadVisitedTracking: true, threadPullRequests: true, + threadPullRequestWatch: true, pullRequestStackActions: true, threadPullRequestLinking: true, serverResolvedCommandContext: true, diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index d2a244e40f05..2b88cd79f8e6 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -1000,7 +1000,7 @@ export const make = Effect.gen(function* () { const canonicalizeExistingPath = (value: string) => fileSystem.realPath(value).pipe(Effect.orElseSucceed(() => value)); const normalizeStatusCacheKey = canonicalizeExistingPath; - const nonRepositoryStatusDetails = { + const nonRepositoryStatusDetails: GitVcsDriver.GitStatusDetails = { isRepo: false, hasOriginRemote: false, isDefaultBranch: false, @@ -1012,10 +1012,10 @@ export const make = Effect.gen(function* () { aheadCount: 0, behindCount: 0, aheadOfDefaultCount: 0, - } satisfies GitVcsDriver.GitStatusDetails; + }; const readLocalStatus = Effect.fn("readLocalStatus")(function* (cwd: string) { const details = yield* gitCore - .statusDetailsLocal(cwd, { includeDivergence: false }) + .statusDetailsLocal(cwd, { includeDivergence: false, includeBranchChanges: true }) .pipe( Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(nonRepositoryStatusDetails)), ); @@ -1031,6 +1031,7 @@ export const make = Effect.gen(function* () { refName: details.branch, hasWorkingTreeChanges: details.hasWorkingTreeChanges, workingTree: details.workingTree, + ...(details.branchChanges ? { branchChanges: details.branchChanges } : {}), } satisfies VcsStatusLocalResult; }); const localStatusResultCache = yield* Cache.makeWith(readLocalStatus, { diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index fb256f25500a..202b5de45c5b 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -11,7 +11,7 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as ServerConfig from "./config.ts"; import * as Keybindings from "./keybindings.ts"; -import { KeybindingsConfigError } from "@t3tools/contracts"; +import { KeybindingsConfigError, MAX_KEYBINDINGS_COUNT } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig); @@ -283,6 +283,76 @@ it.layer(NodeServices.layer)("keybindings", (it) => { }).pipe(Effect.provide(makeKeybindingsLayer())), ); + it.effect("adds a late default to an existing command once", () => + Effect.gen(function* () { + const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; + const keybindings = yield* Keybindings.Keybindings; + const when = "composerFocus && draftThreadRoute"; + const existing = { key: "mod+alt+enter", command: "composer.sendBackground", when } as const; + const backgroundRules = Effect.map(readKeybindingsConfig(keybindingsConfigPath), (rules) => + rules.filter((entry) => entry.command === "composer.sendBackground"), + ); + yield* writeKeybindingsConfig(keybindingsConfigPath, [existing]); + + yield* keybindings.syncDefaultKeybindingsOnStartup; + assert.deepStrictEqual(yield* backgroundRules, [ + existing, + { key: "mod+enter", command: "composer.sendBackground", when }, + ]); + + // Removing the added rule later must survive the next startup. + yield* writeKeybindingsConfig(keybindingsConfigPath, [existing]); + yield* keybindings.syncDefaultKeybindingsOnStartup; + assert.deepStrictEqual(yield* backgroundRules, [existing]); + }).pipe(Effect.provide(makeKeybindingsLayer())), + ); + + it.effect("leaves a customized command without the late default", () => + Effect.gen(function* () { + const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; + const keybindings = yield* Keybindings.Keybindings; + const custom = { + key: "alt+b", + command: "composer.sendBackground", + when: "composerFocus && draftThreadRoute", + } as const; + yield* writeKeybindingsConfig(keybindingsConfigPath, [custom]); + + yield* keybindings.syncDefaultKeybindingsOnStartup; + const persisted = yield* readKeybindingsConfig(keybindingsConfigPath); + assert.deepStrictEqual( + persisted.filter((entry) => entry.command === "composer.sendBackground"), + [custom], + ); + }).pipe(Effect.provide(makeKeybindingsLayer())), + ); + + it.effect("keeps a late default pending while the config is full", () => + Effect.gen(function* () { + const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; + const keybindings = yield* Keybindings.Keybindings; + const when = "composerFocus && draftThreadRoute"; + const existing = { key: "mod+alt+enter", command: "composer.sendBackground", when } as const; + const fillers = Array.from({ length: MAX_KEYBINDINGS_COUNT - 1 }, (_, index) => ({ + key: "mod+alt+f1", + command: `script.filler-${index}.run` as const, + })); + const hasModEnter = Effect.map(readKeybindingsConfig(keybindingsConfigPath), (rules) => + rules.some( + (entry) => entry.command === "composer.sendBackground" && entry.key === "mod+enter", + ), + ); + + yield* writeKeybindingsConfig(keybindingsConfigPath, [existing, ...fillers]); + yield* keybindings.syncDefaultKeybindingsOnStartup; + assert.isFalse(yield* hasModEnter); + + yield* writeKeybindingsConfig(keybindingsConfigPath, [existing]); + yield* keybindings.syncDefaultKeybindingsOnStartup; + assert.isTrue(yield* hasModEnter); + }).pipe(Effect.provide(makeKeybindingsLayer())), + ); + it.effect("skips conflicting default keybindings on startup and logs a detailed warning", () => { const messages: string[] = []; const logger = Logger.make(({ message }) => { diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 7a1bcd7f356a..0484296edd7e 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -104,6 +104,31 @@ function isSameKeybindingRule(left: KeybindingRule, right: KeybindingRule): bool ); } +// Default rules added to a command after startup sync had already persisted +// that command. Backfill skips commands a config already has, so startup adds +// each rule once per config, only next to the untouched earlier default +// (`alongside`), and records its id. Customized commands and later removals +// stay as the user set them. +const LATE_DEFAULT_KEYBINDINGS: ReadonlyArray<{ + readonly id: string; + readonly alongside: KeybindingRule; + readonly rule: KeybindingRule; +}> = [ + { + id: "composer.sendBackground:mod+enter", + alongside: { + key: "mod+alt+enter", + command: "composer.sendBackground", + when: "composerFocus && draftThreadRoute", + }, + rule: { + key: "mod+enter", + command: "composer.sendBackground", + when: "composerFocus && draftThreadRoute", + }, + }, +]; + function keybindingShortcutContext(rule: KeybindingRule): string | null { const parsed = parseKeybindingShortcut(rule.key); if (!parsed) return null; @@ -399,6 +424,32 @@ const make = Effect.gen(function* () { return { keybindings, issues }; }); + // One applied migration id per line, next to the config file. + const appliedMigrationsPath = path.join( + path.dirname(keybindingsConfigPath), + "keybindings-migrations", + ); + const readAppliedMigrationIds = fs.readFileString(appliedMigrationsPath).pipe( + Effect.map((contents) => new Set(contents.split("\n").filter((line) => line.length > 0))), + Effect.orElseSucceed(() => new Set()), + ); + const recordAppliedMigrations = (ids: Iterable) => + writeFileStringAtomically({ + filePath: appliedMigrationsPath, + contents: `${[...new Set(ids)].join("\n")}\n`, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.mapError( + (cause) => + new KeybindingsConfigError({ + configPath: keybindingsConfigPath, + detail: "failed to record keybinding migrations", + cause, + }), + ), + ); + const writeConfigAtomically = (rules: readonly KeybindingRule[]) => { return encodeKeybindingsConfigPrettyJson(rules).pipe( Effect.map((encoded) => `${encoded}\n`), @@ -450,9 +501,19 @@ const make = Effect.gen(function* () { const syncDefaultKeybindingsOnStartup = upsertSemaphore.withPermits(1)( Effect.gen(function* () { + const appliedMigrationIds = yield* readAppliedMigrationIds; + const pendingLateDefaults = LATE_DEFAULT_KEYBINDINGS.filter( + (late) => !appliedMigrationIds.has(late.id), + ); const configExists = yield* readConfigExists; if (!configExists) { yield* writeConfigAtomically(DEFAULT_KEYBINDINGS); + if (pendingLateDefaults.length > 0) { + yield* recordAppliedMigrations([ + ...appliedMigrationIds, + ...LATE_DEFAULT_KEYBINDINGS.map((late) => late.id), + ]); + } yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey); return; } @@ -496,6 +557,16 @@ const make = Effect.gen(function* () { } missingDefaults.push(defaultRule); } + // The loop above backfills whole missing commands. Late defaults join + // an untouched earlier default, when their shortcut is still free. + for (const { alongside, rule } of pendingLateDefaults) { + if ( + customConfig.some((entry) => isSameKeybindingRule(entry, alongside)) && + !customConfig.some((entry) => hasSameShortcutContext(entry, rule)) + ) { + missingDefaults.push(rule); + } + } for (const conflict of shortcutConflictWarnings) { yield* Effect.logWarning("skipping default keybinding due to shortcut conflict", { path: keybindingsConfigPath, @@ -506,21 +577,18 @@ const make = Effect.gen(function* () { reason: "shortcut context already used by existing rule", }); } - if (missingDefaults.length === 0) { - yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey); - return; - } - - const matchingDefaults = Array.filterMap(DEFAULT_KEYBINDINGS, (defaultRule) => - customConfig.some((entry) => isSameKeybindingRule(entry, defaultRule)) - ? Result.succeed(defaultRule.command) - : Result.failVoid, - ); - if (matchingDefaults.length > 0) { - yield* Effect.logWarning("default keybinding rule already defined in user config", { - path: keybindingsConfigPath, - commands: matchingDefaults, - }); + if (missingDefaults.length > 0) { + const matchingDefaults = Array.filterMap(DEFAULT_KEYBINDINGS, (defaultRule) => + customConfig.some((entry) => isSameKeybindingRule(entry, defaultRule)) + ? Result.succeed(defaultRule.command) + : Result.failVoid, + ); + if (matchingDefaults.length > 0) { + yield* Effect.logWarning("default keybinding rule already defined in user config", { + path: keybindingsConfigPath, + commands: matchingDefaults, + }); + } } // Startup backfill must never evict persisted user rules: append only @@ -535,12 +603,19 @@ const make = Effect.gen(function* () { commands: skippedDefaults.map((rule) => rule.command), }); } - if (defaultsToAppend.length === 0) { - yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey); - return; + if (defaultsToAppend.length > 0) { + yield* writeConfigAtomically([...customConfig, ...defaultsToAppend]); + } + // A late default skipped at max entries stays pending for a later start. + const settledLateDefaults = pendingLateDefaults.filter( + (late) => !skippedDefaults.includes(late.rule), + ); + if (settledLateDefaults.length > 0) { + yield* recordAppliedMigrations([ + ...appliedMigrationIds, + ...settledLateDefaults.map((late) => late.id), + ]); } - - yield* writeConfigAtomically([...customConfig, ...defaultsToAppend]); yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey); }), ); diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 3f927c283ef8..eae4ffa8c0b1 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -11,10 +11,12 @@ import { type OrchestrationV2ThreadProjection, type ServerProvider, } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; +import { OrchestratorProjectionError } from "../orchestration-v2/Orchestrator.ts"; import type { ProviderAdapterV2Shape } from "../orchestration-v2/ProviderAdapter.ts"; import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; @@ -52,7 +54,22 @@ describe("OrchestratorMcpService", () => { } as unknown as OrchestrationV2ThreadProjection; const childProjection = { thread: { id: childThreadId }, - runs: [{ id: childRunId, ordinal: 1, status: "completed" }], + runs: [ + { + id: childRunId, + ordinal: 1, + status: "completed", + startedAt: DateTime.makeUnsafe("2026-10-03T10:00:00Z"), + completedAt: DateTime.makeUnsafe("2026-10-03T10:10:00Z"), + }, + { + id: RunId.make("run:mcp-ack-continuation"), + ordinal: 2, + status: "failed", + startedAt: DateTime.makeUnsafe("2026-10-03T10:02:00Z"), + completedAt: DateTime.makeUnsafe("2026-10-03T10:05:00Z"), + }, + ], contextTransfers: [], messages: [], subagents: [], @@ -126,6 +143,8 @@ describe("OrchestratorMcpService", () => { const result = yield* service.taskStatus(scope, taskId); assert.equal(result.status, "completed"); assert.equal(result.summary, "terminal result"); + assert.equal(result.latestTerminalRunId, childRunId); + assert.equal(result.latestTerminalStatus, "completed"); const commandIds = yield* Ref.get(acknowledgementCommandIds); assert.equal(commandIds.length, 2); assert.notEqual(commandIds[0], commandIds[1]); @@ -133,6 +152,87 @@ describe("OrchestratorMcpService", () => { }), ); + it.effect("reports a restart-cut child as working until its continuation settles", () => + Effect.gen(function* () { + const parentThreadId = ThreadId.make("thread:mcp-restart-parent"); + const childThreadId = ThreadId.make("thread:mcp-restart-child"); + const taskId = NodeId.make("node:mcp-restart-task"); + const dispatched = yield* Ref.make(0); + let awaitsRestart = true; + let readFails = true; + const parentProjection = { + thread: { id: parentThreadId }, + runs: [], + contextTransfers: [], + subagents: [ + { + id: taskId, + threadId: parentThreadId, + origin: "app_owned", + childThreadId, + driver: "codex", + model: "gpt-5.6-terra", + status: "running", + result: null, + completionDelivery: { state: "pending" }, + }, + ], + } as unknown as OrchestrationV2ThreadProjection; + const childProjection = { + thread: { id: childThreadId }, + runs: [{ id: RunId.make("run:mcp-restart-child"), ordinal: 1, status: "cancelled" }], + contextTransfers: [], + messages: [], + subagents: [], + providerThreads: [], + turnItems: [], + } as unknown as OrchestrationV2ThreadProjection; + const dependencies = Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: (threadId) => + Effect.succeed(threadId === parentThreadId ? parentProjection : childProjection), + delegatedTaskResultPending: () => + readFails + ? Effect.fail(new OrchestratorProjectionError({ threadId: childThreadId })) + : Effect.succeed(awaitsRestart), + dispatch: () => Ref.update(dispatched, (count) => count + 1).pipe(Effect.as({} as never)), + }), + Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), + Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ + list: () => Effect.succeed([]), + }), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + ); + const scope: McpInvocationScope = { + environmentId: EnvironmentId.make("environment:mcp-restart"), + threadId: parentThreadId, + providerSessionId: "provider-session:mcp-restart", + providerInstanceId: ProviderInstanceId.make("codex"), + capabilities: new Set(["orchestration"]), + issuedAt: 1, + }; + + yield* Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const failed = yield* service.taskStatus(scope, taskId).pipe(Effect.flip); + assert.equal(failed.code, "orchestration_error"); + assert.equal(yield* Ref.get(dispatched), 0); + readFails = false; + const held = yield* service.taskStatus(scope, taskId); + assert.equal(held.status, "running"); + assert.equal(held.workState, "working"); + assert.isNull(held.summary); + // Acknowledging the cut run would suppress the real result's wake. + assert.equal(yield* Ref.get(dispatched), 0); + awaitsRestart = false; + const settled = yield* service.taskStatus(scope, taskId); + assert.equal(settled.status, "cancelled"); + assert.equal(yield* Ref.get(dispatched), 1); + }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(dependencies)))); + }), + ); + it.effect("does not dispose delivery when a nonterminal task has no active child run", () => Effect.gen(function* () { const parentThreadId = ThreadId.make("thread:mcp-cancel-parent"); diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 5fdc7eb81c80..83a7a4cbfde6 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -52,6 +52,7 @@ import { type ServerProvider, ThreadId, } from "@t3tools/contracts"; +import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; @@ -412,7 +413,10 @@ function latestTerminalResultRun( run.status !== "rolled_back" && (run.id === delegatedRun?.id || run.startedAt !== null), ) - .toSorted((left, right) => right.ordinal - left.ordinal)[0]; + .reduce( + (latest, run) => (latest === undefined || runRanAfter(run, latest) ? run : latest), + undefined, + ); } function canExposeTaskRunResult(run: OrchestrationV2Run | undefined): run is OrchestrationV2Run { @@ -1055,7 +1059,16 @@ const make = Effect.gen(function* () { messages: [...childControls.messages, ...resultRecords.messages], turnItems: resultRecords.turnItems, }; - const workState = task.result !== null ? "result_available" : progress.state; + // A restart cut the child's run and its continuation has not settled, or + // the child started working again after this read. + const heldForRestart = + task.result === null && + progress.state === "result_available" && + (yield* threadManagement + .delegatedTaskResultPending(task.childThreadId) + .pipe(Effect.mapError(threadManagementFailure))); + const workState = + task.result !== null ? "result_available" : heldForRestart ? "working" : progress.state; const status = task.result !== null ? taskStatusForRun( @@ -1510,6 +1523,8 @@ const make = Effect.gen(function* () { ), ), ); + // Published task results stay terminal. Later child-thread messages do not + // reopen the task, so cancelling it must not interrupt those separate runs. if (isTerminalTaskStatus(current.status)) { yield* disposeCompletionDelivery; return { diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index 7d20bf512b10..142e0cc55f12 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -1651,6 +1651,14 @@ describe("orchestrator MCP toolkit", () => { taskId: delegated.taskId, status: "completed", }); + expect( + (yield* orchestrator.getThreadProjection(parentThreadId)).subagents.find( + (task) => task.id === delegated.taskId, + ), + ).toMatchObject({ + result: delegatedResult, + completionDelivery: { state: "disposed" }, + }); expect( (yield* orchestrator.getThreadProjection(delegated.childThreadId)).runs.find( (run) => run.id === activeChildFollowup.runId, diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts index 7a8ed739d734..adf55cad5e91 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.test.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts @@ -1,6 +1,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import { + AuthOrchestrationOperateScope, EnvironmentId, PreviewAutomationClientDisconnectedError, PreviewAutomationInvalidSelectorError, @@ -17,6 +18,7 @@ import { type PreviewAutomationStreamEvent, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import * as Exit from "effect/Exit"; import * as Deferred from "effect/Deferred"; import * as Fiber from "effect/Fiber"; @@ -27,6 +29,7 @@ import * as TestClock from "effect/testing/TestClock"; import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; import * as RpcTest from "effect/unstable/rpc/RpcTest"; +import { rpcScopeAuthorizationLayer } from "../auth/RpcAuthorization.ts"; import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; const makeBroker = PreviewAutomationBroker.make.pipe(Effect.provide(NodeServices.layer)); @@ -1255,9 +1258,12 @@ it.effect("evicts an unanswered host and lets later calls use a healthy runtime" ); const client = yield* RpcTest.makeClient(group).pipe( Effect.provide( - group.toLayer({ - [WS_METHODS.previewAutomationConnect]: (host) => Stream.unwrap(broker.connect(host)), - }), + Layer.merge( + group.toLayer({ + [WS_METHODS.previewAutomationConnect]: (host) => Stream.unwrap(broker.connect(host)), + }), + rpcScopeAuthorizationLayer([AuthOrchestrationOperateScope]), + ), ), ); const events = client[WS_METHODS.previewAutomationConnect](makeHost()); diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts index de85b6f72e0c..572cdcbf43e9 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts @@ -4,6 +4,7 @@ import { Tool } from "effect/unstable/ai"; import { CreateThreadsTool, DelegateTaskTool, + OrchestratorToolkit, ScheduleTaskTool, ThreadUpdateTool, } from "./tools.ts"; @@ -17,6 +18,16 @@ describe("orchestrator MCP tool guidance", () => { assert.include(DelegateTaskTool.description ?? "", "waitTimedOut"); assert.include(DelegateTaskTool.description ?? "", "does not cancel the child"); assert.include(DelegateTaskTool.description ?? "", "keep that taskId"); + assert.include(DelegateTaskTool.description ?? "", "call delegate_task again"); + assert.include(DelegateTaskTool.description ?? "", "childThreadId is backing storage"); + assert.include( + OrchestratorToolkit.tools.t3_thread_send.description ?? "", + "Do not use a delegated task's childThreadId to start another review round", + ); + assert.include( + OrchestratorToolkit.tools.task_cancel.description ?? "", + "without interrupting later child-thread runs", + ); }); it("documents wait timeout as a parent budget, not a child failure", () => { diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 833e88bd2ae8..a86f1ce214c5 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -57,7 +57,7 @@ const OrchestratorCapabilitiesTool = Tool.make("orchestrator_capabilities", { export const DelegateTaskTool = Tool.make("delegate_task", { description: - "Delegate one task to a T3-owned child agent/subagent of THIS thread and run it with only the supplied task prompt, without copying parent conversation history. Choose providers and models from orchestrator_capabilities, which uses the same live catalog as the composer. Prefer native subagent tools for same-provider work only when they support the chosen model. Use this for any model missing from the native tool, including same-provider work, for cross-provider work, or for explicitly T3-owned child tasks. The childThreadId is backing storage, not an ordinary top-level thread. Provider, model, model options (see orchestrator_capabilities), runtime mode, and interaction mode inherit unless target overrides them. Prefer mode='async' for long work; mode='wait' blocks until completion or timeout. timeoutMs on mode=wait is only the parent's wait budget and does not cancel the child. waitTimedOut on that wait call means the timeout fired; keep that taskId and read status on later task_status. An async child's completion wakes this thread through a notification, steered into active turns where supported or queued otherwise, so end the turn instead of polling or spawning watchers; use task_status only when the result is needed mid-turn.", + "Delegate one task to a T3-owned child agent/subagent of THIS thread and run it with only the supplied task prompt, without copying parent conversation history. Choose providers and models from orchestrator_capabilities, which uses the same live catalog as the composer. Prefer native subagent tools for same-provider work only when they support the chosen model. Use this for any model missing from the native tool, including same-provider work, for cross-provider work, or for explicitly T3-owned child tasks. For every T3 delegated review round, call delegate_task again with the original brief, prior findings, responses, and unresolved objections in the task prompt. Track each round by its own taskId and use a distinct clientRequestId per round, stable across retries of that round. The childThreadId is backing storage, not the target for starting another delegated review round through t3_thread_send. Provider, model, model options (see orchestrator_capabilities), runtime mode, and interaction mode inherit unless target overrides them. Prefer mode='async' for long work; mode='wait' blocks until completion or timeout. timeoutMs on mode=wait is only the parent's wait budget and does not cancel the child. waitTimedOut on that wait call means the timeout fired; keep that taskId and read status on later task_status. An async child's completion wakes this thread through a notification, steered into active turns where supported or queued otherwise, so end the turn instead of polling or spawning watchers; use task_status only when the result is needed mid-turn.", parameters: OrchestratorMcpDelegateTaskInput, success: OrchestratorMcpDelegateTaskResult, failure: OrchestratorMcpFailure, @@ -70,7 +70,7 @@ export const DelegateTaskTool = Tool.make("delegate_task", { const TaskStatusTool = Tool.make("task_status", { description: - "Read a T3-owned delegated task created by this parent thread. childRunId identifies the original delegated run. workState distinguishes working, waiting_for_children, and result_available; a completed turn with live nested work is not a completed task. summary is the final task result, including provider errors on failure, and remains stable after publication. hasPendingChildRuns reports later queued or executing turns; latestTerminal* provides later non-monitor turn results. Reading a terminal result acknowledges its automatic parent delivery.", + "Read a T3-owned delegated task created by this parent thread. childRunId identifies the original delegated run. workState distinguishes working, waiting_for_children, and result_available; a completed turn with live nested work is not a completed task. summary is the final task result, including provider errors on failure, and remains stable after publication. hasPendingChildRuns reports later queued or executing turns in the backing child thread, even after the task is terminal; it does not reopen the task or extend task_cancel to those turns. latestTerminal* provides later non-monitor turn results. Reading a terminal result acknowledges its automatic parent delivery.", parameters: OrchestratorMcpTaskStatusInput, success: OrchestratorMcpDelegateTaskResult, failure: OrchestratorMcpFailure, @@ -84,7 +84,7 @@ const TaskStatusTool = Tool.make("task_status", { const TaskCancelTool = Tool.make("task_cancel", { description: - "Request interruption of an active T3-owned delegated task and dispose its automatic parent delivery. Completed task results remain available.", + "Request interruption of an active T3-owned delegated task and dispose its automatic parent delivery. For a terminal task, return its existing status and dispose delivery without interrupting later child-thread runs, even when task_status reports hasPendingChildRuns=true. Published task results remain available. Use t3_thread_interrupt for a later active run.", parameters: OrchestratorMcpTaskCancelInput, success: OrchestratorMcpTaskCancelResult, failure: OrchestratorMcpFailure, @@ -200,7 +200,7 @@ export const ThreadUpdateTool = Tool.make("t3_thread_update", { const ThreadSendTool = Tool.make("t3_thread_send", { description: - "Send a message to a T3 thread in the calling project. mode='auto' starts an idle thread, steers a fully active turn, or queues behind a turn that is not yet steerable. Use queue for a separate follow-up turn, steer for an in-flight update, or restart to interrupt-and-restart the active turn. clientRequestId makes retries idempotent.", + "Send a message to a T3 thread in the calling project. Do not use a delegated task's childThreadId to start another review round here; use delegate_task with the full review context and a new clientRequestId for that round. Thread messages do not create a new delegated task or reopen a completed task. mode='auto' starts an idle thread, steers a fully active turn, or queues behind a turn that is not yet steerable. Use queue for a separate follow-up turn, steer for an in-flight update, or restart to interrupt-and-restart the active turn. clientRequestId makes retries idempotent.", parameters: OrchestratorMcpThreadSendInput, success: OrchestratorMcpThreadSendResult, failure: OrchestratorMcpFailure, diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts index dab6e7176498..e329ade90d9e 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts @@ -222,6 +222,58 @@ describe("pull request toolkit handlers", () => { }), ); + it.effect("watching an unlinked pull request links it first", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const result = yield* harness.call("watch_pull_request", { + url: "https://github.com/t3tools/t3code/pull/9", + }); + // The harness thread never changes, so the result reports what it still holds. + expect(result).toMatchObject({ number: 9, watching: false, wasWatching: false }); + expect(yield* Ref.get(harness.commands)).toMatchObject([ + { + type: "thread.pull-request.watch", + number: 9, + watching: true, + link: { url: "https://github.com/t3tools/t3code/pull/9", source: "agent" }, + }, + ]); + }), + ); + + it.effect("refuses to watch a merged pull request and stops an existing watch", () => + Effect.gen(function* () { + const watch = { + startedAt: "2026-08-20T00:00:00.000Z", + headSha: null, + failedChecks: [], + passed: false, + remarksThrough: "2026-08-20T00:00:00.000Z", + remarkIds: [], + conflicting: false, + wakes: 0, + }; + const merged = makeLink(1, { headBranch: "done" }); + const harness = yield* makeHarness({ + thread: makeThread([ + { ...merged, snapshot: merged.snapshot && { ...merged.snapshot, state: "merged" } }, + makeLink(2, { headBranch: "idle" }), + makeLink(3, { headBranch: "watched", watch }), + ]), + }); + const error = yield* harness + .call("watch_pull_request", { repository: "t3tools/t3code", number: 1 }) + .pipe(Effect.flip); + expect(error).toMatchObject({ _tag: "PullRequestNotOpenError", state: "merged" }); + expect( + yield* harness.call("unwatch_pull_request", { repository: "t3tools/t3code", number: 3 }), + ).toMatchObject({ wasWatching: true }); + expect(yield* Ref.get(harness.commands)).toMatchObject([ + { type: "thread.pull-request.watch", number: 3, watching: false }, + ]); + }), + ); + it.effect("links by repository and number, defaulting the host to the project's", () => Effect.gen(function* () { const harness = yield* makeHarness(); @@ -396,6 +448,7 @@ describe("pull request toolkit handlers", () => { number: 3, url: "https://github.com/t3tools/t3code/pull/3", source: "agent", + watching: false, state: "open", title: "PR 3", headBranch: "feat-c", diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts index 19677e1bd212..97af65e1a7bc 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts @@ -34,7 +34,9 @@ import { PullRequestHostRequiredError, PullRequestUnlinkFailedError, PullRequestListFailedError, + PullRequestNotOpenError, type PullRequestTargetInput, + PullRequestWatchFailedError, PullRequestThreadNotFoundError, PullRequestsToolkit, type ThreadPullRequestEntry, @@ -122,6 +124,7 @@ function entryOf( number: link.number, url: link.url, source: link.source, + watching: link.watch !== undefined, state: link.snapshot?.state ?? null, title: link.snapshot?.title ?? null, headBranch: link.snapshot?.headBranch ?? null, @@ -163,7 +166,8 @@ const make = Effect.gen(function* () { Failure: | typeof PullRequestLinkFailedError | typeof PullRequestUnlinkFailedError - | typeof PullRequestListFailedError, + | typeof PullRequestListFailedError + | typeof PullRequestWatchFailedError, ) { const scope = yield* McpInvocationContext.requireMcpCapability("pull-requests"); const thread = yield* engine @@ -178,7 +182,10 @@ const make = Effect.gen(function* () { const projectOf = ( thread: OrchestrationV2ThreadShell, - Failure: typeof PullRequestLinkFailedError | typeof PullRequestUnlinkFailedError, + Failure: + | typeof PullRequestLinkFailedError + | typeof PullRequestUnlinkFailedError + | typeof PullRequestWatchFailedError, ) => projects.getShell(thread.projectId).pipe( Effect.map(Option.getOrUndefined), @@ -186,14 +193,65 @@ const make = Effect.gen(function* () { ); const dispatchFailure = - (Failure: typeof PullRequestLinkFailedError | typeof PullRequestUnlinkFailedError) => + ( + Failure: + | typeof PullRequestLinkFailedError + | typeof PullRequestUnlinkFailedError + | typeof PullRequestWatchFailedError, + ) => ( cause: Cause.Cause, - ): Effect.Effect => + ): Effect.Effect< + never, + PullRequestLinkFailedError | PullRequestUnlinkFailedError | PullRequestWatchFailedError + > => Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause as Cause.Cause) : Effect.fail(new Failure({ cause })); + /** + * Starts or stops a watch. One command links an unlinked pull request and watches it, and the + * result reports the state the thread holds afterwards. + */ + const setWatching = Effect.fn("PullRequestsToolkit.setWatching")(function* ( + input: PullRequestTargetInput, + watching: boolean, + ) { + const thread = yield* requireThread(PullRequestWatchFailedError); + const project = yield* projectOf(thread, PullRequestWatchFailedError); + const target = yield* resolveTarget(input, project); + const watchedLink = (shell: OrchestrationV2ThreadShell) => + threadPullRequestsOf(shell).find( + (link) => link.source !== "stack-dismissed" && threadPullRequestKeysEqual(link, target), + ); + const before = watchedLink(thread); + const state = before?.snapshot?.state; + if (watching && state !== undefined && state !== "open") { + return yield* new PullRequestNotOpenError({ state }); + } + yield* engine + .dispatch({ + type: "thread.pull-request.watch", + commandId: yield* commandId("mcp-pr-watch", thread.id), + threadId: thread.id, + host: target.host, + repository: target.repository, + number: target.number, + watching, + ...(watching ? { link: { url: target.url, source: "agent" as const } } : {}), + }) + .pipe(Effect.catchCause(dispatchFailure(PullRequestWatchFailedError))); + const after = yield* requireThread(PullRequestWatchFailedError); + return { + host: target.host, + repository: target.repository, + number: target.number, + url: target.url, + watching: watchedLink(after)?.watch !== undefined, + wasWatching: before?.watch !== undefined, + }; + }); + return PullRequestsToolkit.of({ link_pull_request: (input) => Effect.gen(function* () { @@ -260,6 +318,8 @@ const make = Effect.gen(function* () { }), list_thread_pull_requests: () => requireThread(PullRequestListFailedError).pipe(Effect.map(listThreadPullRequests)), + watch_pull_request: (input) => setWatching(input, true), + unwatch_pull_request: (input) => setWatching(input, false), }); }); diff --git a/apps/server/src/mcp/toolkits/pullRequests/tools.ts b/apps/server/src/mcp/toolkits/pullRequests/tools.ts index 90f22cea2615..52ac473a3d50 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/tools.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/tools.ts @@ -108,6 +108,24 @@ export class PullRequestUnlinkFailedError extends Schema.TaggedError()( + "PullRequestWatchFailedError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Could not change whether the pull request is watched."; + } +} + +export class PullRequestNotOpenError extends Schema.TaggedError()( + "PullRequestNotOpenError", + { state: Schema.String }, +) { + override get message(): string { + return `The pull request is ${this.state}, so there is nothing to watch.`; + } +} + export class PullRequestListFailedError extends Schema.TaggedError()( "PullRequestListFailedError", { cause: Schema.Defect() }, @@ -126,6 +144,8 @@ export const PullRequestToolError = Schema.Union([ PullRequestLinkFailedError, PullRequestUnlinkFailedError, PullRequestListFailedError, + PullRequestWatchFailedError, + PullRequestNotOpenError, ]); export type PullRequestToolError = typeof PullRequestToolError.Type; @@ -154,9 +174,21 @@ export const UnlinkPullRequestResult = Schema.Struct({ }); export type UnlinkPullRequestResult = typeof UnlinkPullRequestResult.Type; +export const WatchPullRequestResult = Schema.Struct({ + ...PullRequestIdentity, + watching: Schema.Boolean.annotate({ + description: "Whether T3 Code now watches the pull request for this thread.", + }), + wasWatching: Schema.Boolean.annotate({ + description: "Whether it was already watched before the call.", + }), +}); +export type WatchPullRequestResult = typeof WatchPullRequestResult.Type; + export const ThreadPullRequestEntry = Schema.Struct({ ...PullRequestIdentity, source: ThreadPullRequestLinkSource, + watching: Schema.Boolean, state: Schema.NullOr(PullRequestState), title: Schema.NullOr(Schema.String), headBranch: Schema.NullOr(Schema.String), @@ -224,8 +256,38 @@ const ListThreadPullRequestsTool = Tool.make("list_thread_pull_requests", { .annotate(Tool.Idempotent, true) .annotate(Tool.OpenWorld, false); +const WatchPullRequestTool = Tool.make("watch_pull_request", { + description: + "Have T3 Code watch an open pull request for this thread, linking it first if needed. T3 Code checks it every minute and wakes you with a message when a check fails, the required checks pass, someone else comments or reviews, or the branch starts to conflict with its base. Use this to monitor or babysit a pull request instead of polling, sleeping, or running a watcher. Only comments posted after this call wake you, so handle the existing ones first, then end your turn. A wake is news, not a merge decision: check readiness yourself before merging. Watching ends when the pull request merges or closes, when T3 Code cannot read it for 15 minutes, or when you call unwatch_pull_request.", + parameters: PullRequestTargetInput, + success: WatchPullRequestResult, + failure: PullRequestToolError, + dependencies, +}) + .annotate(Tool.Title, "Watch pull request") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, false); + +const UnwatchPullRequestTool = Tool.make("unwatch_pull_request", { + description: + "Stop T3 Code from watching a pull request for this thread. The pull request stays linked. Pass the URL, or repository plus number.", + parameters: PullRequestTargetInput, + success: WatchPullRequestResult, + failure: PullRequestToolError, + dependencies, +}) + .annotate(Tool.Title, "Stop watching pull request") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, false); + export const PullRequestsToolkit = Toolkit.make( LinkPullRequestTool, UnlinkPullRequestTool, ListThreadPullRequestsTool, + WatchPullRequestTool, + UnwatchPullRequestTool, ); diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index 33b91614b4da..b6cb0be45acf 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -604,8 +604,9 @@ function makeTurnInput(input: { } describe("AcpAdapterV2", () => { - for (const outcome of ["failed", "recovered", "completed", "cancelled"] as const) { - it.live(`projects Mistral retry notices and their ${outcome} outcome`, () => + it.live.each(["failed", "recovered", "completed", "cancelled"] as const)( + "projects Mistral retry notices and their %s outcome", + (outcome) => Effect.gen(function* () { const path = yield* Path.Path; const instanceId = ProviderInstanceId.make(`vibe-retry-${outcome}`); @@ -692,8 +693,7 @@ describe("AcpAdapterV2", () => { assert.equal(retries.at(-1)?.title, "Provider recovered"); } }).pipe(Effect.provide(testLayer), Effect.scoped), - ); - } + ); it("preserves legacy ids and scopes v2 ids by provider instance", () => { const instanceId = ProviderInstanceId.make("acp-identity-test"); @@ -2914,8 +2914,9 @@ describe("AcpAdapterV2", () => { }).pipe(Effect.provide(testLayer)), ); - for (const model of ["grok-build", "composer-2"]) { - it.effect(`Grok configures the native session for ${model}`, () => + it.effect.each(["grok-build", "composer-2"])( + "Grok configures the native session for %s", + (model) => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileSystem = yield* FileSystem.FileSystem; @@ -2968,8 +2969,7 @@ describe("AcpAdapterV2", () => { model === "grok-build" ? 0 : 1, ); }).pipe(Effect.provide(testLayer), Effect.scoped), - ); - } + ); it.live("Grok reapplies an explicit return to the session's setup-time model", () => Effect.gen(function* () { @@ -4925,9 +4925,13 @@ describe("AcpAdapterV2", () => { const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const protocolEvents = yield* Queue.bounded(256); const instanceId = ProviderInstanceId.make("acp-test"); + const promptSettled = yield* Deferred.make(); const adapter = makeAcpAdapterV2({ + testHooks: { + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), + }, crypto: yield* Crypto.Crypto, instanceId, flavor: { @@ -4950,7 +4954,6 @@ describe("AcpAdapterV2", () => { childProcessSpawner, mockAgentPath, environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - protocolEvents, }), }, fileSystem, @@ -4986,28 +4989,13 @@ describe("AcpAdapterV2", () => { makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), ); // The still-running subagent defers finalize after session/prompt returns. - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "incoming" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes('"stopReason"'), - ), - Stream.runHead, - ); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + yield* Deferred.await(promptSettled); const providerTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - const interruptFiber = yield* runtime - .interruptTurn({ providerThread, providerTurnId }) - .pipe(Effect.forkScoped); - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(interruptFiber); + yield* runtime.interruptTurn({ providerThread, providerTurnId }); let terminalStatus: string | null = null; while (terminalStatus === null) { @@ -5033,10 +5021,14 @@ describe("AcpAdapterV2", () => { const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const protocolEvents = yield* Queue.bounded(256); const instanceId = ProviderInstanceId.make("acp-test"); let subagentPhase: "spawn" | "complete" = "spawn"; + const promptSettled = yield* Deferred.make(); const adapter = makeAcpAdapterV2({ + testHooks: { + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), + }, crypto: yield* Crypto.Crypto, instanceId, flavor: { @@ -5072,7 +5064,6 @@ describe("AcpAdapterV2", () => { childProcessSpawner, mockAgentPath, environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - protocolEvents, }), }, fileSystem, @@ -5107,28 +5098,13 @@ describe("AcpAdapterV2", () => { yield* runtime.startTurn( makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), ); - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "incoming" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes('"stopReason"'), - ), - Stream.runHead, - ); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + yield* Deferred.await(promptSettled); const firstProviderTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - const interruptFiber = yield* runtime - .interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }) - .pipe(Effect.forkScoped); - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(interruptFiber); + yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); let subagentTurnItemId: string | null = null; let firstTerminalStatus: string | null = null; @@ -5193,10 +5169,14 @@ describe("AcpAdapterV2", () => { const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const protocolEvents = yield* Queue.bounded(256); const instanceId = ProviderInstanceId.make("acp-test"); let subagentPhase: "spawn" | "complete" = "spawn"; + const promptSettled = yield* Deferred.make(); const adapter = makeAcpAdapterV2({ + testHooks: { + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), + }, crypto: yield* Crypto.Crypto, instanceId, flavor: { @@ -5230,7 +5210,6 @@ describe("AcpAdapterV2", () => { childProcessSpawner, mockAgentPath, environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - protocolEvents, }), }, fileSystem, @@ -5272,28 +5251,13 @@ describe("AcpAdapterV2", () => { yield* runtime.startTurn( makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), ); - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "incoming" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes('"stopReason"'), - ), - Stream.runHead, - ); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + yield* Deferred.await(promptSettled); const firstProviderTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - const interruptFiber = yield* runtime - .interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }) - .pipe(Effect.forkScoped); - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(interruptFiber); + yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); let firstTerminalStatus: string | null = null; while (firstTerminalStatus === null) { @@ -5352,7 +5316,6 @@ describe("AcpAdapterV2", () => { const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const protocolEvents = yield* Queue.bounded(256); const secondPromptWireReturned = yield* Deferred.make(); const releaseSecondPromptCompletion = yield* Deferred.make(); const instanceId = ProviderInstanceId.make("acp-test"); @@ -5361,7 +5324,12 @@ describe("AcpAdapterV2", () => { let subagentPhase: "spawn" | "complete" = "spawn"; type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; let sessionUpdateHandler: Parameters[0] | undefined; + const promptSettled = yield* Deferred.make(); const adapter = makeAcpAdapterV2({ + testHooks: { + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), + }, crypto: yield* Crypto.Crypto, instanceId, flavor: { @@ -5395,7 +5363,6 @@ describe("AcpAdapterV2", () => { childProcessSpawner, mockAgentPath, environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - protocolEvents, wrapRuntime: (runtime) => ({ ...runtime, handleSessionUpdate: (handler) => @@ -5456,28 +5423,13 @@ describe("AcpAdapterV2", () => { yield* runtime.startTurn( makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), ); - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "incoming" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes('"stopReason"'), - ), - Stream.runHead, - ); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + yield* Deferred.await(promptSettled); const firstProviderTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - const interruptFiber = yield* runtime - .interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }) - .pipe(Effect.forkScoped); - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(interruptFiber); + yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); let firstTerminalStatus: string | null = null; while (firstTerminalStatus === null) { @@ -5601,7 +5553,6 @@ describe("AcpAdapterV2", () => { const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const protocolEvents = yield* Queue.bounded(256); const baseClock = yield* Clock.Clock; const finalizationClockRead = yield* Deferred.make(); const releaseFinalizationClockRead = yield* Deferred.make(); @@ -5629,7 +5580,12 @@ describe("AcpAdapterV2", () => { let sessionUpdateHandler: | Parameters[0] | undefined; + const promptSettled = yield* Deferred.make(); const adapter = makeAcpAdapterV2({ + testHooks: { + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), + }, crypto: yield* Crypto.Crypto, instanceId, flavor: { @@ -5668,7 +5624,6 @@ describe("AcpAdapterV2", () => { childProcessSpawner, mockAgentPath, environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - protocolEvents, wrapRuntime: (runtime) => ({ ...runtime, handleSessionUpdate: (handler) => @@ -5719,18 +5674,7 @@ describe("AcpAdapterV2", () => { yield* runtime.startTurn( makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), ); - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "incoming" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes('"stopReason"'), - ), - Stream.runHead, - ); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + yield* Deferred.await(promptSettled); yield* TestClock.adjust("1 second"); assert.isDefined(sessionUpdateHandler, "session update handler must be wired"); @@ -6451,14 +6395,18 @@ describe("AcpAdapterV2", () => { const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const protocolEvents = yield* Queue.bounded(256); const continuationRequests: Array = []; const bufferedAssistantText = "BUFFERED_WAKE_AFTER_USER_ATTACH"; const instanceId = ProviderInstanceId.make("acp-test"); let subagentPhase: "spawn" | "complete" = "spawn"; type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; let sessionUpdateHandler: Parameters[0] | undefined; + const promptSettled = yield* Deferred.make(); const adapter = makeAcpAdapterV2({ + testHooks: { + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), + }, crypto: yield* Crypto.Crypto, instanceId, flavor: { @@ -6492,7 +6440,6 @@ describe("AcpAdapterV2", () => { childProcessSpawner, mockAgentPath, environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - protocolEvents, wrapRuntime: (runtime) => ({ ...runtime, handleSessionUpdate: (handler) => @@ -6548,28 +6495,13 @@ describe("AcpAdapterV2", () => { yield* runtime.startTurn( makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), ); - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "incoming" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes('"stopReason"'), - ), - Stream.runHead, - ); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + yield* Deferred.await(promptSettled); const firstProviderTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - const interruptFiber = yield* runtime - .interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }) - .pipe(Effect.forkScoped); - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(interruptFiber); + yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); let firstTerminalStatus: string | null = null; while (firstTerminalStatus === null) { @@ -6752,14 +6684,18 @@ describe("AcpAdapterV2", () => { new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); const continuationRequests: Array = []; - const protocolEvents = yield* Queue.bounded(256); const promptWireReturned = yield* Deferred.make(); const releasePromptCompletion = yield* Deferred.make(); const instanceId = ProviderInstanceId.make("acp-test"); let subagentPhase: "spawn" | "complete" = "spawn"; type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; let sessionUpdateHandler: Parameters[0] | undefined; + const promptSettled = yield* Deferred.make(); const adapter = makeAcpAdapterV2({ + testHooks: { + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), + }, crypto: yield* Crypto.Crypto, instanceId, flavor: { @@ -6795,7 +6731,6 @@ describe("AcpAdapterV2", () => { childProcessSpawner, mockAgentPath, environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - protocolEvents, wrapRuntime: (runtime) => ({ ...runtime, handleSessionUpdate: (handler) => @@ -6875,18 +6810,13 @@ describe("AcpAdapterV2", () => { }, }); yield* Deferred.succeed(releasePromptCompletion, undefined); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + yield* Deferred.await(promptSettled); const firstProviderTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - const interruptFiber = yield* runtime - .interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }) - .pipe(Effect.forkScoped); - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(interruptFiber); + yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); let firstTerminalStatus: string | null = null; while (firstTerminalStatus === null) { @@ -7223,7 +7153,6 @@ describe("AcpAdapterV2", () => { const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const protocolEvents = yield* Queue.bounded(256); const continuationRequests: Array = []; const instanceId = ProviderInstanceId.make("acp-test"); const childSessionId = "mock-child-session-pending-continuation"; @@ -7231,7 +7160,12 @@ describe("AcpAdapterV2", () => { let subagentPhase: "spawn" | "complete" = "spawn"; type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; let sessionUpdateHandler: Parameters[0] | undefined; + const promptSettled = yield* Deferred.make(); const adapter = makeAcpAdapterV2({ + testHooks: { + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), + }, crypto: yield* Crypto.Crypto, instanceId, flavor: { @@ -7265,7 +7199,6 @@ describe("AcpAdapterV2", () => { childProcessSpawner, mockAgentPath, environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - protocolEvents, wrapRuntime: (runtime) => ({ ...runtime, handleSessionUpdate: (handler) => @@ -7321,28 +7254,13 @@ describe("AcpAdapterV2", () => { yield* runtime.startTurn( makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), ); - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "incoming" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes('"stopReason"'), - ), - Stream.runHead, - ); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + yield* Deferred.await(promptSettled); const firstProviderTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - const interruptFiber = yield* runtime - .interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }) - .pipe(Effect.forkScoped); - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(interruptFiber); + yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); let firstTerminalStatus: string | null = null; while (firstTerminalStatus === null) { @@ -7506,14 +7424,18 @@ describe("AcpAdapterV2", () => { const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const protocolEvents = yield* Queue.bounded(256); const continuationRequests: Array = []; const instanceId = ProviderInstanceId.make("acp-test"); const childSessionId = "mock-child-session-root-then-child"; let subagentPhase: "spawn" | "complete" = "spawn"; type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; let sessionUpdateHandler: Parameters[0] | undefined; + const promptSettled = yield* Deferred.make(); const adapter = makeAcpAdapterV2({ + testHooks: { + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), + }, crypto: yield* Crypto.Crypto, instanceId, flavor: { @@ -7547,7 +7469,6 @@ describe("AcpAdapterV2", () => { childProcessSpawner, mockAgentPath, environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - protocolEvents, wrapRuntime: (runtime) => ({ ...runtime, handleSessionUpdate: (handler) => @@ -7603,28 +7524,13 @@ describe("AcpAdapterV2", () => { yield* runtime.startTurn( makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), ); - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "incoming" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes('"stopReason"'), - ), - Stream.runHead, - ); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + yield* Deferred.await(promptSettled); const firstProviderTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - const interruptFiber = yield* runtime - .interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }) - .pipe(Effect.forkScoped); - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(interruptFiber); + yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); let firstTerminalStatus: string | null = null; while (firstTerminalStatus === null) { @@ -7760,12 +7666,16 @@ describe("AcpAdapterV2", () => { const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const protocolEvents = yield* Queue.bounded(256); const instanceId = ProviderInstanceId.make("acp-test"); let subagentPhase: "spawn" | "complete" = "spawn"; let cancelCalled = false; let runtimeOrdinalSeen = 0; + const promptSettled = yield* Deferred.make(); const adapter = makeAcpAdapterV2({ + testHooks: { + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), + }, crypto: yield* Crypto.Crypto, instanceId, flavor: { @@ -7812,7 +7722,6 @@ describe("AcpAdapterV2", () => { runtimeOrdinalSeen = Math.max(runtimeOrdinalSeen, runtimeOrdinal); return { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }; }, - protocolEvents, wrapCancel: (cancel) => Effect.sync(() => { cancelCalled = true; @@ -7853,28 +7762,13 @@ describe("AcpAdapterV2", () => { ); // The still-running subagent defers finalize after session/prompt returns, // so the interrupt below hits a settled turn held open for background work. - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "incoming" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes('"stopReason"'), - ), - Stream.runHead, - ); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + yield* Deferred.await(promptSettled); const firstProviderTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - const interruptFiber = yield* runtime - .interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }) - .pipe(Effect.forkScoped); - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(interruptFiber); + yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); assert.isFalse( cancelCalled, "settled soft steer must not send session/cancel (the real Grok CLI kills background subagents on cancel)", @@ -13725,10 +13619,14 @@ describe("AcpAdapterV2", () => { const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const protocolEvents = yield* Queue.bounded(256); const instanceId = ProviderInstanceId.make("acp-test"); let subagentPhase: "spawn" | "complete" = "spawn"; + const promptSettled = yield* Deferred.make(); const adapter = makeAcpAdapterV2({ + testHooks: { + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), + }, crypto: yield* Crypto.Crypto, instanceId, flavor: { @@ -13762,7 +13660,6 @@ describe("AcpAdapterV2", () => { childProcessSpawner, mockAgentPath, environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - protocolEvents, }), }, fileSystem, @@ -13799,32 +13696,17 @@ describe("AcpAdapterV2", () => { yield* runtime.startTurn( makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), ); - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "incoming" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes('"stopReason"'), - ), - Stream.runHead, - ); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + yield* Deferred.await(promptSettled); const firstProviderTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - const interruptFiber = yield* runtime - .interruptTurn({ - providerThread, - providerTurnId: firstProviderTurnId, - requestRuntimeRestart: true, - }) - .pipe(Effect.forkScoped); - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(interruptFiber); + yield* runtime.interruptTurn({ + providerThread, + providerTurnId: firstProviderTurnId, + requestRuntimeRestart: true, + }); let subagentStatus: string | null = null; let firstTerminalStatus: string | null = null; diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 6d3d1f9cdd07..b837575c1faf 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -45,6 +45,8 @@ import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import { Tool } from "effect/unstable/ai"; import { formatClaudeResumeCompactionQuestion } from "@t3tools/shared/claudeCompaction"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import { attachmentRelativePath } from "../../attachmentStore.ts"; import * as ServerConfig from "../../config.ts"; @@ -55,6 +57,7 @@ import { ProjectToolkit } from "../../mcp/toolkits/project/tools.ts"; import { WorktreeToolkit } from "../../mcp/toolkits/worktree/tools.ts"; import { ThreadToolkit } from "../../mcp/toolkits/thread/tools.ts"; import { OrchestratorToolkit } from "../../mcp/toolkits/orchestrator/tools.ts"; +import { ClaudeExecutableFileCheck } from "../../provider/Drivers/ClaudeExecutable.ts"; import type { EventNdjsonLogger } from "../../provider/Layers/EventNdjsonLogger.ts"; import { ProviderAdapterV2RuntimePolicy, @@ -944,6 +947,7 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => { messages: Stream.never, offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Effect.void, }; @@ -1064,72 +1068,98 @@ describe("ClaudeAdapterV2 approval cancellation", () => { ); }); +// Opens a session with the given configured binary path, runs one turn, and +// returns the executable paths the SDK was asked to spawn. +const captureSdkExecutablePaths = Effect.fn("captureSdkExecutablePaths")(function* ( + binaryPath: string, +) { + const executablePaths: Array = []; + const adapter = yield* ClaudeAdapterV2.createClaudeAdapterV2( + { + instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, + displayName: undefined, + environment: [], + enabled: true, + config: { ...DEFAULT_CLAUDE_SETTINGS, binaryPath }, + }, + {}, + ).pipe( + Effect.provide( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-claude-binary-path-", + }), + ), + Effect.provideService(ClaudeAdapterV2.ClaudeAgentSdkQueryRunner, { + allocateSessionId: Effect.succeed("native-thread-claude-binary-path"), + open: (input) => + Effect.sync(() => { + executablePaths.push(input.options.pathToClaudeCodeExecutable); + return { + messages: Stream.never, + offer: () => Effect.void, + setModel: () => Effect.void, + setPermissionMode: () => Effect.void, + interrupt: Effect.void, + close: Effect.void, + }; + }), + forkSession: () => Effect.die("unused"), + subagentLaunchToolUseId: () => Effect.succeed(null), + assertComplete: Effect.void, + }), + ); + const threadId = ThreadId.make("thread-claude-binary-path"); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-claude-binary-path"), + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + yield* runtime.startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-claude-binary-path"), + text: "hello", + attachments: [], + }), + ); + return executablePaths; +}); + describe("ClaudeAdapterV2 executable path", () => { it.effect("expands ~ in the configured binary path for the SDK", () => Effect.scoped( Effect.gen(function* () { const path = yield* Path.Path; - const executablePaths: Array = []; - const adapter = yield* ClaudeAdapterV2.createClaudeAdapterV2( - { - instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, - displayName: undefined, - environment: [], - enabled: true, - config: { ...DEFAULT_CLAUDE_SETTINGS, binaryPath: "~/bin/claude" }, - }, - {}, - ).pipe( - Effect.provide( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-claude-binary-home-", - }), - ), - Effect.provideService(ClaudeAdapterV2.ClaudeAgentSdkQueryRunner, { - allocateSessionId: Effect.succeed("native-thread-claude-binary-home"), - open: (input) => - Effect.sync(() => { - executablePaths.push(input.options.pathToClaudeCodeExecutable); - return { - messages: Stream.never, - offer: () => Effect.void, - setModel: () => Effect.void, - interrupt: Effect.void, - close: Effect.void, - }; - }), - forkSession: () => Effect.die("unused"), - subagentLaunchToolUseId: () => Effect.succeed(null), - assertComplete: Effect.void, - }), - ); - const threadId = ThreadId.make("thread-claude-binary-home"); - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-claude-binary-home"), - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, - }); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, - }); - yield* runtime.startTurn( - makeClaudeTestTurnInput({ - threadId, - providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("attempt-claude-binary-home"), - text: "hello", - attachments: [], - }), - ); + const executablePaths = yield* captureSdkExecutablePaths("~/bin/claude"); assert.deepEqual(executablePaths, [path.join(NodeOS.homedir(), "bin", "claude")]); }), ).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), ); + + it.effect("follows a bare claude on Windows to the npm package executable", () => + Effect.scoped( + Effect.gen(function* () { + const npmDir = "C:\\Users\\dev\\AppData\\Roaming\\npm"; + const packageExe = `${npmDir}\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe`; + const executablePaths = yield* captureSdkExecutablePaths("claude").pipe( + Effect.provideService(HostProcessPlatform, "win32"), + Effect.provideService(SpawnExecutableResolution, () => `${npmDir}\\claude.cmd`), + Effect.provideService(ClaudeExecutableFileCheck, (filePath) => filePath === packageExe), + ); + + assert.deepEqual(executablePaths, [packageExe]); + }), + ).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ); }); describe("ClaudeAdapterV2 resume compaction", () => { @@ -1159,6 +1189,7 @@ describe("ClaudeAdapterV2 resume compaction", () => { messages: Stream.never, offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Effect.void, }; @@ -1378,6 +1409,7 @@ describe("ClaudeAdapterV2 attachments", () => { offeredMessages.push(message); }), setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Effect.void, }), @@ -1516,6 +1548,7 @@ describe("ClaudeAdapterV2 attachments", () => { messages: Stream.never, offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Effect.void, }; @@ -1604,6 +1637,7 @@ describe("ClaudeAdapterV2 native fork", () => { messages: Stream.empty, offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Effect.void, }; @@ -1775,6 +1809,7 @@ describe("ClaudeAdapterV2 native session identity", () => { messages: Stream.empty, offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Effect.void, }; @@ -1976,6 +2011,13 @@ describe("ClaudeAdapterV2 background wake turns", () => { uuid: "00000000-0000-4000-8000-000000000107", text: WAKE_ASSISTANT_TEXT, }); + // The CLI opens the wake turn with `init`, seconds before its first output. + const wakeTurnInit = claudeSdkFrame({ + type: "system", + subtype: "init", + uuid: "00000000-0000-4000-8000-000000000110", + session_id: WAKE_NATIVE_SESSION, + }); const wakeResult = makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000104", result: WAKE_RESULT_TEXT, @@ -2023,6 +2065,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* Deferred.await(processed); }); const offeredMessages: Array = []; + const permissionModeChanges: Array = []; const continuationRequests: Array = []; const terminalReceipts = yield* Queue.unbounded>(); @@ -2071,6 +2114,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { offeredMessages.push(message); }), setModel: () => Effect.void, + setPermissionMode: (mode) => + Effect.sync(() => { + permissionModeChanges.push(mode); + }), interrupt: options?.interrupt ?? Effect.void, close: options?.close?.(sdkMessages) ?? Effect.void, }; @@ -2123,6 +2170,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { sdkMessages, offerAndWait, offeredMessages, + permissionModeChanges, continuationRequests, events, terminalReceipts, @@ -2252,78 +2300,78 @@ describe("ClaudeAdapterV2 background wake turns", () => { }).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), ); - for (const terminalReason of ["aborted_tools", "aborted_streaming"] as const) { - for (const steered of [true, false]) { - it.effect(`handles ${terminalReason} with active steering=${steered}`, () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const attemptId = RunAttemptId.make("attempt-steering-abort"); - const input = makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId, - text: "Audit the settings pages.", + it.effect.each( + (["aborted_tools", "aborted_streaming"] as const).flatMap((terminalReason) => + [true, false].map((steered) => ({ terminalReason, steered })), + ), + )("handles $terminalReason with active steering=$steered", ({ terminalReason, steered }) => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const attemptId = RunAttemptId.make("attempt-steering-abort"); + const input = makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId, + text: "Audit the settings pages.", + attachments: [], + }); + yield* harness.runtime.startTurn(input); + if (steered) { + yield* harness.runtime.steerTurn({ + threadId: harness.threadId, + runId: input.runId, + providerThread: harness.providerThread, + providerTurnId: idAllocator.derive.providerTurn({ + driver: ClaudeAdapterV2.CLAUDE_PROVIDER, + nativeTurnId: `turn:${attemptId}`, + }), + message: { + createdBy: "user", + creationSource: "web", + messageId: MessageId.make("message-steering-abort"), + text: "Include the hierarchy mock.", attachments: [], - }); - yield* harness.runtime.startTurn(input); - if (steered) { - yield* harness.runtime.steerTurn({ - threadId: harness.threadId, - runId: input.runId, - providerThread: harness.providerThread, - providerTurnId: idAllocator.derive.providerTurn({ - driver: ClaudeAdapterV2.CLAUDE_PROVIDER, - nativeTurnId: `turn:${attemptId}`, - }), - message: { - createdBy: "user", - creationSource: "web", - messageId: MessageId.make("message-steering-abort"), - text: "Include the hierarchy mock.", - attachments: [], - }, - }); - assert.equal(harness.offeredMessages[1]?.priority, "now"); - } - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000901", - result: "", - terminalReason, - }), - ); - if (steered) { - yield* Queue.offer(harness.sdkMessages, wakeAssistant); - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000902", - result: "Audit finished after the steer.", - }), - ); - } - const terminal = yield* Queue.take(harness.terminalReceipts); - assert.equal(terminal.status, steered ? "completed" : "interrupted"); - if (steered) { - assert.isTrue( - harness.events.some( - (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "assistant_message" && - event.turnItem.text === WAKE_ASSISTANT_TEXT, - ), - ); - } - assert.lengthOf(harness.terminalEvents(), 1); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), - ), - ); - } - } + }, + }); + assert.equal(harness.offeredMessages[1]?.priority, "now"); + } + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000901", + result: "", + terminalReason, + }), + ); + if (steered) { + yield* Queue.offer(harness.sdkMessages, wakeAssistant); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000902", + result: "Audit finished after the steer.", + }), + ); + } + const terminal = yield* Queue.take(harness.terminalReceipts); + assert.equal(terminal.status, steered ? "completed" : "interrupted"); + if (steered) { + assert.isTrue( + harness.events.some( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "assistant_message" && + event.turnItem.text === WAKE_ASSISTANT_TEXT, + ), + ); + } + assert.lengthOf(harness.terminalEvents(), 1); + }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ), + ); it.effect("announces usage-limit pauses once per window and again on a new turn", () => Effect.gen(function* () { @@ -2771,7 +2819,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, IdAllocator.layer))), ); - it.effect("retains image preview paths on Claude Read tool completion", () => + it.effect("titles Claude reads, searches, and skills on tool completion", () => Effect.gen(function* () { const harness = yield* makeWakeHarness; const now = yield* DateTime.now; @@ -2789,6 +2837,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { { id: "image", name: "Read", input: { file_path: " /workspace/reference.png " } }, { id: "text", name: "Read", input: { file_path: "/workspace/README.md" } }, { id: "search", name: "Grep", input: { pattern: "TODO", path: "/workspace/src" } }, + { id: "skill", name: "Skill", input: { skill: "full-send" } }, { id: "write", name: "Write", @@ -2859,6 +2908,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { items.find((item) => item.nativeItemRef?.nativeId === "search")?.title, "Searched TODO in src", ); + assert.equal( + items.find((item) => item.nativeItemRef?.nativeId === "skill")?.title, + "Skill: full-send", + ); for (const item of items.filter((item) => item.nativeItemRef?.nativeId !== "image")) assert.notProperty(item, "viewedImagePath"); }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, IdAllocator.layer))), @@ -2945,6 +2998,18 @@ describe("ClaudeAdapterV2 background wake turns", () => { harness.sdkMessages, toolResults("00000000-0000-4000-8000-000000000502", ["tool-todo-1"]), ); + // Claude entered plan mode on its own (EnterPlanMode). + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "status", + status: null, + permissionMode: "plan", + uuid: "00000000-0000-4000-8000-000000000508", + session_id: WAKE_NATIVE_SESSION, + }), + ); yield* Queue.offer( harness.sdkMessages, makeResultFrame({ @@ -3055,6 +3120,9 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); const proposedPlan = [...plans.values()].find((plan) => plan.kind === "proposed_plan"); assert.equal(proposedPlan?.status, "active"); + // The second prompt reuses the live process, which is still in the + // plan mode Claude entered, so it is put back in the thread's mode. + assert.deepEqual(harness.permissionModeChanges, ["bypassPermissions"]); }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), ), ); @@ -3187,7 +3255,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - for (const terminalReason of [ + it.effect.each([ "api_error", "malformed_tool_use_exhausted", "budget_exhausted", @@ -3200,56 +3268,54 @@ describe("ClaudeAdapterV2 background wake turns", () => { "image_error", "model_error", "overloaded_status", - ] as const) { - it.effect(`fails a success-shaped Claude result with ${terminalReason}`, () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-structured-terminal-failure"), - text: "Complete the task.", - attachments: [], - }), - ); - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000205", - result: "Provider failure details.", - isError: false, - ...(terminalReason === "overloaded_status" - ? { apiErrorStatus: 529 } - : { terminalReason }), - }), - ); - const terminal = yield* Queue.take(harness.terminalReceipts); - assert.equal(terminal.status, "failed"); - if (terminal.status !== "failed") return; - assert.isNotEmpty(terminal.failure.message); - assert.equal( - terminal.failure.class, - terminalReason === "blocking_limit" ? "usage_limit" : "provider_error", - ); - assert.isFalse( - harness.events.some( - (event) => - event.type === "message.updated" && - event.message.text === "Provider failure details.", - ), - ); - assert.equal( - terminal.failure.code, - terminalReason === "overloaded_status" ? "api_error_529" : terminalReason, - ); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), - ), - ); - } + ] as const)("fails a success-shaped Claude result with %s", (terminalReason) => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-structured-terminal-failure"), + text: "Complete the task.", + attachments: [], + }), + ); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000205", + result: "Provider failure details.", + isError: false, + ...(terminalReason === "overloaded_status" + ? { apiErrorStatus: 529 } + : { terminalReason }), + }), + ); + const terminal = yield* Queue.take(harness.terminalReceipts); + assert.equal(terminal.status, "failed"); + if (terminal.status !== "failed") return; + assert.isNotEmpty(terminal.failure.message); + assert.equal( + terminal.failure.class, + terminalReason === "blocking_limit" ? "usage_limit" : "provider_error", + ); + assert.isFalse( + harness.events.some( + (event) => + event.type === "message.updated" && + event.message.text === "Provider failure details.", + ), + ); + assert.equal( + terminal.failure.code, + terminalReason === "overloaded_status" ? "api_error_529" : terminalReason, + ); + }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ), + ); const providerThreadRosterEvents = (events: ReadonlyArray) => events.filter( @@ -3577,6 +3643,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { messages: Stream.fromQueue(sdkMessages), offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, // The first CLI process keeps streaming until the test ends // it, so Stop stays parked waiting for it to exit. @@ -3826,6 +3893,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { messages: Stream.fromQueue(queue), offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(queue), }; @@ -4610,6 +4678,63 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect("starts the wake run when Claude opens the wake turn, before its output", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-wake-init-1"), + text: "Run the build in the background.", + attachments: [], + }), + ); + yield* Queue.offer(harness.sdkMessages, wakeTaskStarted); + yield* Queue.offer(harness.sdkMessages, turnOneResult); + yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); + + yield* harness.offerAndWait(wakeNotification); + assert.lengthOf(harness.continuationRequests, 0); + yield* harness.offerAndWait(wakeTurnInit); + assert.lengthOf(harness.continuationRequests, 1); + assert.equal(harness.continuationRequests[0]?.detail, WAKE_SUMMARY); + + // The run attaches while Claude still thinks: only the notification + // and `init` are buffered, so the run waits for the turn's output. + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-wake-init-2"), + text: "Background task completed.", + attachments: [], + providerTurnOrdinal: 2, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + assert.lengthOf(harness.terminalEvents(), 1); + + yield* harness.offerAndWait(wakeAssistant); + yield* harness.offerAndWait(wakeResult); + yield* awaitUntil(() => harness.terminalEvents().length === 2, "wake run terminal"); + assert.equal(harness.terminalEvents()[1]?.status, "completed"); + assert.lengthOf(harness.continuationRequests, 1); + assert.isTrue( + harness.events.some( + (event) => event.type === "message.updated" && event.message.text === WAKE_RESULT_TEXT, + ), + ); + }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ), + ); + it.effect("does not offer a continuation for notification-only opaque work", () => Effect.scoped( Effect.gen(function* () { @@ -6560,6 +6685,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { messages: Stream.fromQueue(sdkMessages), offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, // End this process stream so openQuery can replace it. close: Queue.shutdown(sdkMessages), @@ -6742,6 +6868,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { messages: Stream.fromQueue(sdkMessages), offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), }; @@ -6976,6 +7103,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { messages: Stream.fromQueue(sdkMessages), offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), }; @@ -7162,6 +7290,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { messages: Stream.fromQueue(sdkMessages), offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), }; @@ -7338,6 +7467,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { messages: Stream.fromQueue(sdkMessages), offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), }; @@ -7469,6 +7599,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { messages: Stream.fromQueue(sdkMessages), offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), }; diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts index ca133173a9eb..39b0d44ba9ed 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts @@ -184,6 +184,11 @@ interface ClaudeQuerySetModelFrame { readonly model: string; } +interface ClaudeQuerySetPermissionModeFrame { + readonly type: "query.set_permission_mode"; + readonly mode: string; +} + interface ClaudeQueryInterruptFrame { readonly type: "query.interrupt"; } @@ -239,6 +244,7 @@ type ClaudeOutboundFrame = | ClaudeQueryOpenFrame | ClaudePromptOfferFrame | ClaudeQuerySetModelFrame + | ClaudeQuerySetPermissionModeFrame | ClaudeQueryInterruptFrame | ClaudePermissionResponseFrame | ClaudeSessionForkFrame @@ -811,6 +817,13 @@ function makeReplayQueryRunner( model, }); }), + setPermissionMode: (mode) => + replayEffect(() => { + assertNextOutboundFrame({ + type: "query.set_permission_mode", + mode, + }); + }), interrupt: replayEffect(() => { assertNextOutboundFrame({ type: "query.interrupt" }); }), diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 99f77ee0779c..0aca36abe77c 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -1,7 +1,11 @@ import * as NodeCrypto from "node:crypto"; import { makeProviderTextDeltaCoalescer } from "./ProviderTextDeltaCoalescer.ts"; -import { formatReadToolLabel, formatSearchToolLabel } from "@t3tools/shared/toolActivity"; +import { + dynamicToolTitle, + formatReadToolLabel, + formatSearchToolLabel, +} from "@t3tools/shared/toolActivity"; import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; import { normalizeClaudeTurnTokenUsage } from "../../provider/ClaudeTurnTokenUsage.ts"; import { @@ -83,6 +87,7 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { resolveClaudeSdkExecutablePath } from "../../provider/Drivers/ClaudeExecutable.ts"; import { planClaudeSkillDispatch } from "../../provider/Drivers/ClaudeSkillDispatch.ts"; import { discoverClaudeSkills } from "../../provider/Drivers/ClaudeSkills.ts"; import { compileClaudeModelSelection } from "../../claudeModelOptions.ts"; @@ -322,6 +327,9 @@ export interface ClaudeAgentSdkQuerySession { readonly messages: Stream.Stream; readonly offer: (message: SDKUserMessage) => Effect.Effect; readonly setModel: (model: string) => Effect.Effect; + readonly setPermissionMode: ( + mode: PermissionMode, + ) => Effect.Effect; readonly interrupt: Effect.Effect; readonly close: Effect.Effect; } @@ -461,6 +469,14 @@ export type ClaudeAgentSdkProtocolLogEvent = readonly model: string; }; } + | { + readonly direction: "outgoing"; + readonly stage: "decoded"; + readonly payload: { + readonly type: "query.set_permission_mode"; + readonly mode: PermissionMode; + }; + } | { readonly direction: "outgoing"; readonly stage: "decoded"; @@ -665,6 +681,22 @@ export const claudeAgentSdkQueryRunnerLiveLayer: Layer.Layer< }), ), ), + setPermissionMode: (mode) => + Effect.tryPromise({ + try: () => queryRuntime.setPermissionMode(mode), + catch: (cause) => queryRunnerError(cause, "setPermissionMode"), + }).pipe( + Effect.tap(() => + logProtocolEvent({ + direction: "outgoing", + stage: "decoded", + payload: { + type: "query.set_permission_mode", + mode, + }, + }), + ), + ), interrupt: Effect.tryPromise({ try: () => queryRuntime.interrupt(), catch: (cause) => queryRunnerError(cause, "interrupt"), @@ -1734,6 +1766,13 @@ function isClaudeBackgroundTasksChangedMessage(message: SDKMessage): boolean { ); } +// Claude opens every turn it runs with a root `init` frame. Outside a T3 turn +// that turn is a wake, and `init` comes 20-110 ms after the notification that +// caused it but seconds before its first output (model thinking time). +function isClaudeTurnStartMessage(message: SDKMessage): boolean { + return message.type === "system" && message.subtype === "init"; +} + function claudePendingBackgroundTasksFromRoster( roster: ReadonlyMap, ): ReadonlyArray { @@ -2676,6 +2715,11 @@ interface ClaudeLiveQueryContext { // uuid before any echo, so it echoes, but a resume's own turns can still // run ahead of that prompt. promptEchoMode: "unknown" | "acknowledged" | "early" | "result_only"; + // The mode this process was opened in, and the mode the CLI last reported + // (init and status frames). Claude changes the latter itself through + // EnterPlanMode. + readonly openedPermissionMode: PermissionMode; + permissionMode: PermissionMode; // Stop, rollback or fork is closing this process; its work is ending. stopping: boolean; // Registry entries still running when this process opened. Their process @@ -3661,7 +3705,7 @@ export function makeClaudeAdapterV2( readonly output: ClaudeNativeToolOutput; readonly status: Extract< OrchestrationV2TurnItem["status"], - "running" | "completed" | "failed" + "running" | "completed" | "failed" | "interrupted" | "cancelled" >; readonly startedAt: DateTime.Utc; readonly updatedAt: DateTime.Utc; @@ -3726,7 +3770,10 @@ export function makeClaudeAdapterV2( title: readPath !== undefined ? formatReadToolLabel(readPath) - : (searchTitle ?? input.presentation?.title ?? null), + : (searchTitle ?? + dynamicToolTitle(input.toolName, nativeToolInput) ?? + input.presentation?.title ?? + null), startedAt: input.startedAt, completedAt, updatedAt: input.updatedAt, @@ -4725,7 +4772,9 @@ export function makeClaudeAdapterV2( parentNodeId: toolCall.parentNodeId, ordinal: toolCall.ordinal, output: NO_CLAUDE_NATIVE_TOOL_OUTPUT, - status: "failed", + // A stopped turn cuts its open tools short; only a turn that + // ended on its own leaves them failed. + status: input.status === "completed" ? "failed" : input.status, startedAt: toolCall.startedAt, updatedAt: input.completedAt, presentation: toolCall.presentation, @@ -5087,12 +5136,14 @@ export function makeClaudeAdapterV2( // replay them to the turn that was still starting when they // arrived; the offer gate below keeps them from requesting a // continuation on their own. + const isWakeTurnStart = isClaudeTurnStartMessage(message); const isWakeEvidence = isPendingTaskNotification || isPendingSubagentNotification || isNestedSubagentNotification || isKnownSubagentTaskStarted || isNewSubagentTaskStarted || + isWakeTurnStart || message.type === "assistant" || message.type === "user" || message.type === "result" || @@ -5149,12 +5200,14 @@ export function makeClaudeAdapterV2( } // A terminal task notification can clear the Waiting roster without // Claude dequeuing it into a native model turn. Buffer it for replay, - // but do not open an opaque-task continuation until native user, - // assistant, or result output proves that Claude actually began the - // wake turn. Subagent notifications retain their existing immediate - // offer because their projected lifecycle owns the continuation. - // Only root output proves it: a background subagent keeps streaming - // its own frames while the root is idle. + // but do not open an opaque-task continuation until the turn's `init`, + // or native user, assistant, or result output, proves that Claude + // actually began the wake turn. `init` comes first, so the thread + // shows working while Claude thinks instead of looking finished. + // Subagent notifications retain their existing immediate offer + // because their projected lifecycle owns the continuation. Only + // root frames prove it: a background subagent keeps streaming its + // own frames while the root is idle. const buffered = (yield* Ref.get(wakeBuffers)).get(wakeInput.nativeThreadId); const hasBufferedNotification = buffered?.messages.some( @@ -5167,6 +5220,7 @@ export function makeClaudeAdapterV2( if ( !isPendingSubagentNotification && !isNativeOpaqueWakeFrame && + !isWakeTurnStart && message.type !== "result" ) { return; @@ -6821,12 +6875,25 @@ export function makeClaudeAdapterV2( if ( existing !== null && existing.nativeThreadId === nativeThreadId && - (isClaudeProviderContinuationTurn(turnInput) || - (existing.queryPolicyKey === queryPolicyKey && - existing.selectionKey === compiledSelection.queryIdentity)) + isClaudeProviderContinuationTurn(turnInput) ) { return existing; } + if ( + existing !== null && + existing.nativeThreadId === nativeThreadId && + existing.queryPolicyKey === queryPolicyKey && + existing.selectionKey === compiledSelection.queryIdentity + ) { + // Claude can switch its own mode mid-session (EnterPlanMode), and + // a denied ExitPlanMode leaves it there. Put the live process back + // in the thread's mode before the next prompt. + if (existing.permissionMode !== existing.openedPermissionMode) { + yield* existing.query.setPermissionMode(existing.openedPermissionMode); + existing.permissionMode = existing.openedPermissionMode; + } + return existing; + } // Background agents and shells run inside the CLI process, so a // new selection would kill them and lose their results. Refuse until @@ -6867,31 +6934,32 @@ export function makeClaudeAdapterV2( const hasPersistedProviderTurn = turnInput.providerTurnOrdinal > 1; const shouldResume = resumeSessionAt !== undefined || openedWithResume || hasPersistedProviderTurn; + const queryOptions = makeClaudeQueryOptions({ + modelSelection: turnInput.modelSelection, + nativeThreadId, + resume: shouldResume, + ...(resumeSessionAt === undefined ? {} : { resumeSessionAt }), + cwd: turnInput.runtimePolicy.cwd, + attachmentsDir, + settings: adapterOptions.settings, + environment: adapterOptions.environment, + tools: queryPolicy.tools ?? CLAUDE_CODE_PRESET_TOOLS, + ...mcpOverrides, + permissionMode: queryPolicy.permissionMode, + ...(queryPolicy.allowDangerouslySkipPermissions === undefined + ? {} + : { + allowDangerouslySkipPermissions: queryPolicy.allowDangerouslySkipPermissions, + }), + canUseTool, + onUserDialog, + supportedDialogKinds: ["resume_return"], + }); const querySession = yield* queryRunner .open({ threadId: turnInput.threadId, providerSessionId: input.providerSessionId, - options: makeClaudeQueryOptions({ - modelSelection: turnInput.modelSelection, - nativeThreadId, - resume: shouldResume, - ...(resumeSessionAt === undefined ? {} : { resumeSessionAt }), - cwd: turnInput.runtimePolicy.cwd, - attachmentsDir, - settings: adapterOptions.settings, - environment: adapterOptions.environment, - tools: queryPolicy.tools ?? CLAUDE_CODE_PRESET_TOOLS, - ...mcpOverrides, - permissionMode: queryPolicy.permissionMode, - ...(queryPolicy.allowDangerouslySkipPermissions === undefined - ? {} - : { - allowDangerouslySkipPermissions: queryPolicy.allowDangerouslySkipPermissions, - }), - canUseTool, - onUserDialog, - supportedDialogKinds: ["resume_return"], - }), + options: queryOptions, }) .pipe( Effect.tapError(() => @@ -6938,6 +7006,8 @@ export function makeClaudeAdapterV2( selectionKey: compiledSelection.queryIdentity, closed, promptEchoMode: "unknown", + openedPermissionMode: queryOptions.permissionMode, + permissionMode: queryOptions.permissionMode, stopping: false, subagentsFromEarlierProcesses: new Set( [...(yield* Ref.get(sessionSubagentsByTaskId)).values()].filter( @@ -6947,7 +7017,16 @@ export function makeClaudeAdapterV2( }; yield* Ref.set(queryContext, context); yield* querySession.messages.pipe( - Stream.runForEach((message) => handleSdkMessage({ query: querySession, message })), + Stream.runForEach((message) => { + if ( + message.type === "system" && + (message.subtype === "init" || message.subtype === "status") && + message.permissionMode !== undefined + ) { + context.permissionMode = message.permissionMode; + } + return handleSdkMessage({ query: querySession, message }); + }), Effect.exit, Effect.flatMap( Effect.fnUntraced(function* (exit: ClaudeQueryStreamExit) { @@ -7127,8 +7206,13 @@ export function makeClaudeAdapterV2( yield* handleSdkMessage({ query: querySession.query, message: lastResult }); return; } + // A drained `init` means Claude began the wake turn, so its output + // may still be on the way: stay open for it. const hasNativeWakeFrame = drained.some( - (entry) => entry.type === "user" || entry.type === "assistant", + (entry) => + entry.type === "user" || + entry.type === "assistant" || + isClaudeTurnStartMessage(entry), ); if (hasOpaqueTaskNotification && !hasNativeWakeFrame) { const completedAt = yield* DateTime.now; @@ -7659,9 +7743,13 @@ export const createClaudeAdapterV2 = Effect.fn("ClaudeAdapterV2Driver.create")( const baseEnvironment = mergeProviderInstanceEnvironment(environment, hostEnvironment); const claudeEnvironment = yield* makeClaudeEnvironment(config, baseEnvironment); const path = yield* Path.Path; + const binaryPath = yield* resolveClaudeSdkExecutablePath( + expandHomePath(config.binaryPath), + claudeEnvironment, + ); return makeClaudeAdapterV2({ instanceId, - settings: { ...config, enabled, binaryPath: expandHomePath(config.binaryPath) }, + settings: { ...config, enabled, binaryPath }, environment: claudeEnvironment, attachmentsDir: serverConfig.attachmentsDir, fileSystem, diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 27fadf2c4381..85748fa056e7 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -1728,8 +1728,9 @@ describe("CodexAdapterV2 post-settle continuation", () => { }; }); - for (const response of ["supported", "unsupported", "invalid"] as const) { - it.effect(`delivers native history with ${response} app-server protocol`, () => + it.effect.each(["supported", "unsupported", "invalid"] as const)( + "delivers native history with %s app-server protocol", + (response) => Effect.gen(function* () { const nativeThreadId = `inject-${response}`; const prompt = "Only the current request"; @@ -1830,8 +1831,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.equal(requests.filter((method) => method === "turn/start").length, 1); assert.isBelow(requests.indexOf("thread/inject_items"), requests.indexOf("turn/start")); }).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), - ); - } + ); it.effect("identifies sessions to Codex with the same client info as main", () => Effect.gen(function* () { @@ -2597,6 +2597,161 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); + it.effect.each( + ( + [ + ["recovers archived-session errors", "session saved-thread is archived", 0, ""], + [ + "recovers unarchive hints", + "Run `codex unarchive saved-thread` to unarchive it first.", + 0, + "", + ], + ["preserves missing-thread errors", "thread not found", 3, "thread not found"], + ["preserves missing-rollout errors", "no rollout found", 3, "no rollout found"], + ["preserves authentication errors", "authentication failed", 3, "authentication failed"], + [ + "preserves archived-workspace errors", + "workspace is archived", + 3, + "workspace is archived", + ], + [ + "preserves unrelated archive-path errors", + "permission denied reading archived_sessions/saved-thread", + 3, + "permission denied reading archived_sessions/saved-thread", + ], + [ + "propagates unarchive missing-thread errors", + "session saved-thread is archived", + 4, + "thread not found", + ], + [ + "propagates unarchive archived errors", + "session saved-thread is archived", + 4, + "session saved-thread is archived", + ], + [ + "propagates retry missing-thread errors", + "session saved-thread is archived", + 5, + "thread not found", + ], + [ + "does not retry archived errors twice", + "session saved-thread is archived", + 5, + "session saved-thread is archived", + ], + ] as const + ).map(([name, resumeError, failAt, finalError]) => ({ name, resumeError, failAt, finalError })), + )("$name", ({ name, resumeError, failAt, finalError }) => + Effect.scoped( + Effect.gen(function* () { + const nativeThreadId = "saved-thread"; + const params = { + threadId: nativeThreadId, + excludeTurns: true, + cwd: CODEX_TEST_RUNTIME_POLICY.cwd, + model: CODEX_TEST_MODEL_SELECTION.model, + config: CodexAdapterV2.CODEX_THREAD_CONFIG, + }; + const entries: Array = [ + ...codexReplayPreamble({ + nativeThreadId, + nativeTurnId: "unused-turn", + prompt: "unused-prompt", + }).slice(0, 5), + { + type: "expect_outbound", + label: "resume archived thread", + frame: { id: 3, method: "thread/resume", params }, + }, + { + type: "emit_inbound", + label: "resume error", + frame: { id: 3, error: { code: -32600, message: resumeError } }, + }, + ]; + if (failAt !== 3) { + entries.push( + { + type: "expect_outbound", + label: "unarchive same thread", + frame: { id: 4, method: "thread/unarchive", params: { threadId: nativeThreadId } }, + }, + { + type: "emit_inbound", + label: "unarchive result", + frame: + failAt === 4 + ? { id: 4, error: { code: -32600, message: finalError } } + : { id: 4, result: { thread: { turns: [{ type: "unknown-history-item" }] } } }, + }, + ); + } + if (failAt === 0 || failAt === 5) { + entries.push( + { + type: "expect_outbound", + label: "retry identical resume", + frame: { id: 5, method: "thread/resume", params }, + }, + { + type: "emit_inbound", + label: "retry result", + frame: + failAt === 5 + ? { id: 5, error: { code: -32600, message: finalError } } + : { + id: 5, + result: { + thread: { + id: nativeThreadId, + updatedAt: 1782622450, + turns: [{ type: "unknown-history-item" }], + }, + }, + }, + }, + ); + } + const harness = yield* makeCodexReplayHarness( + makeCodexReplayTranscript({ scenario: name, entries }), + ); + const resume = harness.runtime.resumeThread({ + providerThread: harness.providerThread, + modelSelection: CODEX_TEST_MODEL_SELECTION, + runtimePolicy: CODEX_TEST_RUNTIME_POLICY, + }); + if (failAt !== 0) { + const error = yield* Effect.flip(resume); + assert.equal(error._tag, "ProviderAdapterResumeThreadError"); + assert.nestedPropertyVal(error, "cause.errorMessage", finalError); + assert.nestedPropertyVal( + error, + "cause.method", + failAt === 4 ? "thread/unarchive" : "thread/resume", + ); + assert.nestedPropertyVal(error, "cause.requestId", String(failAt)); + return; + } + const resumed = yield* resume; + assert.equal(resumed.id, harness.providerThread.id); + assert.equal(resumed.nativeThreadRef?.nativeId, nativeThreadId); + assert.deepEqual( + resumed.nativeConversationHeadRef, + harness.providerThread.nativeConversationHeadRef, + ); + assert.equal(resumed.status, "idle"); + assert.equal(DateTime.toEpochMillis(resumed.updatedAt), 1782622450000); + }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ), + ); + it.effect("continues an interrupted native thread with empty input and reasoning summaries", () => Effect.scoped( Effect.gen(function* () { @@ -2926,8 +3081,9 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); - for (const terminalStatus of ["completed", "interrupted"] as const) { - it.effect(`retains Codex reasoning parts when the turn is ${terminalStatus}`, () => + it.effect.each(["completed", "interrupted"] as const)( + "retains Codex reasoning parts when the turn is %s", + (terminalStatus) => Effect.scoped( Effect.gen(function* () { const scenario = `codex-reasoning-${terminalStatus}`; @@ -3087,8 +3243,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.deepEqual(assistantMessages(harness.events), []); }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), ), - ); - } + ); const finalAnswerTranscript = ( scenario: string, @@ -3714,9 +3869,10 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); - for (const terminated of [true, false, "still_running"] as const) { + const backgroundStopCases = [true, false, "still_running"] as const; + const makeBackgroundStopTranscript = (terminated: (typeof backgroundStopCases)[number]) => { const stillRunning = terminated === "still_running"; - const transcript = makeCodexReplayTranscript({ + return makeCodexReplayTranscript({ scenario: `codex-bg-stop-${terminated}`, entries: [ ...backgroundExecTranscript.entries.slice(0, -1), @@ -3796,9 +3952,14 @@ describe("CodexAdapterV2 post-settle continuation", () => { backgroundExecTranscript.entries.at(-1)!, ], }); + }; - it.effect(`stops a command after root completion when termination returns ${terminated}`, () => - Effect.scoped( + it.effect.each(backgroundStopCases)( + "stops a command after root completion when termination returns %s", + (terminated) => { + const stillRunning = terminated === "still_running"; + const transcript = makeBackgroundStopTranscript(terminated); + return Effect.scoped( Effect.gen(function* () { const stopped = yield* Deferred.make(); const harness = yield* makeCodexReplayHarness(transcript, (event) => @@ -3852,140 +4013,137 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.equal(harness.terminalEvents()[0]?.status, "completed"); assert.lengthOf(harness.continuationRequests, 0); }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), - ), - ); - if (terminated === true) { - it.effect("interrupts a completed run's background command through orchestration", () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-bg-stop-workspace-" }); - const localTranscript = yield* decodeReplayTranscriptJson( - (yield* encodeReplayTranscriptJson(transcript)).replaceAll( - yield* encodeStringJson("/workspace"), - yield* encodeStringJson(cwd), - ), - ); - const replayDriver = yield* CodexReplay.makeReplayDriver(localTranscript); - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - assert.equal( - Number( - yield* spawner.exitCode(ChildProcess.make("git", ["init", "--quiet"], { cwd })), - ), - 0, - ); - assert.equal( - Number( - yield* spawner.exitCode( - ChildProcess.make( - "git", - [ - "-c", - "user.name=Test", - "-c", - "user.email=test@example.com", - "commit", - "--allow-empty", - "--quiet", - "-m", - "Initial commit", - ], - { cwd }, - ), - ), - ), - 0, - ); - yield* Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; - const threadId = ThreadId.make("thread:background-stop"); - yield* orchestrator.dispatch({ - type: "thread.create", - commandId: CommandId.make("create-background-stop"), - threadId, - projectId: ProjectId.make("project:background-stop"), - title: "Background stop", - modelSelection: CODEX_TEST_MODEL_SELECTION, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: cwd, - createdBy: "user", - creationSource: "web", - }); - const waiting = yield* orchestrator.streamDomainEvents.pipe( - Stream.filter( - (event) => event.type === "run.updated" && event.payload.status === "waiting", - ), - Stream.runHead, - Effect.forkChild({ startImmediately: true }), - ); - yield* orchestrator.dispatch({ - type: "message.dispatch", - commandId: CommandId.make("start-background-stop"), - threadId, - messageId: MessageId.make("message:background-stop"), - text: BG_PROMPT, - attachments: [], - createdBy: "user", - creationSource: "web", - dispatchMode: { type: "start_immediately" }, - }); - yield* worker.drain(); - assert.isNull((yield* Ref.get(replayDriver.state)).failure); - yield* Fiber.join(waiting); - yield* worker.drain(); - const projection = yield* orchestrator.getThreadProjection(threadId); - const run = projection.runs.at(-1)!; - assert.equal(run.status, "completed"); - assert.equal( - (yield* orchestrator.getThreadShell(threadId))?.pendingBackgroundTasks?.length, - 1, - ); - const stopped = yield* orchestrator.streamDomainEvents.pipe( - Stream.filter( - (event) => - event.type === "turn-item.updated" && - event.payload.type === "command_execution" && - event.payload.status === "interrupted", - ), - Stream.runHead, - Effect.forkChild({ startImmediately: true }), - ); - yield* orchestrator.dispatch({ - type: "run.interrupt", - commandId: CommandId.make("stop-background-command"), - threadId, - runId: run.id, - }); - yield* worker.drain(); - yield* Fiber.join(stopped); - assert.equal( - (yield* orchestrator.getThreadProjection(threadId)).runs.at(-1)?.status, - "completed", - ); - assert.deepEqual( - (yield* orchestrator.getThreadShell(threadId))?.pendingBackgroundTasks, - [], - ); - }).pipe( - Effect.provide( - makeOrchestratorV2ReplayLayerWithRegistry( - { name: "codex-background-stop", runtimePolicyOverride: { cwd } }, - makeCodexProviderAdapterRegistryReplayLayer({ - transcript: localTranscript, - driver: replayDriver, - }), - { runEffectWorker: false }, - ), - ), - ); - }).pipe(Effect.provide(NodeServices.layer)), - ), ); - } - } + }, + ); + it.effect("interrupts a completed run's background command through orchestration", () => { + const transcript = makeBackgroundStopTranscript(true); + return Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-bg-stop-workspace-" }); + const localTranscript = yield* decodeReplayTranscriptJson( + (yield* encodeReplayTranscriptJson(transcript)).replaceAll( + yield* encodeStringJson("/workspace"), + yield* encodeStringJson(cwd), + ), + ); + const replayDriver = yield* CodexReplay.makeReplayDriver(localTranscript); + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + assert.equal( + Number(yield* spawner.exitCode(ChildProcess.make("git", ["init", "--quiet"], { cwd }))), + 0, + ); + assert.equal( + Number( + yield* spawner.exitCode( + ChildProcess.make( + "git", + [ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "commit", + "--allow-empty", + "--quiet", + "-m", + "Initial commit", + ], + { cwd }, + ), + ), + ), + 0, + ); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = ThreadId.make("thread:background-stop"); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-background-stop"), + threadId, + projectId: ProjectId.make("project:background-stop"), + title: "Background stop", + modelSelection: CODEX_TEST_MODEL_SELECTION, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + createdBy: "user", + creationSource: "web", + }); + const waiting = yield* orchestrator.streamDomainEvents.pipe( + Stream.filter( + (event) => event.type === "run.updated" && event.payload.status === "waiting", + ), + Stream.runHead, + Effect.forkChild({ startImmediately: true }), + ); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("start-background-stop"), + threadId, + messageId: MessageId.make("message:background-stop"), + text: BG_PROMPT, + attachments: [], + createdBy: "user", + creationSource: "web", + dispatchMode: { type: "start_immediately" }, + }); + yield* worker.drain(); + assert.isNull((yield* Ref.get(replayDriver.state)).failure); + yield* Fiber.join(waiting); + yield* worker.drain(); + const projection = yield* orchestrator.getThreadProjection(threadId); + const run = projection.runs.at(-1)!; + assert.equal(run.status, "completed"); + assert.equal( + (yield* orchestrator.getThreadShell(threadId))?.pendingBackgroundTasks?.length, + 1, + ); + const stopped = yield* orchestrator.streamDomainEvents.pipe( + Stream.filter( + (event) => + event.type === "turn-item.updated" && + event.payload.type === "command_execution" && + event.payload.status === "interrupted", + ), + Stream.runHead, + Effect.forkChild({ startImmediately: true }), + ); + yield* orchestrator.dispatch({ + type: "run.interrupt", + commandId: CommandId.make("stop-background-command"), + threadId, + runId: run.id, + }); + yield* worker.drain(); + yield* Fiber.join(stopped); + assert.equal( + (yield* orchestrator.getThreadProjection(threadId)).runs.at(-1)?.status, + "completed", + ); + assert.deepEqual( + (yield* orchestrator.getThreadShell(threadId))?.pendingBackgroundTasks, + [], + ); + }).pipe( + Effect.provide( + makeOrchestratorV2ReplayLayerWithRegistry( + { name: "codex-background-stop", runtimePolicyOverride: { cwd } }, + makeCodexProviderAdapterRegistryReplayLayer({ + transcript: localTranscript, + driver: replayDriver, + }), + { runEffectWorker: false }, + ), + ), + ); + }).pipe(Effect.provide(NodeServices.layer)), + ); + }); // The app-server exits after the root turn, before the command's own // item/completed (Codex always sends one, so only a lost notification or a @@ -5506,7 +5664,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); - for (const scenario of [ + it.effect.each([ { name: "usage", code: "usageLimitExceeded", @@ -5557,192 +5715,190 @@ describe("CodexAdapterV2 post-settle continuation", () => { expectedClass: "usage_limit", }, { name: "retry", code: "usageLimitExceeded", notification: true, expectedClass: "usage_limit" }, - ] as const) { - it.effect(`classifies Codex terminal failures from ${scenario.name} evidence`, () => - Effect.scoped( - Effect.gen(function* () { - const nativeThreadId = `native-limit-${scenario.name}`; - const nativeTurnId = `turn-limit-${scenario.name}`; - const message = "Provider stopped this request."; - const resetAt = "2033-05-19T07:20:00.000Z"; - const snapshot = { - type: "emit_inbound" as const, - label: "account/rateLimits/updated", - frame: { - method: "account/rateLimits/updated", - params: { - rateLimits: { - limitId: "codex", - primary: { usedPercent: 100, resetsAt: 2000100000, windowDurationMins: 300 }, - }, + ] 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 resetAt = "2033-05-19T07:20:00.000Z"; + const snapshot = { + type: "emit_inbound" as const, + label: "account/rateLimits/updated", + frame: { + method: "account/rateLimits/updated", + params: { + rateLimits: { + limitId: "codex", + primary: { usedPercent: 100, resetsAt: 2000100000, windowDurationMins: 300 }, }, }, - }; - const transcript = makeCodexReplayTranscript({ - scenario: `codex-limit-${scenario.name}`, - entries: [ - ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "Continue." }), - ...(scenario.name === "known-reset" || scenario.name === "deferred-reset" - ? [snapshot] - : []), - ...(scenario.name === "deferred-reset" - ? [ - { - type: "emit_inbound" as const, - label: "item/completed/subAgentActivity-started", - frame: { - method: "item/completed", - params: { - threadId: nativeThreadId, - turnId: nativeTurnId, - item: { - type: "subAgentActivity", - id: "limit-child-spawn", - kind: "started", - agentThreadId: "native-limit-child", - agentPath: "/root/limit_child", - }, + }, + }; + const transcript = makeCodexReplayTranscript({ + scenario: `codex-limit-${scenario.name}`, + entries: [ + ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "Continue." }), + ...(scenario.name === "known-reset" || scenario.name === "deferred-reset" + ? [snapshot] + : []), + ...(scenario.name === "deferred-reset" + ? [ + { + type: "emit_inbound" as const, + label: "item/completed/subAgentActivity-started", + frame: { + method: "item/completed", + params: { + threadId: nativeThreadId, + turnId: nativeTurnId, + item: { + type: "subAgentActivity", + id: "limit-child-spawn", + kind: "started", + agentThreadId: "native-limit-child", + agentPath: "/root/limit_child", }, }, }, - { - type: "emit_inbound" as const, - label: "turn/started/child", - frame: { - method: "turn/started", - params: { - threadId: "native-limit-child", - turn: makeCodexReplayTurn({ - id: "limit-child-turn", - status: "inProgress", - }), - }, + }, + { + type: "emit_inbound" as const, + label: "turn/started/child", + frame: { + method: "turn/started", + params: { + threadId: "native-limit-child", + turn: makeCodexReplayTurn({ + id: "limit-child-turn", + status: "inProgress", + }), }, }, - ] - : []), - ...(scenario.notification - ? [ - { - type: "emit_inbound" as const, - label: "error", - frame: { - method: "error", - params: { - threadId: nativeThreadId, - turnId: nativeTurnId, - willRetry: scenario.name === "retry", - error: { - message, - codexErrorInfo: scenario.code, - additionalDetails: - scenario.name === "matching-details" - ? "Detailed provider allowance explanation." - : null, - }, + }, + ] + : []), + ...(scenario.notification + ? [ + { + type: "emit_inbound" as const, + label: "error", + frame: { + method: "error", + params: { + threadId: nativeThreadId, + turnId: nativeTurnId, + willRetry: scenario.name === "retry", + error: { + message, + codexErrorInfo: scenario.code, + additionalDetails: + scenario.name === "matching-details" + ? "Detailed provider allowance explanation." + : null, }, }, }, - ] - : []), - { - type: "emit_inbound", - label: "turn/completed", - frame: { - method: "turn/completed", - params: { - threadId: nativeThreadId, - turn: { - ...makeCodexReplayTurn({ id: nativeTurnId, status: "failed" }), - error: { - message: scenario.name === "replacement" ? "A different failure." : message, - ...(scenario.notification && scenario.name !== "matching-details" - ? {} - : { codexErrorInfo: scenario.code }), - }, + }, + ] + : []), + { + type: "emit_inbound", + label: "turn/completed", + frame: { + method: "turn/completed", + params: { + threadId: nativeThreadId, + turn: { + ...makeCodexReplayTurn({ id: nativeTurnId, status: "failed" }), + error: { + message: scenario.name === "replacement" ? "A different failure." : message, + ...(scenario.notification && scenario.name !== "matching-details" + ? {} + : { codexErrorInfo: scenario.code }), }, }, }, }, - ...(scenario.name === "late-reset" ? [snapshot] : []), - ...(scenario.name === "deferred-reset" - ? [ - { - ...snapshot, - frame: { - method: "account/rateLimits/updated", - params: { - rateLimits: { - limitId: "codex", - primary: { - usedPercent: 100, - resetsAt: 2000200000, - windowDurationMins: 300, - }, + }, + ...(scenario.name === "late-reset" ? [snapshot] : []), + ...(scenario.name === "deferred-reset" + ? [ + { + ...snapshot, + frame: { + method: "account/rateLimits/updated", + params: { + rateLimits: { + limitId: "codex", + primary: { + usedPercent: 100, + resetsAt: 2000200000, + windowDurationMins: 300, }, }, }, }, - { - type: "emit_inbound" as const, - label: "turn/completed/child", - frame: { - method: "turn/completed", - params: { - threadId: "native-limit-child", - turn: makeCodexReplayTurn({ - id: "limit-child-turn", - status: "completed", - }), - }, + }, + { + type: "emit_inbound" as const, + label: "turn/completed/child", + frame: { + method: "turn/completed", + params: { + threadId: "native-limit-child", + turn: makeCodexReplayTurn({ + id: "limit-child-turn", + status: "completed", + }), }, }, - ] - : []), - ], - }); - const resetReceipt = yield* Deferred.make(); - const harness = yield* makeCodexReplayHarness(transcript, (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "error" && - event.turnItem.failure.resetAt === resetAt - ? Deferred.succeed(resetReceipt, undefined) - : Effect.void, - ); - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - text: "Continue.", - attemptId: RunAttemptId.make(`attempt-limit-${scenario.name}`), - }), + }, + ] + : []), + ], + }); + const resetReceipt = yield* Deferred.make(); + const harness = yield* makeCodexReplayHarness(transcript, (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "error" && + event.turnItem.failure.resetAt === resetAt + ? Deferred.succeed(resetReceipt, undefined) + : Effect.void, + ); + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + text: "Continue.", + attemptId: RunAttemptId.make(`attempt-limit-${scenario.name}`), + }), + ); + yield* harness.firstTerminal; + const terminal = harness.terminalEvents()[0]; + assert.equal(terminal?.status, "failed"); + if (terminal?.status !== "failed") return; + assert.equal(terminal.failure.class, scenario.expectedClass); + assert.equal(terminal.threadDisposition, "reusable"); + if (scenario.name === "known-reset" || scenario.name === "deferred-reset") + assert.equal(terminal.failure.resetAt, resetAt); + if (scenario.name === "matching-details") + assert.equal(terminal.failure.message, "Detailed provider allowance explanation."); + if (scenario.name === "late-reset") { + yield* Deferred.await(resetReceipt); + const item = harness.events.find( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "error" && + event.turnItem.failure.resetAt === resetAt, ); - yield* harness.firstTerminal; - const terminal = harness.terminalEvents()[0]; - assert.equal(terminal?.status, "failed"); - if (terminal?.status !== "failed") return; - assert.equal(terminal.failure.class, scenario.expectedClass); - assert.equal(terminal.threadDisposition, "reusable"); - if (scenario.name === "known-reset" || scenario.name === "deferred-reset") - assert.equal(terminal.failure.resetAt, resetAt); - if (scenario.name === "matching-details") - assert.equal(terminal.failure.message, "Detailed provider allowance explanation."); - if (scenario.name === "late-reset") { - yield* Deferred.await(resetReceipt); - const item = harness.events.find( - (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "error" && - event.turnItem.failure.resetAt === resetAt, - ); - assert.isDefined(item); - } - if (scenario.name === "retry") assert.equal(terminal.retry?.attempt, 1); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), - ), - ); - } + assert.isDefined(item); + } + if (scenario.name === "retry") assert.equal(terminal.retry?.attempt, 1); + }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ), + ); const FAILED_SCENARIO = "codex-failed-mid-command"; const FAILED_NATIVE_THREAD = "native-codex-failed-thread"; @@ -6511,7 +6667,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); - for (const [nativeStatus, expectedStatus] of [ + it.effect.each([ ["pendingInit", "pending"], ["running", "running"], ["interrupted", "interrupted"], @@ -6523,8 +6679,9 @@ describe("CodexAdapterV2 post-settle continuation", () => { ["late-activity-completed", "completed"], ["stale-running", "completed"], ["duplicate-completed", "completed"], - ] as const) { - it.effect(`normalizes subagent ${nativeStatus} without losing its lifecycle`, () => + ] as const)( + "normalizes subagent %s without losing its lifecycle", + ([nativeStatus, expectedStatus]) => Effect.scoped( Effect.gen(function* () { const marker = yield* Deferred.make(); @@ -6670,8 +6827,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { } }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), ), - ); - } + ); const codexReplayThreadResult = (input: { readonly nativeThreadId: string; diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 88cd870f53c2..8da6ffe4357d 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -30,7 +30,7 @@ import { } from "@t3tools/contracts"; import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens"; import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; -import { computerUseToolTitle } from "@t3tools/shared/toolActivity"; +import { dynamicToolTitle } from "@t3tools/shared/toolActivity"; import { getModelSelectionStringOptionValue, modelSelectionsEqual } from "@t3tools/shared/model"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import type { @@ -489,7 +489,7 @@ export function projectCodexDynamicToolItem( item.type === "mcpToolCall" ? `${item.server}.${item.tool}` : [trimText(item.namespace), item.tool].filter(Boolean).join("."); - const title = computerUseToolTitle(toolName, item.arguments); + const title = dynamicToolTitle(toolName, item.arguments); const projection: CodexDynamicToolProjection = { ...(item.type === "mcpToolCall" ? mcpToolPresentation(item) : {}), toolName, @@ -5429,23 +5429,39 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi resumeThread: (threadInput) => Effect.gen(function* () { const nativeThreadId = yield* getNativeThreadId(threadInput.providerThread); - + // excludeTurns is not in the generated request schema yet. + const resume = client.raw.request("thread/resume", { + threadId: nativeThreadId, + excludeTurns: true, + ...codexThreadRuntimeParams({ + threadId: threadInput.threadId ?? threadInput.providerThread.appThreadId, + ...(threadInput.modelSelection === undefined + ? {} + : { modelSelection: threadInput.modelSelection }), + ...(threadInput.runtimePolicy === undefined + ? {} + : { runtimePolicy: threadInput.runtimePolicy }), + }), + }); const response = yield* ensureInitialized.pipe( Effect.andThen( - // excludeTurns is not in the generated request schema yet. - client.raw.request("thread/resume", { - threadId: nativeThreadId, - excludeTurns: true, - ...codexThreadRuntimeParams({ - threadId: threadInput.threadId ?? threadInput.providerThread.appThreadId, - ...(threadInput.modelSelection === undefined - ? {} - : { modelSelection: threadInput.modelSelection }), - ...(threadInput.runtimePolicy === undefined - ? {} - : { runtimePolicy: threadInput.runtimePolicy }), + resume.pipe( + Effect.catchTags({ + CodexAppServerRequestError: (cause) => { + if ( + !/\bsession \S+ is archived\b|\bcodex unarchive\b/i.test( + cause.errorMessage, + ) + ) { + return Effect.fail(cause); + } + // Keep the session's history without decoding the unarchive response. + return client.raw + .request("thread/unarchive", { threadId: nativeThreadId }) + .pipe(Effect.andThen(resume)); + }, }), - }), + ), ), Effect.flatMap(decodeCodexResumeMetadata), ); diff --git a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.test.ts index d31e91eac08c..3a172baa67c8 100644 --- a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.test.ts @@ -39,12 +39,13 @@ import { isCursorCancellationError, loggedCursorAgentOptions } from "./CursorAge const decodeCursorSettings = Schema.decodeEffect(CursorSettings); describe("CursorAdapterV2", () => { - for (const { status, model } of [ + it.effect.each([ { status: "finished", model: undefined }, { status: "cancelled", model: "claude-opus-4-6" }, { status: "error", model: "custom-fable" }, - ] as const) { - it.effect(`settles missing task completions when the Cursor run is ${status}`, () => + ] as const)( + "settles missing task completions when the Cursor run is $status", + ({ status, model }) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -185,8 +186,7 @@ describe("CursorAdapterV2", () => { ); assert.isNotNull(rows.at(-1)?.subagent.completedAt); }).pipe(Effect.scoped, Effect.provide(Layer.merge(NodeServices.layer, IdAllocator.layer))), - ); - } + ); it.effect("fails standalone SDK transport diagnostics and sends compaction as /compress", () => Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts index 60be8b82f067..281882c9eb55 100644 --- a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts @@ -371,17 +371,19 @@ describe("Grok launch permission mode", () => { ...override, }); - for (const [runtimeMode, args] of [ - ["approval-required", ["--permission-mode", "default", "agent", "stdio"]], - ["auto", ["--permission-mode", "auto", "agent", "stdio"]], - ["full-access", ["agent", "--always-approve", "stdio"]], - ] as const) { - it.effect(`launches ${runtimeMode} threads with ${args.join(" ")}`, () => - Effect.gen(function* () { - assert.deepEqual(yield* launchArgs(policy(runtimeMode)), [args]); - }), - ); - } + it.effect.each( + ( + [ + ["approval-required", ["--permission-mode", "default", "agent", "stdio"]], + ["auto", ["--permission-mode", "auto", "agent", "stdio"]], + ["full-access", ["agent", "--always-approve", "stdio"]], + ] as const + ).map(([runtimeMode, args]) => [runtimeMode, args.join(" "), args] as const), + )("launches %s threads with %s", ([runtimeMode, , args]) => + Effect.gen(function* () { + assert.deepEqual(yield* launchArgs(policy(runtimeMode)), [args]); + }), + ); it.effect("launches a thread stored as Auto-accept edits asking", () => Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index e321a1369cc0..6df5f827fa76 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -410,6 +410,11 @@ describe("OpenCode2 adapter", () => { promptAccepted, event("session.execution.succeeded", { sessionID: SESSION }), ]); + assert.deepEqual(thread.nativeMetadata?.modelSelection, { + ...bigPickle, + options: [{ id: "variant", value: "default" }], + }); + assert.equal(bigPickle.options, undefined); const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped); yield* runtime.startTurn( turnInput(thread, { @@ -2833,6 +2838,10 @@ describe("OpenCode2 adapter", () => { modelSelection: bigPickle, runtimePolicy: customPolicy, }); + assert.deepEqual(thread.nativeMetadata?.modelSelection, { + ...bigPickle, + options: [{ id: "variant", value: "default" }], + }); // Each directory keeps its own limit, whichever was read last. assert.equal(runtime.getModelContextWindow?.(bigPickle, WORK), 160000); assert.equal(runtime.getModelContextWindow?.(bigPickle, custom), 48000); @@ -3090,81 +3099,78 @@ describe("OpenCode2 adapter", () => { }).pipe(Effect.scoped, Effect.provide(IdAllocator.layer)), ); - for (const running of [true, false]) { - it.effect( - running - ? "refuses a rollback while a timed-out Stop's run is still going" - : "rolls back once a timed-out Stop's run has left the server", - () => - Effect.gen(function* () { - const prompt = `msg_t3_turn_${SESSION}:attempt:opencode2-adapter`; - const { runtime, thread } = yield* resumed([ - ...stopTimedOut, - // The server says whether the stopped run still goes. - out("session.active"), - reply("session.active", { data: running ? { [SESSION]: { type: "running" } } : {} }), - // Gone: the cut is made. Running: nothing is read or cut meanwhile. - ...(running - ? [] - : [ - out("message.list", ""), - reply("message.list", { - data: [{ id: prompt, time: { created: 1 }, text: "hi", type: "user" }], - cursor: {}, - }), - out("session.revert.stage", { - sessionID: SESSION, - messageID: prompt, - files: false, - }), - replyData("session.revert.stage", { messageID: prompt, files: [] }), - out("session.revert.commit", { sessionID: SESSION }), - reply("session.revert.commit", null), - out("message.list", ""), - reply("message.list", { data: [], cursor: {} }), - ]), - ]); - yield* stopFirstTurn(runtime, thread); - const now = yield* DateTime.now; - const first = { - id: yield* providerTurnId, - providerThreadId: thread.id, - nodeId: NodeId.make("node:opencode2-adapter"), - runAttemptId: RunAttemptId.make("attempt:opencode2-adapter"), - nativeTurnRef: { - driver: OPENCODE_PROVIDER, - nativeId: prompt, - strength: "weak" as const, - }, - ordinal: 1, - status: "interrupted" as const, - startedAt: now, - completedAt: now, - }; - const rollback = yield* runtime - .rollbackThread({ - providerThread: thread, - target: { - type: "thread_start", - checkpointId: CheckpointId.make("checkpoint:start"), - appRunOrdinal: 0, - }, - providerThreadTurns: [first], - }) - .pipe(Effect.exit); - if (running) { - assert.isTrue(Exit.isFailure(rollback)); - const error = Exit.isFailure(rollback) ? Cause.squash(rollback.cause) : undefined; - assert.equal( - (error as { _tag?: string } | undefined)?._tag, - "ProviderAdapterProtocolError", - ); - } else { - assert.isTrue(Exit.isSuccess(rollback)); - } - }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), - ); - } + it.effect.each([ + ["refuses a rollback while a timed-out Stop's run is still going", true], + ["rolls back once a timed-out Stop's run has left the server", false], + ] as const)("%s", ([, running]) => + Effect.gen(function* () { + const prompt = `msg_t3_turn_${SESSION}:attempt:opencode2-adapter`; + const { runtime, thread } = yield* resumed([ + ...stopTimedOut, + // The server says whether the stopped run still goes. + out("session.active"), + reply("session.active", { data: running ? { [SESSION]: { type: "running" } } : {} }), + // Gone: the cut is made. Running: nothing is read or cut meanwhile. + ...(running + ? [] + : [ + out("message.list", ""), + reply("message.list", { + data: [{ id: prompt, time: { created: 1 }, text: "hi", type: "user" }], + cursor: {}, + }), + out("session.revert.stage", { + sessionID: SESSION, + messageID: prompt, + files: false, + }), + replyData("session.revert.stage", { messageID: prompt, files: [] }), + out("session.revert.commit", { sessionID: SESSION }), + reply("session.revert.commit", null), + out("message.list", ""), + reply("message.list", { data: [], cursor: {} }), + ]), + ]); + yield* stopFirstTurn(runtime, thread); + const now = yield* DateTime.now; + const first = { + id: yield* providerTurnId, + providerThreadId: thread.id, + nodeId: NodeId.make("node:opencode2-adapter"), + runAttemptId: RunAttemptId.make("attempt:opencode2-adapter"), + nativeTurnRef: { + driver: OPENCODE_PROVIDER, + nativeId: prompt, + strength: "weak" as const, + }, + ordinal: 1, + status: "interrupted" as const, + startedAt: now, + completedAt: now, + }; + const rollback = yield* runtime + .rollbackThread({ + providerThread: thread, + target: { + type: "thread_start", + checkpointId: CheckpointId.make("checkpoint:start"), + appRunOrdinal: 0, + }, + providerThreadTurns: [first], + }) + .pipe(Effect.exit); + if (running) { + assert.isTrue(Exit.isFailure(rollback)); + const error = Exit.isFailure(rollback) ? Cause.squash(rollback.cause) : undefined; + assert.equal( + (error as { _tag?: string } | undefined)?._tag, + "ProviderAdapterProtocolError", + ); + } else { + assert.isTrue(Exit.isSuccess(rollback)); + } + }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), + ); it.effect("takes back a stranded steer again when its first cancel failed", () => Effect.gen(function* () { @@ -3376,92 +3382,92 @@ describe("OpenCode2 adapter", () => { }).pipe(Effect.scoped), ); - for (const running of [true, false]) { - it.effect( - running - ? "refuses to roll back a session not loaded yet while the server runs it" - : "keeps the recorded turns before the target when rolling back a session not loaded yet", - () => - Effect.gen(function* () { - const first = `msg_t3_turn_${SESSION}:attempt:first`; - const second = `msg_t3_turn_${SESSION}:attempt:second`; - // A runtime that never loaded the session, as after a T3 restart - // against a server that kept running. - const runtime = yield* openCode2ReplayRuntime([ - ...opening, - out("session.active"), - reply("session.active", { data: running ? { [SESSION]: { type: "running" } } : {} }), - ...(running - ? [] - : [ - out("session.get", { sessionID: SESSION }), - replyData("session.get", sessionInfo()), - ...noOpenRequests, - out("message.list", ""), - reply("message.list", { - data: [ - { id: second, time: { created: 2 }, text: "second", type: "user" }, - { id: first, time: { created: 1 }, text: "first", type: "user" }, - ], - cursor: {}, - }), - out("session.revert.stage", { - sessionID: SESSION, - messageID: second, - files: false, - }), - replyData("session.revert.stage", { messageID: second, files: [] }), - out("session.revert.commit", { sessionID: SESSION }), - reply("session.revert.commit", null), - out("message.list", ""), - reply("message.list", { - data: [{ id: first, time: { created: 1 }, text: "first", type: "user" }], - cursor: {}, - }), - ]), - ]); - const thread = providerThread(yield* DateTime.now); - const now = yield* DateTime.now; - const recorded = (key: string, ordinal: number, nativeId: string) => ({ - id: ProviderTurnId.make(`provider-turn:${key}`), - providerThreadId: thread.id, - nodeId: NodeId.make(`node:${key}`), - runAttemptId: RunAttemptId.make(`attempt:${key}`), - nativeTurnRef: { driver: OPENCODE_PROVIDER, nativeId, strength: "weak" as const }, - ordinal, - status: "completed" as const, - startedAt: now, - completedAt: now, - }); - const kept = recorded("first", 1, first); - const rollback = yield* runtime - .rollbackThread({ - providerThread: thread, - target: { - type: "provider_turn", - checkpointId: CheckpointId.make("checkpoint:first"), - appRunOrdinal: 1, - providerTurn: kept, - }, - providerThreadTurns: [kept, recorded("second", 2, second)], - }) - .pipe(Effect.exit); - if (running) { - const error = Exit.isFailure(rollback) ? Cause.squash(rollback.cause) : undefined; - assert.equal( - (error as { _tag?: string } | undefined)?._tag, - "ProviderAdapterProtocolError", - ); - } else { - assert.isTrue(Exit.isSuccess(rollback)); - assert.deepEqual( - Exit.isSuccess(rollback) ? rollback.value.providerTurns.map((turn) => turn.id) : [], - [kept.id], - ); - } - }).pipe(Effect.scoped), - ); - } + it.effect.each([ + ["refuses to roll back a session not loaded yet while the server runs it", true], + [ + "keeps the recorded turns before the target when rolling back a session not loaded yet", + false, + ], + ] as const)("%s", ([, running]) => + Effect.gen(function* () { + const first = `msg_t3_turn_${SESSION}:attempt:first`; + const second = `msg_t3_turn_${SESSION}:attempt:second`; + // A runtime that never loaded the session, as after a T3 restart + // against a server that kept running. + const runtime = yield* openCode2ReplayRuntime([ + ...opening, + out("session.active"), + reply("session.active", { data: running ? { [SESSION]: { type: "running" } } : {} }), + ...(running + ? [] + : [ + out("session.get", { sessionID: SESSION }), + replyData("session.get", sessionInfo()), + ...noOpenRequests, + out("message.list", ""), + reply("message.list", { + data: [ + { id: second, time: { created: 2 }, text: "second", type: "user" }, + { id: first, time: { created: 1 }, text: "first", type: "user" }, + ], + cursor: {}, + }), + out("session.revert.stage", { + sessionID: SESSION, + messageID: second, + files: false, + }), + replyData("session.revert.stage", { messageID: second, files: [] }), + out("session.revert.commit", { sessionID: SESSION }), + reply("session.revert.commit", null), + out("message.list", ""), + reply("message.list", { + data: [{ id: first, time: { created: 1 }, text: "first", type: "user" }], + cursor: {}, + }), + ]), + ]); + const thread = providerThread(yield* DateTime.now); + const now = yield* DateTime.now; + const recorded = (key: string, ordinal: number, nativeId: string) => ({ + id: ProviderTurnId.make(`provider-turn:${key}`), + providerThreadId: thread.id, + nodeId: NodeId.make(`node:${key}`), + runAttemptId: RunAttemptId.make(`attempt:${key}`), + nativeTurnRef: { driver: OPENCODE_PROVIDER, nativeId, strength: "weak" as const }, + ordinal, + status: "completed" as const, + startedAt: now, + completedAt: now, + }); + const kept = recorded("first", 1, first); + const rollback = yield* runtime + .rollbackThread({ + providerThread: thread, + target: { + type: "provider_turn", + checkpointId: CheckpointId.make("checkpoint:first"), + appRunOrdinal: 1, + providerTurn: kept, + }, + providerThreadTurns: [kept, recorded("second", 2, second)], + }) + .pipe(Effect.exit); + if (running) { + const error = Exit.isFailure(rollback) ? Cause.squash(rollback.cause) : undefined; + assert.equal( + (error as { _tag?: string } | undefined)?._tag, + "ProviderAdapterProtocolError", + ); + } else { + assert.isTrue(Exit.isSuccess(rollback)); + assert.deepEqual( + Exit.isSuccess(rollback) ? rollback.value.providerTurns.map((turn) => turn.id) : [], + [kept.id], + ); + } + }).pipe(Effect.scoped), + ); it.effect("refuses to fork a session while OpenCode runs a follow-up on it", () => Effect.gen(function* () { @@ -3872,3 +3878,44 @@ const providerTurnId = Effect.gen(function* () { nativeTurnId: `${SESSION}:attempt:attempt:opencode2-adapter`, }); }).pipe(Effect.provide(IdAllocator.layer)); + +describe("OpenCode reported model variants", () => { + it.effect( + "updates reported variants from selected-model and step events without duplicate updates", + () => + Effect.gen(function* () { + const model = { providerID: "opencode", id: "big-pickle", variant: "thinking" }; + const step = { + sessionID: SESSION, + assistantMessageID: "msg_0eb735d5b001oAFVeY5jz3WD4Z", + agent: "build", + model, + started: 1, + }; + const { runtime, thread } = yield* resumed([ + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + event("session.execution.started", { sessionID: SESSION }), + event("session.model.selected", { sessionID: SESSION, model }), + event("session.step.started", step), + event("session.step.started", { ...step, model: { ...model, variant: "none" } }), + event("session.execution.succeeded", { sessionID: SESSION }), + ]); + const collected = yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + Effect.forkScoped, + ); + yield* runtime.startTurn(turnInput(thread)); + const updates = (yield* Fiber.join(collected)).filter( + (event) => event.type === "provider_thread.updated", + ); + assert.deepEqual( + updates.map( + (event) => event.providerThread.nativeMetadata?.modelSelection?.options?.[0]?.value, + ), + ["default", "thinking", "none", "none"], + ); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts index c972e060ad91..d1e81eb725c4 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts @@ -78,7 +78,7 @@ import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { buildRuntimeInstructions } from "../../provider/RuntimeInstructions.ts"; import { t3OrchestrationSystemPrompt } from "../../provider/T3OrchestrationInstructions.ts"; import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens"; -import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; +import { getModelSelectionStringOptionValue, modelSelectionsEqual } from "@t3tools/shared/model"; import { causeErrorTag } from "@t3tools/shared/observability"; import { providerMessageTextWithAttachmentPaths } from "../AttachmentPrompt.ts"; @@ -1385,6 +1385,27 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr }); }); + const withReportedModel = ( + providerThread: OrchestrationV2ProviderThread, + model: ModelRef | undefined, + ): OrchestrationV2ProviderThread => { + if (model === undefined) return providerThread; + const modelSelection: ModelSelection = { + instanceId, + model: `${model.providerID}/${model.id}`, + options: model.variant === undefined ? [] : [{ id: "variant", value: model.variant }], + }; + if ( + providerThread.nativeMetadata?.modelSelection !== undefined && + modelSelectionsEqual(providerThread.nativeMetadata.modelSelection, modelSelection) + ) + return providerThread; + return { + ...providerThread, + nativeMetadata: { ...providerThread.nativeMetadata, modelSelection }, + }; + }; + /** * Gives a subagent call its session once both are known: OpenCode names * the session on the call's progress, and announces it just before. The @@ -1463,7 +1484,8 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr const child = previous ?? newThreadState(childId, providerThread, call.state.directory, subagent); child.subagent = subagent; - child.providerThread = providerThread; + child.model = info?.model ?? child.model; + child.providerThread = withReportedModel(providerThread, child.model); child.directory = call.state.directory; child.agent = info?.agent ?? call.agent ?? child.agent; // OpenCode gives a new session its parent's rules, which are the thread's. @@ -1472,7 +1494,11 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr childOwners.set(childId, rootOf(call.state)); call.child = child; yield* emit({ type: "app_thread.created", driver, appThread }); - yield* emit({ type: "provider_thread.updated", driver, providerThread }); + yield* emit({ + type: "provider_thread.updated", + driver, + providerThread: child.providerThread, + }); yield* emitSubagent(call); // A session called again was not made now, so it may hold the rules of // a mode the thread has left. OpenCode applies a rules change to the @@ -2520,6 +2546,18 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr if (sessionId === undefined) return; const state = threads.get(sessionId); if (state === undefined) return; + if (event.type === "session.model.selected" || event.type === "session.step.started") { + if (event.type === "session.model.selected") state.model = event.data.model; + const providerThread = withReportedModel(state.providerThread, event.data.model); + if (providerThread !== state.providerThread) { + state.providerThread = { ...providerThread, updatedAt: yield* DateTime.now }; + yield* emit({ + type: "provider_thread.updated", + driver, + providerThread: state.providerThread, + }); + } + } if ( event.type === "session.inbox.enqueued" && state.subagent !== undefined && @@ -2962,6 +3000,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr }, directory: string, ) => { + providerThread = withReportedModel(providerThread, native.model); const existing = threads.get(native.id); if (existing !== undefined) { existing.providerThread = providerThread; @@ -3582,7 +3621,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr directory, ); state.policy = policy; - return providerThread; + return state.providerThread; }).pipe( Effect.mapError((cause) => isProviderAdapterError(cause) @@ -3625,7 +3664,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr directory: AbsolutePath.make(cwd), }); } - return providerThread; + return state.providerThread; }).pipe( Effect.mapError((cause) => isProviderAdapterError(cause) @@ -4183,7 +4222,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr directory: AbsolutePath.make(cwd), }); } - return providerThread; + return state.providerThread; }).pipe( exclusive(forkInput.sourceProviderThread), Effect.mapError((cause) => diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts index 3a3f9a2af6cb..eca4aa9115a4 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts @@ -237,8 +237,9 @@ const makeOpenCodeRuntimeHarness = Effect.fn("makeOpenCodeRuntimeHarness")(funct }); describe("OpenCodeAdapterV2", () => { - for (const ending of ["completed", "failed", "unresolved", "unavailable", "reconnect"] as const) { - it.effect(`normalizes OpenCode step usage for ${ending} turns`, () => + it.effect.each(["completed", "failed", "unresolved", "unavailable", "reconnect"] as const)( + "normalizes OpenCode step usage for %s turns", + (ending) => Effect.gen(function* () { const nativeEvents = asyncEventStream(); let promptId = ""; @@ -375,11 +376,11 @@ describe("OpenCodeAdapterV2", () => { }, ); }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), - ); - } + ); - for (const kind of ["permission", "question"] as const) { - it.effect(`cancels an undelivered ${kind} reply at its deadline`, () => + it.effect.each(["permission", "question"] as const)( + "cancels an undelivered %s reply at its deadline", + (kind) => Effect.gen(function* () { const nativeEvents = asyncEventStream(); const called = promiseGate(); @@ -453,8 +454,7 @@ describe("OpenCodeAdapterV2", () => { // Failed delivery leaves the request available for an explicit retry. yield* harness.runtime.respondToRuntimeRequest(response); }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), - ); - } + ); it.effect("aborts external root and descendants before closing the event stream", () => Effect.gen(function* () { @@ -494,8 +494,9 @@ describe("OpenCodeAdapterV2", () => { }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), ); - for (const failure of ["enumeration", "abort", "not-found", "timeout"] as const) { - it.effect(`reports descendant cleanup ${failure}`, () => + it.effect.each(["enumeration", "abort", "not-found", "timeout"] as const)( + "reports descendant cleanup %s", + (failure) => Effect.gen(function* () { const nativeEvents = asyncEventStream(); const called = promiseGate(); @@ -545,8 +546,7 @@ describe("OpenCodeAdapterV2", () => { assert.equal(Exit.isSuccess(result), failure === "not-found"); if (failure === "timeout") assert.isTrue(childSignal?.aborted); }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), - ); - } + ); it.effect( "preserves tool lifecycle, approval kinds, and late assistant text without cached tool payloads", diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts index 9d1f11d6e287..fb912714f12f 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts @@ -589,8 +589,9 @@ describe("PiAdapterV2", () => { }).pipe(Effect.scoped, Effect.provide(testLayer)), ); - for (const invalidReplacement of ["veto", "same identity"] as const) { - it.effect(`rejects a replacement with ${invalidReplacement}`, () => + it.effect.each(["veto", "same identity"] as const)( + "rejects a replacement with %s", + (invalidReplacement) => Effect.gen(function* () { const fake = yield* makeFakePi; const { runtime } = yield* openRuntime(fake); @@ -617,8 +618,7 @@ describe("PiAdapterV2", () => { yield* startTurn(runtime, providerThread, "default").pipe(Effect.flip); assert.isFalse(fake.allRequests().some((request) => request.type === "prompt")); }).pipe(Effect.scoped, Effect.provide(testLayer)), - ); - } + ); it.effect("retires a timed-out lifecycle process before a late switch can race replacement", () => Effect.gen(function* () { @@ -983,90 +983,85 @@ describe("PiAdapterV2", () => { }).pipe(Effect.scoped, Effect.provide(testLayer)), ); - for (const historical of [false, true]) { - it.effect( - `natively forks ${historical ? "a historical turn" : "the latest turn"} into an independent session`, - () => - Effect.gen(function* () { - const fake = yield* makeFakePi; - const forkFake = yield* makeFakePi; - const forkFile = "/fake/forked.jsonl"; - const { runtime, takeEvent } = yield* openRuntime( - fake, - "default", - THREAD_ID, - SESSION_ID, - forkFake, - ); - const source = yield* runtime.ensureThread({ - threadId: THREAD_ID, - modelSelection: modelSelection("default"), - runtimePolicy, - }); - const turn = (ordinal: number): OrchestrationV2ProviderTurn => ({ - id: ProviderTurnId.make(`turn-${ordinal}`), - providerThreadId: source.id, - nodeId: NodeId.make(`node-${ordinal}`), - runAttemptId: null, - nativeTurnRef: { driver: PI_PROVIDER, nativeId: `u${ordinal}`, strength: "strong" }, - ordinal, - status: "completed", - startedAt: null, - completedAt: null, - }); - forkFake.queueState({ sessionFile: forkFile }); - fake.queueState({ sessionFile: forkFile }); - const target = ThreadId.make("fork-target"); - const forked = yield* runtime.forkThread({ - sourceProviderThread: source, - sourceProviderTurns: historical ? [turn(1), turn(2)] : [turn(1)], - providerTurnId: turn(1).id, - targetThreadId: target, - }); - assert.equal(forked.appThreadId, target); - assert.equal(forked.nativeThreadRef?.nativeId, forkFile); - assert.notEqual(forked.id, source.id); - assert.equal(source.nativeThreadRef?.nativeId, FAKE_SESSION_FILE); - const args = forkFake.lastSpawn().args; - assert.equal(args[args.indexOf("--fork") + 1], FAKE_SESSION_FILE); - assert.include(args, "--no-extensions"); - assert.include(args, "--no-tools"); - assert.notInclude(args, "--no-session"); - assert.deepEqual( - forkFake - .allRequests() - .filter((request) => request.type === "fork") - .map((request) => request.entryId), - historical ? ["u2"] : [], - ); - assert.isFalse( - fake - .allRequests() - .some((request) => request.type === "fork" || request.type === "clone"), - ); - // ProviderTurnStartService adopts the fork into its pending row. - const adopted = { ...forked, id: ProviderThreadId.make("pending-fork-row") }; - yield* startTurn(runtime, adopted, "default", [], "Continue", undefined, 1, target); - yield* fake.emit({ type: "agent_start" }); - yield* fake.emit({ type: "agent_settled" }); - const updated = yield* takeEvent( - (event) => - event.type === "provider_thread.updated" && - event.providerThread.appThreadId === target, - ); - assert.isTrue( - updated.type === "provider_thread.updated" && updated.providerThread.id === adopted.id, - ); - yield* takeEvent((event) => event.type === "turn.terminal"); - yield* runtime.resumeThread({ providerThread: adopted }); - assert.equal( - fake.allRequests().findLast((request) => request.type === "switch_session") - ?.sessionPath, - forkFile, - ); - }).pipe(Effect.scoped, Effect.provide(testLayer)), - ); - } + it.effect.each([ + { historical: false, label: "the latest turn" }, + { historical: true, label: "a historical turn" }, + ])("natively forks $label into an independent session", ({ historical }) => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const forkFake = yield* makeFakePi; + const forkFile = "/fake/forked.jsonl"; + const { runtime, takeEvent } = yield* openRuntime( + fake, + "default", + THREAD_ID, + SESSION_ID, + forkFake, + ); + const source = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + const turn = (ordinal: number): OrchestrationV2ProviderTurn => ({ + id: ProviderTurnId.make(`turn-${ordinal}`), + providerThreadId: source.id, + nodeId: NodeId.make(`node-${ordinal}`), + runAttemptId: null, + nativeTurnRef: { driver: PI_PROVIDER, nativeId: `u${ordinal}`, strength: "strong" }, + ordinal, + status: "completed", + startedAt: null, + completedAt: null, + }); + forkFake.queueState({ sessionFile: forkFile }); + fake.queueState({ sessionFile: forkFile }); + const target = ThreadId.make("fork-target"); + const forked = yield* runtime.forkThread({ + sourceProviderThread: source, + sourceProviderTurns: historical ? [turn(1), turn(2)] : [turn(1)], + providerTurnId: turn(1).id, + targetThreadId: target, + }); + assert.equal(forked.appThreadId, target); + assert.equal(forked.nativeThreadRef?.nativeId, forkFile); + assert.notEqual(forked.id, source.id); + assert.equal(source.nativeThreadRef?.nativeId, FAKE_SESSION_FILE); + const args = forkFake.lastSpawn().args; + assert.equal(args[args.indexOf("--fork") + 1], FAKE_SESSION_FILE); + assert.include(args, "--no-extensions"); + assert.include(args, "--no-tools"); + assert.notInclude(args, "--no-session"); + assert.deepEqual( + forkFake + .allRequests() + .filter((request) => request.type === "fork") + .map((request) => request.entryId), + historical ? ["u2"] : [], + ); + assert.isFalse( + fake.allRequests().some((request) => request.type === "fork" || request.type === "clone"), + ); + // ProviderTurnStartService adopts the fork into its pending row. + const adopted = { ...forked, id: ProviderThreadId.make("pending-fork-row") }; + yield* startTurn(runtime, adopted, "default", [], "Continue", undefined, 1, target); + yield* fake.emit({ type: "agent_start" }); + yield* fake.emit({ type: "agent_settled" }); + const updated = yield* takeEvent( + (event) => + event.type === "provider_thread.updated" && event.providerThread.appThreadId === target, + ); + assert.isTrue( + updated.type === "provider_thread.updated" && updated.providerThread.id === adopted.id, + ); + yield* takeEvent((event) => event.type === "turn.terminal"); + yield* runtime.resumeThread({ providerThread: adopted }); + assert.equal( + fake.allRequests().findLast((request) => request.type === "switch_session")?.sessionPath, + forkFile, + ); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); it.effect("observes official subagent results without inventing child threads", () => Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.test.ts b/apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.test.ts index 2d448e3b7a53..9dc9fa4526e6 100644 --- a/apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.test.ts @@ -28,8 +28,9 @@ async function loadRequestHook(): Promise { } describe("Pi upstream output-budget workaround", () => { - for (const key of ["max_tokens", "max_completion_tokens"]) { - it(`caps ${key} without changing the conversation or tools`, async () => { + it.each(["max_tokens", "max_completion_tokens"])( + "caps %s without changing the conversation or tools", + async (key) => { const hook = await loadRequestHook(); const payload = { model: "moonshotai/kimi-k2.6", @@ -43,8 +44,8 @@ describe("Pi upstream output-budget workaround", () => { assert.strictEqual(result?.tools, payload.tools); assert.equal(result?.model, payload.model); assert.equal(payload[key], 231_969); - }); - } + }, + ); it("preserves smaller budgets and other providers' payloads", async () => { const hook = await loadRequestHook(); diff --git a/apps/server/src/orchestration-v2/BackgroundWorkStop.integration.test.ts b/apps/server/src/orchestration-v2/BackgroundWorkStop.integration.test.ts new file mode 100644 index 000000000000..2b1c529ba177 --- /dev/null +++ b/apps/server/src/orchestration-v2/BackgroundWorkStop.integration.test.ts @@ -0,0 +1,446 @@ +import { assert, it } from "@effect/vitest"; +import { + CommandId, + EventId, + MessageId, + NodeId, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ProviderThreadId, + ProviderTurnId, + RunAttemptId, + RunId, + ThreadId, + TurnItemId, + type OrchestrationV2DomainEvent, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; +import * as EffectWorker from "./EffectWorker.ts"; +import * as EventSink from "./EventSink.ts"; +import * as Orchestrator from "./Orchestrator.ts"; +import type { + ProviderAdapterV2Event, + ProviderAdapterV2InterruptInput, + ProviderAdapterV2Shape, + ProviderAdapterV2TurnInput, +} from "./ProviderAdapter.ts"; +import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; +import { makeOrchestratorV2ReplayLayerWithRegistry } from "./testkit/ProviderReplayHarness.ts"; +import { checkpointWorkspace } from "./testkit/ReplayFixtureWorkspace.ts"; + +const driver = ProviderDriverKind.make("codex"); +const instanceId = ProviderInstanceId.make("codex"); +const modelSelection = { instanceId, model: "test-model" }; + +// Codex turns leave commands running, then the thread moves to another +// provider thread (a provider switch). Stop on the newer, settled run must +// reach both provider threads and end all of the Codex work. +it.effect("Stop reaches background work an earlier provider thread still runs", () => + Effect.scoped( + Effect.gen(function* () { + const cwd = yield* checkpointWorkspace("background-work-stop"); + const events = yield* Queue.unbounded(); + const started: ProviderAdapterV2TurnInput[] = []; + const interrupts: ProviderAdapterV2InterruptInput[] = []; + const adapter: ProviderAdapterV2Shape = { + instanceId, + driver, + getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), + planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" }), + openSession: (input) => + Effect.gen(function* () { + const now = yield* DateTime.now; + return { + instanceId, + driver, + providerSessionId: input.providerSessionId, + providerSession: { + id: input.providerSessionId, + driver, + providerInstanceId: instanceId, + status: "ready", + cwd, + model: modelSelection.model, + capabilities: CodexProviderCapabilitiesV2, + createdAt: now, + updatedAt: now, + lastError: null, + }, + events: Stream.fromQueue(events), + ensureThread: ({ threadId }) => + Effect.succeed({ + id: ProviderThreadId.make(`provider-thread:codex:${threadId}`), + driver, + providerInstanceId: instanceId, + providerSessionId: input.providerSessionId, + appThreadId: threadId, + ownerNodeId: null, + nativeThreadRef: { driver, nativeId: "native-thread", strength: "strong" }, + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + }), + resumeThread: ({ providerThread }) => Effect.succeed(providerThread), + startTurn: (turn) => + Effect.gen(function* () { + started.push(turn); + yield* Queue.offer(events, { + type: "provider_turn.updated", + driver, + providerTurn: { + id: ProviderTurnId.make(`provider-turn:${turn.attemptId}`), + providerThreadId: turn.providerThread.id, + nodeId: turn.rootNodeId, + runAttemptId: turn.attemptId, + nativeTurnRef: { + driver, + nativeId: `native:${turn.attemptId}`, + strength: "strong", + }, + ordinal: turn.providerTurnOrdinal, + status: "running", + startedAt: now, + completedAt: null, + }, + }); + }), + steerTurn: () => Effect.die("unused"), + interruptTurn: (interrupt) => + Effect.sync(() => { + interrupts.push(interrupt); + }), + respondToRuntimeRequest: () => Effect.die("unused"), + readThreadSnapshot: () => Effect.die("unused"), + rollbackThread: () => Effect.die("unused"), + forkThread: () => Effect.die("unused"), + }; + }), + }; + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const sink = yield* EventSink.EventSinkV2; + const threadId = ThreadId.make("thread:background-work-stop"); + const watch = (predicate: (event: OrchestrationV2DomainEvent) => boolean) => + orchestrator.streamDomainEvents.pipe( + Stream.filter(predicate), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped, + ); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create"), + threadId, + projectId: ProjectId.make("project:background-work-stop"), + title: "Background work stop", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + createdBy: "user", + creationSource: "web", + }); + const running = yield* watch( + (event) => event.type === "provider-turn.updated" && event.payload.status === "running", + ); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("start-dev-server"), + threadId, + messageId: MessageId.make("message:start-dev-server"), + text: "Start the dev server", + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + yield* worker.drain(); + yield* Fiber.join(running); + const first = started[0]!; + const codexTurn = (yield* orchestrator.getThreadProjection(threadId)).providerTurns[0]!; + const devServerId = TurnItemId.make("turn-item:dev-server"); + const now = yield* DateTime.now; + yield* sink.write({ + events: [ + { + id: EventId.make("dev-server"), + type: "turn-item.updated", + threadId, + runId: first.runId, + occurredAt: now, + payload: { + id: devServerId, + threadId, + runId: first.runId, + nodeId: first.rootNodeId, + providerThreadId: codexTurn.providerThreadId, + providerTurnId: codexTurn.id, + nativeItemRef: null, + parentItemId: null, + ordinal: 100, + status: "running", + title: null, + startedAt: now, + completedAt: null, + updatedAt: now, + type: "command_execution", + input: "vp run dev --share", + }, + }, + ], + }); + const settled = yield* watch( + (event) => + event.type === "run.updated" && + event.payload.id === first.runId && + event.payload.status === "waiting", + ); + yield* Queue.offer(events, { + type: "provider_turn.updated", + driver, + providerTurn: { ...codexTurn, status: "completed", completedAt: now }, + }); + yield* Queue.offer(events, { + type: "turn.terminal", + driver, + providerThreadId: codexTurn.providerThreadId, + providerTurnId: codexTurn.id, + runOrdinal: first.runOrdinal, + status: "completed", + failure: null, + threadDisposition: "reusable", + }); + yield* Fiber.join(settled); + yield* worker.drain(); + + const codexProviderThread = (yield* orchestrator.getThreadProjection(threadId)) + .providerThreads[0]!; + // A settled run with its root node, attempt, provider turn and, + // optionally, a command or native subagent it left running. + const settledRun = (input: { + readonly ordinal: number; + readonly providerThreadId: ProviderThreadId; + readonly runningItem?: { readonly id: TurnItemId; readonly kind: "command" | "subagent" }; + }) => { + const runId = RunId.make(`run:${input.ordinal}`); + const attemptId = RunAttemptId.make(`attempt:${input.ordinal}`); + const nodeId = NodeId.make(`node:${input.ordinal}`); + const providerTurnId = ProviderTurnId.make(`provider-turn:${input.ordinal}`); + const events: Array = [ + { + id: EventId.make(`run:${input.ordinal}`), + type: "run.created", + threadId, + occurredAt: now, + payload: { + id: runId, + threadId, + ordinal: input.ordinal, + providerInstanceId: instanceId, + modelSelection, + providerThreadId: input.providerThreadId, + userMessageId: MessageId.make(`message:${input.ordinal}`), + rootNodeId: nodeId, + activeAttemptId: attemptId, + status: "completed", + requestedAt: now, + startedAt: now, + completedAt: now, + checkpointId: null, + contextHandoffId: null, + }, + }, + { + id: EventId.make(`node:${input.ordinal}`), + type: "node.updated", + threadId, + runId, + occurredAt: now, + payload: { + id: nodeId, + threadId, + runId, + parentNodeId: null, + rootNodeId: nodeId, + kind: "root_turn", + status: "completed", + countsForRun: true, + providerThreadId: input.providerThreadId, + providerTurnId, + nativeItemRef: null, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: now, + completedAt: now, + }, + }, + { + id: EventId.make(`attempt:${input.ordinal}`), + type: "run-attempt.created", + threadId, + occurredAt: now, + payload: { + id: attemptId, + runId, + attemptOrdinal: 1, + rootNodeId: nodeId, + providerInstanceId: instanceId, + providerThreadId: input.providerThreadId, + providerTurnId, + reason: "initial", + status: "completed", + startedAt: now, + completedAt: now, + }, + }, + { + id: EventId.make(`provider-turn:${input.ordinal}`), + type: "provider-turn.updated", + threadId, + occurredAt: now, + payload: { + id: providerTurnId, + providerThreadId: input.providerThreadId, + nodeId, + runAttemptId: attemptId, + nativeTurnRef: null, + ordinal: input.ordinal, + status: "completed", + startedAt: now, + completedAt: now, + }, + }, + ]; + const item = input.runningItem; + if (item !== undefined) { + const base = { + id: item.id, + threadId, + runId, + nodeId, + providerThreadId: input.providerThreadId, + providerTurnId, + nativeItemRef: null, + parentItemId: null, + ordinal: input.ordinal * 100, + status: "running", + title: null, + startedAt: now, + completedAt: null, + updatedAt: now, + } as const; + events.push({ + id: EventId.make(`item:${input.ordinal}`), + type: "turn-item.updated", + threadId, + runId, + occurredAt: now, + payload: + item.kind === "command" + ? { ...base, type: "command_execution", input: "vp run test --watch" } + : { + ...base, + // A native subagent item names its own provider thread + // but its parent's provider turn. + providerThreadId: ProviderThreadId.make("provider-thread:codex-subagent"), + type: "subagent", + subagentId: NodeId.make(`subagent:${input.ordinal}`), + origin: "provider_native", + driver, + providerInstanceId: instanceId, + childThreadId: null, + prompt: "Review the change", + result: null, + }, + }); + } + return { runId, providerTurnId, events }; + }; + + // Later Codex runs leave a second command and a native subagent. Then + // the thread moves on to another provider thread, which also has a + // live session. + const watcherId = TurnItemId.make("turn-item:watcher"); + const watcherRun = settledRun({ + ordinal: 2, + providerThreadId: codexProviderThread.id, + runningItem: { id: watcherId, kind: "command" }, + }); + const reviewerId = TurnItemId.make("turn-item:reviewer"); + const reviewerRun = settledRun({ + ordinal: 3, + providerThreadId: codexProviderThread.id, + runningItem: { id: reviewerId, kind: "subagent" }, + }); + const otherProviderThreadId = ProviderThreadId.make("provider-thread:other"); + const latestRun = settledRun({ ordinal: 4, providerThreadId: otherProviderThreadId }); + yield* sink.write({ + events: [ + ...watcherRun.events, + ...reviewerRun.events, + { + id: EventId.make("provider-thread:other"), + type: "provider-thread.updated", + threadId, + occurredAt: now, + payload: { + ...codexProviderThread, + id: otherProviderThreadId, + firstRunOrdinal: 4, + lastRunOrdinal: 4, + }, + }, + ...latestRun.events, + ], + }); + + yield* orchestrator.dispatch({ + type: "run.interrupt", + commandId: CommandId.make("stop-background-work"), + threadId, + runId: latestRun.runId, + }); + yield* worker.drain(); + + // Stop reaches both provider threads. The Codex one is interrupted at + // its latest pending work, the subagent's parent turn, so its settle + // covers all three Codex runs. + assert.sameDeepMembers( + interrupts.map((interrupt) => [interrupt.providerThread.id, interrupt.providerTurnId]), + [ + [otherProviderThreadId, latestRun.providerTurnId], + [codexProviderThread.id, reviewerRun.providerTurnId], + ], + ); + const after = yield* orchestrator.getThreadProjection(threadId); + assert.deepEqual( + [devServerId, watcherId, reviewerId].map( + (id) => after.turnItems.find((item) => item.id === id)?.status, + ), + ["interrupted", "interrupted", "interrupted"], + ); + }).pipe( + Effect.provide( + makeOrchestratorV2ReplayLayerWithRegistry( + { name: "background-work-stop" }, + ProviderAdapterRegistry.makeSingleLayer(adapter), + { runEffectWorker: false }, + ), + ), + ); + }), + ), +); diff --git a/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts b/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts index 067b851d6a14..2c6c878b25e9 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts @@ -413,55 +413,51 @@ describe("handoff budget", () => { }); describe("handoff delivery", () => { - for (const native of [true, false]) { - it.effect( - `records omitted recovery coverage separately from ${native ? "injected" : "inline"} text`, - () => - Effect.gen(function* () { - const omittedBeforeDelivery = TurnItemId.make("item:omitted-during-preparation"); - const oversized = message("item:oversized", "user", "x".repeat(20_000)); - let durable: OrchestrationV2ContextHandoff = { - ...handoff, - history: { - ...handoff.history!, - messages: [...messages, oversized], - omittedItems: 1, - omittedItemIds: [omittedBeforeDelivery], - }, - }; - const result = yield* deliverContextHandoffs({ - handoffs: [durable], - providerThread, - budget: 16_000, - alreadyDeliveredItemIds: new Set(), - inject: (value) => { - assert.include(value.context, "omitted 2 items"); - assert.notInclude( - value.messages.map((item) => item.itemId), - oversized.itemId, - ); - return Effect.succeed(native); - }, - persist: (value) => - Effect.sync(() => { - durable = value; - }), - }); - assert.equal(durable.delivery?.status, native ? "injected" : "pending"); - yield* result.delivered; - assert.equal(durable.delivery?.status, native ? "injected" : "inline"); - assert.deepEqual( - durable.delivery?.itemIds, - messages.map((item) => item.itemId), - ); - assert.deepEqual(durable.delivery?.omittedItemIds, [ - omittedBeforeDelivery, + it.effect.each([ + { native: true, label: "injected" }, + { native: false, label: "inline" }, + ])("records omitted recovery coverage separately from $label text", ({ native }) => + Effect.gen(function* () { + const omittedBeforeDelivery = TurnItemId.make("item:omitted-during-preparation"); + const oversized = message("item:oversized", "user", "x".repeat(20_000)); + let durable: OrchestrationV2ContextHandoff = { + ...handoff, + history: { + ...handoff.history!, + messages: [...messages, oversized], + omittedItems: 1, + omittedItemIds: [omittedBeforeDelivery], + }, + }; + const result = yield* deliverContextHandoffs({ + handoffs: [durable], + providerThread, + budget: 16_000, + alreadyDeliveredItemIds: new Set(), + inject: (value) => { + assert.include(value.context, "omitted 2 items"); + assert.notInclude( + value.messages.map((item) => item.itemId), oversized.itemId, - ]); - assert.deepEqual(decodeHandoff(durable).delivery, durable.delivery); - }), - ); - } + ); + return Effect.succeed(native); + }, + persist: (value) => + Effect.sync(() => { + durable = value; + }), + }); + assert.equal(durable.delivery?.status, native ? "injected" : "pending"); + yield* result.delivered; + assert.equal(durable.delivery?.status, native ? "injected" : "inline"); + assert.deepEqual( + durable.delivery?.itemIds, + messages.map((item) => item.itemId), + ); + assert.deepEqual(durable.delivery?.omittedItemIds, [omittedBeforeDelivery, oversized.itemId]); + assert.deepEqual(decodeHandoff(durable).delivery, durable.delivery); + }), + ); it.effect("loads the history budget only when a handoff needs delivery", () => Effect.gen(function* () { let reads = 0; diff --git a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts index bec742080701..939a562272f2 100644 --- a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts +++ b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts @@ -7,6 +7,7 @@ import { MessageId, type ModelSelection, NodeId, + type OrchestrationV2Run, ProjectId, ProviderDriverKind, ProviderInstanceId, @@ -35,6 +36,7 @@ import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as EventSink from "./EventSink.ts"; import * as Orchestrator from "./Orchestrator.ts"; import type { ProviderAdapterV2Shape } from "./ProviderAdapter.ts"; +import { continueRestartedRun } from "./RestartContinuation.ts"; import { OrchestrationV2EventSinkLayerLive, OrchestrationV2LayerLive, @@ -386,6 +388,98 @@ it.layer(TestLayer)("delegated completion delivery repairs", (it) => { }), ); + it.effect("acceptance batches a settled_only sibling once its spawning run ended", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const sink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("mailbox-mixed"); + const runId = RunId.make("mailbox-mixed-parent"); + const taskId = NodeId.make("mailbox-mixed-always"); + const siblingId = NodeId.make("mailbox-mixed-settled-only"); + const messageId = MessageId.make(`message:delegated-delivery:${threadId}`); + yield* seedParentWithTerminalTask({ + threadId, + runId, + projectId: ProjectId.make("mailbox-mixed-project"), + rootNodeId: NodeId.make("mailbox-mixed-root"), + taskId, + deliveryState: "claimed", + completionWake: "always", + deliveryTaskIds: [taskId], + now, + }); + const projection = yield* orchestrator.getThreadProjection(threadId); + const task = projection.subagents[0]!; + const spawningRun = projection.runs.find((row) => row.id === runId)!; + // A restart cut the spawning run; its continuation is the live run now. + yield* sink.write({ + events: [ + { + ...runEvent({ threadId, runId, ordinal: 1, status: "cancelled", now }), + payload: { ...spawningRun, status: "cancelled" as const, completedAt: now }, + }, + runEvent({ + threadId, + runId: RunId.make("mailbox-mixed-continuation"), + ordinal: 2, + status: "running", + now, + }), + { + id: EventId.make("mailbox-mixed-message"), + type: "message.updated", + threadId, + runId, + occurredAt: now, + payload: { + id: messageId, + threadId, + runId, + nodeId: task.parentNodeId, + role: "user", + text: "Background task finished", + attachments: [], + streaming: false, + createdBy: "agent", + creationSource: "server", + createdAt: now, + updatedAt: now, + delegatedCompletion: { parentRunId: runId, generation: 1, taskIds: [taskId] }, + }, + }, + { + id: EventId.make(`event:${siblingId}`), + type: "subagent.updated" as const, + threadId, + runId, + nodeId: siblingId, + occurredAt: now, + payload: { + ...task, + id: siblingId, + completionWake: "settled_only" as const, + completionDelivery: { state: "pending" as const, observedByRunId: null }, + }, + }, + ], + }); + yield* orchestrator.dispatch({ + type: "notification.delivery.accept", + commandId: CommandId.make("accept-mixed"), + threadId, + messageId, + }); + const accepted = yield* orchestrator.getThreadProjection(threadId); + const cohort = accepted.runs.find((row) => row.id === runId)?.delegatedCompletion; + assert.deepEqual(cohort?.delivery?.taskIds, [siblingId]); + assert.equal( + accepted.subagents.find((row) => row.id === siblingId)?.completionDelivery?.state, + "claimed", + ); + }), + ); + it.effect("builds completion text and metadata from the same live cohort", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; @@ -654,3 +748,533 @@ it.layer(TestLayer)("delegated completion delivery repairs", (it) => { }), ); }); + +// Runtime reconciliation writes restart cancellations under this command +// prefix, which the live terminal-run listener skips. +const reconcileCommandId = (name: string) => CommandId.make(`command:runtime-reconcile:${name}`); + +const parentProviderThreadId = (threadId: ThreadId) => + ProviderThreadId.make(`provider-thread:${String(threadId).replace("thread:", "")}`); + +const runEvent = (input: { + readonly threadId: ThreadId; + readonly runId: RunId; + readonly ordinal: number; + readonly status: OrchestrationV2Run["status"]; + readonly now: DateTime.Utc; + readonly providerThreadId?: ProviderThreadId; + readonly userMessageId?: MessageId; + readonly delegatedCompletion?: OrchestrationV2Run["delegatedCompletion"]; +}) => ({ + id: EventId.make(`event:${input.runId}:${input.status}`), + type: "run.updated" as const, + threadId: input.threadId, + runId: input.runId, + providerInstanceId: modelSelection.instanceId, + occurredAt: input.now, + payload: { + id: input.runId, + threadId: input.threadId, + ordinal: input.ordinal, + providerInstanceId: modelSelection.instanceId, + modelSelection, + providerThreadId: input.providerThreadId ?? null, + userMessageId: input.userMessageId ?? MessageId.make(`message:${input.runId}`), + rootNodeId: null, + activeAttemptId: null, + status: input.status, + requestedAt: input.now, + startedAt: input.now, + completedAt: input.status === "running" ? null : input.now, + checkpointId: null, + contextHandoffId: null, + ...(input.delegatedCompletion === undefined + ? {} + : { delegatedCompletion: input.delegatedCompletion }), + }, +}); + +/** A running app-owned task whose child thread's first run the restart cancelled. */ +const seedRestartCancelledChild = (input: { + readonly parentThreadId: ThreadId; + readonly projectId: ProjectId; + readonly parentRunId: RunId; + readonly rootNodeId: NodeId; + readonly name: string; + readonly completionWake: "always" | "settled_only"; + readonly continuationPending: boolean; + /** "completed" seeds a settled turn whose background work the restart cancelled. */ + readonly runStatus?: "cancelled" | "completed"; + readonly now: DateTime.Utc; +}) => + Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const taskId = NodeId.make(`node:${input.name}`); + const childThreadId = ThreadId.make(`thread:${input.name}`); + const childRunId = RunId.make(`run:${input.name}:1`); + yield* eventSink.write({ + commandId: CommandId.make(`command:seed-child:${input.name}`), + events: [ + { + id: EventId.make(`event:${input.name}:thread`), + type: "thread.created", + threadId: childThreadId, + occurredAt: input.now, + payload: { + createdBy: "agent", + creationSource: "server", + id: childThreadId, + projectId: input.projectId, + title: input.name, + providerInstanceId: modelSelection.instanceId, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { + parentThreadId: input.parentThreadId, + relationshipToParent: "subagent", + rootThreadId: input.parentThreadId, + }, + forkedFrom: { type: "node", nodeId: taskId }, + createdAt: input.now, + updatedAt: input.now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }, + }, + { + id: EventId.make(`event:${input.name}:task`), + type: "subagent.updated", + threadId: input.parentThreadId, + runId: input.parentRunId, + nodeId: taskId, + driver, + providerInstanceId: modelSelection.instanceId, + occurredAt: input.now, + payload: { + id: taskId, + threadId: input.parentThreadId, + runId: input.parentRunId, + parentNodeId: input.rootNodeId, + origin: "app_owned", + createdBy: "agent", + driver, + providerInstanceId: modelSelection.instanceId, + providerThreadId: null, + childThreadId, + nativeTaskRef: null, + prompt: `Run ${input.name}.`, + title: null, + model: null, + completionWake: input.completionWake, + status: "running", + result: null, + startedAt: input.now, + completedAt: null, + updatedAt: input.now, + }, + }, + ], + }); + yield* eventSink.writeWithEffects({ + commandId: reconcileCommandId(input.name), + events: [ + runEvent({ + threadId: childThreadId, + runId: childRunId, + ordinal: 1, + status: input.runStatus ?? "cancelled", + now: input.now, + }), + ], + effects: input.continuationPending + ? [ + { + id: `effect:restart-continuation:${childRunId}`, + commandId: reconcileCommandId(input.name), + threadId: childThreadId, + request: { type: "provider-runtime.continue", sourceRunId: childRunId }, + }, + ] + : [], + }); + return { taskId, childThreadId, childRunId }; + }); + +it.layer(TestLayer)("delegated tasks across a server restart", (it) => { + it.effect("holds a restart-cancelled child for its continuation's result", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread:restart-parent"); + const projectId = ProjectId.make("project:restart-parent"); + const runId = RunId.make("run:restart-parent"); + const rootNodeId = NodeId.make("node:restart-parent-root"); + yield* seedParentWithTerminalTask({ + threadId, + projectId, + runId, + rootNodeId, + taskId: NodeId.make("node:restart-parent-settled"), + deliveryState: "delivered", + now, + }); + const child = ( + name: string, + continuationPending: boolean, + runStatus?: "cancelled" | "completed", + ) => + seedRestartCancelledChild({ + parentThreadId: threadId, + projectId, + parentRunId: runId, + rootNodeId, + name, + completionWake: "always", + continuationPending, + ...(runStatus === undefined ? {} : { runStatus }), + now, + }); + const resumed = yield* child("restart-resumed-child", true); + const stopped = yield* child("restart-stopped-child", false); + // Settled with only background work left: its interim reply is not the result. + const backgrounded = yield* child("restart-backgrounded-child", true, "completed"); + // A second restart cut the first continuation before it started. + const recut = yield* child("restart-recut-child", false); + const recutContinuationId = RunId.make("run:restart-recut-child:2"); + const recutCommandId = CommandId.make("command:restart-recut-child:reconcile"); + const recutRun = runEvent({ + threadId: recut.childThreadId, + runId: recutContinuationId, + ordinal: 2, + status: "cancelled", + now, + }); + yield* eventSink.writeWithEffects({ + commandId: recutCommandId, + events: [ + { + ...recutRun, + payload: { + ...recutRun.payload, + startedAt: null, + restartContinuationOfRunId: recut.childRunId, + }, + }, + ], + effects: [ + { + id: `effect:restart-continuation:${recutContinuationId}`, + commandId: recutCommandId, + threadId: recut.childThreadId, + request: { type: "provider-runtime.continue", sourceRunId: recutContinuationId }, + }, + ], + }); + + yield* orchestrator.recoverDelegatedTasks; + + const recovered = yield* orchestrator.getThreadProjection(threadId); + const task = (id: NodeId) => recovered.subagents.find((row) => row.id === id); + assert.equal(task(stopped.taskId)?.status, "cancelled"); + assert.equal(task(stopped.taskId)?.completionDelivery?.state, "claimed"); + assert.equal(task(resumed.taskId)?.status, "running"); + assert.isNull(task(resumed.taskId)?.result ?? null); + assert.equal(task(backgrounded.taskId)?.status, "running"); + assert.isNull(task(backgrounded.taskId)?.result ?? null); + assert.equal(task(recut.taskId)?.status, "running"); + assert.isTrue(yield* orchestrator.delegatedTaskResultPending(recut.childThreadId)); + assert.isTrue(yield* orchestrator.delegatedTaskResultPending(resumed.childThreadId)); + assert.isFalse(yield* orchestrator.delegatedTaskResultPending(stopped.childThreadId)); + // A replayed first continuation settling must not release the second one's hold. + yield* orchestrator.recoverDelegatedTask(recut.childThreadId, recut.childRunId); + const replayed = yield* orchestrator.getThreadProjection(threadId); + assert.equal(replayed.subagents.find((row) => row.id === recut.taskId)?.status, "running"); + // A caller that read the cancelled run before the child resumed sees it as pending. + yield* eventSink.write({ + commandId: CommandId.make("command:restart-stopped-child:resumed"), + events: [ + runEvent({ + threadId: stopped.childThreadId, + runId: RunId.make("run:restart-stopped-child:2"), + ordinal: 2, + status: "running", + now, + }), + ], + }); + assert.isTrue(yield* orchestrator.delegatedTaskResultPending(stopped.childThreadId)); + assert.isFalse( + recovered.contextTransfers.some( + (transfer) => transfer.sourceThreadId === resumed.childThreadId, + ), + ); + + // The continuation's own run finishing settles the task with its result. + const afterSequence = yield* eventSink.latestSequence(); + const continuationRunId = RunId.make("run:restart-resumed-child:2"); + yield* eventSink.write({ + commandId: CommandId.make("command:restart-resumed-child:completed"), + events: [ + { + id: EventId.make("event:restart-resumed-child:result"), + type: "message.updated", + threadId: resumed.childThreadId, + runId: continuationRunId, + occurredAt: now, + payload: { + id: MessageId.make("message:restart-resumed-child:result"), + threadId: resumed.childThreadId, + runId: continuationRunId, + nodeId: null, + role: "assistant", + text: "Finished after the restart.", + attachments: [], + streaming: false, + createdBy: "agent", + creationSource: "server", + createdAt: now, + updatedAt: now, + }, + }, + runEvent({ + threadId: resumed.childThreadId, + runId: continuationRunId, + ordinal: 2, + status: "completed", + now, + }), + ], + }); + const settled = yield* eventSink + .stream({ afterSequence, eventType: "subagent.updated" }) + .pipe( + Stream.filter( + (stored) => + stored.event.type === "subagent.updated" && + stored.event.payload.id === resumed.taskId, + ), + Stream.take(1), + Stream.runHead, + ); + assert.isTrue(settled._tag === "Some"); + const finished = yield* orchestrator.getThreadProjection(threadId); + const finishedTask = finished.subagents.find((row) => row.id === resumed.taskId); + assert.equal(finishedTask?.status, "completed"); + assert.equal(finishedTask?.result, "Finished after the restart."); + }), + ); + + it.effect("settles a restart-cancelled child whose continuation declines to start", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread:restart-declined-parent"); + const projectId = ProjectId.make("project:restart-declined-parent"); + const runId = RunId.make("run:restart-declined-parent"); + const rootNodeId = NodeId.make("node:restart-declined-parent-root"); + yield* seedParentWithTerminalTask({ + threadId, + projectId, + runId, + rootNodeId, + taskId: NodeId.make("node:restart-declined-parent-settled"), + deliveryState: "delivered", + now, + }); + const child = yield* seedRestartCancelledChild({ + parentThreadId: threadId, + projectId, + parentRunId: runId, + rootNodeId, + name: "restart-declined-child", + completionWake: "always", + continuationPending: true, + now, + }); + yield* orchestrator.recoverDelegatedTasks; + const held = yield* orchestrator.getThreadProjection(threadId); + assert.equal(held.subagents.find((row) => row.id === child.taskId)?.status, "running"); + + yield* continueRestartedRun({ + threadId: child.childThreadId, + sourceRunId: child.childRunId, + }).pipe( + Effect.provide(ServerSettings.layerTest({ continueThreadsAfterServerUpdate: false })), + ); + + const settled = yield* orchestrator.getThreadProjection(threadId); + const task = settled.subagents.find((row) => row.id === child.taskId); + assert.equal(task?.status, "cancelled"); + assert.equal(task?.completionDelivery?.state, "claimed"); + }), + ); + + // A running provider turn does not prove the provider consumed the delivery + // (Claude marks the turn running before it reads the prompt), so a cut + // delivery is offered again even when a continuation resumes that turn. + it.effect("re-offers a cut delivery even when a continuation resumes its turn", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + const seed = (name: string, continuationPending: boolean) => + Effect.gen(function* () { + const threadId = ThreadId.make(`thread:${name}`); + const runId = RunId.make(`run:${name}`); + const taskId = NodeId.make(`node:${name}-task`); + const messageId = MessageId.make(`message:delegated-delivery:${threadId}`); + const deliveryRunId = RunId.make(`run:${name}:delivery`); + yield* seedParentWithTerminalTask({ + threadId, + projectId: ProjectId.make(`project:${name}`), + runId, + rootNodeId: NodeId.make(`node:${name}-root`), + taskId, + deliveryState: "claimed", + completionWake: "always", + deliveryTaskIds: [taskId], + now, + }); + yield* eventSink.writeWithEffects({ + commandId: reconcileCommandId(name), + events: [ + { + id: EventId.make(`event:${name}:delivery-message`), + type: "message.updated", + threadId, + runId: deliveryRunId, + occurredAt: now, + payload: { + id: messageId, + threadId, + runId: deliveryRunId, + nodeId: null, + role: "user", + text: `Delegated task ${taskId} reached a terminal state.`, + attachments: [], + streaming: false, + createdBy: "agent", + creationSource: "server", + createdAt: now, + updatedAt: now, + delegatedCompletion: { parentRunId: runId, generation: 1, taskIds: [taskId] }, + }, + }, + runEvent({ + threadId, + runId: deliveryRunId, + ordinal: 2, + status: "cancelled", + now, + providerThreadId: parentProviderThreadId(threadId), + userMessageId: messageId, + }), + ], + effects: continuationPending + ? [ + { + id: `effect:restart-continuation:${deliveryRunId}`, + commandId: reconcileCommandId(name), + threadId, + request: { type: "provider-runtime.continue", sourceRunId: deliveryRunId }, + }, + ] + : [], + }); + return { threadId, runId, taskId }; + }); + const cut = yield* seed("delivery-cut", false); + const resumed = yield* seed("delivery-resumed", true); + + yield* orchestrator.recoverDelegatedTasks; + + for (const seeded of [cut, resumed]) { + const projection = yield* orchestrator.getThreadProjection(seeded.threadId); + assert.deepEqual( + projection.subagents.find((row) => row.id === seeded.taskId)?.completionDelivery?.state, + "claimed", + ); + const delivery = projection.runs.find((row) => row.id === seeded.runId)?.delegatedCompletion + ?.delivery; + assert.equal(delivery?.generation, 2); + assert.deepEqual(delivery?.taskIds, [seeded.taskId]); + } + }), + ); + + it.effect("wakes the parent for a settled_only task once its spawning turn ended", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread:settled-only-restart"); + const projectId = ProjectId.make("project:settled-only-restart"); + const runId = RunId.make("run:settled-only-restart"); + const rootNodeId = NodeId.make("node:settled-only-restart-root"); + yield* seedParentWithTerminalTask({ + threadId, + projectId, + runId, + rootNodeId, + taskId: NodeId.make("node:settled-only-restart-settled"), + deliveryState: "delivered", + now, + }); + // The restart cut the turn blocked in delegate_task(wait); its + // continuation is a new, live run. + yield* eventSink.write({ + commandId: reconcileCommandId("settled-only-restart"), + events: [ + runEvent({ + threadId, + runId, + ordinal: 1, + status: "cancelled", + now, + providerThreadId: parentProviderThreadId(threadId), + userMessageId: MessageId.make(`message:seed-user:${threadId}`), + delegatedCompletion: { disposition: "open", nextGeneration: 2, delivery: null }, + }), + runEvent({ + threadId, + runId: RunId.make("run:settled-only-restart:continuation"), + ordinal: 2, + status: "running", + now, + providerThreadId: parentProviderThreadId(threadId), + }), + ], + }); + const child = yield* seedRestartCancelledChild({ + parentThreadId: threadId, + projectId, + parentRunId: runId, + rootNodeId, + name: "settled-only-restart-child", + completionWake: "settled_only", + continuationPending: false, + now, + }); + + yield* orchestrator.recoverDelegatedTasks; + + const projection = yield* orchestrator.getThreadProjection(threadId); + assert.equal( + projection.subagents.find((row) => row.id === child.taskId)?.completionDelivery?.state, + "claimed", + ); + assert.deepEqual( + projection.runs.find((row) => row.id === runId)?.delegatedCompletion?.delivery?.taskIds, + [child.taskId], + ); + }), + ); +}); diff --git a/apps/server/src/orchestration-v2/EffectOutbox.ts b/apps/server/src/orchestration-v2/EffectOutbox.ts index 565be0580320..20b74a183c2a 100644 --- a/apps/server/src/orchestration-v2/EffectOutbox.ts +++ b/apps/server/src/orchestration-v2/EffectOutbox.ts @@ -294,9 +294,16 @@ export const layer: Layer.Layer = La available, Array.from({ length: Math.min(64, Math.max(0, Math.floor(count))) }, () => undefined), ).pipe(Effect.asVoid); + // Each thread runs its effects one at a time, in enqueue (rowid) order. An earlier + // effect waiting out a retry backoff still blocks later ones, so a turn + // cannot start while a failed rollback is about to restore files. A claim + // that skips restart continuations is not blocked by them either. // Title generation is correlated metadata work, so it has its own // per-thread lane and cannot delay provider lifecycle effects. - const claimableCandidatePredicate = (availableBefore?: string) => + const claimableCandidatePredicate = ( + availableBefore?: string, + excludeRestartContinuations = false, + ) => sql` ${ availableBefore === undefined @@ -308,7 +315,18 @@ export const layer: Layer.Layer = La SELECT 1 FROM orchestration_v2_effect_outbox AS active WHERE active.thread_id = candidate.thread_id - AND active.status = 'running' + AND ( + active.status = 'running' + OR ( + active.status = 'pending' + AND active.rowid < candidate.rowid + AND ${ + excludeRestartContinuations + ? sql`active.effect_type != 'provider-runtime.continue'` + : sql`1 = 1` + } + ) + ) AND ( ( candidate.effect_type = 'thread-title.generate' @@ -509,7 +527,7 @@ export const layer: Layer.Layer = La WHERE effect_id = ( SELECT candidate.effect_id FROM orchestration_v2_effect_outbox AS candidate - WHERE ${claimableCandidatePredicate(nowIso)} + WHERE ${claimableCandidatePredicate(nowIso, excludeRestartContinuations)} AND ${excludeRestartContinuations ? sql`candidate.effect_type != 'provider-runtime.continue'` : sql`1 = 1`} ORDER BY candidate.available_at ASC, candidate.created_at ASC, candidate.effect_id ASC LIMIT 1 diff --git a/apps/server/src/orchestration-v2/EffectWorker.test.ts b/apps/server/src/orchestration-v2/EffectWorker.test.ts index e33b575a3464..f213fc7cce44 100644 --- a/apps/server/src/orchestration-v2/EffectWorker.test.ts +++ b/apps/server/src/orchestration-v2/EffectWorker.test.ts @@ -80,6 +80,8 @@ function restartEffect( function makeExecutorLayer(input: { readonly events: Ref.Ref>; readonly failFirstStart?: Ref.Ref; + readonly threads?: Partial; + readonly continueAfterRestart?: boolean; }) { const record = (event: string) => Ref.update(input.events, (events) => [...events, event]); const dependencies = Layer.mergeAll( @@ -149,8 +151,10 @@ function makeExecutorLayer(input: { Layer.provide( Layer.mergeAll( dependencies, - Layer.mock(ThreadManagementService.ThreadManagementService)({}), - ServerSettings.layerTest(), + Layer.mock(ThreadManagementService.ThreadManagementService)(input.threads ?? {}), + ServerSettings.layerTest( + input.continueAfterRestart === true ? { continueThreadsAfterServerUpdate: true } : {}, + ), ), ), ); @@ -807,3 +811,46 @@ it.effect("safely retries after replacement cleanup succeeds and start fails", ( ]); }), ); + +it.effect("settles a delegated child once its restart continuation fails for good", () => + Effect.gen(function* () { + const timestamp = DateTime.formatIso(yield* DateTime.now); + const events = yield* Ref.make>([]); + const recovered = yield* Ref.make>([]); + const layer = makeExecutorLayer({ + events, + continueAfterRestart: true, + threads: { + getThreadRecords: () => Effect.fail(new Error("provider instance removed") as never), + recoverDelegatedTask: (childThreadId) => + Ref.update(recovered, (ids) => [...ids, childThreadId]), + }, + }); + const effect: EffectOutbox.OrchestrationEffectV2 = { + id: `effect:restart-continuation:${runId}`, + commandId: CommandId.make("command:restart-continuation-failure"), + threadId, + request: { type: "provider-runtime.continue", sourceRunId: runId }, + status: "running", + attemptCount: 1, + availableAt: timestamp, + leaseOwner: "test-worker", + leaseExpiresAt: timestamp, + createdAt: timestamp, + updatedAt: timestamp, + completedAt: null, + lastError: null, + }; + yield* Effect.gen(function* () { + const executor = yield* EffectWorker.OrchestrationEffectExecutorV2; + assert.isTrue( + Exit.isFailure(yield* Effect.exit(executor.execute(effect, { willRetry: true }))), + ); + assert.deepEqual(yield* Ref.get(recovered), []); + assert.isTrue( + Exit.isFailure(yield* Effect.exit(executor.execute(effect, { willRetry: false }))), + ); + assert.deepEqual(yield* Ref.get(recovered), [threadId]); + }).pipe(Effect.provide(layer)); + }), +); diff --git a/apps/server/src/orchestration-v2/EffectWorker.ts b/apps/server/src/orchestration-v2/EffectWorker.ts index 76a0e4533c40..b5a5af2a6bf8 100644 --- a/apps/server/src/orchestration-v2/EffectWorker.ts +++ b/apps/server/src/orchestration-v2/EffectWorker.ts @@ -109,13 +109,17 @@ export const executorLayer: Layer.Layer< execute: (effect, options) => { const willRetry = options?.willRetry ?? false; switch (effect.request.type) { - case "provider-runtime.continue": - return continueRestartedRun({ - threadId: effect.threadId, - sourceRunId: effect.request.sourceRunId, - }).pipe( + case "provider-runtime.continue": { + const sourceRunId = effect.request.sourceRunId; + return continueRestartedRun({ threadId: effect.threadId, sourceRunId }).pipe( Effect.provideService(ThreadManagementService.ThreadManagementService, threads), Effect.provideService(ServerSettings.ServerSettingsService, settings), + // A continuation that will never run still owes a delegated parent a result. + Effect.tapError(() => + willRetry + ? Effect.void + : threads.recoverDelegatedTask(effect.threadId, sourceRunId), + ), Effect.mapError( (cause) => new OrchestrationEffectExecutionError({ @@ -125,6 +129,7 @@ export const executorLayer: Layer.Layer< }), ), ); + } case "provider-session.detach": return providerSessions .detach({ @@ -170,10 +175,12 @@ export const executorLayer: Layer.Layer< // The provider has stopped what it still ran and reported it. // Whatever the thread still shows on that provider thread is // work no process will report on, so the Stop ends it too. + // One Stop can interrupt several provider threads, so the + // settle is keyed by effect, not by the Stop command. Effect.andThen( threads.dispatch({ type: "thread.background-work.settle", - commandId: CommandId.make(`${effect.commandId}:background-work-settled`), + commandId: CommandId.make(`${effect.id}:background-work-settled`), threadId: effect.threadId, providerThreadId: effect.request.providerThreadId, providerTurnId: effect.request.providerTurnId, diff --git a/apps/server/src/orchestration-v2/EventSink.ts b/apps/server/src/orchestration-v2/EventSink.ts index 6296b922948f..08db4e8bda09 100644 --- a/apps/server/src/orchestration-v2/EventSink.ts +++ b/apps/server/src/orchestration-v2/EventSink.ts @@ -17,6 +17,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PubSub from "effect/PubSub"; +import * as Semaphore from "effect/Semaphore"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as SqlClient from "effect/unstable/sql/SqlClient"; @@ -221,6 +222,40 @@ const baseLayer: Layer.Layer< ); } }); + const publishStoredEvents = (events: ReadonlyArray) => + eventStore.publishCommitted(events).pipe(Effect.andThen(publishLiveEvents(events))); + + // Transactions commit one at a time, but each writer publishes after its + // commit. If a writer is descheduled in between, a later commit reaches + // subscribers first, and clients drop any event at or below the newest + // sequence they have applied. So a writer takes this lane as the last step + // of its transaction and holds it until it has published. Publishing never + // waits, so a writer that holds the transaction while it waits for the + // lane is not blocked for long. + const publishLane = yield* Semaphore.make(1); + const commitThenPublish = ( + transaction: Effect.Effect, + publish: (committed: A) => Effect.Effect, + ) => + Effect.suspend(() => { + let holdsLane = false; + const takeLane = publishLane.take(1).pipe( + Effect.andThen( + Effect.sync(() => { + holdsLane = true; + }), + ), + Effect.uninterruptible, + ); + return sql + .withTransaction(Effect.tap(transaction, () => takeLane)) + .pipe( + Effect.tap(publish), + Effect.ensuring( + Effect.suspend(() => (holdsLane ? publishLane.release(1) : Effect.void)), + ), + ); + }); // A user can answer after terminal normalization reads the pending request. // Recheck inside the write transaction so stale cleanup cannot erase answers. @@ -329,7 +364,7 @@ const baseLayer: Layer.Layer< "orchestration_v2.thread_id": input.events[0]?.threadId ?? null, }); - const storedEvents = yield* sql.withTransaction( + return yield* commitThenPublish( Effect.gen(function* () { const normalized = yield* normalizeEvents( input.guardPendingUserInputCancellations === true @@ -344,13 +379,14 @@ const baseLayer: Layer.Layer< yield* effectOutbox.enqueue(input.effects); return committed; }), + (storedEvents) => + Effect.gen(function* () { + if (input.effects.length > 0) { + yield* effectOutbox.notifyAvailable(input.effects.length); + } + yield* publishStoredEvents(storedEvents); + }), ); - if (input.effects.length > 0) { - yield* effectOutbox.notifyAvailable(input.effects.length); - } - yield* eventStore.publishCommitted(storedEvents); - yield* publishLiveEvents(storedEvents); - return storedEvents; }); const writeIfRunCurrentEffect = Effect.fn("orchestrationV2.EventSink.writeIfRunCurrent")( @@ -362,7 +398,7 @@ const baseLayer: Layer.Layer< "orchestration_v2.thread_id": input.threadId, }); - const result = yield* sql.withTransaction( + return yield* commitThenPublish( Effect.gen(function* () { const rows = yield* sql<{ readonly status: string; @@ -400,12 +436,8 @@ const baseLayer: Layer.Layer< yield* applyStoredEvents(storedEvents); return { committed: true as const, storedEvents }; }), + (result) => (result.committed ? publishStoredEvents(result.storedEvents) : Effect.void), ); - if (result.committed) { - yield* eventStore.publishCommitted(result.storedEvents); - yield* publishLiveEvents(result.storedEvents); - } - return result; }, ); @@ -421,7 +453,7 @@ const baseLayer: Layer.Layer< "orchestration_v2.expected_last_run_ordinal": input.expectedLastRunOrdinal, }); - const result = yield* sql.withTransaction( + return yield* commitThenPublish( Effect.gen(function* () { const rows = yield* sql<{ readonly active_attempt_id: string | null; @@ -461,12 +493,8 @@ const baseLayer: Layer.Layer< yield* applyStoredEvents(storedEvents); return { committed: true as const, storedEvents }; }), + (result) => (result.committed ? publishStoredEvents(result.storedEvents) : Effect.void), ); - if (result.committed) { - yield* eventStore.publishCommitted(result.storedEvents); - yield* publishLiveEvents(result.storedEvents); - } - return result; }); const existingCommandResult = (commandId: CommandId) => @@ -487,7 +515,7 @@ const baseLayer: Layer.Layer< const commitCommandEffect = Effect.fn("orchestrationV2.EventSink.commitCommand")(function* ( input: Parameters[0], ) { - const result = yield* sql.withTransaction( + const result = yield* commitThenPublish( Effect.gen(function* () { const reserved = yield* commandReceipts.insertIfAbsent({ commandId: input.commandId, @@ -535,15 +563,15 @@ const baseLayer: Layer.Layer< }); return { receipt, storedEvents, committed: true as const, cancelledEffectIds }; }), + (result) => + Effect.gen(function* () { + yield* effectOutbox.signalCancellations(result.cancelledEffectIds); + if (result.committed && input.effects.length > 0) { + yield* effectOutbox.notifyAvailable(input.effects.length); + } + if (result.committed) yield* publishStoredEvents(result.storedEvents); + }), ); - yield* effectOutbox.signalCancellations(result.cancelledEffectIds); - if (result.committed && input.effects.length > 0) { - yield* effectOutbox.notifyAvailable(input.effects.length); - } - if (result.committed) { - yield* eventStore.publishCommitted(result.storedEvents); - yield* publishLiveEvents(result.storedEvents); - } return { receipt: result.receipt, storedEvents: result.storedEvents, @@ -590,7 +618,7 @@ const baseLayer: Layer.Layer< const commitProjectCommandEffect = Effect.fn("orchestrationV2.EventSink.commitProjectCommand")( function* (input: Parameters[0]) { - const result = yield* sql.withTransaction( + const result = yield* commitThenPublish( Effect.gen(function* () { const reserved: CommandReceiptStore.ProjectCommandReceiptV2 = { commandId: input.commandId, @@ -610,10 +638,9 @@ const baseLayer: Layer.Layer< yield* commandReceipts.upsert(receipt); return { receipt, event }; }), + (result) => + result.event === undefined ? Effect.void : eventStore.publishCommitted([result.event]), ); - if (result.event !== undefined) { - yield* eventStore.publishCommitted([result.event]); - } return { receipt: result.receipt, committed: result.event !== undefined }; }, ); diff --git a/apps/server/src/orchestration-v2/FoundationPersistence.test.ts b/apps/server/src/orchestration-v2/FoundationPersistence.test.ts index b7f6e642cffe..dc7eacc59ba5 100644 --- a/apps/server/src/orchestration-v2/FoundationPersistence.test.ts +++ b/apps/server/src/orchestration-v2/FoundationPersistence.test.ts @@ -51,7 +51,9 @@ import * as EventStore from "./EventStore.ts"; import * as IdAllocator from "./IdAllocator.ts"; import * as ProjectionMaintenance from "./ProjectionMaintenance.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; +import * as ProjectStore from "./ProjectStore.ts"; import * as ProviderRuntimeRecovery from "./ProviderRuntimeRecoveryService.ts"; +import * as TurnItemPositionStore from "./TurnItemPositionStore.ts"; const isLiveStreamBufferError = Schema.is(LiveStreamBufferError); const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); @@ -433,8 +435,9 @@ it.layer(TestLayer)("orchestration V2 foundation persistence", (it) => { ), ); - for (const phase of ["high-water", "replay"] as const) { - it.effect(`bounds live events while the V2 ${phase} query is blocked`, () => + it.effect.each(["high-water", "replay"] as const)( + "bounds live events while the V2 %s query is blocked", + (phase) => Effect.scoped( Effect.gen(function* () { const sink = yield* EventSink.EventSinkV2; @@ -486,8 +489,7 @@ it.layer(TestLayer)("orchestration V2 foundation persistence", (it) => { } }), ), - ); - } + ); it.effect( "keeps internal streams subscribed while replay is blocked beyond the RPC buffer cap", @@ -2303,6 +2305,76 @@ it.layer(TestLayer)("orchestration V2 foundation persistence", (it) => { }).pipe(Effect.provide(Layer.fresh(effectOutboxProvided))), ); + it.effect("keeps later thread effects behind an earlier effect waiting to retry", () => + Effect.gen(function* () { + const outbox = yield* EffectOutbox.EffectOutboxV2; + const workerId = "retry-order-worker"; + const commandId = CommandId.make("command:foundation-retry-order"); + const threadId = ThreadId.make("thread:foundation-retry-order"); + yield* outbox.enqueue([ + { + id: "effect:foundation-retry-order:z-rollback", + commandId, + threadId, + request: { + type: "provider-thread.rollback", + providerThreadId: ProviderThreadId.make("provider-thread:foundation-retry-order"), + checkpointId: CheckpointId.make("checkpoint:foundation-retry-order"), + scopeId: CheckpointScopeId.make("scope:foundation-retry-order"), + }, + }, + ]); + const rollback = yield* outbox.claimNext({ workerId, leaseDurationMs: 30_000 }); + assert.isTrue(Option.isSome(rollback)); + if (Option.isNone(rollback)) return; + yield* outbox.retry({ + effectId: rollback.value.id, + workerId, + error: "rollback failed once", + delayMs: 60_000, + }); + + // A turn the user starts during the rollback's backoff must not run first, + // even when its timestamp ties and its id sorts first. + yield* outbox.enqueue([ + { + id: "effect:foundation-retry-order:a-start", + commandId: CommandId.make("command:foundation-retry-order:start"), + threadId, + request: { type: "provider-turn.start", runId: RunId.make("run:foundation-retry-order") }, + }, + { + id: "effect:foundation-retry-order:b-title", + commandId: CommandId.make("command:foundation-retry-order:title"), + threadId, + request: { type: "thread-title.generate", kind: { type: "regenerate" } }, + }, + ]); + const title = yield* outbox.claimNext({ workerId, leaseDurationMs: 30_000 }); + assert.equal(Option.getOrUndefined(title)?.id, "effect:foundation-retry-order:b-title"); + const blocked = yield* outbox.claimNext({ workerId, leaseDurationMs: 30_000 }); + assert.isTrue(Option.isNone(blocked)); + const nextClaimable = yield* outbox.nextClaimableAt; + assert.isTrue(Option.isSome(nextClaimable)); + if (Option.isSome(nextClaimable)) { + assert.equal( + DateTime.formatIso(nextClaimable.value), + (yield* outbox.get(rollback.value.id)).pipe(Option.getOrThrow).availableAt, + ); + } + + yield* outbox.cancelUnsettled({ + threadId, + effectTypes: ["provider-thread.rollback"], + reason: "Test cleanup.", + }); + const unblocked = yield* outbox.claimNext({ workerId, leaseDurationMs: 30_000 }); + assert.equal(Option.getOrUndefined(unblocked)?.id, "effect:foundation-retry-order:a-start"); + yield* outbox.succeed({ effectId: "effect:foundation-retry-order:a-start", workerId }); + yield* outbox.succeed({ effectId: "effect:foundation-retry-order:b-title", workerId }); + }).pipe(Effect.provide(Layer.fresh(effectOutboxProvided))), + ); + it.effect("executes a retry at its durable deadline instead of the liveness interval", () => Effect.gen(function* () { const outbox = yield* EffectOutbox.EffectOutboxV2; @@ -2661,68 +2733,66 @@ it.layer(TestLayer)("orchestration V2 foundation persistence", (it) => { }), ); - for (const replayRequest of [ + it.effect.each([ { type: "terminal.cleanup" }, { type: "provider-runtime.continue", sourceRunId: RunId.make("run:restart-replay") }, - ] as const) { - it.effect( - `retires live provider effects and requeues ${replayRequest.type} after process loss`, - () => - Effect.gen(function* () { - const outbox = yield* EffectOutbox.EffectOutboxV2; - const commandId = CommandId.make( - `command:foundation-reclaim-running:${replayRequest.type}`, - ); - yield* outbox.enqueue([ - { - id: `effect:a-foundation-cancel-provider-turn:${replayRequest.type}`, - commandId, - threadId: ThreadId.make(`thread:foundation-reclaim-running:${replayRequest.type}`), - request: { - type: "provider-turn.start", - runId: RunId.make(`run:foundation-reclaim-running:${replayRequest.type}`), - }, - }, - { - id: `effect:b-foundation-requeue-cleanup:${replayRequest.type}`, - commandId, - threadId: ThreadId.make(`thread:foundation-reclaim-cleanup:${replayRequest.type}`), - request: replayRequest, + ] as const)( + "retires live provider effects and requeues $type after process loss", + (replayRequest) => + Effect.gen(function* () { + const outbox = yield* EffectOutbox.EffectOutboxV2; + const commandId = CommandId.make( + `command:foundation-reclaim-running:${replayRequest.type}`, + ); + yield* outbox.enqueue([ + { + id: `effect:a-foundation-cancel-provider-turn:${replayRequest.type}`, + commandId, + threadId: ThreadId.make(`thread:foundation-reclaim-running:${replayRequest.type}`), + request: { + type: "provider-turn.start", + runId: RunId.make(`run:foundation-reclaim-running:${replayRequest.type}`), }, - ]); - assert.isTrue( - Option.isSome( - yield* outbox.claimNext({ workerId: "crashed-worker", leaseDurationMs: 30_000 }), - ), - ); - assert.isTrue( - Option.isSome( - yield* outbox.claimNext({ workerId: "crashed-worker", leaseDurationMs: 30_000 }), - ), - ); - assert.deepEqual(yield* outbox.reconcileAfterProcessLoss, { - cancelled: 1, - requeued: 1, - }); - const cancelled = yield* outbox.get( - `effect:a-foundation-cancel-provider-turn:${replayRequest.type}`, - ); - assert.isTrue(Option.isSome(cancelled)); - if (Option.isSome(cancelled)) assert.equal(cancelled.value.status, "cancelled"); + }, + { + id: `effect:b-foundation-requeue-cleanup:${replayRequest.type}`, + commandId, + threadId: ThreadId.make(`thread:foundation-reclaim-cleanup:${replayRequest.type}`), + request: replayRequest, + }, + ]); + assert.isTrue( + Option.isSome( + yield* outbox.claimNext({ workerId: "crashed-worker", leaseDurationMs: 30_000 }), + ), + ); + assert.isTrue( + Option.isSome( + yield* outbox.claimNext({ workerId: "crashed-worker", leaseDurationMs: 30_000 }), + ), + ); + assert.deepEqual(yield* outbox.reconcileAfterProcessLoss, { + cancelled: 1, + requeued: 1, + }); + const cancelled = yield* outbox.get( + `effect:a-foundation-cancel-provider-turn:${replayRequest.type}`, + ); + assert.isTrue(Option.isSome(cancelled)); + if (Option.isSome(cancelled)) assert.equal(cancelled.value.status, "cancelled"); - const reclaimed = yield* outbox.claimNext({ - workerId: "recovery-worker", - leaseDurationMs: 30_000, - }); - assert.isTrue(Option.isSome(reclaimed)); - if (Option.isSome(reclaimed)) { - assert.equal(reclaimed.value.request.type, replayRequest.type); - assert.equal(reclaimed.value.attemptCount, 2); - yield* outbox.succeed({ effectId: reclaimed.value.id, workerId: "recovery-worker" }); - } - }), - ); - } + const reclaimed = yield* outbox.claimNext({ + workerId: "recovery-worker", + leaseDurationMs: 30_000, + }); + assert.isTrue(Option.isSome(reclaimed)); + if (Option.isSome(reclaimed)) { + assert.equal(reclaimed.value.request.type, replayRequest.type); + assert.equal(reclaimed.value.attemptCount, 2); + yield* outbox.succeed({ effectId: reclaimed.value.id, workerId: "recovery-worker" }); + } + }), + ); it.effect("atomically cancels stale runs and their process-bound effects", () => Effect.gen(function* () { @@ -3224,3 +3294,90 @@ it.live("keeps claiming new work after repeated idle periods", () => }).pipe(Effect.provide(workerLayer), Effect.scoped); }).pipe(Effect.provide(TestLayer)), ); + +it.effect("publishes live events in commit order across concurrent writers", () => + Effect.gen(function* () { + const firstCommitted = yield* Deferred.make(); + const releaseFirst = yield* Deferred.make(); + // The first writer's post-commit wakeup stands in for any scheduler yield + // between its commit and its publish. + const pausingOutbox = Layer.effect( + EffectOutbox.EffectOutboxV2, + Effect.gen(function* () { + const delegate = yield* EffectOutbox.EffectOutboxV2; + return EffectOutbox.EffectOutboxV2.of({ + ...delegate, + notifyAvailable: (count) => + Deferred.succeed(firstCommitted, undefined).pipe( + Effect.andThen(Deferred.await(releaseFirst)), + Effect.andThen(delegate.notifyAvailable(count)), + ), + }); + }), + ).pipe(Layer.provide(effectOutboxProvided)); + const eventSinkLayer = EventSink.layerFromStores.pipe( + Layer.provide( + Layer.mergeAll( + storesProvided, + pausingOutbox, + commandReceiptStoreProvided, + ProjectStore.layer.pipe(Layer.provide(databaseLayer)), + TurnItemPositionStore.layer.pipe(Layer.provide(databaseLayer)), + ), + ), + ); + + yield* Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + const first = makeThread(ThreadId.make("thread:foundation-publish-order:first"), now); + const second = makeThread(ThreadId.make("thread:foundation-publish-order:second"), now); + const published = yield* eventSink + .stream({ afterSequence: yield* eventSink.latestSequence() }) + .pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped({ startImmediately: true })); + + const firstWrite = yield* eventSink + .writeWithEffects({ + events: [ + threadCreatedEvent({ id: "event:foundation-publish-order:first", thread: first, now }), + ], + effects: [ + { + id: "effect:foundation-publish-order:first", + commandId: CommandId.make("command:foundation-publish-order:first"), + threadId: first.id, + request: { type: "terminal.cleanup" }, + }, + ], + }) + .pipe(Effect.forkScoped); + yield* Deferred.await(firstCommitted); + // The second writer commits after the first. It may run as far as it can + // before the first writer resumes. + const secondWrite = yield* eventSink + .write({ + events: [ + threadCreatedEvent({ + id: "event:foundation-publish-order:second", + thread: second, + now, + }), + ], + }) + .pipe( + Effect.provideService(Scheduler.MaxOpsBeforeYield, Number.POSITIVE_INFINITY), + Effect.forkScoped, + ); + yield* Effect.yieldNow; + yield* Deferred.succeed(releaseFirst, undefined); + yield* Fiber.join(firstWrite); + yield* Fiber.join(secondWrite); + + const sequences = Array.from(yield* Fiber.join(published), (stored) => stored.sequence); + assert.deepEqual( + sequences, + [...sequences].sort((left, right) => left - right), + ); + }).pipe(Effect.provide(eventSinkLayer)); + }).pipe(Effect.provide(databaseLayer)), +); diff --git a/apps/server/src/orchestration-v2/LiveStreamBudget.test.ts b/apps/server/src/orchestration-v2/LiveStreamBudget.test.ts index f003aea73dbf..ad50b410c906 100644 --- a/apps/server/src/orchestration-v2/LiveStreamBudget.test.ts +++ b/apps/server/src/orchestration-v2/LiveStreamBudget.test.ts @@ -78,8 +78,9 @@ it.effect("stops draining a slow subscriber when its unacknowledged tail fills", type Event = { readonly sequence: number; readonly text: string; readonly threadId?: string }; describe("replayAndBufferLiveEvents", () => { - for (const phase of ["high-water", "replay"] as const) { - it.effect(`unsubscribes and cancels a blocked ${phase} read on live overflow`, () => + it.effect.each(["high-water", "replay"] as const)( + "unsubscribes and cancels a blocked %s read on live overflow", + (phase) => Effect.scoped( Effect.gen(function* () { const pubsub = yield* PubSub.unbounded(); @@ -121,11 +122,11 @@ describe("replayAndBufferLiveEvents", () => { expect(yield* PubSub.size(pubsub)).toBe(0); }), ), - ); - } + ); - for (const limit of ["items", "bytes"] as const) { - it.effect(`counts an unacknowledged replay batch toward the live ${limit} limit`, () => + it.effect.each(["items", "bytes"] as const)( + "counts an unacknowledged replay batch toward the live %s limit", + (limit) => Effect.scoped( Effect.gen(function* () { const pubsub = yield* PubSub.unbounded(); @@ -158,8 +159,7 @@ describe("replayAndBufferLiveEvents", () => { if (result._tag === "Failure") expect(result.failure._tag).toBe("LiveStreamBufferError"); }), ), - ); - } + ); it.effect("lets a fast reader replay a database page larger than both buffer limits", () => Effect.scoped( diff --git a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts index eb1fb203420a..024b94c24236 100644 --- a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts +++ b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts @@ -332,61 +332,59 @@ const runScenario = (input: { }); describe("OpenCode 2 through the orchestrator", () => { - for (const via of ["message", "thread settings"] as const) { - it.effect( - `switches the session's model before the next prompt when changed from the ${via}`, - () => - Effect.gen(function* () { - const name = `opencode2-model-switch-${via.replace(" ", "-")}`; - const cwd = yield* checkpointWorkspace(name); - const thread = threadCommands({ name, worktreePath: cwd }); - const projection = yield* runScenario({ - name, - threadId: thread.threadId, - entries: [ - ...createdSession(cwd, name), - ...instructionsWritten, - ...answeredPrompt("FIRST"), - // The next turn resumes the session at its new selection. - out("session.get", { sessionID: SESSION }), - reply("session.get", sessionInfo(cwd, t3Rules(name))), - out("session.switchModel", { - sessionID: SESSION, - model: { providerID: "opencode", id: "mimo-v2.6-flash-free" }, - }), - reply("session.switchModel", null), - // The instructions name the model, so they are written again. - ...instructionsWritten, - ...answeredPrompt("SECOND"), - ], - commands: [ - thread.create, - thread.message("first"), - ...(via === "thread settings" - ? [ - { - type: "thread.model-selection.set", - commandId: thread.command("model"), - threadId: thread.threadId, - modelSelection: mimo, - } satisfies OrchestrationV2Command, - ] - : []), - thread.message("second", mimo), - ], - }); - assert.deepEqual( - projection.runs.map((run) => [run.status, run.modelSelection.model]), - [ - ["completed", bigPickle.model], - ["completed", mimo.model], - ], - ); - // One native session carried both turns. - assert.lengthOf(projection.providerThreads, 1); - }).pipe(Effect.scoped), - ); - } + it.effect.each(["message", "thread settings"] as const)( + "switches the session's model before the next prompt when changed from the %s", + (via) => + Effect.gen(function* () { + const name = `opencode2-model-switch-${via.replace(" ", "-")}`; + const cwd = yield* checkpointWorkspace(name); + const thread = threadCommands({ name, worktreePath: cwd }); + const projection = yield* runScenario({ + name, + threadId: thread.threadId, + entries: [ + ...createdSession(cwd, name), + ...instructionsWritten, + ...answeredPrompt("FIRST"), + // The next turn resumes the session at its new selection. + out("session.get", { sessionID: SESSION }), + reply("session.get", sessionInfo(cwd, t3Rules(name))), + out("session.switchModel", { + sessionID: SESSION, + model: { providerID: "opencode", id: "mimo-v2.6-flash-free" }, + }), + reply("session.switchModel", null), + // The instructions name the model, so they are written again. + ...instructionsWritten, + ...answeredPrompt("SECOND"), + ], + commands: [ + thread.create, + thread.message("first"), + ...(via === "thread settings" + ? [ + { + type: "thread.model-selection.set", + commandId: thread.command("model"), + threadId: thread.threadId, + modelSelection: mimo, + } satisfies OrchestrationV2Command, + ] + : []), + thread.message("second", mimo), + ], + }); + assert.deepEqual( + projection.runs.map((run) => [run.status, run.modelSelection.model]), + [ + ["completed", bigPickle.model], + ["completed", mimo.model], + ], + ); + // One native session carried both turns. + assert.lengthOf(projection.providerThreads, 1); + }).pipe(Effect.scoped), + ); it.effect("moves the session to the thread's new worktree before the next prompt", () => Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 78ae2a8ae330..127121876393 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -1,6 +1,7 @@ import { latestExecutedRun, latestRootProviderFailure, + runRanAfter, usageLimitBlockedRun, } from "@t3tools/shared/orchestrationV2ThreadError"; import { threadPullRequestsOf } from "@t3tools/shared/threadPullRequests"; @@ -19,6 +20,8 @@ import { OrchestrationV2Command, type OrchestrationV2InternalCommand, type OrchestrationV2ServerCommand, + type ThreadPullRequestLink, + type ThreadPullRequestWatch, type OrchestrationV2AppThread, type OrchestrationV2ContextHandoff, type OrchestrationV2ContextSourcePoint, @@ -39,6 +42,7 @@ import { type OrchestrationV2Subagent, type OrchestrationV2ThreadProjection, type OrchestrationV2TurnItem, + orchestrationV2RunWorkStartedAt, ProviderInstanceId, type ProviderSessionId, RunId, @@ -77,8 +81,8 @@ import { notificationTurnItem } from "./Notification.ts"; import { isRestartNoteSource } from "./RestartBackgroundNote.ts"; import { isUndeliveredMailboxSteer } from "./NotificationMailbox.ts"; import { EventSinkV2 } from "./EventSink.ts"; +import * as EffectOutbox from "./EffectOutbox.ts"; import { - EffectOutboxV2, SESSION_BOUND_EFFECT_TYPES, type PendingOrchestrationEffectV2, type UnsettledEffectCancellation, @@ -255,6 +259,17 @@ export interface OrchestratorV2DispatchResult { export interface OrchestratorV2Shape { readonly resumeQueuedRuns: Effect.Effect; + /** Startup pass that settles delegated-task results and deliveries runs left behind. */ + readonly recoverDelegatedTasks: Effect.Effect; + /** Settles a delegated child whose restart continuation of `sourceRunId` declined or failed. */ + readonly recoverDelegatedTask: (threadId: ThreadId, sourceRunId: RunId) => Effect.Effect; + /** + * Whether a delegated child's apparent result is not final yet: a restart + * continuation is pending, or the child is working again. + */ + readonly delegatedTaskResultPending: ( + childThreadId: ThreadId, + ) => Effect.Effect; readonly dispatch: ( command: OrchestrationV2ServerCommand, ) => Effect.Effect; @@ -320,7 +335,39 @@ function nextRunOrdinal(projection: Pick, + trigger: { + readonly notification?: unknown; + readonly delegatedCompletion?: unknown; + readonly restartContinuationOfRunId?: RunId | undefined; + }, +): Pick { + if ( + trigger.notification === undefined && + trigger.delegatedCompletion === undefined && + trigger.restartContinuationOfRunId === undefined + ) { + return {}; + } + const previous = runs + .flatMap((run) => (run.startedAt === null ? [] : [{ run, startedAt: run.startedAt }])) + .toSorted( + (left, right) => + DateTime.Order(right.startedAt, left.startedAt) || right.run.ordinal - left.run.ordinal, + )[0]?.run; + return previous === undefined ? {} : { workStartedAt: orchestrationV2RunWorkStartedAt(previous) }; +} + +/** A native /compact or /logout turn: provider maintenance, not agent work. */ +export function isNativeMaintenanceCommand(message: { readonly text: string; readonly attachments: ReadonlyArray; readonly context?: import("@t3tools/contracts").OrchestrationMessageContext | undefined; @@ -355,6 +402,8 @@ function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId { case "thread.pull-request.link": case "thread.pull-request.unlink": case "thread.pull-request-link.sync": + case "thread.pull-request.watch": + case "thread.pull-request-watch.sync": case "thread.pull-request.sync": case "thread.title.regeneration.complete": case "thread.runtime-mode.set": @@ -437,6 +486,35 @@ function hasLiveRun(projection: Pick): ); } +/** The link with its watch replaced, or removed when `watch` is undefined. */ +function withPullRequestWatch( + link: ThreadPullRequestLink, + watch: ThreadPullRequestWatch | undefined, +): ThreadPullRequestLink { + const { watch: _previous, ...rest } = link; + return watch === undefined ? rest : { ...rest, watch }; +} + +/** A legacy single-PR link as a link entry. Re-linking a pull request keeps its watch. */ +function legacyPullRequestLink( + thread: OrchestrationV2AppThread, + linked: ThreadLinkedPullRequest, + now: DateTime.Utc, +): ThreadPullRequestLink { + const key = legacyThreadPullRequestKey(linked); + return withPullRequestWatch( + { + ...key, + url: linked.url, + source: "manual", + linkedAt: DateTime.formatIso(now), + snapshot: null, + stack: null, + }, + threadPullRequestsOf(thread).find((link) => threadPullRequestKeysEqual(link, key))?.watch, + ); +} + function delegatedCompletionWakeDetail(taskIds: ReadonlyArray): string { const taskList = taskIds.join(", "); return taskIds.length === 1 @@ -676,9 +754,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const eventSink = yield* EventSinkV2; const commandReceipts = yield* CommandReceiptStoreV2; const idAllocator = yield* IdAllocatorV2; - const outbox = yield* EffectOutboxV2; const projects = yield* ProjectStore.ProjectStoreV2; const projectionStore = yield* ProjectionStoreV2; + const effectOutbox = yield* EffectOutbox.EffectOutboxV2; const nextTurnItemOrdinal = ( projection: Pick & Partial>, @@ -1485,6 +1563,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio queuePosition: null, startedAt: null, contextHandoffId: activeHandoff?.id ?? null, + ...wakeWorkStartedAt(projection.runs, { + notification: queuedMessage.notification, + delegatedCompletion: queuedMessage.delegatedCompletion, + restartContinuationOfRunId: queuedRun.restartContinuationOfRunId, + }), }; const userTurnItem: OrchestrationV2TurnItem = { ...(legacyQueuedTurnItem ?? { @@ -2177,9 +2260,66 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); }); + // Checked under the thread lock: the watch or the thread can change while the host is read. + // The watch is recorded first so the wake's own thread events carry it. + const dispatchPullRequestWatchSync = Effect.fn("orchestrationV2.dispatch.pullRequestWatchSync")( + function* ( + command: Extract< + OrchestrationV2ServerCommand, + { readonly type: "thread.pull-request-watch.sync" } + >, + events: Ref.Ref>, + effects: Ref.Ref>, + ) { + const thread = yield* projectionStore + .getThread(command.threadId) + .pipe( + Effect.mapError( + (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }), + ), + ); + const key = normalizeThreadPullRequestKey(command); + const link = threadPullRequestsOf(thread).find( + (candidate) => + candidate.source !== "stack-dismissed" && threadPullRequestKeysEqual(candidate, key), + ); + // Same rule as a direct message.dispatch: a provider-native subagent takes no messages. + const inactive = + thread.archivedAt !== null || + thread.settledOverride === "settled" || + thread.settledAt !== null || + isProviderNativeSubagentThread(thread); + if (link?.watch?.startedAt !== command.startedAt || (command.wake && inactive)) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: "The pull request watch ended or its thread settled while it was read.", + }); + } + yield* dispatchThreadMutation(command, events, effects); + if (command.wake === undefined) return; + yield* dispatchMessage( + { + type: "message.dispatch", + commandId: command.commandId, + threadId: command.threadId, + messageId: command.wake.messageId, + text: command.wake.text, + notification: command.wake.notification, + attachments: [], + dispatchMode: { type: "queue_after_active" }, + createdBy: "agent", + creationSource: "server", + }, + events, + effects, + ); + }, + ); + const dispatchThreadMutation = Effect.fn("orchestrationV2.dispatch.threadMutation")(function* ( command: Extract< - OrchestrationV2Command, + OrchestrationV2ServerCommand, { readonly type: | "thread.archive" @@ -2198,6 +2338,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio | "thread.pull-request.link" | "thread.pull-request.unlink" | "thread.pull-request-link.sync" + | "thread.pull-request.watch" + | "thread.pull-request-watch.sync" | "thread.pull-request.sync" | "thread.title.regeneration.complete" | "thread.runtime-mode.set" @@ -2225,6 +2367,16 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio cause: `Thread ${command.threadId} is deleted.`, }); } + if ( + command.type === "thread.pull-request.watch" && + command.watching && + isProviderNativeSubagentThread(thread) + ) { + return yield* new OrchestratorSubagentThreadReadOnlyError({ + commandId: command.commandId, + threadId: command.threadId, + }); + } if ( command.type === "thread.metadata.update" && command.expectedWorktreePath !== undefined && @@ -2708,16 +2860,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ), ), ...(command.linkedPullRequest - ? [ - { - ...legacyThreadPullRequestKey(command.linkedPullRequest), - url: command.linkedPullRequest.url, - source: "manual" as const, - linkedAt: DateTime.formatIso(now), - snapshot: null, - stack: null, - }, - ] + ? [legacyPullRequestLink(thread, command.linkedPullRequest, now)] : []), ], }), @@ -2790,7 +2933,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ); pullRequests = belongsToStack ? links.map((link) => - link === existing ? { ...link, source: "stack-dismissed" as const } : link, + link === existing + ? { + ...withPullRequestWatch(link, undefined), + source: "stack-dismissed" as const, + } + : link, ) : links.filter((link) => link !== existing); } else { @@ -2819,6 +2967,61 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio updatedAt: command.type === "thread.pull-request-link.sync" ? thread.updatedAt : now, }; } + case "thread.pull-request.watch": + case "thread.pull-request-watch.sync": { + const key = normalizeThreadPullRequestKey(command); + const startedAt = DateTime.formatIso(now); + const linked = threadPullRequestsOf(thread); + const visible = (link: ThreadPullRequestLink) => + link.source !== "stack-dismissed" && threadPullRequestKeysEqual(link, key); + // A watch started on an unlinked (or dismissed) pull request links it in the same step. + const links = + command.type === "thread.pull-request.watch" && + command.watching && + command.link !== undefined && + !linked.some(visible) + ? [ + ...linked.filter((link) => !threadPullRequestKeysEqual(link, key)), + { + ...key, + url: command.link.url, + source: command.link.source, + linkedAt: startedAt, + snapshot: null, + stack: null, + }, + ] + : linked; + const existing = links.find(visible); + if (existing === undefined) return thread; + const watch = + command.type === "thread.pull-request-watch.sync" + ? // Progress read before a stop or restart must not bring the old watch back. + existing.watch?.startedAt === command.startedAt + ? (command.watch ?? undefined) + : existing.watch + : !command.watching + ? undefined + : (existing.watch ?? { + startedAt, + headSha: null, + failedChecks: [], + passed: false, + remarksThrough: startedAt, + remarkIds: [], + conflicting: false, + wakes: 0, + }); + if (watch === existing.watch && links === linked) return thread; + return { + ...thread, + pullRequests: links.map((link) => + link === existing ? withPullRequestWatch(link, watch) : link, + ), + // A user or agent starting or stopping a watch is activity; recorded progress is not. + updatedAt: command.type === "thread.pull-request.watch" ? now : thread.updatedAt, + }; + } case "thread.pull-request.sync": return { ...thread, @@ -2846,16 +3049,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ), ), ...(command.linkedPullRequest - ? [ - { - ...legacyThreadPullRequestKey(command.linkedPullRequest), - url: command.linkedPullRequest.url, - source: "manual" as const, - linkedAt: DateTime.formatIso(now), - snapshot: null, - stack: null, - }, - ] + ? [legacyPullRequestLink(thread, command.linkedPullRequest, now)] : []), ], }), @@ -2916,6 +3110,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio case "thread.pull-request.link": case "thread.pull-request.unlink": case "thread.pull-request-link.sync": + case "thread.pull-request.watch": + case "thread.pull-request-watch.sync": case "thread.pull-request.sync": return "thread.pull-request-synced" as const; case "thread.runtime-mode.set": @@ -4200,7 +4396,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio projection.thread.archivedAt !== null || projection.thread.deletedAt !== null || projection.thread.providerInstanceId !== source.providerInstanceId || - projection.runs.some((run) => run.ordinal > source.ordinal) + // Held queued runs never started; they wait behind the continuation. + projection.runs.some( + (run) => run.id !== source.id && run.status !== "queued" && runRanAfter(run, source), + ) ) { // Preserve the current row so stale automatic deliveries receive an // accepted receipt without changing work or repeatedly retrying. @@ -4975,6 +5174,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(command.restartContinuationOfRunId === undefined ? {} : { restartContinuationOfRunId: command.restartContinuationOfRunId }), + ...wakeWorkStartedAt(projection.runs, command), }; const attempt: OrchestrationV2RunAttempt = { id: attemptId, @@ -5668,6 +5868,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(command.restartContinuationOfRunId === undefined ? {} : { restartContinuationOfRunId: command.restartContinuationOfRunId }), + ...wakeWorkStartedAt(projection.runs, command), }; const attempt: OrchestrationV2RunAttempt = { id: attemptId, @@ -6435,12 +6636,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio // policy when the child terminalizes. Both writers hold this parent lock, // so a terminal task means finalize already committed the terminal row and // already made its offer decision under the pre-upgrade policy: under - // settled_only it offered iff the parent had no live run. Plan a delivery - // only when the parent has a live run now, which is precisely the case + // settled_only it offered iff the spawning run was not live. Plan a + // delivery only when that run is live now, which is precisely the case // where finalize skipped. The mailbox steers a capable active session or - // queues behind that run. When the parent is not live, finalize already - // offered and a second - // offer would wake the parent twice. (If the parent settled in between, + // queues behind that run. When that run is not live, finalize already + // offered and a second offer would wake the parent twice. (If the parent settled in between, // this skips a wake that finalize also skipped; a missed wake is cheaper // than a duplicate one, and the result is already in the projection.) const parentRun = @@ -6450,7 +6650,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const completionPlan = command.completionWake === "always" && isTerminalDelegatedTaskStatus(task.status) && - hasLiveRun(parentProjection) + parentRun !== undefined && + hasLiveRun({ runs: [parentRun] }) ? yield* planDelegatedCompletionDelivery({ parentProjection, parentRun, @@ -7946,6 +8147,63 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio cause: `Run ${command.runId} is not interruptible.`, }); } + // Background work can outlive a provider switch, such as a Codex dev + // server left running when the thread moved to Claude. Stop also reaches + // each other live provider thread that owns pending work; that + // interrupt's settle follow-up ends what its provider leaves behind. + // Work on a dead session is settled with this run's below. + const otherProviderInterrupts: Array = []; + if (hasBackgroundWork) { + 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, + ); + 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, + }, + }); + } + } // Stop on a settled thread's background work. Its process may be gone // (released, restarted) and only the projection still shows the work; // the settle follow-up ends whatever no provider reports ending. @@ -7977,6 +8235,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio throughRunOrdinal: run.ordinal, now, }); + yield* Ref.update(effects, (existing) => [...existing, ...otherProviderInterrupts]); return undefined; } // The projection still shows this turn running, but its provider @@ -8020,7 +8279,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio commandId: command.commandId, now, ids: idAllocator, - outbox, + outbox: effectOutbox, }).pipe( Effect.mapError( (cause) => @@ -8107,32 +8366,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }), ); - /* - * TODO(interrupt-hardening): before shipping, make these interrupt - * semantics explicit in tests and policy. - * - * Current behavior: - * - emit a `run_interrupt_request` item as user intent; - * - call the provider interrupt RPC; - * - keep the run active and continue ingesting provider chunks; - * - let RunExecutionService emit `run_interrupt_result` only if the - * provider later reports terminal status `interrupted`. - * - * Known scenarios we do not fully harden yet: - * - provider accepts interrupt, then emits more chunks before terminal; - * - provider accepts interrupt, then completes normally instead; - * - provider accepts interrupt but never terminalizes; - * - user queues, steers, or starts another message while the interrupted - * provider turn is still active. - * - * Likely policy: - * - queue should wait behind the still-active provider turn; - * - explicit steer may target the active turn if provider steering is - * supported; - * - starting a new root turn before provider terminalization should be - * an explicit policy decision because it can weaken native-item - * correlation. - */ + // Open interrupt edge cases are tracked in https://github.com/pingdotgg/t3code/issues/15013. yield* emitEvent({ type: "turn-item.updated", threadId: command.threadId, @@ -8157,6 +8391,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio providerTurnId: providerTurn.id, }, } satisfies PendingOrchestrationEffectV2, + ...otherProviderInterrupts, ]); return undefined; }); @@ -8392,6 +8627,39 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio : undefined; }); + /** + * A run with a pending restart continuation has no outcome yet: restart + * reconciliation cancelled it mid-turn, or cancelled the background work a + * settled turn was waiting on. The continuation's own run settles it, or + * RestartContinuation recovers the thread when it declines to continue. + */ + const awaitsRestartContinuation = (run: OrchestrationV2Run) => + effectOutbox + .get(`effect:restart-continuation:${run.id}`) + .pipe( + Effect.map( + Option.exists((effect) => effect.status === "pending" || effect.status === "running"), + ), + ); + + /** + * A delegated child's result is held while its result run, or a run after it + * that a second restart cut before it started, still has a continuation pending. + */ + const childAwaitsRestartContinuation = ( + runs: ReadonlyArray, + resultRun: OrchestrationV2Run, + settledContinuationOf?: RunId, + ) => + Effect.forEach( + runs.filter( + (run) => + run.id !== settledContinuationOf && + (run.id === resultRun.id || runRanAfter(run, resultRun)), + ), + awaitsRestartContinuation, + ).pipe(Effect.map((pending) => pending.includes(true))); + const planDelegatedCompletionDelivery = Effect.fn( "orchestrationV2.planDelegatedCompletionDelivery", )(function* (input: { @@ -8439,9 +8707,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio offer: false, }; } + // settled_only defers to a blocking delegate_task wait, which lives only as + // long as the turn that spawned the task. Once that turn is over (a restart + // continues it as a new run), nothing else delivers the result. if ( (input.task.completionWake ?? "settled_only") === "settled_only" && - hasLiveRun(input.parentProjection) + hasLiveRun({ runs: [input.parentRun] }) ) { return { task: input.updatedTask, @@ -8592,9 +8863,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio * delegated_task.wake-policy handler rewrites the same subagent row under * that lock with a full-row payload, and unserialized writers clobber each * other (stale policy on the terminal row, or a terminal row regressed to - * running). + * running). `settledContinuationOf` names the restart continuation that just + * declined or failed, so its own still-running effect does not hold the child. */ - const finalizeAppOwnedSubagent = (childThreadId: ThreadId) => + const finalizeAppOwnedSubagent = ( + childThreadId: ThreadId, + options?: { readonly settledContinuationOf?: RunId }, + ) => Effect.gen(function* () { const childControls = yield* projectionStore.getThreadRecords( childThreadId, @@ -8617,6 +8892,15 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio if (terminalStatus === null) { return; } + if ( + yield* childAwaitsRestartContinuation( + childControls.runs, + childRun, + options?.settledContinuationOf, + ) + ) { + return; + } const childResult = yield* projectionStore.getThreadRecords( childThreadId, @@ -9045,7 +9329,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); return; } - const parentIsLive = hasLiveRun(projection); + // settled_only tasks defer only to a blocking wait in their spawning run. + const parentIsLive = hasLiveRun({ runs: [parentRun] }); const pendingTaskIds = projection.thread.archivedAt === null && projection.thread.deletedAt === null ? projection.subagents @@ -9225,6 +9510,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio case "thread.pull-request.link": case "thread.pull-request.unlink": case "thread.pull-request-link.sync": + case "thread.pull-request.watch": case "thread.pull-request.sync": case "thread.title.regeneration.complete": case "thread.runtime-mode.set": @@ -9233,6 +9519,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio case "provider.switch": yield* dispatchThreadMutation(command, events, effects); break; + case "thread.pull-request-watch.sync": + yield* dispatchPullRequestWatchSync(command, events, effects); + break; case "provider-session.detach": yield* dispatchProviderSessionDetach(command, events, effects); break; @@ -9655,97 +9944,140 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio Effect.forkDetach, ); - // Recover child results from projections. Queue recovery instead holds - // unstarted runs until an explicit queue.resume command arrives. - yield* projectionStore.getRecoveryThreadIds("subagent-results").pipe( - Effect.flatMap((threadIds) => - Effect.forEach( - threadIds, - (threadId) => - Effect.gen(function* () { - const thread = yield* projectionStore.getThreadShell(threadId); - const parentThreadId = thread?.lineage.parentThreadId; - if (parentThreadId === undefined || parentThreadId === null) return; - yield* threadDispatch.withLock(parentThreadId, finalizeAppOwnedSubagent(threadId)); - }).pipe( - Effect.catchCause((cause) => - Effect.logWarning("Failed to recover terminal app-owned subagent", { - childThreadId: threadId, - cause, - }), - ), - ), - { concurrency: 8, discard: true }, - ), - ), - Effect.catchCause((cause) => - Effect.logWarning("Failed to inspect app-owned subagents during recovery", { - cause, - }), - ), - ); - yield* projectionStore.getRecoveryThreadIds("delegated-completions").pipe( - Effect.flatMap((threadIds) => - Effect.forEach( - threadIds, - (threadId) => - threadDispatch - .withLock( - threadId, - Effect.gen(function* () { - const projection = yield* projectionStore.getThreadRecords(threadId, [ - "runs", - "messages", - ]); - const terminalDeliveryRunIds = projection.runs - .filter((run) => delegatedTaskTerminalStatus(run.status) !== null) - .filter((run) => - projection.messages.some( - (message) => - message.id === run.userMessageId && - message.delegatedCompletion !== undefined, - ), - ) - .map((run) => run.id); - for (const runId of terminalDeliveryRunIds) { - yield* finalizeDelegatedCompletionDelivery(threadId, runId); - } - const refreshed = - terminalDeliveryRunIds.length === 0 - ? projection - : yield* projectionStore.getThreadRecords(threadId, ["runs", "messages"], { - messageRoles: ["user"], - }); - for (const run of refreshed.runs) { - if ( - run.delegatedCompletion?.delivery !== null && - run.delegatedCompletion !== undefined - ) { - yield* offerDelegatedCompletionDelivery(threadId, run.id); - } - } - }), - ) - .pipe( + // Settles child results and completion deliveries whose runs ended without + // the listener above: before this boot, or in runtime reconciliation, which + // it skips. Startup runs this after reconciliation and before the effect + // worker. Queue recovery instead holds unstarted runs until an explicit + // queue.resume command arrives. + const recoverDelegatedTasks = Effect.gen(function* () { + yield* projectionStore.getRecoveryThreadIds("subagent-results").pipe( + Effect.flatMap((threadIds) => + Effect.forEach( + threadIds, + (threadId) => + Effect.gen(function* () { + const thread = yield* projectionStore.getThreadShell(threadId); + const parentThreadId = thread?.lineage.parentThreadId; + if (parentThreadId === undefined || parentThreadId === null) return; + yield* threadDispatch.withLock(parentThreadId, finalizeAppOwnedSubagent(threadId)); + }).pipe( Effect.catchCause((cause) => - Effect.logWarning("Failed to recover delegated completion delivery", { - threadId, + Effect.logWarning("Failed to recover terminal app-owned subagent", { + childThreadId: threadId, cause, }), ), ), - { concurrency: 8, discard: true }, + { concurrency: 8, discard: true }, + ), ), - ), - Effect.catchCause((cause) => - Effect.logWarning("Failed to inspect delegated completion delivery during recovery", { - cause, - }), - ), - ); + Effect.catchCause((cause) => + Effect.logWarning("Failed to inspect app-owned subagents during recovery", { + cause, + }), + ), + ); + yield* projectionStore.getRecoveryThreadIds("delegated-completions").pipe( + Effect.flatMap((threadIds) => + Effect.forEach( + threadIds, + (threadId) => + threadDispatch + .withLock( + threadId, + Effect.gen(function* () { + const projection = yield* projectionStore.getThreadRecords(threadId, [ + "runs", + "messages", + ]); + const terminalDeliveryRunIds = projection.runs + .filter((run) => delegatedTaskTerminalStatus(run.status) !== null) + .filter((run) => + projection.messages.some( + (message) => + message.id === run.userMessageId && + message.delegatedCompletion !== undefined, + ), + ) + .map((run) => run.id); + for (const runId of terminalDeliveryRunIds) { + yield* finalizeDelegatedCompletionDelivery(threadId, runId); + } + const refreshed = + terminalDeliveryRunIds.length === 0 + ? projection + : yield* projectionStore.getThreadRecords(threadId, ["runs", "messages"], { + messageRoles: ["user"], + }); + for (const run of refreshed.runs) { + if ( + run.delegatedCompletion?.delivery !== null && + run.delegatedCompletion !== undefined + ) { + yield* offerDelegatedCompletionDelivery(threadId, run.id); + } + } + }), + ) + .pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to recover delegated completion delivery", { + threadId, + cause, + }), + ), + ), + { concurrency: 8, discard: true }, + ), + ), + Effect.catchCause((cause) => + Effect.logWarning("Failed to inspect delegated completion delivery during recovery", { + cause, + }), + ), + ); + }); + + const delegatedTaskResultPending = (childThreadId: ThreadId) => + Effect.gen(function* () { + const child = yield* projectionStore.getThreadRecords( + childThreadId, + ["runs", "messages", "subagents", "providerThreads", "providerTurns", "attempts"], + { messageRoles: ["user"] }, + ); + const progress = delegatedTaskProgress(child); + // A caller's older read saw a result; newer work since then means it is not final. + 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 }), + ), + ); + + const recoverDelegatedTask = (threadId: ThreadId, sourceRunId: RunId) => + Effect.gen(function* () { + 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, + }), + ), + ); return OrchestratorV2.of({ resumeQueuedRuns, + recoverDelegatedTasks, + recoverDelegatedTask, + delegatedTaskResultPending, dispatch: dispatchWithReceipt, getTimelinePage: (threadId, options) => projectionStore @@ -9839,7 +10171,7 @@ export const layer: Layer.Layer< | CommandPolicyV2 | CommandReceiptStoreV2 | ContextHandoffServiceV2 - | EffectOutboxV2 + | EffectOutbox.EffectOutboxV2 | EventSinkV2 | IdAllocatorV2 | ProjectStore.ProjectStoreV2 @@ -9863,6 +10195,9 @@ const layerUnavailable: Layer.Layer = Layer.succeed( cause: "Orchestration V2 live runtime is not configured.", }), ), + recoverDelegatedTasks: Effect.void, + recoverDelegatedTask: () => Effect.void, + delegatedTaskResultPending: () => Effect.succeed(false), dispatch: (command) => Effect.fail( new OrchestratorDispatchError({ diff --git a/apps/server/src/orchestration-v2/ProjectCommands.test.ts b/apps/server/src/orchestration-v2/ProjectCommands.test.ts index 55f2218fbd01..bcbc5a889569 100644 --- a/apps/server/src/orchestration-v2/ProjectCommands.test.ts +++ b/apps/server/src/orchestration-v2/ProjectCommands.test.ts @@ -128,16 +128,17 @@ describe("planProjectCommand", () => { assert.isNull(payloadOf(update({ defaultThreadEnvMode: null })).defaultThreadEnvMode); }); - for (const id of ["install-javascript-dependencies", "A", "a.b", "a b", "-a", "a".repeat(25)]) { - it(`rejects a new script ID that cannot have a shortcut: ${id}`, () => { + it.each(["install-javascript-dependencies", "A", "a.b", "a b", "-a", "a".repeat(25)])( + "rejects a new script ID that cannot have a shortcut: %s", + (id) => { const failure = failureOf(update({ scripts: [script("lint"), script(id)] })); assert.equal(failure._tag, "ProjectCommandInvariantError"); assert.include(failure.message, "Script ID"); assert.include(failure.message, "24"); // The detail is persisted in the rejected receipt, so it omits the raw ID. assert.notInclude(failure.message, `'${id}'`); - }); - } + }, + ); it("accepts a script ID at the shortcut length limit", () => { const scripts = [script("a".repeat(24))]; diff --git a/apps/server/src/orchestration-v2/ProjectionControlReads.test.ts b/apps/server/src/orchestration-v2/ProjectionControlReads.test.ts index 221c773149ac..4889a48b3d83 100644 --- a/apps/server/src/orchestration-v2/ProjectionControlReads.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionControlReads.test.ts @@ -186,12 +186,17 @@ function fixtureEvents(now: DateTime.Utc): ReadonlyArray ({ + storage, + storeLayer: storage === "sqlite" ? ProjectionStore.layer.pipe(Layer.provideMerge(SqlitePersistenceMemory)) - : ProjectionStore.layerMemory; - it.effect(`${storage}: finds the active root turn without an attempt reverse link`, () => + : ProjectionStore.layerMemory, +})); + +it.effect.each(storageCases)( + "$storage: finds the active root turn without an attempt reverse link", + ({ storeLayer }) => Effect.gen(function* () { const store = yield* ProjectionStore.ProjectionStoreV2; const now = yield* DateTime.now; @@ -210,8 +215,10 @@ for (const storage of ["sqlite", "memory"] as const) { }); assert.isUndefined((yield* store.getRunningTurnContext(threadId)).providerTurn); }).pipe(Effect.provide(storeLayer)), - ); - it.effect(`${storage}: controls and replies read only their exact durable targets`, () => +); +it.effect.each(storageCases)( + "$storage: controls and replies read only their exact durable targets", + ({ storage, storeLayer }) => Effect.gen(function* () { const store = yield* ProjectionStore.ProjectionStoreV2; const now = yield* DateTime.now; @@ -344,5 +351,4 @@ for (const storage of ["sqlite", "memory"] as const) { ), ); }).pipe(Effect.provide(Layer.merge(storeLayer, SqlitePersistenceMemory))), - ); -} +); diff --git a/apps/server/src/orchestration-v2/ProjectionRecovery.test.ts b/apps/server/src/orchestration-v2/ProjectionRecovery.test.ts index 8db18a0d5f08..617655e38fb1 100644 --- a/apps/server/src/orchestration-v2/ProjectionRecovery.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionRecovery.test.ts @@ -420,6 +420,73 @@ it.effect("includes shared sessions and provider-owned background rosters in rec }).pipe(Effect.provide(TestLayer)), ); +it.effect("reads the run that owns a background roster, not a queued or resumed one", () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const now = yield* DateTime.now; + const withRoster = Effect.fn(function* (threadId: ThreadId) { + yield* projections.apply({ + id: EventId.make(`event:${threadId}:roster`), + type: "provider-thread.updated", + threadId, + driver, + providerInstanceId, + occurredAt: now, + payload: { + id: ProviderThreadId.make(`provider-thread:${threadId}`), + appThreadId: threadId, + ownerNodeId: null, + driver, + providerInstanceId, + providerSessionId: null, + nativeThreadRef: null, + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + pendingBackgroundTasks: [ + { taskId: "background", description: "Still running", kind: "command" }, + ], + }, + }); + }); + const runIds = (threadId: ThreadId) => + projections + .getRuntimeRecoveryProjection(threadId) + .pipe(Effect.map((state) => state.runs.map((run) => run.id))); + // Settled with background work left, then a queued follow-up. + const settled = yield* createThread("roster-before-queue"); + const settledRun = yield* createRun(settled, "completed", { + providerThreadId: ProviderThreadId.make(`provider-thread:${settled}`), + }); + const queuedRun = yield* createRun(settled, "queued", { ordinal: 2, completedAt: null }); + yield* withRoster(settled); + assert.deepEqual(yield* runIds(settled), [settledRun, queuedRun]); + // A resumed queued run (ordinal 1) ended after a continuation (ordinal 2). + const resumed = yield* createThread("roster-after-resume"); + const resumedRun = yield* createRun(resumed, "completed", { + providerThreadId: ProviderThreadId.make(`provider-thread:${resumed}`), + completedAt: DateTime.makeUnsafe("2026-10-03T10:05:00.000Z"), + }); + const continuationRun = yield* createRun(resumed, "completed", { + providerThreadId: ProviderThreadId.make(`provider-thread:${resumed}`), + ordinal: 2, + completedAt: DateTime.makeUnsafe("2026-10-03T10:00:00.000Z"), + }); + yield* withRoster(resumed); + assert.deepEqual(yield* runIds(resumed), [resumedRun, continuationRun]); + // Without a roster, settled history is not read. + const quiet = yield* createThread("no-roster-before-queue"); + yield* createRun(quiet, "completed"); + const quietQueued = yield* createRun(quiet, "queued", { ordinal: 2, completedAt: null }); + assert.deepEqual(yield* runIds(quiet), [quietQueued]); + }).pipe(Effect.provide(TestLayer)), +); + it.effect("marks fork descendants unreadable when their source is missing or corrupt", () => Effect.gen(function* () { const projections = yield* ProjectionStore.ProjectionStoreV2; diff --git a/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts b/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts index 4fb806e61e9c..1e8297c362c2 100644 --- a/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts @@ -140,159 +140,211 @@ const createItem = Effect.fn(function* ( }); }); -for (const [name, testLayer] of [ +it.effect.each([ ["sql", SqlLayer], ["memory", ProjectionStore.layerMemory], -] as const) { - it.effect( - `${name}: discovers settlement work with the same activity and background semantics as the shell`, - () => - Effect.gen(function* () { - const store = yield* ProjectionStore.ProjectionStoreV2; - const idle = yield* createThread("idle"); - const completed = yield* createThread("completed"); - yield* createRun(completed); - for (const [name, overrides] of [ - ["archived", { archivedAt: old }], - ["deleted", { deletedAt: old }], - ["settled", { settledOverride: "settled" }], - ["unsettled", { settledOverride: "active" }], - ["pinned", { pinnedAt: old }], - ["auto-settle-disabled", { autoSettleDisabledAt: old }], - ] satisfies ReadonlyArray]>) { - yield* createRun(yield* createThread(name, overrides)); - } - for (const status of ["preparing", "starting", "running", "waiting"] as const) { - yield* createRun(yield* createThread(status), status); - } - const queued = yield* createThread("queued"); - yield* createRun(queued, "queued"); - const blocked = yield* createThread("blocked"); - yield* store.apply({ - id: EventId.make("event:settlement:request"), - type: "runtime-request.updated", - threadId: blocked, - occurredAt: old, - payload: { - id: RuntimeRequestId.make("request:settlement"), - nodeId: NodeId.make("node:settlement"), - providerTurnId: null, - nativeRequestRef: null, - kind: "user_input", - status: "pending", - responseCapability: { type: "not_resumable", reason: "Process stopped" }, - createdAt: old, - resolvedAt: null, - }, - }); - const recent = yield* createThread("recent-message"); - yield* createRun(recent); - yield* store.apply({ - id: EventId.make("event:settlement:message"), - type: "message.updated", +] as const)( + "%s: discovers settlement work with the same activity and background semantics as the shell", + ([, testLayer]) => + Effect.gen(function* () { + const store = yield* ProjectionStore.ProjectionStoreV2; + const idle = yield* createThread("idle"); + const completed = yield* createThread("completed"); + yield* createRun(completed); + for (const [name, overrides] of [ + ["archived", { archivedAt: old }], + ["deleted", { deletedAt: old }], + ["settled", { settledOverride: "settled" }], + ["unsettled", { settledOverride: "active" }], + ["pinned", { pinnedAt: old }], + ["auto-settle-disabled", { autoSettleDisabledAt: old }], + ] satisfies ReadonlyArray]>) { + yield* createRun(yield* createThread(name, overrides)); + } + for (const status of ["preparing", "starting", "running", "waiting"] as const) { + yield* createRun(yield* createThread(status), status); + } + const queued = yield* createThread("queued"); + yield* createRun(queued, "queued"); + const blocked = yield* createThread("blocked"); + yield* store.apply({ + id: EventId.make("event:settlement:request"), + type: "runtime-request.updated", + threadId: blocked, + occurredAt: old, + payload: { + id: RuntimeRequestId.make("request:settlement"), + nodeId: NodeId.make("node:settlement"), + providerTurnId: null, + nativeRequestRef: null, + kind: "user_input", + status: "pending", + responseCapability: { type: "not_resumable", reason: "Process stopped" }, + createdAt: old, + resolvedAt: null, + }, + }); + const recent = yield* createThread("recent-message"); + yield* createRun(recent); + yield* store.apply({ + id: EventId.make("event:settlement:message"), + type: "message.updated", + threadId: recent, + occurredAt: now, + payload: { + createdBy: "user", + creationSource: "web", + id: MessageId.make("message:settlement:recent"), threadId: recent, - occurredAt: now, - payload: { - createdBy: "user", - creationSource: "web", - id: MessageId.make("message:settlement:recent"), - threadId: recent, - runId: null, - nodeId: null, - role: "user", - text: "Next turn", - attachments: [], - streaming: false, - createdAt: now, - updatedAt: now, - }, - }); - yield* createRun( - yield* createThread("snoozed", { - snoozedUntil: DateTime.add(now, { days: 1 }), - snoozedAt: now, - }), - ); - const woke = yield* createThread("woke", { + runId: null, + nodeId: null, + role: "user", + text: "Next turn", + attachments: [], + streaming: false, + createdAt: now, + updatedAt: now, + }, + }); + yield* createRun( + yield* createThread("snoozed", { snoozedUntil: DateTime.add(now, { days: 1 }), - snoozedAt: DateTime.subtract(old, { days: 1 }), - }); - yield* createRun(woke); - const background = yield* createThread("idle-background"); - yield* createItem(background, yield* createRun(background), "idle"); - const persistent = yield* createThread("persistent-monitor"); - yield* createItem(persistent, yield* createRun(persistent), "running", true); - const rolledBack = yield* createThread("rolled-back-background"); - yield* createItem(rolledBack, yield* createRun(rolledBack, "rolled_back"), "running"); - yield* createRun(rolledBack, "completed", 2); - const roster = yield* createThread("provider-roster"); - yield* createRun(roster); - yield* store.apply({ - id: EventId.make("event:settlement:roster"), - type: "provider-thread.updated", - threadId: roster, - occurredAt: old, - payload: { - id: ProviderThreadId.make("provider-thread:settlement"), - appThreadId: roster, - ownerNodeId: null, - driver: ProviderDriverKind.make("codex"), - providerInstanceId, - providerSessionId: null, - nativeThreadRef: null, - nativeConversationHeadRef: null, - status: "idle", - firstRunOrdinal: null, - lastRunOrdinal: null, - handoffIds: [], - forkedFrom: null, - createdAt: old, - updatedAt: old, - pendingBackgroundTasks: [{ taskId: "running-task", kind: "command" }], - }, - }); - const candidates = yield* store.getSettlementCandidates(); - const shell = yield* store.getShellSnapshot({ location: "active" }); - const eligible = candidates.filter((thread) => - isAutoSettlementCandidate(thread, DateTime.toEpochMillis(now)), - ); - assert.deepEqual( - new Set(eligible.map((thread) => thread.id)), - new Set([idle, completed, queued, woke, background, persistent, rolledBack]), - ); + snoozedAt: now, + }), + ); + const woke = yield* createThread("woke", { + snoozedUntil: DateTime.add(now, { days: 1 }), + snoozedAt: DateTime.subtract(old, { days: 1 }), + }); + yield* createRun(woke); + const background = yield* createThread("idle-background"); + yield* createItem(background, yield* createRun(background), "idle"); + const persistent = yield* createThread("persistent-monitor"); + yield* createItem(persistent, yield* createRun(persistent), "running", true); + const rolledBack = yield* createThread("rolled-back-background"); + yield* createItem(rolledBack, yield* createRun(rolledBack, "rolled_back"), "running"); + yield* createRun(rolledBack, "completed", 2); + const roster = yield* createThread("provider-roster"); + yield* createRun(roster); + yield* store.apply({ + id: EventId.make("event:settlement:roster"), + type: "provider-thread.updated", + threadId: roster, + occurredAt: old, + payload: { + id: ProviderThreadId.make("provider-thread:settlement"), + appThreadId: roster, + ownerNodeId: null, + driver: ProviderDriverKind.make("codex"), + providerInstanceId, + providerSessionId: null, + nativeThreadRef: null, + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: null, + createdAt: old, + updatedAt: old, + pendingBackgroundTasks: [{ taskId: "running-task", kind: "subagent" }], + }, + }); + const candidates = yield* store.getSettlementCandidates(); + const shell = yield* store.getShellSnapshot({ location: "active" }); + const eligible = candidates.filter((thread) => + isAutoSettlementCandidate(thread, DateTime.toEpochMillis(now)), + ); + assert.deepEqual( + new Set(eligible.map((thread) => thread.id)), + new Set([idle, completed, queued, woke, background, persistent, rolledBack]), + ); + assert.deepEqual( + new Set(eligible.map((thread) => thread.id)), + new Set( + shell.threads + .filter((thread) => isAutoSettlementCandidate(thread, DateTime.toEpochMillis(now))) + .map((thread) => thread.id), + ), + ); + for (const candidate of candidates) { + const expected = shell.threads.find((thread) => thread.id === candidate.id)!; + assert.deepEqual(candidate.pendingBackgroundTasks, expected.pendingBackgroundTasks); + const settings = { + pullRequest: null, + nowMs: DateTime.toEpochMillis(now), + autoSettleAfterDays: 7, + autoSettleOnMerge: false, + }; assert.deepEqual( - new Set(eligible.map((thread) => thread.id)), - new Set( - shell.threads - .filter((thread) => isAutoSettlementCandidate(thread, DateTime.toEpochMillis(now))) - .map((thread) => thread.id), - ), - ); - for (const candidate of candidates) { - const expected = shell.threads.find((thread) => thread.id === candidate.id)!; - assert.deepEqual(candidate.pendingBackgroundTasks, expected.pendingBackgroundTasks); - const settings = { - pullRequest: null, - nowMs: DateTime.toEpochMillis(now), - autoSettleAfterDays: 7, - autoSettleOnMerge: false, - }; - assert.deepEqual( - resolveAutoSettlementAt({ ...settings, thread: candidate }), - resolveAutoSettlementAt({ ...settings, thread: expected }), - ); - } - assert.equal( - DateTime.formatIso((yield* store.getThread(completed)).createdAt), - DateTime.formatIso(old), - ); - assert.equal( - (yield* store.getThread(ThreadId.make("missing")).pipe(Effect.flip))._tag, - "ProjectionStoreThreadNotFoundError", + resolveAutoSettlementAt({ ...settings, thread: candidate }), + resolveAutoSettlementAt({ + ...settings, + thread: { + ...expected, + latestUserAuthoredMessageAt: candidate.latestUserAuthoredMessageAt, + }, + }), ); - }).pipe(Effect.provide(testLayer)), - ); -} + } + assert.equal( + DateTime.formatIso((yield* store.getThread(completed)).createdAt), + DateTime.formatIso(old), + ); + assert.equal( + (yield* store.getThread(ThreadId.make("missing")).pipe(Effect.flip))._tag, + "ProjectionStoreThreadNotFoundError", + ); + }).pipe(Effect.provide(testLayer)), +); + +it.effect.each([ + ["sql", SqlLayer], + ["memory", ProjectionStore.layerMemory], +] as const)("%s: the user-authored message time ignores agent notifications", ([, testLayer]) => + Effect.gen(function* () { + const store = yield* ProjectionStore.ProjectionStoreV2; + const threadId = yield* createThread("agent-woken"); + yield* createRun(threadId); + const message = ( + id: string, + createdBy: "user" | "agent", + creationSource: "web" | "provider", + at: DateTime.Utc, + ) => + store.apply({ + id: EventId.make(`event:settlement:${id}`), + type: "message.updated", + threadId, + occurredAt: at, + payload: { + createdBy, + creationSource, + id: MessageId.make(`message:settlement:${id}`), + threadId, + runId: null, + nodeId: null, + role: "user", + text: id, + attachments: [], + streaming: false, + createdAt: at, + updatedAt: at, + }, + }); + const written = DateTime.subtract(now, { hours: 2 }); + yield* message("written", "user", "web", written); + yield* message("notification", "agent", "provider", now); + + const [candidate] = yield* store.getSettlementCandidates(threadId); + assert.isDefined(candidate); + assert.equal(DateTime.formatIso(candidate.latestUserMessageAt!), DateTime.formatIso(now)); + assert.equal( + DateTime.formatIso(candidate.latestUserAuthoredMessageAt!), + DateTime.formatIso(written), + ); + }).pipe(Effect.provide(testLayer)), +); const pullRequestLink = (number: number) => ({ host: "github.com", @@ -305,11 +357,12 @@ const pullRequestLink = (number: number) => ({ stack: null, }); -for (const [name, testLayer] of [ +it.effect.each([ ["sql", SqlLayer], ["memory", ProjectionStore.layerMemory], -] as const) { - it.effect(`${name}: lists only active threads with pull request links, oldest first`, () => +] as const)( + "%s: lists only active threads with pull request links, oldest first", + ([, testLayer]) => Effect.gen(function* () { const store = yield* ProjectionStore.ProjectionStoreV2; yield* createThread("no-links"); @@ -336,34 +389,32 @@ for (const [name, testLayer] of [ [open, null, 2], ], ); + assert.deepEqual( + (yield* store.getThreadsWithPullRequests(open)).map((thread) => thread.id), + [open], + ); }).pipe(Effect.provide(testLayer)), - ); -} +); -for (const [name, testLayer] of [ +it.effect.each([ ["sql", SqlLayer], ["memory", ProjectionStore.layerMemory], -] as const) { - it.effect(`${name}: an unsettled-only shell read skips settled threads`, () => - Effect.gen(function* () { - const store = yield* ProjectionStore.ProjectionStoreV2; - const open = yield* createThread("unsettled-open"); - const reopened = yield* createThread("unsettled-reopened", { settledOverride: "active" }); - yield* createThread("unsettled-manual", { settledOverride: "settled", settledAt: old }); - yield* createThread("unsettled-auto", { settledAt: old }); - yield* createThread("unsettled-archived", { archivedAt: old }); +] as const)("%s: an unsettled-only shell read skips settled threads", ([, testLayer]) => + Effect.gen(function* () { + const store = yield* ProjectionStore.ProjectionStoreV2; + const open = yield* createThread("unsettled-open"); + const reopened = yield* createThread("unsettled-reopened", { settledOverride: "active" }); + yield* createThread("unsettled-manual", { settledOverride: "settled", settledAt: old }); + yield* createThread("unsettled-auto", { settledAt: old }); + yield* createThread("unsettled-archived", { archivedAt: old }); - const shell = yield* store.getShellSnapshot({ location: "active", unsettledOnly: true }); - assert.deepEqual( - new Set(shell.threads.map((thread) => thread.id)), - new Set([open, reopened]), - ); - assert.equal(shell.archivedThreads.length, 0); - const all = yield* store.getShellSnapshot({ location: "active" }); - assert.equal(all.threads.length, 4); - }).pipe(Effect.provide(testLayer)), - ); -} + const shell = yield* store.getShellSnapshot({ location: "active", unsettledOnly: true }); + assert.deepEqual(new Set(shell.threads.map((thread) => thread.id)), new Set([open, reopened])); + assert.equal(shell.archivedThreads.length, 0); + const all = yield* store.getShellSnapshot({ location: "active" }); + assert.equal(all.threads.length, 4); + }).pipe(Effect.provide(testLayer)), +); it.effect( "reads settlement candidates and thread metadata without loading historical or archived payloads", diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index f3b6348c5d31..3e979544c87b 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -1377,6 +1377,36 @@ it.layer(TestLayer)("ProjectionStoreV2", (it) => { (row) => row.sourceItemId === interruptResultId, ), ); + + yield* sql` + UPDATE orchestration_v2_projection_turn_items + SET payload_json = json_set(payload_json, '$.createdBy', 'agent') + WHERE thread_id = ${threadId} AND type = 'user_message' + `; + const agentPromptId = TurnItemId.make("turn-item:bounded-sql-history:interrupt-filler:1281"); + yield* sql` + UPDATE orchestration_v2_projection_turn_items + SET type = 'user_message', + payload_json = json_set(payload_json, + '$.type', 'user_message', '$.inputIntent', 'turn_start', + '$.createdBy', 'agent', '$.creationSource', 'provider', + '$.messageId', 'message:bounded-sql-history:agent-prompt', + '$.text', 'Continue the child task', '$.attachments', json('[]')) + WHERE turn_item_id = ${agentPromptId} + `; + const agentWindow = yield* projectionStore.getThreadSnapshotWindow(threadId, { + rowLimit: sqlPageLimit, + userTurnLimit: THREAD_HISTORY_PAGE_POLICY.maxUserTurns, + }); + assert.lengthOf( + agentWindow.projection.visibleTurnItems.filter( + (row) => row.item.type !== "run_interrupt_request", + ), + sqlPageLimit, + ); + assert.isTrue( + agentWindow.projection.visibleTurnItems.some((row) => row.sourceItemId === agentPromptId), + ); }), ); @@ -1982,6 +2012,37 @@ it.layer(TestLayer)("ProjectionStoreV2", (it) => { assert.isNull(onlyHeldShell.latestRunId); assert.equal(onlyHeldShell.status, "idle"); } + + // A wake run counts from the start of the work it continues. + yield* projectionStore.apply({ + id: EventId.make("event:projection-shell-interruptible:wake"), + type: "run.updated", + threadId, + runId, + nodeId: rootNodeId, + driver, + occurredAt: later, + payload: { + ...run, + status: "running", + requestedAt: later, + startedAt: later, + workStartedAt: now, + }, + }); + const wakeProjection = yield* projectionStore.getThreadProjection(threadId); + const wakeSqlShell = (yield* projectionStore.getShellSnapshot()).threads.find( + (row) => row.id === threadId, + )!; + for (const wakeShell of [ + wakeSqlShell, + ProjectionStore.threadShellFromProjection(wakeProjection), + ]) { + assert.equal( + wakeShell.activityRunStartedAt && DateTime.toEpochMillis(wakeShell.activityRunStartedAt), + DateTime.toEpochMillis(now), + ); + } }), ); @@ -2245,8 +2306,33 @@ it.layer(TestLayer)("ProjectionStoreV2", (it) => { }, }); yield* assertSummary("Plan limit reached.", "usage_limit"); + // A restart continuation ran ahead of the held queue and ended before the + // resumed failed run: the failure is still the latest executed run. + const aheadRunId = RunId.make("run:limit-shell:ran-ahead"); + yield* store.apply({ + id: EventId.make("event:limit-shell:ran-ahead"), + type: "run.created", + threadId, + runId: aheadRunId, + nodeId: NodeId.make("node:limit-shell:ran-ahead"), + driver, + providerInstanceId, + occurredAt: now, + payload: { + ...original, + id: aheadRunId, + ordinal: original.ordinal + 3, + rootNodeId: NodeId.make("node:limit-shell:ran-ahead"), + userMessageId: MessageId.make("message:limit-shell:ran-ahead"), + status: "completed", + startedAt: DateTime.subtract(now, { minutes: 10 }), + completedAt: DateTime.subtract(now, { minutes: 5 }), + }, + }); + yield* assertSummary("Plan limit reached.", "usage_limit"); const sql = yield* SqlClient.SqlClient; // The rest of this case treats the failed run as the latest run. + yield* sql`DELETE FROM orchestration_v2_projection_runs WHERE run_id = ${aheadRunId}`; yield* sql`DELETE FROM orchestration_v2_projection_runs WHERE run_id = ${queuedRunId}`; yield* assertSummary("Plan limit reached.", "usage_limit"); yield* sql`DELETE FROM orchestration_v2_projection_runs WHERE run_id = ${cancelledRunId}`; @@ -4341,6 +4427,36 @@ it.layer(TestLayer)("ProjectionStoreV2", (it) => { released && projectThreadAwarenessV2({ environmentId, project, thread: released }); assert.equal(state?.phase, "completed"); assert.equal(state?.updatedAt, DateTime.formatIso(releasedAt)); + + // A later provider must not hide this owner's roster from restart recovery. + yield* store.apply({ + id: EventId.make("event:held-completion:monitor-restarted"), + type: "provider-thread.updated", + threadId, + occurredAt: releasedAt, + payload: { ...providerThread, updatedAt: releasedAt }, + }); + const newerRunId = RunId.make("run:held-completion:new-provider"); + yield* store.apply({ + id: EventId.make("event:held-completion:new-provider"), + type: "run.created", + threadId, + runId: newerRunId, + occurredAt: releasedAt, + payload: { + ...(yield* store.getThreadProjection(threadId)).runs[0]!, + id: newerRunId, + ordinal: 2, + providerThreadId: ProviderThreadId.make("provider-thread:held-completion:new-provider"), + requestedAt: releasedAt, + startedAt: releasedAt, + completedAt: releasedAt, + }, + }); + assert.deepEqual( + (yield* store.getRuntimeRecoveryProjection(threadId)).runs.map((run) => run.id), + [runId, newerRunId], + ); }), ); }); diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 6718240d9e7a..a1b2e0f061f1 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -48,6 +48,7 @@ import { OrchestrationV2RuntimeRequestJson as OrchestrationV2RuntimeRequestJsonSchema, OrchestrationV2SubagentJson as OrchestrationV2SubagentJsonSchema, OrchestrationV2TurnItemJson as OrchestrationV2TurnItemJsonSchema, + orchestrationV2RunWorkStartedAt, RunId, CheckpointScopeId, ThreadId, @@ -158,7 +159,13 @@ export type ProjectionThreadPullRequests = Pick< "id" | "projectId" | "settledOverride" | "settledAt" | "pullRequests" >; -/** Thread activity needed by settlement, without transcript or fork history. */ +/** + * Thread activity needed by settlement, without transcript or fork history. + * `latestUserAuthoredMessageAt` is the last message the user wrote. Agent, + * provider, and server notifications also use the user role, so + * `latestUserMessageAt` moves when background work or a PR watch wakes the + * agent. + */ export type ProjectionSettlementCandidate = Pick< OrchestrationV2ThreadShell, | "id" @@ -186,7 +193,7 @@ export type ProjectionSettlementCandidate = Pick< | "activityRunStatus" | "pendingRuntimeRequest" | "pendingBackgroundTasks" ->; +> & { readonly latestUserAuthoredMessageAt: DateTime.Utc | null }; const ProjectionCheckpointContext = Schema.Struct({ runs: Schema.Array( @@ -348,12 +355,12 @@ export interface ProjectionStoreV2Shape { ) => Effect.Effect, ProjectionStoreV2Error>; /** * Active (not deleted, not archived) threads with at least one pull request - * link, in shell snapshot order. Skips run, message and item reads. + * link, in shell snapshot order, or only `threadId` when given. Skips run, + * message and item reads. */ - readonly getThreadsWithPullRequests: () => Effect.Effect< - ReadonlyArray, - ProjectionStoreV2Error - >; + readonly getThreadsWithPullRequests: ( + threadId?: ThreadId, + ) => Effect.Effect, ProjectionStoreV2Error>; readonly getTurnStartContext: ( threadId: ThreadId, runId: RunId, @@ -515,8 +522,10 @@ function needsRecovery( } case "runtime": return ( - projection.runs.some((run) => - ["queued", "preparing", "starting", "running", "waiting"].includes(run.status), + projection.runs.some( + (run) => + ["preparing", "starting", "running", "waiting"].includes(run.status) || + (run.status === "queued" && run.queueHeld !== true), ) || projection.runtimeRequests.some((request) => request.status === "pending") || projection.providerSessions.some( @@ -929,7 +938,7 @@ type SettlementThreadRow = Pick< | "latest_run_started_at" | "latest_run_completed_at" | "latest_user_message_at" ->; +> & { readonly latest_user_authored_message_at: string | null }; type ShellRunItemCountRow = { readonly thread_id: string; @@ -1368,7 +1377,8 @@ export function threadShellFromProjection( latestRunCompletedAt: latestRun?.completedAt ?? null, activeRunId: activeRun?.id ?? null, activityRunStatus: activityRun?.status ?? null, - activityRunStartedAt: activityRun?.startedAt ?? activityRun?.requestedAt ?? null, + activityRunStartedAt: + activityRun === null ? null : orchestrationV2RunWorkStartedAt(activityRun), status: latestRun?.status ?? "idle", ...threadErrorSummary( latestRootProviderFailure(latestRun, projection.turnItems), @@ -2666,7 +2676,7 @@ export const layer: Layer.Layer = WHEN (SELECT COUNT(*) FROM turn_anchors) >= ${THREAD_HISTORY_MAX_RAW_TURNS + 2} THEN (SELECT MIN(ordinal) FROM turn_anchors) ELSE 0 - END AS ordinal, (SELECT COUNT(*) FROM turn_anchors) AS anchors + END AS ordinal FROM user_anchors ), selected AS ( SELECT payload_json, ordinal, turn_item_id, run_id, type @@ -2675,7 +2685,7 @@ export const layer: Layer.Layer = ORDER BY ordinal DESC, turn_item_id DESC LIMIT CASE WHEN ${window.rowLimit} = 0 THEN 0 - WHEN (SELECT anchors FROM boundary) > 0 THEN -1 + WHEN (SELECT COUNT(*) FROM user_anchors) > 0 THEN -1 ELSE ${window.rowLimit} END ), retained AS ( @@ -3155,7 +3165,7 @@ export const layer: Layer.Layer = localWindow !== undefined && localWindow.rowLimit > 0 && projection.turnItems.length >= localWindow.rowLimit && - !projection.turnItems.some(isThreadHistoryTurnStart) + !projection.turnItems.some(isThreadHistoryUserTurn) ) { return withLocalVisibleTurnItems(projection); } @@ -3299,7 +3309,10 @@ export const layer: Layer.Layer = latest.status = 'cancelled' AND json_extract(latest.payload_json, '$.startedAt') IS NULL ) - ORDER BY latest.ordinal DESC, latest.run_id DESC LIMIT 1 + -- latestExecutedRun: the run that ended last (runRanAfter). + ORDER BY latest.completed_at IS NULL DESC, latest.completed_at DESC, + latest.ordinal DESC, latest.run_id DESC + LIMIT 1 ) AND r.status = 'failed' INNER JOIN orchestration_v2_projection_turn_items item ON item.turn_item_id = ( SELECT error.turn_item_id FROM orchestration_v2_projection_turn_items error @@ -3425,7 +3438,15 @@ export const layer: Layer.Layer = ELSE 0 END ) SELECT thread_id FROM orchestration_v2_projection_runs - WHERE status IN ('queued', 'preparing', 'starting', 'running', 'waiting') + WHERE status IN ('preparing', 'starting', 'running', 'waiting') + UNION + -- A held queue already went through recovery; rereading it on + -- every boot costs a projection read per held thread. + SELECT thread_id FROM orchestration_v2_projection_runs + WHERE status = 'queued' + AND CASE WHEN json_valid(payload_json) + THEN json_extract(payload_json, '$.queueHeld') IS NOT 1 + ELSE 1 END UNION SELECT thread_id FROM orchestration_v2_projection_runtime_requests WHERE status = 'pending' @@ -3797,6 +3818,24 @@ export const layer: Layer.Layer = WHERE latest.thread_id = ${threadId} ORDER BY latest.ordinal DESC LIMIT 1 ) + -- Retain the latest run for each provider thread with lost + -- background work, including owners used before a handoff. + OR run.run_id IN ( + SELECT ( + SELECT ended.run_id FROM orchestration_v2_projection_runs AS ended + WHERE ended.thread_id = ${threadId} + AND ended.provider_thread_id = roster.provider_thread_id + AND ended.status NOT IN ('queued', 'rolled_back') + ORDER BY ended.completed_at IS NULL DESC, ended.completed_at DESC, + ended.ordinal DESC + LIMIT 1 + ) + FROM orchestration_v2_projection_provider_threads AS roster + WHERE roster.thread_id = ${threadId} + AND CASE WHEN json_valid(roster.payload_json) + THEN json_array_length(roster.payload_json, '$.pendingBackgroundTasks') > 0 + ELSE 0 END + ) OR run.run_id IN ( SELECT item.run_id FROM orchestration_v2_projection_turn_items AS item WHERE item.thread_id = ${threadId} @@ -4782,7 +4821,12 @@ export const layer: Layer.Layer = LIMIT 1 ) AS activity_run_status, ( - SELECT COALESCE(json_extract(r.payload_json, '$.startedAt'), r.requested_at) + -- Mirrors orchestrationV2RunWorkStartedAt. + SELECT COALESCE( + json_extract(r.payload_json, '$.workStartedAt'), + json_extract(r.payload_json, '$.startedAt'), + r.requested_at + ) FROM orchestration_v2_projection_runs r WHERE r.thread_id = t.thread_id AND r.status IN ('preparing', 'starting', 'running', 'waiting') @@ -4886,7 +4930,10 @@ export const layer: Layer.Layer = candidate.status = 'cancelled' AND json_extract(candidate.payload_json, '$.startedAt') IS NULL ) - ORDER BY candidate.ordinal DESC, candidate.run_id DESC LIMIT 1 + -- latestExecutedRun: the run that ended last (runRanAfter). + ORDER BY candidate.completed_at IS NULL DESC, candidate.completed_at DESC, + candidate.ordinal DESC, candidate.run_id DESC + LIMIT 1 ) AND blocked.status = 'failed' WHERE t.deleted_at IS NULL${threadId === undefined ? sql`` : sql` AND t.thread_id = ${threadId}`}${ location === "active" @@ -5044,7 +5091,15 @@ export const layer: Layer.Layer = WHERE message.thread_id = t.thread_id AND message.role = 'user' ORDER BY message.updated_at DESC, message.message_id DESC LIMIT 1 - ) AS latest_user_message_at + ) AS latest_user_message_at, + ( + SELECT message.updated_at + FROM orchestration_v2_projection_messages message + WHERE message.thread_id = t.thread_id AND message.role = 'user' + AND json_extract(message.payload_json, '$.createdBy') = 'user' + ORDER BY message.updated_at DESC, message.message_id DESC + LIMIT 1 + ) AS latest_user_authored_message_at FROM orchestration_v2_projection_threads t LEFT JOIN orchestration_v2_projection_runs r ON r.run_id = ( SELECT latest.run_id FROM orchestration_v2_projection_runs latest @@ -5106,6 +5161,10 @@ export const layer: Layer.Layer = row.latest_user_message_at === null ? null : DateTime.makeUnsafe(row.latest_user_message_at), + latestUserAuthoredMessageAt: + row.latest_user_authored_message_at === null + ? null + : DateTime.makeUnsafe(row.latest_user_authored_message_at), activityRunStatus: null, activityRunStartedAt: null, pendingRuntimeRequest: null, @@ -5126,12 +5185,14 @@ export const layer: Layer.Layer = ) .pipe(Effect.mapError((cause) => new ProjectionStoreSetupError({ cause }))); - const getThreadsWithPullRequests: ProjectionStoreV2Shape["getThreadsWithPullRequests"] = () => + const getThreadsWithPullRequests: ProjectionStoreV2Shape["getThreadsWithPullRequests"] = ( + threadId, + ) => Effect.gen(function* () { const rows = yield* sql` SELECT payload_json FROM orchestration_v2_projection_threads - WHERE deleted_at IS NULL + WHERE deleted_at IS NULL${threadId === undefined ? sql`` : sql` AND thread_id = ${threadId}`} AND json_extract(payload_json, '$.archivedAt') IS NULL AND json_array_length(payload_json, '$.pullRequests') > 0 ORDER BY updated_at ASC, thread_id ASC @@ -5569,20 +5630,30 @@ export const layerMemory: Layer.Layer = Layer.effect( !runs.some(isActivityRunForShell) && !runtimeRequests.some((request) => request.status === "pending"), ) - .map(threadShellFromProjection) + .map((projection) => ({ + ...threadShellFromProjection(projection), + latestUserAuthoredMessageAt: + projection.messages + .filter((message) => message.role === "user" && message.createdBy === "user") + .map((message) => message.updatedAt) + .toSorted( + (left, right) => DateTime.toEpochMillis(right) - DateTime.toEpochMillis(left), + )[0] ?? null, + })) .toSorted( (left, right) => DateTime.toEpochMillis(left.updatedAt) - DateTime.toEpochMillis(right.updatedAt) || left.id.localeCompare(right.id), ); }), - getThreadsWithPullRequests: () => + getThreadsWithPullRequests: (threadId) => Ref.get(replayState).pipe( Effect.map((state) => [...state.projections.values()] .map(({ thread }) => thread) .filter( (thread) => + (threadId === undefined || thread.id === threadId) && thread.deletedAt === null && thread.archivedAt === null && (thread.pullRequests ?? []).length > 0, @@ -6048,7 +6119,7 @@ export const layerMemory: Layer.Layer = Layer.effect( ); const anchorLimit = (options.userTurnLimit ?? 0) + 2; const start = - turnAnchors.length > 0 + anchors.length > 0 ? anchors.length < anchorLimit ? rawStart : anchors.at(-anchorLimit)! diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts index e296c27400ba..d20765f043c1 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts @@ -34,6 +34,7 @@ import * as EventStore from "./EventStore.ts"; import * as IdAllocator from "./IdAllocator.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as ProviderEventIngestor from "./ProviderEventIngestor.ts"; +import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; import { makeProviderFailure } from "./ProviderFailure.ts"; import { makeProviderEventRoutingState, @@ -55,8 +56,16 @@ const TestLayer = Layer.mergeAll( TestStoresLayer, TestEventSinkLayer, IdAllocator.layer, + ThreadCommandExecutor.layer, ProviderEventIngestor.layer.pipe( - Layer.provide(Layer.mergeAll(TestStoresLayer, TestEventSinkLayer, IdAllocator.layer)), + Layer.provide( + Layer.mergeAll( + TestStoresLayer, + TestEventSinkLayer, + IdAllocator.layer, + ThreadCommandExecutor.layer, + ), + ), ), ); const modelSelection = { @@ -839,8 +848,9 @@ layer("ProviderEventIngestorV2", (it) => { }), ); - for (const terminal of ["completed", "interrupted", "failed", "cancelled", "control"] as const) { - it.effect(`dismisses only native questions when a provider turn ends with ${terminal}`, () => + it.effect.each(["completed", "interrupted", "failed", "cancelled", "control"] as const)( + "dismisses only native questions when a provider turn ends with %s", + (terminal) => Effect.gen(function* () { const now = yield* DateTime.now; const eventSink = yield* EventSink.EventSinkV2; @@ -1024,8 +1034,7 @@ layer("ProviderEventIngestorV2", (it) => { const repeated = yield* ingestor.ingestNormalized(input); assert.isFalse(repeated.some((entry) => entry.event.type === "runtime-request.updated")); }), - ); - } + ); it.effect( "preserves an answer committed after terminal normalization reads a pending question", @@ -1349,4 +1358,99 @@ layer("ProviderEventIngestorV2", (it) => { assert.equal(messageEvents[0]?.threadId, childThreadId); }), ); + + it.effect("moves a native subagent's thread to the model its provider reports later", () => + Effect.gen(function* () { + const now = yield* DateTime.now; + const eventSink = yield* EventSink.EventSinkV2; + const projectionStore = yield* ProjectionStore.ProjectionStoreV2; + const ingestor = yield* ProviderEventIngestor.ProviderEventIngestorV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const rootEvent = yield* threadCreatedEvent(now); + if (rootEvent.type !== "thread.created") { + throw new Error("Expected a thread.created fixture event"); + } + const childThreadId = idAllocator.derive.threadFromProviderThread({ + driver: CODEX_DRIVER, + nativeThreadId: "native-late-model-subagent", + }); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId: rootEvent.threadId, + }); + const ingest = (event: ProviderEventIngestor.ProviderEventIngestInput["event"]) => + ingestor.ingestNormalized({ + providerSessionId, + providerInstanceId: modelSelection.instanceId, + threadId: rootEvent.threadId, + event, + }); + yield* eventSink.write({ events: [rootEvent] }); + // The subagent's thread starts on the parent's model and options. + yield* ingest({ + type: "app_thread.created", + driver: CODEX_DRIVER, + appThread: { + ...rootEvent.payload, + id: childThreadId, + title: "review design", + modelSelection: { + ...modelSelection, + options: [{ id: "reasoningEffort", value: "xhigh" }], + }, + activeProviderThreadId: null, + lineage: { + parentThreadId: rootEvent.threadId, + relationshipToParent: "subagent", + rootThreadId: rootEvent.threadId, + }, + }, + }); + const subagentUpdated = { + type: "subagent.updated", + driver: CODEX_DRIVER, + subagent: { + id: NodeId.make("node:late-model-subagent"), + threadId: rootEvent.threadId, + runId: null, + parentNodeId: NodeId.make("node:root"), + origin: "provider_native", + createdBy: "agent", + driver: CODEX_DRIVER, + providerInstanceId: modelSelection.instanceId, + providerThreadId: null, + childThreadId, + nativeTaskRef: null, + prompt: "Review the design", + title: "review design", + model: "gpt-6.1-sol", + status: "running", + result: null, + startedAt: now, + completedAt: null, + updatedAt: now, + }, + } satisfies ProviderEventIngestor.ProviderEventIngestInput["event"]; + + const first = yield* ingest(subagentUpdated); + const repeated = yield* ingest(subagentUpdated); + const childThread = yield* projectionStore.getThread(childThreadId); + + assert.deepEqual( + first.map((stored) => [stored.event.type, stored.event.threadId]), + [ + ["subagent.updated", rootEvent.threadId], + ["thread.model-selection-updated", childThreadId], + ], + ); + assert.deepEqual( + repeated.map((stored) => stored.event.type), + ["subagent.updated"], + ); + assert.deepEqual(childThread.modelSelection, { + instanceId: modelSelection.instanceId, + model: "gpt-6.1-sol", + }); + }), + ); }); diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts index 412d4b8c6652..791282eb25b0 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts @@ -6,6 +6,7 @@ import { type OrchestrationV2PlanArtifact, type OrchestrationV2Run, type OrchestrationV2ProviderTurn, + type OrchestrationV2Subagent, type ModelSelection, type RuntimeMode, type ProviderInteractionMode, @@ -32,6 +33,7 @@ import * as ProjectionStore from "./ProjectionStore.ts"; import * as IdAllocator from "./IdAllocator.ts"; import { ProviderAdapterV2Event } from "./ProviderAdapter.ts"; import { makeProviderFailureTurnItem } from "./ProviderFailure.ts"; +import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; export class ProviderEventNormalizeError extends Schema.TaggedError()( "ProviderEventNormalizeError", @@ -250,13 +252,17 @@ const decodeDomainEvent = Schema.decodeUnknownEffect(OrchestrationV2DomainEvent) export const layer: Layer.Layer< ProviderEventIngestorV2, never, - EventSink.EventSinkV2 | IdAllocator.IdAllocatorV2 | ProjectionStore.ProjectionStoreV2 + | EventSink.EventSinkV2 + | IdAllocator.IdAllocatorV2 + | ProjectionStore.ProjectionStoreV2 + | ThreadCommandExecutor.ThreadCommandExecutor > = Layer.effect( ProviderEventIngestorV2, Effect.gen(function* () { const eventSink = yield* EventSink.EventSinkV2; const projections = yield* ProjectionStore.ProjectionStoreV2; const idAllocator = yield* IdAllocator.IdAllocatorV2; + const threadCommands = yield* ThreadCommandExecutor.ThreadCommandExecutor; const analytics = yield* ProviderTurnAnalytics; const completedTurnAnalytics = new Set(); @@ -339,6 +345,48 @@ export const layer: Layer.Layer< }, ); + /** + * A native subagent's thread starts on the parent's model when the + * provider names the real one later (a Claude agent file's model arrives + * with the subagent's first reply). Clients read the thread's model, so + * move the thread to the reported one. Thread commands rewrite the whole + * thread row under the thread's lock, so this read and write take it too. + */ + const syncSubagentThreadModel = Effect.fn("ProviderEventIngestor.syncSubagentThreadModel")( + function* (input: ProviderEventIngestInput, subagent: OrchestrationV2Subagent) { + const { childThreadId, model } = subagent; + if (subagent.origin !== "provider_native" || childThreadId === null || model === null) { + return []; + } + const staleThread = projections.getThread(childThreadId).pipe( + Effect.map((thread) => (thread.modelSelection.model === model ? null : thread)), + Effect.catchTags({ ProjectionStoreThreadNotFoundError: () => Effect.succeed(null) }), + ); + // Nearly every update already matches; only a mismatch takes the lock. + if ((yield* staleThread) === null) return []; + return yield* threadCommands.withLock( + childThreadId, + Effect.gen(function* () { + const thread = yield* staleThread; + if (thread === null) return []; + const now = yield* DateTime.now; + const event = yield* makeDomainEvent(input, { + type: "thread.model-selection-updated", + threadId: thread.id, + // The parent's options belong to the parent's model. + payload: { + ...thread, + modelSelection: { instanceId: thread.modelSelection.instanceId, model }, + updatedAt: now, + }, + occurredAt: now, + }); + return yield* eventSink.write({ events: [event] }); + }), + ); + }, + ); + const normalize: ProviderEventIngestorV2Shape["normalize"] = (input) => Effect.gen(function* () { yield* increment(providerRuntimeEventsTotal, { @@ -552,6 +600,21 @@ export const layer: Layer.Layer< .pipe(Effect.mapError(mapWriteError)); return result.storedEvents; }).pipe( + 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; diff --git a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryPerformance.test.ts b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryPerformance.test.ts new file mode 100644 index 000000000000..b000351508aa --- /dev/null +++ b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryPerformance.test.ts @@ -0,0 +1,510 @@ +import { assert, it } from "@effect/vitest"; +import { + CheckpointScopeId, + CommandId, + EventId, + MessageId, + NodeId, + type OrchestrationV2AppThread, + type OrchestrationV2DomainEvent, + type OrchestrationV2Run, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ProviderSessionId, + ProviderThreadId, + ProviderTurnId, + RunAttemptId, + RunId, + ThreadId, + TurnItemId, +} from "@t3tools/contracts"; +import * as Console from "effect/Console"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; +import * as EffectOutbox from "./EffectOutbox.ts"; +import * as EventSink from "./EventSink.ts"; +import * as EventStore from "./EventStore.ts"; +import * as IdAllocator from "./IdAllocator.ts"; +import * as ProjectionStore from "./ProjectionStore.ts"; +import * as ProviderRuntimeRecovery from "./ProviderRuntimeRecoveryService.ts"; + +// Startup recovery cost for restart continuation. The default case is small +// enough for CI and asserts correctness; T3_BENCH_RECOVERY=1 adds the matrix. +// `it.live` keeps a real clock: each reconcile gets a fresh command id, so the +// second recover cannot hide behind command receipt dedup. + +const stores = Layer.mergeAll( + EventStore.layer, + ProjectionStore.layer, + EffectOutbox.layer, + IdAllocator.layer, +).pipe(Layer.provideMerge(SqlitePersistenceMemory)); +const TestLayer = ProviderRuntimeRecovery.layer.pipe( + Layer.provideMerge(EventSink.layer.pipe(Layer.provideMerge(stores))), + Layer.provideMerge(ServerSettings.layerTest({ continueThreadsAfterServerUpdate: true })), +); + +const providerInstanceId = ProviderInstanceId.make("codex"); +const driver = ProviderDriverKind.make("codex"); +const modelSelection = { instanceId: providerInstanceId, model: "gpt-5.4" }; +const projectId = ProjectId.make("project:recovery-bench"); +const ITEMS_PER_SETTLED_THREAD = 10; +const OPEN_ITEMS_PER_ACTIVE_THREAD = 3; +const WAITING_THREADS = 3; +const SEED_BATCH = 500; + +type UnstampedEvent = OrchestrationV2DomainEvent extends infer Event + ? Event extends OrchestrationV2DomainEvent + ? Omit + : never + : never; + +interface Scenario { + readonly settled: number; + readonly active: number; +} + +interface Seeded { + readonly unheldQueuedThreads: number; + readonly waitingThreads: number; +} + +const seedScenario = Effect.fn(function* (scenario: Scenario) { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const outbox = yield* EffectOutbox.EffectOutboxV2; + const sql = yield* SqlClient.SqlClient; + const now = yield* DateTime.now; + let eventIndex = 0; + const apply = (event: UnstampedEvent) => + projections.apply({ + ...event, + id: EventId.make(`event:bench:${eventIndex++}`), + occurredAt: now, + } as OrchestrationV2DomainEvent); + + const thread = (id: ThreadId, overrides: Partial = {}) => + apply({ + type: "thread.created", + threadId: id, + payload: { + createdBy: "user", + creationSource: "web", + id, + projectId, + title: id, + providerInstanceId, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: id }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + ...overrides, + }, + }); + + const run = ( + threadId: ThreadId, + ordinal: number, + status: OrchestrationV2Run["status"], + overrides: Partial = {}, + ) => { + const runId = RunId.make(`run:${threadId}:${ordinal}`); + return apply({ + type: "run.created", + threadId, + runId, + payload: { + id: runId, + threadId, + ordinal, + providerInstanceId, + modelSelection, + providerThreadId: null, + userMessageId: MessageId.make(`message:${runId}`), + rootNodeId: null, + activeAttemptId: null, + status, + requestedAt: now, + startedAt: status === "queued" ? null : now, + completedAt: status === "completed" ? now : null, + checkpointId: null, + contextHandoffId: null, + ...overrides, + }, + }).pipe(Effect.as(runId)); + }; + + const commandItem = (input: { + readonly threadId: ThreadId; + readonly runId: RunId; + readonly ordinal: number; + readonly status: "running" | "completed"; + readonly nodeId?: NodeId; + readonly providerThreadId?: ProviderThreadId; + readonly providerTurnId?: ProviderTurnId; + }) => + apply({ + type: "turn-item.updated", + threadId: input.threadId, + runId: input.runId, + payload: { + id: TurnItemId.make(`item:${input.runId}:${input.ordinal}`), + threadId: input.threadId, + runId: input.runId, + nodeId: input.nodeId ?? null, + providerThreadId: input.providerThreadId ?? null, + providerTurnId: input.providerTurnId ?? null, + nativeItemRef: null, + parentItemId: null, + ordinal: input.ordinal, + type: "command_execution", + status: input.status, + title: `Command ${input.ordinal}`, + input: `echo ${input.ordinal}`, + output: input.status === "completed" ? `${input.ordinal}\n` : "", + ...(input.status === "completed" ? { exitCode: 0 } : {}), + startedAt: now, + completedAt: input.status === "completed" ? now : null, + updatedAt: now, + }, + }); + + const settledThread = Effect.fn(function* (index: number) { + const threadId = ThreadId.make(`thread:bench:settled:${index}`); + yield* thread(threadId); + const runId = yield* run(threadId, 1, "completed"); + for (let ordinal = 1; ordinal <= ITEMS_PER_SETTLED_THREAD; ordinal += 1) { + yield* commandItem({ threadId, runId, ordinal, status: "completed" }); + } + // ~5% of threads carry a queued follow-up; half are already held, and an + // earlier recovery already handled those, so only unheld ones are candidates. + if (index % 20 === 0) { + const queueHeld = index % 40 === 0; + yield* run(threadId, 2, "queued", { queueHeld }); + return !queueHeld; + } + return false; + }); + + const activeThread = Effect.fn(function* (index: number) { + const threadId = ThreadId.make(`thread:bench:active:${index}`); + const providerSessionId = ProviderSessionId.make(`session:bench:${index}`); + const providerThreadId = ProviderThreadId.make(`provider-thread:bench:${index}`); + const runId = RunId.make(`run:${threadId}:1`); + const attemptId = RunAttemptId.make(`attempt:bench:${index}`); + const nodeId = NodeId.make(`node:bench:${index}`); + const providerTurnId = ProviderTurnId.make(`provider-turn:bench:${index}`); + yield* thread(threadId, { activeProviderThreadId: providerThreadId }); + yield* apply({ + type: "provider-session.attached", + threadId, + driver, + providerInstanceId, + payload: { + id: providerSessionId, + driver, + providerInstanceId, + status: "ready", + cwd: "/workspace", + model: modelSelection.model, + capabilities: CodexProviderCapabilitiesV2, + createdAt: now, + updatedAt: now, + lastError: null, + }, + }); + yield* apply({ + type: "provider-thread.updated", + threadId, + driver, + providerInstanceId, + payload: { + id: providerThreadId, + appThreadId: threadId, + ownerNodeId: null, + driver, + providerInstanceId, + providerSessionId, + nativeThreadRef: { driver, nativeId: `native:bench:${index}`, strength: "strong" }, + nativeConversationHeadRef: null, + status: "active", + firstRunOrdinal: 1, + lastRunOrdinal: 1, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + pendingBackgroundTasks: [], + }, + }); + yield* run(threadId, 1, "running", { + providerThreadId, + rootNodeId: nodeId, + activeAttemptId: attemptId, + }); + yield* apply({ + type: "run-attempt.created", + threadId, + runId, + payload: { + id: attemptId, + runId, + attemptOrdinal: 1, + rootNodeId: nodeId, + providerInstanceId, + providerThreadId, + providerTurnId, + reason: "initial", + status: "running", + startedAt: now, + completedAt: null, + }, + }); + yield* apply({ + type: "node.updated", + threadId, + runId, + nodeId, + providerInstanceId, + payload: { + id: nodeId, + threadId, + runId, + parentNodeId: null, + rootNodeId: nodeId, + kind: "root_turn", + status: "running", + countsForRun: true, + providerThreadId, + providerTurnId, + nativeItemRef: null, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: now, + completedAt: null, + }, + }); + yield* apply({ + type: "provider-turn.updated", + threadId, + runId, + nodeId, + providerInstanceId, + payload: { + id: providerTurnId, + providerThreadId, + nodeId, + runAttemptId: attemptId, + nativeTurnRef: { driver, nativeId: `native-turn:bench:${index}`, strength: "strong" }, + ordinal: 1, + status: "running", + startedAt: now, + completedAt: null, + }, + }); + for (let ordinal = 1; ordinal <= OPEN_ITEMS_PER_ACTIVE_THREAD; ordinal += 1) { + yield* commandItem({ + threadId, + runId, + ordinal, + status: "running", + nodeId, + providerThreadId, + providerTurnId, + }); + } + }); + + const waitingThread = Effect.fn(function* (index: number) { + const threadId = ThreadId.make(`thread:bench:waiting:${index}`); + yield* thread(threadId); + yield* run(threadId, 1, "completed"); + const runId = yield* run(threadId, 2, "waiting"); + yield* outbox.enqueue([ + { + id: `effect:bench:checkpoint:${runId}`, + commandId: CommandId.make(`command:effect:checkpoint.capture:${runId}`), + threadId, + request: { + type: "checkpoint.capture", + runId, + scopeId: CheckpointScopeId.make(`scope:bench:${index}`), + }, + }, + ]); + }); + + let unheldQueuedThreads = 0; + for (let start = 0; start < scenario.settled; start += SEED_BATCH) { + const end = Math.min(scenario.settled, start + SEED_BATCH); + yield* sql.withTransaction( + Effect.gen(function* () { + for (let index = start; index < end; index += 1) { + if (yield* settledThread(index)) unheldQueuedThreads += 1; + } + }), + ); + } + yield* sql.withTransaction( + Effect.gen(function* () { + for (let index = 0; index < scenario.active; index += 1) yield* activeThread(index); + for (let index = 0; index < WAITING_THREADS; index += 1) yield* waitingThread(index); + }), + ); + return { unheldQueuedThreads, waitingThreads: WAITING_THREADS } satisfies Seeded; +}); + +const timed = (effect: Effect.Effect) => + Effect.gen(function* () { + const start = performance.now(); + const value = yield* effect; + return [value, performance.now() - start] as const; + }); + +const continuationEffectCount = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const rows = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM orchestration_v2_effect_outbox + WHERE effect_id LIKE 'effect:restart-continuation:%' AND status = 'pending' + `; + return Number(rows[0]?.count ?? 0); +}); + +interface Measurement { + readonly settled: number; + readonly active: number; + readonly seedMs: number; + readonly candidates: number; + readonly selectMs: number; + readonly prepareMs: number; + readonly shutdownMs: number; + readonly startupAfterShutdownMs: number; + readonly crashRecoverMs: number; + readonly secondRecoverMs: number; +} + +/** A graceful restart: prepare intent, reconcile on shutdown, recover on boot. */ +const measureGraceful = Effect.fn(function* (scenario: Scenario) { + const [seeded, seedMs] = yield* timed(seedScenario(scenario)); + const projections = yield* ProjectionStore.ProjectionStoreV2; + const recovery = yield* ProviderRuntimeRecovery.ProviderRuntimeRecoveryService; + const [candidates, selectMs] = yield* timed(projections.getRecoveryThreadIds("runtime")); + assert.equal( + candidates.length, + scenario.active + seeded.unheldQueuedThreads + seeded.waitingThreads, + "runtime recovery candidates", + ); + const [, prepareMs] = yield* timed(recovery.prepareForShutdown); + assert.equal(yield* continuationEffectCount, scenario.active, "prepared continuations"); + const [, shutdownMs] = yield* timed(recovery.reconcile("shutdown")); + const [, startupAfterShutdownMs] = yield* timed(recovery.recover); + assert.equal(yield* continuationEffectCount, scenario.active, "continuations after boot"); + return { + seedMs, + candidates: candidates.length, + selectMs, + prepareMs, + shutdownMs, + startupAfterShutdownMs, + }; +}); + +/** A crash: no shutdown hook ran, so boot recovery records the continuations. */ +const measureCrash = Effect.fn(function* (scenario: Scenario) { + yield* seedScenario(scenario); + const recovery = yield* ProviderRuntimeRecovery.ProviderRuntimeRecoveryService; + const [summary, crashRecoverMs] = yield* timed(recovery.recover); + assert.equal(summary.terminalizedRuns, scenario.active, "terminalized active runs"); + assert.equal(summary.stoppedSessions, scenario.active, "stopped active sessions"); + assert.equal(yield* continuationEffectCount, scenario.active, "recorded continuations"); + const [, secondRecoverMs] = yield* timed(recovery.recover); + assert.equal(yield* continuationEffectCount, scenario.active, "second recover is idempotent"); + return { crashRecoverMs, secondRecoverMs }; +}); + +const measure = Effect.fn(function* (scenario: Scenario) { + const graceful = yield* measureGraceful(scenario).pipe(Effect.provide(Layer.fresh(TestLayer))); + const crash = yield* measureCrash(scenario).pipe(Effect.provide(Layer.fresh(TestLayer))); + return { ...scenario, ...graceful, ...crash } satisfies Measurement; +}); + +const formatTable = (rows: ReadonlyArray) => { + const ms = (value: number) => value.toFixed(1); + const perActive = (value: number, active: number) => (value / Math.max(active, 1)).toFixed(2); + const header = [ + "N", + "A", + "seed", + "cand", + "select", + "prepare", + "prep/A", + "shutdown", + "boot-after", + "crash-recover", + "recover/A", + "recover-2", + ]; + const lines = rows.map((row) => [ + String(row.settled), + String(row.active), + ms(row.seedMs), + String(row.candidates), + ms(row.selectMs), + ms(row.prepareMs), + perActive(row.prepareMs, row.active), + ms(row.shutdownMs), + ms(row.startupAfterShutdownMs), + ms(row.crashRecoverMs), + perActive(row.crashRecoverMs, row.active), + ms(row.secondRecoverMs), + ]); + const widths = header.map((cell, column) => + Math.max(cell.length, ...lines.map((line) => line[column]!.length)), + ); + return [header, ...lines] + .map((line) => line.map((cell, column) => cell.padStart(widths[column]!)).join(" ")) + .join("\n"); +}; + +it.live( + "restart recovery records one continuation per active thread and is idempotent", + () => + Effect.gen(function* () { + const row = yield* measure({ settled: 500, active: 20 }); + yield* Console.log(`provider runtime recovery (ms)\n${formatTable([row])}`); + }), + 60_000, +); + +it.live.skipIf(process.env.T3_BENCH_RECOVERY !== "1")( + "restart recovery scales with active threads, not settled history", + () => + Effect.gen(function* () { + const rows: Array = []; + for (const settled of [5_000, 50_000]) { + for (const active of [10, 100, 1_000]) { + rows.push(yield* measure({ settled, active })); + yield* Console.log(`provider runtime recovery (ms)\n${formatTable(rows)}`); + } + } + }), + 3_600_000, +); diff --git a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts index 8df85818a761..b36239de290d 100644 --- a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts @@ -1272,3 +1272,151 @@ it.effect( }).pipe(Effect.provide(layer)); }, ); + +it.effect("leaves delegated tasks to their own child threads after process loss", () => { + const threadId = ThreadId.make("thread_recovery_delegation"); + const settledRunId = RunId.make("run_recovery_delegation_settled"); + const runningRunId = RunId.make("run_recovery_delegation_running"); + const providerThreadId = ProviderThreadId.make("provider_thread_recovery_delegation"); + const claudeInstanceId = ProviderInstanceId.make("claude"); + const driver = ProviderDriverKind.make("claude"); + const settledTaskId = NodeId.make("node_recovery_delegation_settled"); + const runningTaskId = NodeId.make("node_recovery_delegation_running"); + const nativeSubagentId = NodeId.make("node_recovery_native_subagent"); + const delegatedTask = (id: NodeId, runId: RunId) => ({ + id, + runId, + origin: "app_owned", + childThreadId: ThreadId.make(`thread:delegated-task:${id}`), + driver, + providerInstanceId: claudeInstanceId, + status: "running", + }); + const subagentItem = (subagentId: NodeId, runId: RunId, origin: string) => ({ + id: TurnItemId.make(`turn_item:${subagentId}`), + runId, + nodeId: subagentId, + providerThreadId, + type: "subagent", + status: "running", + subagentId, + origin, + childThreadId: + origin === "app_owned" ? ThreadId.make(`thread:delegated-task:${subagentId}`) : null, + providerInstanceId: claudeInstanceId, + title: `subagent ${subagentId}`, + }); + let committedInput: Parameters[0] | null = + null; + const projection = { + thread: { id: threadId, providerInstanceId: claudeInstanceId }, + runtimeRequests: [], + providerSessions: [], + providerThreads: [ + { + id: providerThreadId, + driver, + providerInstanceId: claudeInstanceId, + ownerNodeId: null, + status: "idle", + pendingBackgroundTasks: [], + }, + ], + providerTurns: [], + runs: [ + { + id: settledRunId, + ordinal: 1, + status: "completed", + providerThreadId, + providerInstanceId: claudeInstanceId, + }, + { + id: runningRunId, + ordinal: 2, + status: "running", + providerThreadId, + providerInstanceId: claudeInstanceId, + }, + ], + attempts: [], + nodes: [ + { id: settledTaskId, runId: settledRunId, status: "running", kind: "subagent" }, + { id: runningTaskId, runId: runningRunId, status: "running", kind: "subagent" }, + { id: nativeSubagentId, runId: settledRunId, status: "running", kind: "subagent" }, + ], + subagents: [ + delegatedTask(settledTaskId, settledRunId), + delegatedTask(runningTaskId, runningRunId), + { + ...delegatedTask(nativeSubagentId, settledRunId), + origin: "provider_native", + childThreadId: null, + }, + ], + messages: [], + turnItems: [ + subagentItem(settledTaskId, settledRunId, "app_owned"), + subagentItem(runningTaskId, runningRunId, "app_owned"), + subagentItem(nativeSubagentId, settledRunId, "provider_native"), + ], + } as unknown as OrchestrationV2ThreadProjection; + const layer = ProviderRuntimeRecovery.layer.pipe( + Layer.provide(ServerSettings.layerTest()), + Layer.provide( + Layer.mergeAll( + Layer.mock(ProjectionStore.ProjectionStoreV2)({ + getRecoveryThreadIds: () => Effect.succeed([threadId]), + getRuntimeRecoveryProjection: () => Effect.succeed(projection), + }), + Layer.mock(EventSink.EventSinkV2)({ + commitCommand: (input) => { + committedInput = input; + return Effect.succeed({ committed: true, cancelledEffectCount: 0 } as never); + }, + }), + IdAllocator.layer, + Layer.mock(EffectWorker.OrchestrationEffectWorkerV2)({ + runRecoveryOnce: Effect.succeed(false), + }), + Layer.mock(EffectOutbox.EffectOutboxV2)({ + listByCommandId: () => Effect.succeed([]), + reconcileAfterProcessLoss: Effect.succeed({ requeued: 0, cancelled: 0 }), + }), + ), + ), + ); + + return Effect.gen(function* () { + yield* (yield* ProviderRuntimeRecovery.ProviderRuntimeRecoveryService).reconcile("startup"); + const events = committedInput?.events ?? []; + const touched = events.flatMap((event) => + event.type === "turn-item.updated" + ? event.payload.type === "subagent" + ? [event.payload.subagentId] + : [] + : event.type === "subagent.updated" || event.type === "node.updated" + ? [event.payload.id] + : [], + ); + // The cut run itself is cancelled; only the provider-native subagent dies with it. + assert.isTrue( + events.some( + (event) => + event.type === "run.updated" && + event.payload.id === runningRunId && + event.payload.status === "cancelled", + ), + ); + assert.notInclude(touched, settledTaskId); + assert.notInclude(touched, runningTaskId); + assert.include(touched, nativeSubagentId); + // The note lists only provider-native work: delegated tasks report back on their own. + const noted = events.flatMap((event) => + event.type === "run.background-work-cancelled" + ? event.payload.restartCancelledBackgroundWork.map((work) => work.label) + : [], + ); + assert.deepEqual(noted, [`subagent ${nativeSubagentId}`]); + }).pipe(Effect.provide(layer)); +}); diff --git a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts index 27a52b33a114..b858098abbea 100644 --- a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts +++ b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts @@ -1,13 +1,16 @@ +import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { CommandId, type OrchestrationV2DomainEvent, type ProviderThreadId, type OrchestrationV2RestartCancelledBackgroundWork, + type OrchestrationV2Subagent, type OrchestrationV2ThreadProjection, type RunId, ThreadId, } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -112,6 +115,23 @@ function isNonterminalNodeStatus(status: string): boolean { return status === "pending" || status === "running" || status === "waiting"; } +/** + * A delegate_task child. Its own thread is reconciled and continued on its own + * and reports back through the app, so it is not provider background work. + */ +function isAppOwnedDelegation(task: { + readonly origin: OrchestrationV2Subagent["origin"]; + readonly childThreadId: ThreadId | null; +}): boolean { + return task.origin === "app_owned" && task.childThreadId !== null; +} + +function isAppOwnedDelegationItem( + item: OrchestrationV2ThreadProjection["turnItems"][number], +): boolean { + return item.type === "subagent" && isAppOwnedDelegation(item); +} + function providerThreadHasPendingBackgroundTasks( providerThread: OrchestrationV2ThreadProjection["providerThreads"][number], ): boolean { @@ -157,7 +177,11 @@ function providerThreadsWithOpenBackgroundWork( ): ReadonlySet { const ids = new Set(); for (const item of projection.turnItems ?? []) { - if (!isBackgroundCapableTurnItemType(item.type) || !isNonterminalTurnItemStatus(item.status)) + if ( + !isBackgroundCapableTurnItemType(item.type) || + !isNonterminalTurnItemStatus(item.status) || + isAppOwnedDelegationItem(item) + ) continue; const providerThreadId = item.providerThreadId ?? @@ -185,7 +209,7 @@ function latestStartedRun( run.providerThreadId === providerThreadId && run.status !== "queued" && run.status !== "rolled_back" && - (latest === undefined || run.ordinal > latest.ordinal) + (latest === undefined || runRanAfter(run, latest)) ? run : latest, undefined, @@ -316,6 +340,13 @@ const planThreadReconciliation = Effect.fn( const requests = projection.runtimeRequests.filter( (request) => request.status === "pending" && request.responseCapability.type !== "message", ); + // Delegated task rows, items and nodes stay open: the child settles them. + const delegatedTaskNodeIds = new Set([ + ...(projection.subagents ?? []).filter(isAppOwnedDelegation).map((subagent) => subagent.id), + ...(projection.turnItems ?? []).flatMap((item) => + item.type === "subagent" && isAppOwnedDelegation(item) ? [item.subagentId] : [], + ), + ]); const detail = reconciliationDetail(trigger); const allocateEventId = () => ids.allocate.event({ threadId: projection.thread.id, commandId }).pipe( @@ -424,6 +455,7 @@ const planThreadReconciliation = Effect.fn( (candidate) => candidate.runId === run.id && !messageRequestNodeIds.has(candidate.id) && + !delegatedTaskNodeIds.has(candidate.id) && (candidate.status === "pending" || candidate.status === "running" || candidate.status === "waiting"), @@ -442,6 +474,7 @@ const planThreadReconciliation = Effect.fn( for (const subagent of projection.subagents.filter( (candidate) => candidate.runId === run.id && + !isAppOwnedDelegation(candidate) && (candidate.status === "pending" || candidate.status === "running" || candidate.status === "waiting"), @@ -495,6 +528,7 @@ const planThreadReconciliation = Effect.fn( (candidate) => candidate.runId === run.id && (candidate.nodeId === null || !messageRequestNodeIds.has(candidate.nodeId)) && + !isAppOwnedDelegationItem(candidate) && (candidate.status === "pending" || candidate.status === "running" || candidate.status === "waiting"), @@ -535,7 +569,7 @@ const planThreadReconciliation = Effect.fn( if (!isBackgroundCapableTurnItemType(item.type)) { continue; } - if (!isNonterminalTurnItemStatus(item.status)) { + if (!isNonterminalTurnItemStatus(item.status) || isAppOwnedDelegationItem(item)) { continue; } const providerInstanceId = resolveStaleBackgroundItemProviderInstanceId(item, projection); @@ -925,32 +959,41 @@ export const make = Effect.gen(function* () { if (!enabled) return; const threadIds = yield* projections.getRecoveryThreadIds("runtime"); for (const threadId of threadIds) { - const projection = yield* projections.getRuntimeRecoveryProjection(threadId); - if ( - !resolveProjectSettings(enabled, projection.thread.projectId).settings - .continueThreadsAfterServerUpdate - ) - continue; - // Shutdown reconciliation cancels the background work below, so a - // settled thread's continuation must be captured while it is still open. - const run = restartContinuationRun( - projection, - providerThreadsWithOpenBackgroundWork(projection), + yield* Effect.gen(function* () { + const projection = yield* projections.getRuntimeRecoveryProjection(threadId); + if ( + !resolveProjectSettings(enabled, projection.thread.projectId).settings + .continueThreadsAfterServerUpdate + ) + return; + // Shutdown reconciliation cancels the background work below, so a + // settled thread's continuation must be captured while it is still open. + const run = restartContinuationRun( + projection, + providerThreadsWithOpenBackgroundWork(projection), + ); + if (!run) return; + const commandId = CommandId.make(`command:restart-prepare:${run.id}`); + yield* eventSink.writeWithEffects({ + commandId, + events: [], + effects: [ + { + id: `effect:restart-continuation:${run.id}`, + commandId, + threadId, + request: { type: "provider-runtime.continue", sourceRunId: run.id }, + }, + ], + }); + }).pipe( + // One failing thread must not cost the threads after it their continuation. + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => + Effect.logWarning("Failed to prepare a restart continuation", { threadId, cause }), + ), ); - if (!run) continue; - const commandId = CommandId.make(`command:restart-prepare:${run.id}`); - yield* eventSink.writeWithEffects({ - commandId, - events: [], - effects: [ - { - id: `effect:restart-continuation:${run.id}`, - commandId, - threadId, - request: { type: "provider-runtime.continue", sourceRunId: run.id }, - }, - ], - }); } }).pipe( Effect.mapError((cause) => new ProviderRuntimeRecoveryError({ operation: "reconcile", cause })), diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index f9fbc18d2743..9aa86d9908a2 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -21,6 +21,7 @@ import * as Cause from "effect/Cause"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; 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"; @@ -54,6 +55,7 @@ import { } from "./ProviderAdapter.ts"; import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; import * as ProviderEventIngestor from "./ProviderEventIngestor.ts"; +import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; import * as ProviderSessionManager from "./ProviderSessionManager.ts"; const TestDatabaseLayer = SqlitePersistenceMemory; @@ -81,6 +83,52 @@ const FailingReleaseEventSinkLayer = Layer.effect( }), ).pipe(Layer.provide(TestEventSinkLayer)); +interface FlakyReleaseWrites { + /** Which release writes fail right now. */ + readonly failing: Ref.Ref<"none" | "session" | "session-and-requests">; + /** Receives one item per failed write. */ + readonly failures: Queue.Queue; + /** Holds runtime request writes: completes `paused`, then waits for `resume`. */ + readonly pauseRequestWrites?: { + readonly paused: Deferred.Deferred; + readonly resume: Deferred.Deferred; + }; +} + +// Fails release writes with a defect, the way a failed SQL commit surfaces. +const makeFlakyReleaseEventSinkLayer = (flaky: FlakyReleaseWrites) => + Layer.effect( + EventSink.EventSinkV2, + Effect.gen(function* () { + const delegate = yield* EventSink.EventSinkV2; + return EventSink.EventSinkV2.of({ + ...delegate, + write: (input) => + Effect.gen(function* () { + const failing = yield* Ref.get(flaky.failing); + const fails = input.events.some( + (event) => + (failing !== "none" && + event.type === "provider-session.updated" && + (event.payload.status === "stopped" || event.payload.status === "error")) || + (failing === "session-and-requests" && event.type === "runtime-request.updated"), + ); + const pause = flaky.pauseRequestWrites; + if ( + pause !== undefined && + input.events.some((event) => event.type === "runtime-request.updated") + ) { + yield* Deferred.succeed(pause.paused, undefined); + yield* Deferred.await(pause.resume); + } + if (!fails) return yield* delegate.write(input); + yield* Queue.offer(flaky.failures, undefined); + return yield* Effect.die(new Error("simulated commit failure")); + }), + }); + }), + ).pipe(Layer.provide(TestEventSinkLayer)); + const CodexCapabilities: OrchestrationV2ProviderCapabilities = CodexProviderCapabilitiesV2; const ExclusiveCapabilities: OrchestrationV2ProviderCapabilities = { ...CodexCapabilities, @@ -363,6 +411,7 @@ function makeTestLayer(input: { readonly initialProviderItemIdentityVersion?: 2; }) => Effect.Effect; readonly failReleaseEventWrites?: boolean; + readonly flakyReleaseWrites?: FlakyReleaseWrites; readonly hasPendingBackgroundWork?: Effect.Effect; readonly hangSessionScopeClose?: boolean; readonly beforeUnload?: Effect.Effect; @@ -370,9 +419,12 @@ function makeTestLayer(input: { readonly serverSettingsLayer?: ReturnType; readonly projectServiceLayer?: Layer.Layer; }) { - const configuredEventSinkLayer = input.failReleaseEventWrites - ? FailingReleaseEventSinkLayer - : TestEventSinkLayer; + const configuredEventSinkLayer = + input.flakyReleaseWrites !== undefined + ? makeFlakyReleaseEventSinkLayer(input.flakyReleaseWrites) + : input.failReleaseEventWrites + ? FailingReleaseEventSinkLayer + : TestEventSinkLayer; const registryLayer = ProviderAdapterRegistry.makeSingleLayer( makeProviderAdapter(input.state, { failEventStream: input.failEventStream ?? false, @@ -390,7 +442,14 @@ function makeTestLayer(input: { }), ); const providerEventIngestorTestLayer = ProviderEventIngestor.layer.pipe( - Layer.provide(Layer.mergeAll(configuredEventSinkLayer, IdAllocator.layer, TestStoresLayer)), + Layer.provide( + Layer.mergeAll( + configuredEventSinkLayer, + IdAllocator.layer, + TestStoresLayer, + ThreadCommandExecutor.layer, + ), + ), ); return Layer.mergeAll( TestStoresLayer, @@ -2349,6 +2408,299 @@ it.effect("ProviderSessionManagerV2 marks pending runtime requests non-live on r yield* effect.pipe(Effect.provide(makeTestLayer({ state, idleTimeoutMs: 1000 }))); }), ); +it.effect("ProviderSessionManagerV2 retries release records that failed to persist", () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const flaky: FlakyReleaseWrites = { + failing: yield* Ref.make<"none" | "session" | "session-and-requests">("session"), + failures: yield* Queue.unbounded(), + }; + const effect = Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const projectionStore = yield* ProjectionStore.ProjectionStoreV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread-provider-session-manager-release-retry"); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + const providerThread = makeProviderThread({ idAllocator, threadId, providerSessionId, now }); + yield* eventSink.write({ + events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], + }); + const pendingRequest = yield* makePendingRuntimeRequestEvents({ + idAllocator, + threadId, + providerSessionId, + providerThread, + now, + }); + yield* eventSink.write({ events: pendingRequest.events }); + yield* manager.open({ threadId, providerSessionId, modelSelection, runtimePolicy }); + + assert.isTrue(Exit.isFailure(yield* Effect.exit(manager.close(providerSessionId)))); + yield* Queue.take(flaky.failures); + // The failed session write does not keep the approval answerable. + const afterClose = yield* projectionStore.getThreadProjection(threadId); + assert.equal(afterClose.runtimeRequests.at(-1)?.responseCapability.type, "not_resumable"); + assert.equal(afterClose.providerSessions.at(-1)?.status, "ready"); + + // The first retry fails as well, and the retries continue. + yield* TestClock.adjust("1 second"); + yield* Queue.take(flaky.failures); + yield* Ref.set(flaky.failing, "none"); + const stopped = yield* eventSink + .stream({ + threadId, + afterSequence: yield* eventSink.latestSequence({ threadId }), + eventType: "provider-session.updated", + }) + .pipe(Stream.runHead, Effect.forkScoped); + yield* TestClock.adjust("1 second"); + yield* Fiber.join(stopped); + + const afterRetry = yield* projectionStore.getThreadProjection(threadId); + assert.equal(afterRetry.providerSessions.at(-1)?.status, "stopped"); + }); + + yield* effect.pipe( + Effect.provide(makeTestLayer({ state, idleTimeoutMs: 60_000, flakyReleaseWrites: flaky })), + ); + }), +); + +it.effect("ProviderSessionManagerV2 release retries leave a replacement session alone", () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const flaky: FlakyReleaseWrites = { + failing: yield* Ref.make<"none" | "session" | "session-and-requests">("none"), + failures: yield* Queue.unbounded(), + }; + const effect = Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const projectionStore = yield* ProjectionStore.ProjectionStoreV2; + const threadId = ThreadId.make("thread-provider-session-manager-release-replacement"); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + const writePendingRequest = Effect.gen(function* () { + const now = yield* DateTime.now; + const request = yield* makePendingRuntimeRequestEvents({ + idAllocator, + threadId, + providerSessionId, + providerThread: makeProviderThread({ idAllocator, threadId, providerSessionId, now }), + now, + }); + yield* eventSink.write({ events: request.events }); + return request.requestId; + }); + yield* eventSink.write({ + events: [ + yield* makeThreadCreatedEvent({ idAllocator, threadId, now: yield* DateTime.now }), + ], + }); + const oldRequestId = yield* writePendingRequest; + yield* manager.open({ threadId, providerSessionId, modelSelection, runtimePolicy }); + yield* Ref.set(flaky.failing, "session-and-requests"); + assert.isTrue(Exit.isFailure(yield* Effect.exit(manager.close(providerSessionId)))); + yield* Queue.take(flaky.failures); + yield* Queue.take(flaky.failures); + yield* Ref.set(flaky.failing, "none"); + + // A replacement opens with the same id before the retry runs. + yield* TestClock.adjust("500 millis"); + yield* manager.open({ threadId, providerSessionId, modelSelection, runtimePolicy }); + const newRequestId = yield* writePendingRequest; + const replacementStatus = (yield* projectionStore.getThreadProjection( + threadId, + )).providerSessions.at(-1)?.status; + const settled = yield* eventSink + .stream({ + threadId, + afterSequence: yield* eventSink.latestSequence({ threadId }), + eventType: "runtime-request.updated", + }) + .pipe(Stream.runHead, Effect.forkScoped); + yield* TestClock.adjust("500 millis"); + yield* Fiber.join(settled); + + const projection = yield* projectionStore.getThreadProjection(threadId); + const request = (id: typeof oldRequestId) => + projection.runtimeRequests.find((candidate) => candidate.id === id); + assert.equal(request(oldRequestId)?.responseCapability.type, "not_resumable"); + assert.equal(request(newRequestId)?.responseCapability.type, "live"); + assert.equal(projection.providerSessions.at(-1)?.status, replacementStatus); + }); + + yield* effect.pipe( + Effect.provide(makeTestLayer({ state, idleTimeoutMs: 60_000, flakyReleaseWrites: flaky })), + ); + }), +); + +it.effect("ProviderSessionManagerV2 keeps each failed release's cleanup", () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const flaky: FlakyReleaseWrites = { + failing: yield* Ref.make<"none" | "session" | "session-and-requests">("none"), + failures: yield* Queue.unbounded(), + }; + const effect = Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const projectionStore = yield* ProjectionStore.ProjectionStoreV2; + const now = yield* DateTime.now; + const firstThreadId = ThreadId.make("thread-provider-session-manager-release-each-a"); + const secondThreadId = ThreadId.make("thread-provider-session-manager-release-each-b"); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId: firstThreadId, + }); + yield* eventSink.write({ + events: [ + yield* makeThreadCreatedEvent({ idAllocator, threadId: firstThreadId, now }), + yield* makeThreadCreatedEvent({ idAllocator, threadId: secondThreadId, now }), + ], + }); + const secondThreadRequest = yield* makePendingRuntimeRequestEvents({ + idAllocator, + threadId: secondThreadId, + providerSessionId, + providerThread: makeProviderThread({ + idAllocator, + threadId: secondThreadId, + providerSessionId, + now, + }), + now, + }); + yield* eventSink.write({ events: secondThreadRequest.events }); + const failRelease = (failedWrites: number) => + Effect.gen(function* () { + yield* Ref.set(flaky.failing, "session-and-requests"); + assert.isTrue(Exit.isFailure(yield* Effect.exit(manager.close(providerSessionId)))); + yield* Effect.repeat(Queue.take(flaky.failures), { times: failedWrites - 1 }); + yield* Ref.set(flaky.failing, "none"); + }); + + // The first session serves both threads. Its replacement serves one. + yield* manager.open({ + threadId: firstThreadId, + providerSessionId, + modelSelection, + runtimePolicy, + }); + yield* manager.open({ + threadId: secondThreadId, + providerSessionId, + modelSelection, + runtimePolicy, + }); + // Both the session write and the second thread's request write fail. + yield* failRelease(2); + yield* TestClock.adjust("500 millis"); + yield* manager.open({ + threadId: firstThreadId, + providerSessionId, + modelSelection, + runtimePolicy, + }); + // Only the session write fails: the first thread has no requests. + yield* failRelease(1); + + const settled = yield* eventSink + .stream({ + threadId: secondThreadId, + afterSequence: yield* eventSink.latestSequence({ threadId: secondThreadId }), + eventType: "runtime-request.updated", + }) + .pipe(Stream.runHead, Effect.forkScoped); + yield* TestClock.adjust("500 millis"); + yield* Fiber.join(settled); + + const projection = yield* projectionStore.getThreadProjection(secondThreadId); + assert.equal(projection.runtimeRequests.at(-1)?.responseCapability.type, "not_resumable"); + }); + + yield* effect.pipe( + Effect.provide(makeTestLayer({ state, idleTimeoutMs: 60_000, flakyReleaseWrites: flaky })), + ); + }), +); + +it.effect("ProviderSessionManagerV2 settles a request the event pump persists during release", () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const flaky: FlakyReleaseWrites = { + failing: yield* Ref.make<"none" | "session" | "session-and-requests">("none"), + failures: yield* Queue.unbounded(), + pauseRequestWrites: { + paused: yield* Deferred.make(), + resume: yield* Deferred.make(), + }, + }; + const pause = flaky.pauseRequestWrites!; + const effect = Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const projectionStore = yield* ProjectionStore.ProjectionStoreV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread-provider-session-manager-release-pump"); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + yield* eventSink.write({ + events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], + }); + yield* manager.open({ threadId, providerSessionId, modelSelection, runtimePolicy }); + // The runtime creates the request a moment after the release starts. + const createdAt = DateTime.add(now, { seconds: 1 }); + const pendingRequest = yield* makePendingRuntimeRequestEvents({ + idAllocator, + threadId, + providerSessionId, + providerThread: makeProviderThread({ + idAllocator, + threadId, + providerSessionId, + now: createdAt, + }), + now: createdAt, + }); + const adapterEvents = (yield* Ref.get(state)).eventQueues.get(String(providerSessionId)); + assert.isDefined(adapterEvents); + yield* Queue.offerAll(adapterEvents!, pendingRequest.providerEvents); + // The event pump holds the request permit while it persists the request. + yield* Deferred.await(pause.paused); + const closed = yield* manager + .close(providerSessionId) + .pipe(Effect.forkScoped({ startImmediately: true })); + yield* TestClock.adjust("1 second"); + yield* Deferred.succeed(pause.resume, undefined); + yield* Fiber.join(closed); + + const projection = yield* projectionStore.getThreadProjection(threadId); + const request = projection.runtimeRequests.find( + (candidate) => candidate.id === pendingRequest.requestId, + ); + assert.equal(request?.responseCapability.type, "not_resumable"); + }); + + yield* effect.pipe( + Effect.provide(makeTestLayer({ state, idleTimeoutMs: 60_000, flakyReleaseWrites: flaky })), + ); + }), +); + it.effect("ProviderSessionManagerV2 terminalizes a pending input transcript item on release", () => Effect.gen(function* () { const state = yield* Ref.make(emptyState); @@ -3525,8 +3877,9 @@ it.effect( }), ); -for (const workspaceState of ["missing", "file"] as const) { - it.effect(`rejects a ${workspaceState} workspace before opening a provider session`, () => +it.effect.each(["missing", "file"] as const)( + "rejects a %s workspace before opening a provider session", + (workspaceState) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const root = yield* fileSystem.makeTempDirectoryScoped(); @@ -3571,8 +3924,7 @@ for (const workspaceState of ["missing", "file"] as const) { ); }).pipe(Effect.provide(makeTestLayer({ state, idleTimeoutMs: 60_000 }))); }).pipe(Effect.provide(NodeServices.layer)), - ); -} +); it.effect( "rejects a deleted workspace before reusing a live session without changing its state", diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index c66f5d31654d..794a87b0968e 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -18,11 +18,13 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; +import * as FiberSet from "effect/FiberSet"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; +import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; @@ -369,7 +371,29 @@ export const layerWithOptions = ( }, ); const layerScope = yield* Effect.scope; + // Ctrl+C, or a stop that signals the whole process group, reaches the + // provider CLIs with the server. They report their own background work + // stopped before shutdown captures restart continuations, so provider + // events after the signal are dropped; restart recovery owns that state. + const shutdownSignal = { received: false }; + const onShutdownSignal = () => { + shutdownSignal.received = true; + }; + yield* Effect.acquireRelease( + Effect.sync(() => { + process.on("SIGINT", onShutdownSignal); + process.on("SIGTERM", onShutdownSignal); + }), + () => + Effect.sync(() => { + process.off("SIGINT", onShutdownSignal); + process.off("SIGTERM", onShutdownSignal); + }), + ); const sessions = yield* Ref.make(new Map()); + // One retry per released entry, so a later release with the same id + // cannot drop cleanup for threads only the earlier session served. + const releaseRecordRetries = yield* FiberSet.make(); const nextSubscriberId = yield* Ref.make(0); const sessionOpen = yield* makeKeyedSerialExecutor(); // Orders a thread's attach against a detach unloading it on the same session. @@ -617,6 +641,8 @@ export const layerWithOptions = ( const writeReleasedRuntimeRequestEvents = (input: { readonly entry: LiveSessionEntry; readonly reason: ProviderSessionReleaseReason; + /** Requests created later belong to a replacement session with the same id. */ + readonly releasedAt: DateTime.Utc; }) => Effect.gen(function* () { const providerSessionId = input.entry.runtime.providerSessionId; @@ -638,7 +664,8 @@ export const layerWithOptions = ( (request) => request.status === "pending" && request.responseCapability.type === "live" && - request.responseCapability.providerSessionId === providerSessionId, + request.responseCapability.providerSessionId === providerSessionId && + DateTime.isLessThanOrEqualTo(request.createdAt, input.releasedAt), ); for (const request of releasedRequests) { @@ -717,6 +744,126 @@ export const layerWithOptions = ( } }); + // Records a released session as stopped and resolves the live runtime + // requests it left. Each write runs even if the other fails. Once a + // replacement session opens with the same id, it owns the session status, + // so only the requests are settled. + const writeReleaseRecords = (input: { + readonly entry: LiveSessionEntry; + readonly reason: ProviderSessionReleaseReason; + readonly detail?: string; + readonly releasedAt: DateTime.Utc; + readonly replaced: boolean; + }) => + Effect.all( + [ + input.replaced + ? Effect.succeed(Exit.void) + : Effect.exit(writeReleasedSessionEvents(input)), + Effect.exit( + writeReleasedRuntimeRequestEvents(input).pipe( + input.entry.requestEventPermit.withPermits(1), + ), + ), + ], + { concurrency: 1 }, + ).pipe(Effect.flatMap(Exit.asVoidAll)); + + // The session already left the live map, so a later release finds + // nothing to do. Without a retry the UI would keep a ready session and + // answerable approvals until a server restart. Each attempt holds the + // session's open lock, so it sees a replacement that opened meanwhile. + const retryReleaseRecords = ( + input: Omit[0], "replaced">, + ) => { + const providerSessionId = input.entry.runtime.providerSessionId; + const attempt = Effect.gen(function* () { + const exit = yield* Effect.exit( + sessionOpen.withLock( + providerSessionId, + Effect.gen(function* () { + const replaced = (yield* Ref.get(sessions)).has(sessionKey(providerSessionId)); + yield* writeReleaseRecords({ ...input, replaced }); + }), + ), + ); + if (Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause)) return yield* exit; + yield* Effect.logWarning("orchestration-v2.provider-session-release-records-failed", { + providerSessionId, + cause: exit.cause, + }); + // A failed SQL commit is a defect, so every failure but interruption + // is retried. + return yield* Effect.fail(exit.cause); + }); + return attempt.pipe( + Effect.retry({ + schedule: Schedule.exponential("1 second").pipe( + Schedule.modifyDelay(({ duration }) => + Effect.succeed(Duration.min(duration, Duration.seconds(30))), + ), + ), + }), + Effect.delay("1 second"), + FiberSet.run(releaseRecordRetries), + ); + }; + + const logReleaseFailure = + (providerSessionId: ProviderSessionId) => + (release: Effect.Effect) => + release.pipe( + Effect.catchCause((cause) => + Effect.logWarning("orchestration-v2.provider-session-release-failed", { + providerSessionId, + cause, + }), + ), + ); + + // Removes the live entry and reads the request cleanup cutoff while + // holding the entry's request permit. 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. A replacement's + // requests come after its own open. + const removeLiveEntry = (input: { + readonly providerSessionId: ProviderSessionId; + readonly onlyIfIdleGeneration?: number; + }): Effect.Effect, DateTime.Utc]> => + Effect.gen(function* () { + const key = sessionKey(input.providerSessionId); + const candidate = (yield* Ref.get(sessions)).get(key); + if (candidate === undefined) { + return [Option.none(), yield* DateTime.now] as const; + } + const removed = yield* Effect.zip( + Ref.modify(sessions, (current) => { + const existing = current.get(key); + if (existing !== candidate) { + return [existing === undefined ? "gone" : "changed", current] as const; + } + if ( + input.onlyIfIdleGeneration !== undefined && + (existing.busyRunOrdinals.size > 0 || + existing.idleGeneration !== input.onlyIfIdleGeneration) + ) { + return ["kept", current] as const; + } + const updated = new Map(current); + updated.delete(key); + return ["removed", updated] as const; + }), + DateTime.now, + ).pipe(candidate.requestEventPermit.withPermits(1)); + const [outcome, releasedAt] = removed; + // Another entry took this id while the permit was held; release it instead. + if (outcome === "changed") return yield* removeLiveEntry(input); + return [ + outcome === "removed" ? Option.some(candidate) : Option.none(), + releasedAt, + ] as const; + }); + const releaseEntry = (input: { readonly providerSessionId: ProviderSessionId; readonly reason: ProviderSessionReleaseReason; @@ -726,24 +873,8 @@ export const layerWithOptions = ( readonly gracefulSubscribers?: boolean; }) => Effect.acquireUseRelease( - Ref.modify(sessions, (current) => { - const key = sessionKey(input.providerSessionId); - const existing = current.get(key); - if (existing === undefined) { - return [Option.none(), current] as const; - } - if ( - input.onlyIfIdleGeneration !== undefined && - (existing.busyRunOrdinals.size > 0 || - existing.idleGeneration !== input.onlyIfIdleGeneration) - ) { - return [Option.none(), current] as const; - } - const updated = new Map(current); - updated.delete(key); - return [Option.some(existing), updated] as const; - }), - (entry) => + removeLiveEntry(input), + ([entry, releasedAt]) => Option.match(entry, { onNone: () => Effect.void, onSome: (entry) => @@ -804,15 +935,19 @@ export const layerWithOptions = ( Effect.forkDetach, ); } - yield* writeReleasedSessionEvents({ + const records = { entry, reason: input.reason, ...(input.detail === undefined ? {} : { detail: input.detail }), - }); - yield* writeReleasedRuntimeRequestEvents({ - entry, - reason: input.reason, - }).pipe(entry.requestEventPermit.withPermits(1)); + releasedAt, + }; + const recorded = yield* Effect.exit( + writeReleaseRecords({ ...records, replaced: false }), + ); + if (Exit.isFailure(recorded)) { + yield* retryReleaseRecords(records); + return yield* recorded; + } if (Option.isSome(closeExit) && Exit.isFailure(closeExit.value)) { return yield* Effect.failCause(closeExit.value.cause); } @@ -826,7 +961,7 @@ export const layerWithOptions = ( ), ), }), - (entry) => + ([entry]) => Option.match(entry, { onNone: () => Effect.void, onSome: (entry) => @@ -1486,6 +1621,7 @@ export const layerWithOptions = ( let stoppedByProvider = false; return entry.runtime.events.pipe( Stream.runForEach((event) => { + if (shutdownSignal.received) return Effect.void; if ( event.type === "provider_session.updated" && event.providerSession.status === "stopped" @@ -1550,6 +1686,8 @@ export const layerWithOptions = ( Effect.exit, Effect.flatMap((exit) => Effect.gen(function* () { + // A provider that exits on the shutdown signal is released by shutdown. + if (shutdownSignal.received) return; const current = (yield* Ref.get(sessions)).get( sessionKey(entry.runtime.providerSessionId), ); @@ -1561,7 +1699,7 @@ export const layerWithOptions = ( providerSessionId: entry.runtime.providerSessionId, reason: "manual_shutdown", gracefulSubscribers: true, - }).pipe(Effect.ignore); + }).pipe(logReleaseFailure(entry.runtime.providerSessionId)); return; } const cause = Exit.isFailure(exit) @@ -1582,7 +1720,7 @@ export const layerWithOptions = ( providerSessionId: entry.runtime.providerSessionId, reason: "runtime_error", detail: Cause.pretty(cause), - }).pipe(Effect.ignore); + }).pipe(logReleaseFailure(entry.runtime.providerSessionId)); }), ), Effect.forkIn(layerScope), @@ -1794,7 +1932,7 @@ export const layerWithOptions = ( providerSessionId: input.providerSessionId, reason: "runtime_error", detail: "Failed to persist the provider-session attachment.", - }).pipe(Effect.ignore), + }).pipe(logReleaseFailure(input.providerSessionId)), ), ); yield* startEventPump(entry); diff --git a/apps/server/src/orchestration-v2/ProviderSwitchService.test.ts b/apps/server/src/orchestration-v2/ProviderSwitchService.test.ts index b3a9fe9d6176..d946bcd19766 100644 --- a/apps/server/src/orchestration-v2/ProviderSwitchService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSwitchService.test.ts @@ -120,32 +120,30 @@ it.effect( ), ); -for (const deadStatus of ["stopped", "error"] as const) { - it.effect( - `restarts and releases the live session when a newer ${deadStatus} session exists`, - () => - Effect.gen(function* () { - const service = yield* ProviderSwitch.ProviderSwitchServiceV2; - const thread = projection(); - const result = yield* service.plan({ - projection: { - ...thread, - providerSessions: [ - ...thread.providerSessions, - deadSessionRecord("dead_session", deadStatus), - ], - }, - targetModelSelection: { instanceId: currentInstanceId, model: "gpt-5.2-codex" }, - }); - assert.equal(result.transition.type, "restart_and_resume"); - assert.deepEqual(result.releaseProviderSessionIds, [currentSessionId]); - }).pipe( - Effect.provide( - testLayer({ [currentInstanceId]: { continuationKey: "codex:account:primary" } }), - ), +it.effect.each(["stopped", "error"] as const)( + "restarts and releases the live session when a newer %s session exists", + (deadStatus) => + Effect.gen(function* () { + const service = yield* ProviderSwitch.ProviderSwitchServiceV2; + const thread = projection(); + const result = yield* service.plan({ + projection: { + ...thread, + providerSessions: [ + ...thread.providerSessions, + deadSessionRecord("dead_session", deadStatus), + ], + }, + targetModelSelection: { instanceId: currentInstanceId, model: "gpt-5.2-codex" }, + }); + assert.equal(result.transition.type, "restart_and_resume"); + assert.deepEqual(result.releaseProviderSessionIds, [currentSessionId]); + }).pipe( + Effect.provide( + testLayer({ [currentInstanceId]: { continuationKey: "codex:account:primary" } }), ), - ); -} + ), +); it.effect("releases the newest live session, not the newest record overall", () => Effect.gen(function* () { @@ -275,32 +273,29 @@ it.effect("hands off from a native provider thread after its session detaches", ), ); -for (const deadStatus of ["stopped", "error"] as const) { - it.effect( - `applies a model change on next turn when a ${deadStatus} session negotiated model switching`, - () => - Effect.gen(function* () { - const service = yield* ProviderSwitch.ProviderSwitchServiceV2; - const result = yield* service.plan({ - projection: deadNativeThreadProjection(deadStatus, CodexProviderCapabilitiesV2), - targetModelSelection: { instanceId: currentInstanceId, model: "gpt-5.2-codex" }, - }); - // Static capabilities report no in-session switch, but the dead - // record's negotiated capabilities describe the provider: without - // them the ACP classification rejects the selection instead of - // reopening with the requested model on the next run. - assert.equal(result.transition.type, "switch_model_in_session"); - assert.deepEqual(result.releaseProviderSessionIds, []); - }).pipe( - Effect.provide( - testLayer( - { [currentInstanceId]: { continuationKey: "codex:account:primary" } }, - (input) => Effect.succeed(acpSelectionTransition(input)), - ), +it.effect.each(["stopped", "error"] as const)( + "applies a model change on next turn when a %s session negotiated model switching", + (deadStatus) => + Effect.gen(function* () { + const service = yield* ProviderSwitch.ProviderSwitchServiceV2; + const result = yield* service.plan({ + projection: deadNativeThreadProjection(deadStatus, CodexProviderCapabilitiesV2), + targetModelSelection: { instanceId: currentInstanceId, model: "gpt-5.2-codex" }, + }); + // Static capabilities report no in-session switch, but the dead + // record's negotiated capabilities describe the provider: without + // them the ACP classification rejects the selection instead of + // reopening with the requested model on the next run. + assert.equal(result.transition.type, "switch_model_in_session"); + assert.deepEqual(result.releaseProviderSessionIds, []); + }).pipe( + Effect.provide( + testLayer({ [currentInstanceId]: { continuationKey: "codex:account:primary" } }, (input) => + Effect.succeed(acpSelectionTransition(input)), ), ), - ); -} + ), +); it.effect("rejects a model change the dead record never negotiated support for", () => Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index 746fbe235d99..fdb45f193179 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -30,6 +30,7 @@ import * as ProviderAuthService from "../provider/Services/ProviderAuthService.t import * as ContextHandoffService from "./ContextHandoffService.ts"; import * as EventSink from "./EventSink.ts"; import * as IdAllocator from "./IdAllocator.ts"; +import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; import { ProviderAdapterEventStreamError } from "./ProviderAdapter.ts"; import * as ProviderSessionManager from "./ProviderSessionManager.ts"; @@ -168,6 +169,8 @@ function makeLocalCommandHarness(input: { readonly interruptOpen?: boolean; readonly interruptRunBeforeOpenFailure?: boolean; readonly writeFailure?: unknown; + /** Loads the thread and starts the run, then fails every later state read. */ + readonly failReadsAfterRunning?: boolean; }) { const now = DateTime.makeUnsafe("2026-09-04T12:00:00Z"); const threadId = ThreadId.make("thread-native-account-command"); @@ -411,9 +414,40 @@ function makeLocalCommandHarness(input: { ), ), ) - : Effect.die("A local command must not open a native session."), + : input.failReadsAfterRunning === true + ? Effect.succeed({ + driver: providerThread.driver, + providerSession: { + id: providerSessionId, + driver: providerThread.driver, + providerInstanceId: newInstanceId, + status: "ready", + cwd: "/tmp/native-account-command", + model: null, + capabilities: CodexProviderCapabilitiesV2, + createdAt: now, + updatedAt: now, + lastError: null, + }, + ensureThread: () => Effect.succeed(providerThread), + } as never) + : Effect.die("A local command must not open a native session."), + ); + const startRootRun = vi.fn< + (input: RunExecutionService.RunExecutionServiceV2StartRootRunInput) => Effect.Effect + >(() => + input.failReadsAfterRunning === true + ? Effect.void + : Effect.die("A local command must not start a native turn."), + ); + const failReadIfRunning = Effect.suspend(() => + input.failReadsAfterRunning === true && + projection.runs.find((candidate) => candidate.id === runId)?.status === "running" + ? Effect.fail( + new ProjectionStore.ProjectionStoreReadError({ threadId, cause: "database unavailable" }), + ) + : Effect.void, ); - const startRootRun = vi.fn(() => Effect.die("A local command must not start a native turn.")); const tryHandlePromptCommand = vi.fn(() => input.logoutFailure === undefined ? Effect.succeed(true) @@ -471,7 +505,7 @@ function makeLocalCommandHarness(input: { ), }), getRuntimeRecoveryProjection: () => - Effect.succeed({ + Effect.as(failReadIfRunning, { ...projection, hasConversation: projection.messages.some( (m) => @@ -703,6 +737,25 @@ effectIt.effect( }), ); +effectIt.effect("does not mistake a failed state read for a superseded run", () => + Effect.gen(function* () { + const harness = makeLocalCommandHarness({ text: "Continue", failReadsAfterRunning: true }); + + yield* harness.start; + + expect(harness.projection().runs.at(-1)?.status).toBe("running"); + const controls = harness.startRootRun.mock.calls[0]?.[0]; + expect(controls).toBeDefined(); + if (controls === undefined) return; + // "false" would skip the provider turn or the terminal write and leave the + // run active. A read failure must reach the caller instead. + const startCheck = yield* Effect.flip(controls.shouldStartProviderTurn!()); + const finalizeCheck = yield* Effect.flip(controls.shouldFinalizeRun!()); + expect(startCheck._tag).toBe("ProjectionStoreReadError"); + expect(finalizeCheck._tag).toBe("ProjectionStoreReadError"); + }), +); + effectIt.effect("does not overwrite a run interrupted while its thread loads", () => Effect.gen(function* () { const harness = makeLocalCommandHarness({ diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 781491ac7fd6..a3e3b122ca45 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -124,13 +124,14 @@ export const layer: Layer.Layer< }) => { // Guards and background routing need live execution state, not a fresh // allocation of every completed message and tool output in the thread. + // `false` means the run moved on or is gone. A failed read is an error, + // so the caller fails the start or the run instead of skipping it. const isCurrentAttemptInStatus = (expectedStatus: OrchestrationV2Run["status"]) => projectionStore.getRuntimeRecoveryProjection(input.threadId).pipe( Effect.map((current) => { const run = current.runs.find((candidate) => candidate.id === input.runId); return run?.activeAttemptId === input.attemptId && run.status === expectedStatus; }), - Effect.catchCause(() => Effect.succeed(false)), ); return { isCurrentAttemptInStatus, @@ -157,7 +158,6 @@ export const layer: Layer.Layer< (run.status === "starting" || run.status === "running") ); }), - Effect.catchCause(() => Effect.succeed(false)), ), hasUnpairedRunInterruptRequest: () => projectionStore @@ -953,6 +953,7 @@ export const layer: Layer.Layer< run, projection.runs, projection.providerTurns, + projection.attempts, ); const restartCancelledWork = pendingRestartCancelledBackgroundWork({ runs: projection.runs, @@ -967,9 +968,7 @@ export const layer: Layer.Layer< .map((candidate) => candidate.id), ), run, - runAttemptIds: projection.attempts - .filter((candidate) => candidate.runId === run.id) - .map((candidate) => candidate.id), + attempts: projection.attempts, }); const restartNote = restartCancelledWork.length === 0 diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts index 7871b3ed6ee3..6febdb9bbce3 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts @@ -1,10 +1,16 @@ import * as Stream from "effect/Stream"; import { + EventId, + MessageId, ProjectId, ProviderInstanceId, PullRequestOperationError, + RunId, ThreadId, + TurnItemId, type OrchestrationV2Command as OrchestrationCommand, + type OrchestrationV2DomainEvent, + type OrchestrationV2Run, type OrchestrationProjectShell, type PullRequestRef, type PullRequestStack, @@ -171,6 +177,7 @@ const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options: const linkCommands = yield* Ref.make>([]); const summaryCalls = yield* Ref.make>([]); const stackCalls = yield* Ref.make>([]); + const domainEvents = yield* Queue.unbounded(); const summary: PullRequestService.PullRequestService["Service"]["summary"] = ( input, @@ -211,12 +218,17 @@ const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options: }), Layer.mock(ProjectionStore.ProjectionStoreV2)({ // Mirrors the store's filter: active threads that have at least one link. - getThreadsWithPullRequests: () => + getThreadsWithPullRequests: (threadId) => Queue.offer(snapshotReads, undefined).pipe( Effect.andThen(Ref.get(snapshots)), Effect.map((snapshot) => snapshot.threads - .filter((thread) => thread.archivedAt === null && thread.pullRequests.length > 0) + .filter( + (thread) => + (threadId === undefined || thread.id === threadId) && + thread.archivedAt === null && + thread.pullRequests.length > 0, + ) .map((thread) => ({ id: thread.id, projectId: thread.projectId, @@ -233,7 +245,7 @@ const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options: Effect.andThen(Effect.die(new Error("pull request sync must not read the shell"))), ), dispatch, - streamDomainEvents: Stream.empty, + streamDomainEvents: Stream.fromQueue(domainEvents), }), Layer.succeed(ServerActivation.ServerActivation, Deferred.await(activation)), Layer.succeed(Crypto.Crypto, testCrypto), @@ -248,6 +260,7 @@ const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options: linkCommands, summaryCalls, stackCalls, + domainEvents, layer: PullRequestSyncReactor.layer.pipe(Layer.provide(dependencies)), }; }); @@ -272,6 +285,68 @@ const sweepAgain = Effect.fn("sweepPullRequestSyncHarness")(function* ( yield* reactor.drain; }); +function runUpdated( + threadId: ThreadId, + status: OrchestrationV2Run["status"], +): OrchestrationV2DomainEvent { + const runId = RunId.make(`run:${threadId}:1`); + const at = DateTime.makeUnsafe(NOW); + return { + type: "run.updated", + id: EventId.make(`event:${threadId}:${status}`), + threadId, + runId, + occurredAt: at, + payload: { + id: runId, + threadId, + ordinal: 1, + providerInstanceId: ProviderInstanceId.make("codex"), + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + providerThreadId: null, + userMessageId: MessageId.make(`message:${threadId}:1`), + rootNodeId: null, + activeAttemptId: null, + status, + requestedAt: at, + startedAt: at, + completedAt: at, + checkpointId: null, + contextHandoffId: null, + }, + }; +} + +function commandRan(threadId: ThreadId, input: string): OrchestrationV2DomainEvent { + const runId = RunId.make(`run:${threadId}:1`); + const at = DateTime.makeUnsafe(NOW); + return { + type: "turn-item.updated", + id: EventId.make(`event:${threadId}:command`), + threadId, + runId, + occurredAt: at, + payload: { + id: TurnItemId.make(`item:${threadId}:command`), + threadId, + runId, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + status: "completed", + title: null, + startedAt: at, + completedAt: at, + updatedAt: at, + type: "command_execution", + input, + }, + }; +} + /** What the reactor would have persisted, so the next sweep sees its own writes. */ function applySync( snapshot: TestShellSnapshot, @@ -832,4 +907,60 @@ describe("PullRequestSyncReactor", () => { }), ), ); + + it.effect("reads open links fresh only when a run that ran a merge command ends", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const state = yield* Ref.make("open"); + const invalidated = yield* Ref.make>([]); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("agent", { pullRequests: [makeLink(7, { state: "open" })] }), + makeThread("other", { pullRequests: [makeLink(9, { state: "open" })] }), + ]), + summary: (input) => + Ref.get(state).pipe( + Effect.map((current) => + makeSummary(input, current === "merged" ? { state: current, mergedAt: NOW } : {}), + ), + ), + invalidate: ({ reference }) => + Ref.update(invalidated, (numbers) => [...numbers, reference?.number ?? -1]), + }); + + yield* Effect.gen(function* () { + const reactor = yield* startAndSweep(fixture); + // The agent merges from a shell during its turn, inside the summary cache window. + yield* Ref.set(state, "merged"); + yield* Ref.set(fixture.summaryCalls, []); + + yield* Queue.offerAll(fixture.domainEvents, [ + // A run that only reads its pull request costs no host read when it ends. + commandRan(ThreadId.make("other"), "gh pr view 9"), + runUpdated(ThreadId.make("other"), "completed"), + commandRan(ThreadId.make("agent"), "gh pr merge 7 --squash 2>&1 | tail -3"), + runUpdated(ThreadId.make("agent"), "completed"), + ]); + // The agent thread's lookup, then the requested sweep. + yield* Queue.take(fixture.snapshotReads); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + + assert.deepStrictEqual(yield* Ref.get(invalidated), [7]); + assert.deepStrictEqual( + (yield* Ref.get(fixture.summaryCalls)).map((call) => call.number), + [7], + ); + assert.deepStrictEqual( + (yield* Ref.get(fixture.syncCommands)).map((command) => [ + command.number, + command.snapshot.state, + ]), + [[7, "merged"]], + ); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); }); diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts index 670d401004b6..1b526d8f532e 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts @@ -2,6 +2,7 @@ import { siblingPullRequestUrl } from "@t3tools/shared/changeRequestUrl"; import { CommandId, type PullRequestSummary, + type ThreadId, type ThreadPullRequestKey, type ThreadPullRequestLink, type ThreadPullRequestSnapshot, @@ -29,8 +30,11 @@ import * as PullRequestService from "../pullRequest/PullRequestService.ts"; import { forkParked } from "../serverActivation.ts"; import * as Orchestrator from "./Orchestrator.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; +import { isTerminalRunStatus } from "./ThreadManagementService.ts"; const SLOW_SYNC_INTERVAL_MS = 15 * 60 * 1_000; +/** Shell commands that can merge or close a pull request without a merge notification. */ +const PULL_REQUEST_CLOSE_COMMAND = /\b(?:gh\s+pr|glab\s+mr)\s+(?:merge|close)\b/u; type SnapshotFields = Omit; @@ -330,22 +334,60 @@ export const make = Effect.gen(function* () { }).pipe(Effect.catchCause(logSkipped("pull request sync sweep failed", {}))), ); + // Threads whose current run ran a merge or close command, until that run ends. + const closeCommandThreads = new Set(); + const refreshOpenLinks = (threadId: ThreadId) => + projections.getThreadsWithPullRequests(threadId).pipe( + Effect.flatMap((threads) => + Effect.forEach( + threads.flatMap((thread) => + visibleThreadPullRequests(thread.pullRequests ?? []).filter( + (link) => link.snapshot?.state === "open", + ), + ), + requestSync, + { discard: true }, + ), + ), + Effect.catchCause(logSkipped("pull request refresh after run skipped", { threadId })), + ); + const start: PullRequestSyncReactor["Service"]["start"] = Effect.fn( "PullRequestSyncReactor.start", )(function* () { const events = engine.streamDomainEvents; yield* forkParked( - Stream.runForEach(events, (event) => - event.type === "thread.pull-request-synced" - ? Effect.forEach( + Stream.runForEach(events, (event) => { + switch (event.type) { + case "thread.pull-request-synced": + return Effect.forEach( visibleThreadPullRequests(event.payload.pullRequests ?? []).filter( (link) => link.snapshot === null, ), requestSync, { discard: true }, - ) - : Effect.void, - ).pipe(Effect.catchCause(logSkipped("pull request sync event stream failed", {}))), + ); + // An agent can merge or close its pull request from a shell (`gh pr merge`), which + // sends no merge notification. When a run that ran such a command ends, read the + // thread's open links fresh, so settlement does not wait for the next sweep and the + // cached summary. Other runs add no host reads. + case "turn-item.updated": + if ( + event.payload.type === "command_execution" && + PULL_REQUEST_CLOSE_COMMAND.test(event.payload.input) + ) { + closeCommandThreads.add(event.threadId); + } + return Effect.void; + case "run.updated": + return isTerminalRunStatus(event.payload.status) && + closeCommandThreads.delete(event.threadId) + ? refreshOpenLinks(event.threadId) + : Effect.void; + default: + return Effect.void; + } + }).pipe(Effect.catchCause(logSkipped("pull request sync event stream failed", {}))), ); yield* forkParked( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts new file mode 100644 index 000000000000..4eb6ec80ce17 --- /dev/null +++ b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts @@ -0,0 +1,219 @@ +import { + CommandId, + MessageId, + type OrchestrationV2Notification, + type ThreadPullRequestLink, + type ThreadPullRequestWatch, +} from "@t3tools/contracts"; +import { + normalizeThreadPullRequestKey, + threadPullRequestKeyOf, + visibleThreadPullRequests, +} from "@t3tools/shared/threadPullRequests"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Schedule from "effect/Schedule"; +import type * as Scope from "effect/Scope"; + +import * as PullRequestService from "../pullRequest/PullRequestService.ts"; +import { forkParked } from "../serverActivation.ts"; +import * as Orchestrator from "./Orchestrator.ts"; +import * as ProjectionStore from "./ProjectionStore.ts"; +import { evaluatePullRequestWatch, pullRequestWatchMessage } from "./pullRequestWatch.ts"; + +/** Passes in a row that could not read a pull request before its watch ends (one a minute). */ +const READ_FAILURE_LIMIT = 15; + +const logFailure = + (message: string, fields: Record) => + (cause: Cause.Cause): Effect.Effect => + Cause.hasInterruptsOnly(cause) + ? Effect.interrupt + : Effect.logWarning(message, { ...fields, cause }); + +interface WatchTarget { + readonly thread: ProjectionStore.ProjectionThreadPullRequests; + readonly link: ThreadPullRequestLink; + readonly watch: ThreadPullRequestWatch; +} + +const failureKey = ({ thread, link, watch }: WatchTarget) => + `${thread.id} ${threadPullRequestKeyOf(link)} ${watch.startedAt}`; + +function watchesEqual(left: ThreadPullRequestWatch, right: ThreadPullRequestWatch): boolean { + return ( + left.startedAt === right.startedAt && + left.headSha === right.headSha && + left.failedChecks.join("\n") === right.failedChecks.join("\n") && + left.passed === right.passed && + left.remarksThrough === right.remarksThrough && + left.remarkIds.join("\n") === right.remarkIds.join("\n") && + left.conflicting === right.conflicting && + left.wakes === right.wakes + ); +} + +/** + * Wakes a thread's agent when a pull request it watches (`watch_pull_request`) needs a look: + * checks finished on the head commit, someone else commented, or the branch started to + * conflict. One pass a minute reads each watched pull request; settled threads wait until + * they are active again, and a merged or closed pull request ends its watch. + */ +export class PullRequestWatchReactor extends Context.Service< + PullRequestWatchReactor, + { + readonly start: () => Effect.Effect; + /** One pass over every watched pull request. */ + readonly sweep: Effect.Effect; + } +>()("t3/orchestration-v2/PullRequestWatchReactor") {} + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const engine = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const pullRequests = yield* PullRequestService.PullRequestService; + const crypto = yield* Crypto.Crypto; + + // Passes in a row that failed, per watch. Kept in memory: a restart only delays the stop. + const readFailures = new Map(); + + // Host-level identity, with the repository as linked, the way pull request sync reads it. + const identityOf = (link: ThreadPullRequestLink) => ({ + host: normalizeThreadPullRequestKey(link).host, + repository: link.repository, + number: link.number, + }); + + /** + * Records what a pass saw, and wakes the agent with it. 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 = ( + target: WatchTarget, + next: ThreadPullRequestWatch | null, + wake?: { readonly text: string; readonly notification: OrchestrationV2Notification }, + ) => + 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, + ...(wake === undefined + ? {} + : { wake: { ...wake, messageId: MessageId.make(`message:pr-watch:${uuid}`) } }), + }); + }); + + // A watch that cannot read its pull request ends with a wake saying so, rather than showing + // "Watching" while it learns nothing. + const giveUp = (target: WatchTarget) => + record(target, null, { + text: `T3 Code stopped watching pull request #${target.link.number} (${target.link.url}) because it could not read it from the host for ${READ_FAILURE_LIMIT} minutes. Check it yourself, and call watch_pull_request to watch it again.`, + notification: { + source: { kind: "monitor" }, + outcome: "failed", + summary: `#${target.link.number}: stopped watching, could not read it`, + }, + }).pipe(Effect.catch(() => record(target, null))); + + const check = Effect.fn("PullRequestWatchReactor.check")(function* (target: WatchTarget) { + const { thread, link, watch } = target; + const pullRequest = identityOf(link); + // A merged pull request cannot reopen, so its watch ends without a host read, even on a + // settled thread. A closed one can, so the host decides below. + if (link.snapshot?.state === "merged") return yield* record(target, null); + if (thread.settledOverride === "settled" || thread.settledAt !== null) return; + + const reference = { projectId: thread.projectId, ...pullRequest }; + const read = yield* Effect.exit( + Effect.all( + [ + pullRequests.detail({ ...reference, allowStale: false }), + pullRequests.activity(reference), + ], + { concurrency: 2 }, + ), + ); + // Only host reads count towards giving up; a refused wake is not the host's fault. + const key = failureKey(target); + if (Exit.isFailure(read)) { + if (Cause.hasInterruptsOnly(read.cause)) return yield* Effect.failCause(read.cause); + const failures = (readFailures.get(key) ?? 0) + 1; + readFailures.set(key, failures); + // The count stays until the stop lands, so a failed stop is tried again next pass. + if (failures >= READ_FAILURE_LIMIT) { + yield* giveUp(target); + readFailures.delete(key); + } + return yield* Effect.failCause(read.cause); + } + readFailures.delete(key); + const [detail, activity] = read.value; + if (detail.state !== "open") return yield* record(target, null); + + // A degraded read (GitHub's review thread query failed) is truncated with no long thread to + // explain it, and would skip review comments, so remarks wait for a later pass. Replies past + // the first ten of a long review thread are not read. + const degraded = + activity.commentsTruncated && + !activity.reviewThreads.some((reviewThread) => reviewThread.nextCommentsCursor !== undefined); + const report = evaluatePullRequestWatch(watch, detail, degraded ? null : activity.comments); + if (report.changes.length > 0) { + return yield* record( + target, + report.exhausted ? null : report.next, + pullRequestWatchMessage({ + number: link.number, + url: link.url, + baseBranch: detail.baseBranch, + headSha: report.next.headSha, + report, + }), + ); + } + if (!watchesEqual(report.next, watch)) yield* record(target, report.next); + }); + + const sweep = Effect.gen(function* () { + const threads = yield* projections.getThreadsWithPullRequests(); + const targets = threads.flatMap((thread) => + visibleThreadPullRequests(thread.pullRequests ?? []).flatMap((link) => + link.watch === undefined ? [] : [{ thread, link, watch: link.watch }], + ), + ); + const keys = new Set(targets.map(failureKey)); + for (const key of readFailures.keys()) if (!keys.has(key)) readFailures.delete(key); + yield* Effect.forEach( + targets, + (target) => + check(target).pipe( + Effect.catchCause( + logFailure("pull request watch check failed", { + threadId: target.thread.id, + pullRequest: threadPullRequestKeyOf(target.link), + }), + ), + ), + { concurrency: 4, discard: true }, + ); + }).pipe( + Effect.catchCause(logFailure("pull request watch sweep failed", {})), + Effect.withSpan("PullRequestWatchReactor.sweep"), + ); + + const start: PullRequestWatchReactor["Service"]["start"] = () => + forkParked(sweep.pipe(Effect.repeat(Schedule.spaced("1 minute")), Effect.asVoid)); + + return { start, sweep } satisfies PullRequestWatchReactor["Service"]; +}); + +export const layer = Layer.effect(PullRequestWatchReactor, make); diff --git a/apps/server/src/orchestration-v2/RestartBackgroundNote.test.ts b/apps/server/src/orchestration-v2/RestartBackgroundNote.test.ts index f5215c1f3b6b..2f22ed583a91 100644 --- a/apps/server/src/orchestration-v2/RestartBackgroundNote.test.ts +++ b/apps/server/src/orchestration-v2/RestartBackgroundNote.test.ts @@ -6,12 +6,15 @@ import { RunId, type OrchestrationV2Run, } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; import { cancelledRosterTaskWork, cancelledTurnItemWork, + isRestartNoteContinuation, pendingRestartCancelledBackgroundWork, restartCancelledBackgroundWorkNote, + restartContinuationNote, mergeRestartCancelledBackgroundWork, } from "./RestartBackgroundNote.ts"; @@ -51,7 +54,7 @@ it("keeps the note for the provider thread that lost the work across a provider providerTurns: runs.filter((source) => source.id !== target.id).map(turnFor), compactionMessageIds: new Set(), run: target, - runAttemptIds: target.activeAttemptId === null ? [] : [target.activeAttemptId], + attempts: [{ id: target.activeAttemptId!, runId: target.id }], }); // Codex never lost the work, so it neither receives nor consumes the note. @@ -62,6 +65,30 @@ it("keeps the note for the provider thread that lost the work across a provider assert.deepEqual(pending(later, [root, onCodex, backOnClaude, later]), []); }); +it("delivers a resumed queued run's note even after a higher-ordinal run", () => { + // Run 3 ran ahead of held run 2; run 2 then resumed, lost background work in + // a second restart, and its continuation was superseded by a new message. + const ranFirst = run(3, claudeThread, { + completedAt: DateTime.makeUnsafe("2026-10-03T10:00:00.000Z"), + }); + const resumed = run(2, claudeThread, { + completedAt: DateTime.makeUnsafe("2026-10-03T10:05:00.000Z"), + restartCancelledBackgroundWork: lost, + }); + const next = run(4, claudeThread); + const runs = [resumed, ranFirst, next]; + assert.deepEqual( + pendingRestartCancelledBackgroundWork({ + runs, + providerTurns: [resumed, ranFirst].map(turnFor), + compactionMessageIds: new Set(), + run: next, + attempts: [{ id: next.activeAttemptId!, runId: next.id }], + }), + lost, + ); +}); + it("bounds the note so it cannot crowd out the turn's context", () => { const work = Array.from({ length: 25 }, (_, index) => ({ kind: "shell" as const, @@ -123,7 +150,7 @@ it("does not repeat the note when a steer restarts the run on a new attempt", () ], compactionMessageIds: new Set(), run: steered, - runAttemptIds, + attempts: runAttemptIds.map((id) => ({ id, runId: steered.id })), }); // The first attempt reached the provider with the note; its replacement must not repeat it. @@ -131,3 +158,80 @@ it("does not repeat the note when a steer restarts the run on a new attempt", () // A first attempt that never reached the provider did not deliver it. assert.deepEqual(pending([firstAttempt, RunAttemptId.make("attempt:2b")], false), lost); }); + +it("counts a prompted mid-turn or chained continuation as delivering the note", () => { + const cut = run(1, claudeThread, { status: "cancelled", restartCancelledBackgroundWork: lost }); + const cutTurn = { ...turnFor(cut), status: "cancelled" as const }; + const continuation = run(2, claudeThread, { restartContinuationOfRunId: cut.id }); + // A turn cut mid-way that lost work is prompted with the note, not resumed natively. + assert.isTrue(isRestartNoteContinuation(continuation, [cut, continuation], [cutTurn], [])); + assert.isFalse( + isRestartNoteContinuation( + continuation, + [{ ...cut, restartCancelledBackgroundWork: [] }, continuation], + [cutTurn], + [], + ), + ); + const later = run(3, claudeThread); + const pending = (delivered: boolean) => + pendingRestartCancelledBackgroundWork({ + runs: [cut, continuation, later], + providerTurns: [cutTurn, ...(delivered ? [turnFor(continuation)] : [])], + compactionMessageIds: new Set(), + run: later, + attempts: [{ id: later.activeAttemptId!, runId: later.id }], + }); + assert.deepEqual(pending(true), []); + // A continuation that never reached the provider delivered nothing. + assert.deepEqual(pending(false), lost); + + // Its own continuation carries the original note forward. + const unstarted = { ...continuation, status: "cancelled" as const }; + const chained = run(3, claudeThread, { restartContinuationOfRunId: unstarted.id }); + assert.deepEqual(restartContinuationNote(unstarted, [cut, unstarted], [cutTurn], []), { + work: lost, + settled: false, + }); + assert.isTrue(isRestartNoteContinuation(chained, [cut, unstarted, chained], [cutTurn], [])); + // Claude announces running before accepting the prompt. A second restart + // can cancel that turn without delivering anything to the provider. + for (const status of ["running", "cancelled"] as const) { + const turns = [cutTurn, { ...turnFor(unstarted), status }]; + assert.deepEqual(restartContinuationNote(unstarted, [cut, unstarted], turns, []), { + work: lost, + settled: false, + }); + assert.deepEqual( + pendingRestartCancelledBackgroundWork({ + runs: [cut, unstarted, later], + providerTurns: turns, + compactionMessageIds: new Set(), + run: later, + attempts: [{ id: later.activeAttemptId!, runId: later.id }], + }), + lost, + ); + } + + // A steer replaced the attempt after its completed turn delivered the note. + const steered = { ...unstarted, activeAttemptId: RunAttemptId.make("attempt:replacement") }; + const attempts = [{ id: unstarted.activeAttemptId!, runId: steered.id }]; + const turns = [ + cutTurn, + turnFor(unstarted), + { ...turnFor(steered), status: "cancelled" as const }, + ]; + assert.deepEqual(restartContinuationNote(steered, [cut, steered], turns, attempts).work, []); + assert.isFalse(isRestartNoteContinuation(chained, [cut, steered, chained], turns, attempts)); + assert.deepEqual( + pendingRestartCancelledBackgroundWork({ + runs: [cut, steered, later], + providerTurns: turns, + compactionMessageIds: new Set(), + run: later, + attempts, + }), + [], + ); +}); diff --git a/apps/server/src/orchestration-v2/RestartBackgroundNote.ts b/apps/server/src/orchestration-v2/RestartBackgroundNote.ts index 50ea52895e77..11b2cfe37c08 100644 --- a/apps/server/src/orchestration-v2/RestartBackgroundNote.ts +++ b/apps/server/src/orchestration-v2/RestartBackgroundNote.ts @@ -3,10 +3,13 @@ import type { OrchestrationV2ProviderTurn, OrchestrationV2RestartCancelledBackgroundWork, OrchestrationV2Run, + OrchestrationV2RunAttempt, OrchestrationV2TurnItem, } from "@t3tools/contracts"; +import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; type Work = OrchestrationV2RestartCancelledBackgroundWork; +type Attempt = Pick; const MAX_LABEL_LENGTH = 160; @@ -120,25 +123,69 @@ export function isRestartNoteSource( ); } -/** A restart continuation whose prompt is the note rather than a resume. */ +/** + * The note a restart continuation of `source` carries, and whether the turn it + * continues had settled (the note is then the whole prompt). A continuation cut + * before its provider turn completed may not have delivered its own note: + * adapters can announce a running turn before accepting the prompt. Carry the + * note forward until a completed turn proves delivery. + */ +export function restartContinuationNote( + source: OrchestrationV2Run, + runs: ReadonlyArray, + providerTurns: ReadonlyArray>, + attempts: ReadonlyArray, +): { readonly work: ReadonlyArray; readonly settled: boolean } { + const completedAttempts = new Set( + providerTurns.filter((turn) => turn.status === "completed").map((turn) => turn.runAttemptId), + ); + const completedRuns = new Set( + attempts.filter((attempt) => completedAttempts.has(attempt.id)).map((attempt) => attempt.runId), + ); + let current = source; + let work = current.restartCancelledBackgroundWork ?? []; + const visited = new Set([current.id]); + while ( + current.restartContinuationOfRunId !== undefined && + !completedRuns.has(current.id) && + !(current.activeAttemptId !== null && completedAttempts.has(current.activeAttemptId)) + ) { + const previous = runs.find((candidate) => candidate.id === current.restartContinuationOfRunId); + if (previous === undefined || visited.has(previous.id)) break; + visited.add(previous.id); + current = previous; + work = mergeRestartCancelledBackgroundWork(current.restartCancelledBackgroundWork ?? [], work); + } + return { work, settled: isRestartNoteSource(current, providerTurns) }; +} + +/** + * A restart continuation prompted with the note rather than a native resume. + * A turn cut mid-way that lost background work is prompted too, so the note + * is delivered with it; only a continuation without a note resumes natively. + */ export function isRestartNoteContinuation( run: Pick, runs: ReadonlyArray, providerTurns: ReadonlyArray>, + attempts: ReadonlyArray, ): boolean { const source = run.restartContinuationOfRunId === undefined ? undefined : runs.find((candidate) => candidate.id === run.restartContinuationOfRunId); - return source !== undefined && isRestartNoteSource(source, providerTurns); + return ( + source !== undefined && + restartContinuationNote(source, runs, providerTurns, attempts).work.length > 0 + ); } /** * Work cancelled by a restart that the run's provider thread has not been told * about yet. The note belongs to the provider thread that lost the work: turns * on another provider (after a switch) neither owe it nor deliver it. A later - * run on the same provider thread delivers it once its attempt reaches the - * provider, so the pending set is derived rather than cleared. Compactions and + * completed turn on the same provider thread proves delivery, so the pending + * set is derived rather than cleared. Compactions and * resumed turns carry no note, and a rolled-back run left native history, so * none of them counts as delivery. A note continuation's own prompt is the note. */ @@ -150,13 +197,14 @@ export function pendingRestartCancelledBackgroundWork(input: { OrchestrationV2Run, | "id" | "ordinal" + | "completedAt" | "userMessageId" | "providerThreadId" | "restartContinuationOfRunId" | "activeAttemptId" >; - /** Every attempt id of `run`; a steer replaces the attempt but not the run. */ - readonly runAttemptIds: ReadonlyArray; + /** Include earlier attempts: a steer replaces the attempt but not the run. */ + readonly attempts: ReadonlyArray; }): ReadonlyArray { const isCompaction = (run: typeof input.run) => input.compactionMessageIds.has(run.userMessageId); // The current run prepends the note unless it is a compaction or a @@ -170,7 +218,7 @@ export function pendingRestartCancelledBackgroundWork(input: { const providerThreadId = input.run.providerThreadId; const deliveredAttemptIds = new Set( input.providerTurns - .filter((turn) => turn.providerThreadId === providerThreadId) + .filter((turn) => turn.providerThreadId === providerThreadId && turn.status === "completed") .map((turn) => turn.runAttemptId), ); const sameThread = input.runs.filter((run) => run.providerThreadId === providerThreadId); @@ -179,23 +227,29 @@ export function pendingRestartCancelledBackgroundWork(input: { candidate.id !== input.run.id && candidate.activeAttemptId !== null && candidate.status !== "rolled_back" && - deliveredAttemptIds.has(candidate.activeAttemptId) && + (deliveredAttemptIds.has(candidate.activeAttemptId) || + input.attempts.some( + (attempt) => attempt.runId === candidate.id && deliveredAttemptIds.has(attempt.id), + )) && !isCompaction(candidate) && (candidate.restartContinuationOfRunId === undefined || - isRestartNoteContinuation(candidate, input.runs, input.providerTurns)), + isRestartNoteContinuation(candidate, input.runs, input.providerTurns, input.attempts)), ); // A steer restarts this run on a new attempt: an earlier attempt that already // reached the provider delivered the note, so the replacement must not repeat it. - const alreadyDelivered = input.runAttemptIds.some( - (attemptId) => attemptId !== input.run.activeAttemptId && deliveredAttemptIds.has(attemptId), + const alreadyDelivered = input.attempts.some( + (attempt) => + attempt.runId === input.run.id && + attempt.id !== input.run.activeAttemptId && + deliveredAttemptIds.has(attempt.id), ); if (alreadyDelivered) return []; return sameThread .filter( (source) => - source.ordinal < input.run.ordinal && + runRanAfter(input.run, source) && (source.restartCancelledBackgroundWork?.length ?? 0) > 0 && - !prompted.some((later) => later.ordinal > source.ordinal), + !prompted.some((later) => runRanAfter(later, source)), ) .reduce>( (work, source) => diff --git a/apps/server/src/orchestration-v2/RestartContinuation.test.ts b/apps/server/src/orchestration-v2/RestartContinuation.test.ts index f43f4cd01666..e7cd4b9476a7 100644 --- a/apps/server/src/orchestration-v2/RestartContinuation.test.ts +++ b/apps/server/src/orchestration-v2/RestartContinuation.test.ts @@ -1,5 +1,6 @@ import { assert, it } from "@effect/vitest"; import { + MessageId, ProjectId, ProviderDriverKind, ProviderInstanceId, @@ -11,6 +12,7 @@ import { ThreadId, type OrchestrationV2ThreadProjection, } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as ServerSettings from "../serverSettings.ts"; @@ -201,6 +203,7 @@ it.effect("prompts a settled thread's continuation with the note of its lost wor Layer.merge( Layer.mock(ThreadManagementService.ThreadManagementService)({ getThreadRecords: () => Effect.succeed(projection), + recoverDelegatedTask: () => Effect.void, dispatch: (command) => { commands.push(command); return Effect.succeed({} as never); @@ -246,6 +249,7 @@ it.effect("does not continue a failed run that lost background work", () => Layer.merge( Layer.mock(ThreadManagementService.ThreadManagementService)({ getThreadRecords: () => Effect.succeed(projection), + recoverDelegatedTask: () => Effect.void, dispatch: (command) => { commands.push(command); return Effect.succeed({} as never); @@ -259,67 +263,65 @@ it.effect("does not continue a failed run that lost background work", () => }), ); -for (const [enabled, projectOverride] of [ +it.effect.each([ [false, undefined], [true, undefined], [false, true], [true, false], -] as const) { - it.effect( - `atomically records restart intent with cancellation when opt-in is ${enabled} and project override is ${projectOverride}`, - () => - Effect.gen(function* () { - let committed: Parameters[0] | undefined; - const recovery = yield* ProviderRuntimeRecovery.make.pipe( - Effect.provide( - Layer.mergeAll( - ServerSettings.layerTest({ - continueThreadsAfterServerUpdate: enabled, - projectSettingsOverrides: - projectOverride === undefined - ? {} - : { - [ProjectId.make("restart-project")]: { - continueThreadsAfterServerUpdate: projectOverride, - }, +] as const)( + "atomically records restart intent with cancellation when opt-in is %s and project override is %s", + ([enabled, projectOverride]) => + Effect.gen(function* () { + let committed: Parameters[0] | undefined; + const recovery = yield* ProviderRuntimeRecovery.make.pipe( + Effect.provide( + Layer.mergeAll( + ServerSettings.layerTest({ + continueThreadsAfterServerUpdate: enabled, + projectSettingsOverrides: + projectOverride === undefined + ? {} + : { + [ProjectId.make("restart-project")]: { + continueThreadsAfterServerUpdate: projectOverride, }, - }), - Layer.mock(ProjectionStore.ProjectionStoreV2)({ - getRecoveryThreadIds: () => Effect.succeed([threadId]), - getRuntimeRecoveryProjection: () => Effect.succeed(makeProjection()), - }), - Layer.mock(EventSink.EventSinkV2)({ - commitCommand: (input) => { - committed = input; - return Effect.succeed({ committed: true, cancelledEffectCount: 1 } as never); - }, - }), - IdAllocator.layer, - Layer.mock(EffectWorker.OrchestrationEffectWorkerV2)({ - runRecoveryOnce: Effect.succeed(false), - }), - Layer.mock(EffectOutbox.EffectOutboxV2)({ - reconcileAfterProcessLoss: Effect.succeed({ requeued: 0, cancelled: 0 }), - }), - ), - ), - ); - yield* recovery.reconcile("startup"); - assert.isDefined(committed); - assert.isTrue( - committed!.events.some( - (event) => event.type === "run.updated" && event.payload.status === "cancelled", + }, + }), + Layer.mock(ProjectionStore.ProjectionStoreV2)({ + getRecoveryThreadIds: () => Effect.succeed([threadId]), + getRuntimeRecoveryProjection: () => Effect.succeed(makeProjection()), + }), + Layer.mock(EventSink.EventSinkV2)({ + commitCommand: (input) => { + committed = input; + return Effect.succeed({ committed: true, cancelledEffectCount: 1 } as never); + }, + }), + IdAllocator.layer, + Layer.mock(EffectWorker.OrchestrationEffectWorkerV2)({ + runRecoveryOnce: Effect.succeed(false), + }), + Layer.mock(EffectOutbox.EffectOutboxV2)({ + reconcileAfterProcessLoss: Effect.succeed({ requeued: 0, cancelled: 0 }), + }), ), - ); - assert.lengthOf(committed!.effects, (projectOverride ?? enabled) ? 1 : 0); - if (projectOverride ?? enabled) - assert.deepEqual(committed!.effects[0]?.request, { - type: "provider-runtime.continue", - sourceRunId: runId, - }); - }), - ); -} + ), + ); + yield* recovery.reconcile("startup"); + assert.isDefined(committed); + assert.isTrue( + committed!.events.some( + (event) => event.type === "run.updated" && event.payload.status === "cancelled", + ), + ); + assert.lengthOf(committed!.effects, (projectOverride ?? enabled) ? 1 : 0); + if (projectOverride ?? enabled) + assert.deepEqual(committed!.effects[0]?.request, { + type: "provider-runtime.continue", + sourceRunId: runId, + }); + }), +); it.effect("does not duplicate delivery and yields to newer user work or opt-out", () => Effect.gen(function* () { @@ -330,6 +332,7 @@ it.effect("does not duplicate delivery and yields to newer user work or opt-out" >[0][] = []; const threads = Layer.mock(ThreadManagementService.ThreadManagementService)({ getThreadRecords: () => Effect.succeed(projection), + recoverDelegatedTask: () => Effect.void, dispatch: (command) => { commands.push(command); if (command.type === "message.dispatch") @@ -501,6 +504,7 @@ it.effect("does not cancel or resume a run that completes while shutdown intent ServerSettings.layerTest({ continueThreadsAfterServerUpdate: true }), Layer.mock(ThreadManagementService.ThreadManagementService)({ getThreadRecords: () => Effect.succeed(projection), + recoverDelegatedTask: () => Effect.void, dispatch: () => Effect.sync(() => { dispatched = true; @@ -513,3 +517,214 @@ it.effect("does not cancel or resume a run that completes while shutdown intent assert.isFalse(dispatched); }), ); + +const continuationTexts = (projection: OrchestrationV2ThreadProjection) => + Effect.gen(function* () { + const texts: Array = []; + yield* continueRestartedRun({ threadId, sourceRunId: runId }).pipe( + Effect.provide( + Layer.merge( + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: () => Effect.succeed(projection), + recoverDelegatedTask: () => Effect.void, + dispatch: (command) => { + if (command.type === "message.dispatch") texts.push(command.text); + return Effect.succeed({} as never); + }, + }), + ServerSettings.layerTest({ continueThreadsAfterServerUpdate: true }), + ), + ), + ); + return texts; + }); + +const cutMidTurn = (extra: Record = {}) => { + const base = makeProjection(); + return { + ...base, + runs: [ + { + ...base.runs[0]!, + status: "cancelled", + userMessageId: MessageId.make("message:user"), + ...extra, + }, + ], + providerTurns: [{ ...base.providerTurns[0]!, status: "cancelled" }], + } as unknown as OrchestrationV2ThreadProjection; +}; + +const queuedFollowUp = { + id: RunId.make("run:queued"), + ordinal: 2, + providerInstanceId: instanceId, + providerThreadId, + status: "queued", + queueHeld: true, +}; + +it.effect("continues a cut run past queued follow-ups, which stay held", () => + Effect.gen(function* () { + const live = makeProjection(); + assert.equal( + restartContinuationRun({ + ...live, + runs: [...live.runs, queuedFollowUp], + } as unknown as OrchestrationV2ThreadProjection)?.id, + runId, + ); + const cut = cutMidTurn(); + const texts = yield* continuationTexts({ + ...cut, + runs: [...cut.runs, queuedFollowUp], + } as unknown as OrchestrationV2ThreadProjection); + assert.deepEqual(texts, ["Continue where you left off."]); + }), +); + +it.effect("continues a resumed queued run that ran after an earlier continuation", () => + Effect.gen(function* () { + // The continuation (ordinal 3) ran ahead of the held queue, then the user + // resumed the queued run (ordinal 1 here) and the server restarted again. + const finishedContinuation = { + id: RunId.make("run:earlier-continuation"), + ordinal: 3, + providerInstanceId: instanceId, + providerThreadId, + status: "completed", + completedAt: DateTime.makeUnsafe("2026-10-03T10:00:00.000Z"), + }; + const live = makeProjection(); + assert.equal( + restartContinuationRun({ + ...live, + runs: [...live.runs, finishedContinuation], + } as unknown as OrchestrationV2ThreadProjection)?.id, + runId, + ); + const cut = cutMidTurn({ completedAt: DateTime.makeUnsafe("2026-10-03T10:05:00.000Z") }); + const texts = yield* continuationTexts({ + ...cut, + runs: [...cut.runs, finishedContinuation], + } as unknown as OrchestrationV2ThreadProjection); + assert.deepEqual(texts, ["Continue where you left off."]); + }), +); + +it.effect("does not continue a run the user asked to stop before the restart", () => + Effect.gen(function* () { + const texts = yield* continuationTexts({ + ...cutMidTurn(), + turnItems: [{ id: "turn-item:interrupt", runId, type: "run_interrupt_request" }], + } as unknown as OrchestrationV2ThreadProjection); + assert.deepEqual(texts, []); + }), +); + +it.effect("does not continue a cut /compact or /logout turn", () => + Effect.gen(function* () { + for (const text of ["/compact", " /LOGOUT "]) { + const texts = yield* continuationTexts({ + ...cutMidTurn(), + messages: [{ id: MessageId.make("message:user"), text, attachments: [] }], + } as unknown as OrchestrationV2ThreadProjection); + assert.deepEqual(texts, [], text); + } + const texts = yield* continuationTexts({ + ...cutMidTurn(), + messages: [{ id: MessageId.make("message:user"), text: "/compact later", attachments: [] }], + } as unknown as OrchestrationV2ThreadProjection); + assert.lengthOf(texts, 1); + }), +); + +it.effect("tells a turn cut mid-way about the background work it lost", () => + Effect.gen(function* () { + const texts = yield* continuationTexts( + cutMidTurn({ + restartCancelledBackgroundWork: [{ kind: "subagent", label: "Background reviewer" }], + }), + ); + assert.lengthOf(texts, 1); + assert.include(texts[0]!, "Background reviewer"); + assert.isTrue(texts[0]!.endsWith("Continue where you left off.")); + }), +); + +it.effect( + "carries the note forward when its continuation was cut before reaching the provider", + () => + Effect.gen(function* () { + const base = makeProjection(); + const original = { + ...base.runs[0]!, + id: RunId.make("run:original"), + status: "completed", + activeAttemptId: RunAttemptId.make("attempt:original"), + restartCancelledBackgroundWork: [{ kind: "shell", label: "sleep 25 && echo DONE" }], + }; + const projection = { + ...base, + runs: [ + original, + { + ...base.runs[0]!, + ordinal: 2, + status: "cancelled", + restartContinuationOfRunId: original.id, + }, + ], + // The original turn settled; the continuation's attempt never started one. + providerTurns: [ + { + ...base.providerTurns[0]!, + runAttemptId: original.activeAttemptId, + status: "completed", + }, + ], + } as unknown as OrchestrationV2ThreadProjection; + const texts = yield* continuationTexts(projection); + assert.lengthOf(texts, 1); + assert.include(texts[0]!, "sleep 25 && echo DONE"); + assert.notInclude(texts[0]!, "Continue where"); + }), +); + +it.effect("prepares later threads' continuations when one thread fails", () => + Effect.gen(function* () { + const brokenThreadId = ThreadId.make("thread:broken"); + const writes: Parameters[0][] = []; + const recovery = yield* ProviderRuntimeRecovery.make.pipe( + Effect.provide( + Layer.mergeAll( + ServerSettings.layerTest({ continueThreadsAfterServerUpdate: true }), + Layer.mock(ProjectionStore.ProjectionStoreV2)({ + getRecoveryThreadIds: () => Effect.succeed([brokenThreadId, threadId]), + getRuntimeRecoveryProjection: (id) => + id === brokenThreadId + ? Effect.fail( + new ProjectionStore.ProjectionStoreThreadNotFoundError({ threadId: id }), + ) + : Effect.succeed(makeProjection()), + }), + Layer.mock(EventSink.EventSinkV2)({ + writeWithEffects: (input) => + Effect.sync(() => { + writes.push(input); + return []; + }), + }), + IdAllocator.layer, + Layer.mock(EffectWorker.OrchestrationEffectWorkerV2)({}), + Layer.mock(EffectOutbox.EffectOutboxV2)({}), + ), + ), + ); + yield* recovery.prepareForShutdown; + assert.deepEqual( + writes.map((write) => write.effects[0]?.request), + [{ type: "provider-runtime.continue", sourceRunId: runId }], + ); + }), +); diff --git a/apps/server/src/orchestration-v2/RestartContinuation.ts b/apps/server/src/orchestration-v2/RestartContinuation.ts index 216b830de41e..f91d1a1e5f73 100644 --- a/apps/server/src/orchestration-v2/RestartContinuation.ts +++ b/apps/server/src/orchestration-v2/RestartContinuation.ts @@ -1,3 +1,4 @@ +import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { CommandId, @@ -11,12 +12,16 @@ import * as Effect from "effect/Effect"; import type { ProjectionRuntimeRecoveryState } from "./ProjectionStore.ts"; import * as ServerSettings from "../serverSettings.ts"; +import { isNativeMaintenanceCommand } from "./Orchestrator.ts"; import * as ThreadManagementService from "./ThreadManagementService.ts"; import { isRestartNoteSource, restartCancelledBackgroundWorkNote, + restartContinuationNote, } from "./RestartBackgroundNote.ts"; +const CONTINUE_PROMPT = "Continue where you left off."; + /** * The run a restart continuation resumes, if any: an unfinished root run, or a * settled one whose own provider thread lost background work in the restart @@ -30,8 +35,12 @@ export function restartContinuationRun( cancelledWorkProviderThreadIds: ReadonlySet = new Set(), ): OrchestrationV2Run | undefined { if (projection.thread.archivedAt !== null || projection.thread.deletedAt !== null) return; + // Queued runs never started; recovery holds them behind the cut run. const run = projection.runs.reduce( - (latest, candidate) => (!latest || candidate.ordinal > latest.ordinal ? candidate : latest), + (latest, candidate) => + candidate.status !== "queued" && (!latest || runRanAfter(candidate, latest)) + ? candidate + : latest, undefined, ); if (!run) return; @@ -98,7 +107,7 @@ export const continueRestartedRun = Effect.fn("RestartContinuation.continueResta const messageId = MessageId.make(`message:restart-continuation:${input.sourceRunId}`); const projection = yield* threads.getThreadRecords( input.threadId, - ["messages", "runs", "providerTurns"], + ["messages", "runs", "providerTurns", "attempts"], { messageIds: [messageId] }, ); if ( @@ -114,17 +123,54 @@ export const continueRestartedRun = Effect.fn("RestartContinuation.continueResta const noteSource = source !== undefined && isRestartNoteSource(source, projection.providerTurns); if (!source || (source.status !== "cancelled" && !noteSource)) return; - // A user submission after reconciliation takes precedence over an automatic prompt. - if (projection.runs.some((run) => run.ordinal > source.ordinal)) return; + // A user submission after reconciliation takes precedence over an automatic + // prompt. Queued runs never started and stay held behind this one. + if ( + projection.runs.some( + (run) => run.id !== source.id && run.status !== "queued" && runRanAfter(run, source), + ) + ) + return; if (projection.thread.providerInstanceId !== source.providerInstanceId) return; + const sourceRecords = yield* threads.getThreadRecords( + input.threadId, + ["messages", "turnItems"], + { + messageIds: [source.userMessageId], + turnItemRunIds: [source.id], + turnItemTypes: ["run_interrupt_request"], + }, + ); + // The user asked this run to stop before the restart cut it. + if ( + sourceRecords.turnItems.some( + (item) => item.runId === source.id && item.type === "run_interrupt_request", + ) + ) + return; + const sourceMessage = sourceRecords.messages.find( + (message) => message.id === source.userMessageId, + ); + if (sourceMessage !== undefined && isNativeMaintenanceCommand(sourceMessage)) return; + const note = restartContinuationNote( + source, + projection.runs, + projection.providerTurns, + projection.attempts, + ); + const noteText = + note.work.length === 0 ? undefined : restartCancelledBackgroundWorkNote(note.work); yield* threads.dispatch({ type: "message.dispatch", commandId: CommandId.make(`command:restart-continuation:${input.sourceRunId}`), threadId: input.threadId, messageId, - text: noteSource - ? restartCancelledBackgroundWorkNote(source.restartCancelledBackgroundWork ?? []) - : "Continue where you left off.", + text: + noteText === undefined + ? CONTINUE_PROMPT + : note.settled + ? noteText + : `${noteText}\n\n${CONTINUE_PROMPT}`, attachments: [], modelSelection: source.modelSelection, dispatchMode: { type: "start_immediately" }, @@ -133,4 +179,15 @@ export const continueRestartedRun = Effect.fn("RestartContinuation.continueResta restartContinuationOfRunId: input.sourceRunId, }); }, + // A delegated child this declined to continue still owes its parent a + // result. Once a continuation run exists this is a no-op; that run settles it. + (effect, input) => + effect.pipe( + Effect.andThen( + Effect.gen(function* () { + const threads = yield* ThreadManagementService.ThreadManagementService; + yield* threads.recoverDelegatedTask(input.threadId, input.sourceRunId); + }), + ), + ), ); diff --git a/apps/server/src/orchestration-v2/RunExecutionService.test.ts b/apps/server/src/orchestration-v2/RunExecutionService.test.ts index 4a70aa5e84b1..795a0bd97457 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.test.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.test.ts @@ -51,6 +51,7 @@ import { type ProviderAdapterV2Event, type ProviderAdapterV2SessionRuntime, } from "./ProviderAdapter.ts"; +import * as ProjectionStore from "./ProjectionStore.ts"; import * as ProviderEventIngestor from "./ProviderEventIngestor.ts"; import * as RunExecutionService from "./RunExecutionService.ts"; import * as RunFinalizationService from "./RunFinalizationService.ts"; @@ -671,6 +672,113 @@ it.effect("records a provider turn metric for a successful send", () => }).pipe(Effect.provide(RunExecutionTestLayer)), ); +it.effect("fails the run when its ownership check cannot be read before calling the provider", () => + Effect.gen(function* () { + const guardCalls = yield* Ref.make(0); + const providerStarts = yield* Ref.make(0); + const writes = yield* Ref.make>([]); + const threadId = ThreadId.make("thread:run-execution-start-guard-read"); + const runId = RunId.make("run:run-execution-start-guard-read"); + const attemptId = RunAttemptId.make("attempt:run-execution-start-guard-read"); + const providerInstanceId = ProviderInstanceId.make("codex"); + const testLayer = RunExecutionService.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(CheckpointService.CheckpointServiceV2)({ captureBaseline: () => Effect.void }), + Layer.mock(EventSink.EventSinkV2)({ + writeIfRunCurrent: (input) => + Ref.update(writes, (current) => [...current, ...input.events]).pipe( + Effect.as({ committed: true, storedEvents: [] }), + ), + }), + IdAllocator.layer, + Layer.mock(ProviderEventIngestor.ProviderEventIngestorV2)({ + ingestNormalized: () => Effect.succeed([]), + }), + ServerSettings.layerTest(), + ), + ), + ); + + yield* Effect.gen(function* () { + const runExecution = yield* RunExecutionService.RunExecutionServiceV2; + yield* runExecution.startRootRun({ + commandId: CommandId.make("command:run-execution-start-guard-read"), + appThread: { id: threadId } as OrchestrationV2AppThread, + providerSessionId: ProviderSessionId.make("session:run-execution-start-guard-read"), + session: { + events: Stream.never, + startTurn: () => Ref.update(providerStarts, (count) => count + 1), + } as unknown as ProviderAdapterV2SessionRuntime, + run: { id: runId, threadId, ordinal: 1, providerInstanceId } as OrchestrationV2Run, + rootNode: { + id: NodeId.make("node:run-execution-start-guard-read"), + } as OrchestrationV2ExecutionNode, + checkpointScope: { + id: CheckpointScopeId.make("checkpoint-scope:run-execution-start-guard-read"), + } as OrchestrationV2CheckpointScope, + providerThread: { + id: ProviderThreadId.make("provider-thread:run-execution-start-guard-read"), + driver, + } as OrchestrationV2ProviderThread, + attempt: { id: attemptId, providerTurnId: null } as OrchestrationV2RunAttempt, + attemptId, + providerTurnOrdinal: 1, + // The preparation check passes; the check right before the provider + // call cannot read the run. + shouldStartProviderTurn: () => + Ref.getAndUpdate(guardCalls, (calls) => calls + 1).pipe( + Effect.flatMap((calls) => + calls === 0 + ? Effect.succeed(true) + : Effect.fail( + new ProjectionStore.ProjectionStoreReadError({ + threadId, + cause: "database unavailable", + }), + ), + ), + ), + // The failure is settled by the guarded write, not by another read. + shouldFinalizeRun: () => + Effect.fail( + new ProjectionStore.ProjectionStoreReadError({ + threadId, + cause: "database unavailable", + }), + ), + message: { + messageId: MessageId.make("message:run-execution-start-guard-read"), + text: "Start while the store is down.", + attachments: [], + createdBy: "user", + creationSource: "web", + }, + modelSelection: { instanceId: providerInstanceId, model: "gpt-5.4" }, + runtimePolicy: { + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + approvalPolicy: "never", + sandboxPolicy: { + type: "readOnly", + access: { type: "fullAccess" }, + networkAccess: false, + }, + }, + }); + }).pipe(Effect.provide(testLayer)); + + assert.equal(yield* Ref.get(guardCalls), 2); + assert.equal(yield* Ref.get(providerStarts), 0); + const runUpdate = (yield* Ref.get(writes)).find((event) => event.type === "run.updated"); + assert.equal( + runUpdate?.type === "run.updated" ? runUpdate.payload.status : undefined, + "failed", + ); + }), +); + it.effect( "dispatches only attachment-free compact commands through the native compaction path", () => @@ -943,8 +1051,9 @@ it.effect("starts the provider when checkpoint baseline capture fails", () => }), ); -for (const scenario of ["failure", "interruption", "stale-attempt", "start-guard"] as const) { - it.effect(`handles ${scenario} before the provider turn starts`, () => +it.effect.each(["failure", "interruption", "stale-attempt", "start-guard"] as const)( + "handles %s before the provider turn starts", + (scenario) => Effect.gen(function* () { const threadId = ThreadId.make("thread:run-execution-settings-failure"); const runId = RunId.make("run:run-execution-settings-failure"); @@ -1111,8 +1220,7 @@ for (const scenario of ["failure", "interruption", "stale-attempt", "start-guard assert.equal(errorItem.payload.failure.message, "Run preparation failed."); } }), - ); -} +); it.effect("keeps ingesting owned child events after the root turn terminalizes", () => Effect.gen(function* () { @@ -3230,8 +3338,9 @@ it.effect("emits run_interrupt_result when hard-stop finalizes the active attemp }), ); -for (const status of ["completed", "interrupted", "cancelled", "failed"] as const) { - it.effect(`refreshes pull requests after the current root run ${status}`, () => +it.effect.each(["completed", "interrupted", "cancelled", "failed"] as const)( + "refreshes pull requests after the current root run %s", + (status) => Effect.gen(function* () { const { observed } = yield* captureRootRunTermination({ key: `pull-request-refresh:${status}`, @@ -3243,8 +3352,26 @@ for (const status of ["completed", "interrupted", "cancelled", "failed"] as cons "pull-requests-refreshed", ]); }), - ); -} +); + +it.effect("records a finished run as failed when its ownership check cannot be read", () => + Effect.gen(function* () { + const { observed } = yield* captureRootRunTermination({ + key: "finalize-guard-read-failure", + shouldFinalizeRun: () => + Effect.fail( + new ProjectionStore.ProjectionStoreReadError({ + threadId: ThreadId.make("thread:finalize-guard-read-failure"), + cause: "database unavailable", + }), + ), + events: (ids) => Stream.make(rootTerminalEvent(ids, "completed")), + }); + // The fallback settles through the guarded write instead of the same + // failing read, so the run does not stay running. + assert.include(observed, "run:failed"); + }), +); it.effect("does not refresh pull requests for auxiliary or stale provider terminals", () => Effect.gen(function* () { @@ -3335,7 +3462,7 @@ it.effect("keeps completed runs completed when pull request refresh fails", () = function captureRootRunTermination(input: { readonly key: string; - readonly shouldFinalizeRun: () => Effect.Effect; + readonly shouldFinalizeRun: () => Effect.Effect; readonly hasUnpairedRunInterruptRequest?: () => Effect.Effect; readonly seedOpenSubagent?: boolean; readonly events?: ( @@ -3359,6 +3486,17 @@ function captureRootRunTermination(input: { const ingestionDone = yield* Deferred.make(); const captureTurnItem = (payload: OrchestrationV2TurnItem) => Ref.update(writtenItems, (current) => [...current, payload]); + const captureFinalEvents = (events: ReadonlyArray) => + Effect.gen(function* () { + for (const event of events) { + if (event.type === "turn-item.updated") { + yield* captureTurnItem(event.payload); + } + if (event.type === "run.updated") { + yield* Ref.update(observed, (current) => [...current, `run:${event.payload.status}`]); + } + } + }); const testLayer = RunExecutionService.layer.pipe( Layer.provide( Layer.mergeAll( @@ -3373,22 +3511,11 @@ function captureRootRunTermination(input: { } return []; }), - writeWithEffects: (payload) => - Effect.gen(function* () { - for (const event of payload.events) { - if (event.type === "turn-item.updated") { - yield* captureTurnItem(event.payload); - } - if (event.type === "run.updated") { - yield* Ref.update(observed, (current) => [ - ...current, - `run:${event.payload.status}`, - ]); - } - } - return []; - }), - writeIfRunCurrent: () => Effect.succeed({ committed: true, storedEvents: [] }), + writeWithEffects: (payload) => captureFinalEvents(payload.events).pipe(Effect.as([])), + writeIfRunCurrent: (payload) => + captureFinalEvents(payload.events).pipe( + Effect.as({ committed: true, storedEvents: [] }), + ), }), IdAllocator.layer, Layer.mock(ProviderEventIngestor.ProviderEventIngestorV2)({ diff --git a/apps/server/src/orchestration-v2/RunExecutionService.ts b/apps/server/src/orchestration-v2/RunExecutionService.ts index f93c556738a5..57d9472649a1 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.ts @@ -28,6 +28,7 @@ import * as Context from "effect/Context"; import * as Cause from "effect/Cause"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; @@ -53,6 +54,7 @@ import type { } from "./ProviderAdapter.ts"; import { ProviderAdapterTurnStartError } from "./ProviderAdapter.ts"; import * as ProviderEventIngestor from "./ProviderEventIngestor.ts"; +import type { ProjectionStoreV2Error } from "./ProjectionStore.ts"; import { makeProviderFailure, makeProviderFailureTurnItem } from "./ProviderFailure.ts"; import * as RunFinalizationService from "./RunFinalizationService.ts"; @@ -517,8 +519,8 @@ export interface RunExecutionServiceV2StartRootRunInput { >; readonly relatedThreadIds?: ReadonlyArray; readonly relatedProviderThreadIds?: ReadonlyArray; - readonly shouldStartProviderTurn?: () => Effect.Effect; - readonly shouldFinalizeRun?: () => Effect.Effect; + readonly shouldStartProviderTurn?: () => Effect.Effect; + readonly shouldFinalizeRun?: () => Effect.Effect; readonly hasUnpairedRunInterruptRequest?: () => Effect.Effect; readonly message: ProviderAdapterV2TurnMessage; readonly modelSelection: ModelSelection; @@ -563,7 +565,7 @@ export const layer: Layer.Layer< readonly checkpointScope: OrchestrationV2CheckpointScope; readonly providerThread: OrchestrationV2ProviderThread; readonly attempt: OrchestrationV2RunAttempt; - readonly shouldFinalizeRun?: () => Effect.Effect; + readonly shouldFinalizeRun?: () => Effect.Effect; readonly hasUnpairedRunInterruptRequest?: () => Effect.Effect; readonly openRunOwnedSubagents?: OpenRunOwnedSubagentProjection; readonly terminal: ProviderTerminalEvent; @@ -1292,15 +1294,12 @@ export const layer: Layer.Layer< checkpointScope: input.checkpointScope, providerThread, attempt: input.attempt, - ...(input.shouldFinalizeRun === undefined - ? {} - : { shouldFinalizeRun: input.shouldFinalizeRun }), - ...(input.hasUnpairedRunInterruptRequest === undefined - ? {} - : { - hasUnpairedRunInterruptRequest: - input.hasUnpairedRunInterruptRequest, - }), + // The failure may be the ownership + // read itself, so check in the write. + writeIfRunCurrent: { + activeAttemptId: input.attempt.id, + expectedStatus: "running", + }, openRunOwnedSubagents: openSubagents, terminal: makeFailedTerminalEvent( makeProviderFailure({ @@ -1334,10 +1333,13 @@ export const layer: Layer.Layer< Effect.forkDetach, ); - if ( - input.shouldStartProviderTurn !== undefined && - !(yield* input.shouldStartProviderTurn()) - ) { + // A failed read fails the start below, so the run is recorded as + // failed instead of staying active with no provider turn. + const shouldStart = + input.shouldStartProviderTurn === undefined + ? Exit.succeed(true) + : yield* Effect.exit(input.shouldStartProviderTurn()); + if (Exit.isSuccess(shouldStart) && !shouldStart.value) { yield* Fiber.interrupt(providerEventFiber); return; } @@ -1379,7 +1381,7 @@ export const layer: Layer.Layer< }), )) : input.session.startTurn(turnInput); - yield* startTurn.pipe( + yield* Effect.andThen(shouldStart, startTurn).pipe( withMetrics({ counter: providerTurnsTotal, timer: providerTurnDuration, @@ -1407,20 +1409,18 @@ export const layer: Layer.Layer< checkpointScope: input.checkpointScope, providerThread, attempt: input.attempt, - ...(input.shouldFinalizeRun === undefined - ? {} - : { shouldFinalizeRun: input.shouldFinalizeRun }), - ...(input.hasUnpairedRunInterruptRequest === undefined - ? {} - : { - hasUnpairedRunInterruptRequest: - input.hasUnpairedRunInterruptRequest, - }), + // Checked in the write transaction, not by another + // read that can fail like the one before the start. + writeIfRunCurrent: { + activeAttemptId: input.attempt.id, + expectedStatus: "running", + }, openRunOwnedSubagents: openSubagents, terminal: makeFailedTerminalEvent( makeProviderFailure({ cause: Cause.squash(cause), - class: "provider_error", + // A failed ownership read is not the provider's fault. + class: Exit.isFailure(shouldStart) ? "unknown" : "provider_error", }), latestItemOrdinal + 1, ), diff --git a/apps/server/src/orchestration-v2/RunFinalizationService.test.ts b/apps/server/src/orchestration-v2/RunFinalizationService.test.ts index 31b0039876c2..a2e8f50901f4 100644 --- a/apps/server/src/orchestration-v2/RunFinalizationService.test.ts +++ b/apps/server/src/orchestration-v2/RunFinalizationService.test.ts @@ -50,80 +50,82 @@ it.effect("refreshes workspace after checkpoint capture without reading history" }).pipe(Effect.provide(layer)); }); -for (const scenario of [ - { - label: "discovers a new PR for the completed run's branch", - branch: "feature", - checkedOut: "feature", - activeRun: null, - expected: ["/repo"], - }, - { - label: "leaves the default branch's PR cache alone", - branch: "main", - checkedOut: "main", - activeRun: null, - expected: [], - }, - { - label: "does not refresh another thread's checkout", - branch: "feature", - checkedOut: "other", - activeRun: null, - expected: [], - }, - { - label: "does not refresh during a newer active run", - branch: "feature", - checkedOut: "feature", - activeRun: "newer-run", - expected: [], - }, -] as const) { - it.effect(scenario.label, () => { - const refreshed: string[] = []; - const threadId = ThreadId.make("thread-pr-refresh"); - const runId = RunId.make("completed-run"); - const layer = RunFinalization.observerLive.pipe( - Layer.provide( - Layer.mergeAll( - Layer.mock(WorkspaceEntries.WorkspaceEntries)({ refresh: () => Effect.void }), - Layer.mock(PullRequestService.PullRequestService)({ - refreshAfterTurn: () => Effect.void, - }), - Layer.mock(VcsStatusBroadcaster.VcsStatusBroadcaster)({ - refreshLocalStatus: () => - Effect.succeed({ - isRepo: true, - hasPrimaryRemote: true, - isDefaultRef: scenario.checkedOut === "main", - refName: scenario.checkedOut, - hasWorkingTreeChanges: false, - workingTree: { files: [], insertions: 0, deletions: 0 }, - }), - refreshStatus: () => - Effect.die("turn completion must preserve known PRs and lookup backoff"), - refreshPullRequestStatus: (cwd) => - Effect.sync(() => { - refreshed.push(cwd); - return null; - }), - }), - Layer.mock(ProjectionStore.ProjectionStoreV2)({ - getThreadShell: () => - Effect.succeed({ - id: threadId, - branch: scenario.branch, - activeRunId: scenario.activeRun === null ? null : RunId.make(scenario.activeRun), - } as OrchestrationV2ThreadShell), - }), - ), +it.effect.each( + ( + [ + { + label: "discovers a new PR for the completed run's branch", + branch: "feature", + checkedOut: "feature", + activeRun: null, + expected: ["/repo"], + }, + { + label: "leaves the default branch's PR cache alone", + branch: "main", + checkedOut: "main", + activeRun: null, + expected: [], + }, + { + label: "does not refresh another thread's checkout", + branch: "feature", + checkedOut: "other", + activeRun: null, + expected: [], + }, + { + label: "does not refresh during a newer active run", + branch: "feature", + checkedOut: "feature", + activeRun: "newer-run", + expected: [], + }, + ] as const + ).map((scenario) => [scenario.label, scenario] as const), +)("%s", ([, scenario]) => { + const refreshed: string[] = []; + const threadId = ThreadId.make("thread-pr-refresh"); + const runId = RunId.make("completed-run"); + const layer = RunFinalization.observerLive.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(WorkspaceEntries.WorkspaceEntries)({ refresh: () => Effect.void }), + Layer.mock(PullRequestService.PullRequestService)({ + refreshAfterTurn: () => Effect.void, + }), + Layer.mock(VcsStatusBroadcaster.VcsStatusBroadcaster)({ + refreshLocalStatus: () => + Effect.succeed({ + isRepo: true, + hasPrimaryRemote: true, + isDefaultRef: scenario.checkedOut === "main", + refName: scenario.checkedOut, + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + }), + refreshStatus: () => + Effect.die("turn completion must preserve known PRs and lookup backoff"), + refreshPullRequestStatus: (cwd) => + Effect.sync(() => { + refreshed.push(cwd); + return null; + }), + }), + Layer.mock(ProjectionStore.ProjectionStoreV2)({ + getThreadShell: () => + Effect.succeed({ + id: threadId, + branch: scenario.branch, + activeRunId: scenario.activeRun === null ? null : RunId.make(scenario.activeRun), + } as OrchestrationV2ThreadShell), + }), ), - ); - return Effect.gen(function* () { - const observer = yield* RunFinalization.RunFinalizationObserver; - yield* observer.refresh({ cwd: "/repo", threadId, runId }); - assert.deepEqual(refreshed, [...scenario.expected]); - }).pipe(Effect.provide(layer)); - }); -} + ), + ); + return Effect.gen(function* () { + const observer = yield* RunFinalization.RunFinalizationObserver; + yield* observer.refresh({ cwd: "/repo", threadId, runId }); + assert.deepEqual(refreshed, [...scenario.expected]); + }).pipe(Effect.provide(layer)); +}); diff --git a/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts b/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts index 414478b39061..012fb9c3c7ae 100644 --- a/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts +++ b/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts @@ -537,176 +537,174 @@ it.live("restarts selection as a new attempt and retries after old-session clean ), ); -for (const deadStatus of ["stopped", "error"] as const) { - it.live( - `restarts the live session on a model change when a newer ${deadStatus} session record exists`, - () => - Effect.scoped( - Effect.gen(function* () { - const name = `selection-restart-dead-${deadStatus}`; - const cwd = yield* checkpointWorkspace(name); - const threadId = ThreadId.make(`thread:${name}`); - const state = yield* Ref.make({ - activeTurn: null, - opened: [], - started: [], - closedSessionCount: 0, - // The dead record is seeded directly, so the adapter's one-shot - // simulated replacement-open failure is skipped. - failedReplacementOpen: true, - }); - const registry = ProviderAdapterRegistry.makeSingleLayer( - makeRestartAdapter(state, exclusiveCapabilities), - ); +it.live.each(["stopped", "error"] as const)( + "restarts the live session on a model change when a newer %s session record exists", + (deadStatus) => + Effect.scoped( + Effect.gen(function* () { + const name = `selection-restart-dead-${deadStatus}`; + const cwd = yield* checkpointWorkspace(name); + const threadId = ThreadId.make(`thread:${name}`); + const state = yield* Ref.make({ + activeTurn: null, + opened: [], + started: [], + closedSessionCount: 0, + // The dead record is seeded directly, so the adapter's one-shot + // simulated replacement-open failure is skipped. + failedReplacementOpen: true, + }); + const registry = ProviderAdapterRegistry.makeSingleLayer( + makeRestartAdapter(state, exclusiveCapabilities), + ); - const result = yield* Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; - const eventSink = yield* EventSink.EventSinkV2; - const dispatch = (step: string, modelSelection: ModelSelection) => - Effect.gen(function* () { - const terminal = yield* orchestrator.streamDomainEvents.pipe( - Stream.filter( - (event) => event.type === "run.updated" && event.payload.status === "completed", - ), - Stream.take(1), - Stream.runDrain, - Effect.forkScoped, - ); - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make(`${name}:${step}`), - threadId, - messageId: MessageId.make(`${name}:${step}`), - text: step, - attachments: [], - modelSelection, - dispatchMode: { type: "start_immediately" }, - }); - yield* worker.drain(); - yield* Fiber.join(terminal); - yield* worker.drain(); - return yield* orchestrator.getThreadProjection(threadId); + const result = yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const eventSink = yield* EventSink.EventSinkV2; + const dispatch = (step: string, modelSelection: ModelSelection) => + Effect.gen(function* () { + const terminal = yield* orchestrator.streamDomainEvents.pipe( + Stream.filter( + (event) => event.type === "run.updated" && event.payload.status === "completed", + ), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped, + ); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`${name}:${step}`), + threadId, + messageId: MessageId.make(`${name}:${step}`), + text: step, + attachments: [], + modelSelection, + dispatchMode: { type: "start_immediately" }, }); - yield* orchestrator.dispatch({ - type: "thread.create", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make(`${name}:create`), - threadId, - projectId: ProjectId.make(`project:${name}`), - title: name, - modelSelection: seedSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: cwd, + yield* worker.drain(); + yield* Fiber.join(terminal); + yield* worker.drain(); + return yield* orchestrator.getThreadProjection(threadId); }); - const first = yield* dispatch("first", seedSelection); - const liveSession = first.providerSessions.find( - (session) => session.status !== "stopped" && session.status !== "error", - ); - assert.isDefined(liveSession); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`${name}:create`), + threadId, + projectId: ProjectId.make(`project:${name}`), + title: name, + modelSelection: seedSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + }); + const first = yield* dispatch("first", seedSelection); + const liveSession = first.providerSessions.find( + (session) => session.status !== "stopped" && session.status !== "error", + ); + assert.isDefined(liveSession); - // Dead session records stay bound in the projection until - // detachment. A stale stopped/error record written after the - // live session attached must not hide it. - const deadAt = yield* DateTime.now; - const deadSession: OrchestrationV2ProviderSession = { - id: ProviderSessionId.make(`session:${name}:dead`), - driver, - providerInstanceId, - status: "ready", - cwd, - model: seedSelection.model, - capabilities: exclusiveCapabilities, - createdAt: deadAt, - updatedAt: deadAt, - lastError: null, - }; - yield* eventSink.write({ - events: [ - { - id: EventId.make(`event:${name}:dead-attached`), - type: "provider-session.attached", - threadId, - driver, - providerInstanceId, - occurredAt: deadAt, - payload: deadSession, - }, - { - id: EventId.make(`event:${name}:dead-updated`), - type: "provider-session.updated", - threadId, - driver, - providerInstanceId, - occurredAt: deadAt, - payload: { - ...deadSession, - status: deadStatus, - updatedAt: deadAt, - lastError: deadStatus === "error" ? "Simulated session failure." : null, - }, + // Dead session records stay bound in the projection until + // detachment. A stale stopped/error record written after the + // live session attached must not hide it. + const deadAt = yield* DateTime.now; + const deadSession: OrchestrationV2ProviderSession = { + id: ProviderSessionId.make(`session:${name}:dead`), + driver, + providerInstanceId, + status: "ready", + cwd, + model: seedSelection.model, + capabilities: exclusiveCapabilities, + createdAt: deadAt, + updatedAt: deadAt, + lastError: null, + }; + yield* eventSink.write({ + events: [ + { + id: EventId.make(`event:${name}:dead-attached`), + type: "provider-session.attached", + threadId, + driver, + providerInstanceId, + occurredAt: deadAt, + payload: deadSession, + }, + { + id: EventId.make(`event:${name}:dead-updated`), + type: "provider-session.updated", + threadId, + driver, + providerInstanceId, + occurredAt: deadAt, + payload: { + ...deadSession, + status: deadStatus, + updatedAt: deadAt, + lastError: deadStatus === "error" ? "Simulated session failure." : null, }, - ], - }); + }, + ], + }); - const switchCommandId = CommandId.make(`${name}:switch`); - yield* orchestrator.dispatch({ - type: "thread.model-selection.set", - commandId: switchCommandId, - threadId, - modelSelection: replacementSelection, - }); - yield* worker.drain(); - const storedSwitchEvents = yield* eventSink - .readByCommandId({ commandId: switchCommandId }) - .pipe(Stream.runCollect); - const detachedSessionIds = [...storedSwitchEvents].flatMap((stored) => - stored.event.type === "provider-session.detached" - ? [stored.event.payload.providerSessionId] - : [], - ); + const switchCommandId = CommandId.make(`${name}:switch`); + yield* orchestrator.dispatch({ + type: "thread.model-selection.set", + commandId: switchCommandId, + threadId, + modelSelection: replacementSelection, + }); + yield* worker.drain(); + const storedSwitchEvents = yield* eventSink + .readByCommandId({ commandId: switchCommandId }) + .pipe(Stream.runCollect); + const detachedSessionIds = [...storedSwitchEvents].flatMap((stored) => + stored.event.type === "provider-session.detached" + ? [stored.event.payload.providerSessionId] + : [], + ); - const second = yield* dispatch("second", replacementSelection); - return { - projection: second, - captured: yield* Ref.get(state), - liveSessionId: liveSession.id, - detachedSessionIds, - }; - }).pipe(Effect.provide(makeOrchestratorV2ReplayLayerWithRegistry({ name }, registry))); + const second = yield* dispatch("second", replacementSelection); + return { + projection: second, + captured: yield* Ref.get(state), + liveSessionId: liveSession.id, + detachedSessionIds, + }; + }).pipe(Effect.provide(makeOrchestratorV2ReplayLayerWithRegistry({ name }, registry))); - const { projection, captured } = result; - assert.lengthOf(projection.runs, 2); - assert.equal(projection.runs[1]?.modelSelection.model, replacementSelection.model); - // The exact released session is the older live one, never the newer - // dead record. - assert.deepEqual(result.detachedSessionIds, [result.liveSessionId]); - assert.equal(captured.closedSessionCount, 1); - assert.deepEqual( - captured.opened.map((open) => open.model), - [seedSelection.model, replacementSelection.model], - ); - assert.deepEqual( - captured.started.map((turn) => turn.model), - [seedSelection.model, replacementSelection.model], - ); - const servingSession = projection.providerSessions.find( - (session) => - session.id === - projection.providerThreads.find( - (providerThread) => providerThread.id === projection.thread.activeProviderThreadId, - )?.providerSessionId, - ); - assert.equal(servingSession?.model, replacementSelection.model); - }), - ), - ); -} + const { projection, captured } = result; + assert.lengthOf(projection.runs, 2); + assert.equal(projection.runs[1]?.modelSelection.model, replacementSelection.model); + // The exact released session is the older live one, never the newer + // dead record. + assert.deepEqual(result.detachedSessionIds, [result.liveSessionId]); + assert.equal(captured.closedSessionCount, 1); + assert.deepEqual( + captured.opened.map((open) => open.model), + [seedSelection.model, replacementSelection.model], + ); + assert.deepEqual( + captured.started.map((turn) => turn.model), + [seedSelection.model, replacementSelection.model], + ); + const servingSession = projection.providerSessions.find( + (session) => + session.id === + projection.providerThreads.find( + (providerThread) => providerThread.id === projection.thread.activeProviderThreadId, + )?.providerSessionId, + ); + assert.equal(servingSession?.model, replacementSelection.model); + }), + ), +); it.live("detaches the old provider session after an active provider handoff", () => Effect.scoped( @@ -822,8 +820,9 @@ it.live("detaches the old provider session after an active provider handoff", () ), ); -for (const mode of ["active", "idle", "selection-command", "pooled", "separate-home"] as const) { - it.live(`preserves native history only for compatible account switches (${mode})`, () => +it.live.each(["active", "idle", "selection-command", "pooled", "separate-home"] as const)( + "preserves native history only for compatible account switches (%s)", + (mode) => Effect.scoped( Effect.gen(function* () { const name = `shared-home-${mode}`; @@ -994,5 +993,4 @@ for (const mode of ["active", "idle", "selection-command", "pooled", "separate-h }).pipe(Effect.provide(makeOrchestratorV2ReplayLayerWithRegistry({ name }, registry))); }), ), - ); -} +); diff --git a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts index 6a360dd8b6b4..6a8dded63caa 100644 --- a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts +++ b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts @@ -37,371 +37,373 @@ const driver = ProviderDriverKind.make("codex"); const instanceId = ProviderInstanceId.make("codex"); const modelSelection = { instanceId, model: "test-model" }; -for (const mailbox of [false, true]) { - for (const timing of [ - "before delivery", - "during delivery", - "before dispatch", - "after delivery", - "without native steering", - "settled only", - ] as const) { - if (!mailbox && (timing === "without native steering" || timing === "settled only")) continue; - it.effect( - `delivers ${mailbox ? "mailbox notification" : "steering"} when completion wins ${timing}`, - () => - Effect.scoped( +it.effect.each( + [false, true] + .flatMap((mailbox) => + ( + [ + "before delivery", + "during delivery", + "before dispatch", + "after delivery", + "without native steering", + "settled only", + ] as const + ).map((timing) => ({ + mailbox, + timing, + label: mailbox ? "mailbox notification" : "steering", + })), + ) + .filter( + ({ mailbox, timing }) => + mailbox || (timing !== "without native steering" && timing !== "settled only"), + ), +)("delivers $label when completion wins $timing", ({ mailbox, timing }) => + Effect.scoped( + Effect.gen(function* () { + const cwd = yield* checkpointWorkspace(`steering-completion-${timing.replaceAll(" ", "-")}`); + const events = yield* Queue.unbounded(); + const started: ProviderAdapterV2TurnInput[] = []; + const steerEntered = yield* Deferred.make(); + const rejectSteer = yield* Deferred.make(); + let steerCalls = 0; + const capabilities = { + ...CodexProviderCapabilitiesV2, + turns: { + ...CodexProviderCapabilitiesV2.turns, + supportsActiveSteering: timing !== "without native steering", + }, + }; + const adapter: ProviderAdapterV2Shape = { + instanceId, + driver, + getCapabilities: () => Effect.succeed(capabilities), + planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" }), + openSession: (input) => Effect.gen(function* () { - const cwd = yield* checkpointWorkspace( - `steering-completion-${timing.replaceAll(" ", "-")}`, - ); - const events = yield* Queue.unbounded(); - const started: ProviderAdapterV2TurnInput[] = []; - const steerEntered = yield* Deferred.make(); - const rejectSteer = yield* Deferred.make(); - let steerCalls = 0; - const capabilities = { - ...CodexProviderCapabilitiesV2, - turns: { - ...CodexProviderCapabilitiesV2.turns, - supportsActiveSteering: timing !== "without native steering", - }, - }; - const adapter: ProviderAdapterV2Shape = { + const now = yield* DateTime.now; + return { instanceId, driver, - getCapabilities: () => Effect.succeed(capabilities), - planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" }), - openSession: (input) => + providerSessionId: input.providerSessionId, + providerSession: { + id: input.providerSessionId, + driver, + providerInstanceId: instanceId, + status: "ready", + cwd, + model: modelSelection.model, + capabilities, + createdAt: now, + updatedAt: now, + lastError: null, + }, + events: Stream.fromQueue(events), + ensureThread: ({ threadId }) => + Effect.succeed({ + id: ProviderThreadId.make(`provider-thread:${threadId}`), + driver, + providerInstanceId: instanceId, + providerSessionId: input.providerSessionId, + appThreadId: threadId, + ownerNodeId: null, + nativeThreadRef: { driver, nativeId: "native-thread", strength: "strong" }, + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + }), + resumeThread: ({ providerThread }) => Effect.succeed(providerThread), + startTurn: (turn) => Effect.gen(function* () { - const now = yield* DateTime.now; - return { - instanceId, + started.push(turn); + yield* Queue.offer(events, { + type: "provider_turn.updated", driver, - providerSessionId: input.providerSessionId, - providerSession: { - id: input.providerSessionId, - driver, - providerInstanceId: instanceId, - status: "ready", - cwd, - model: modelSelection.model, - capabilities, - createdAt: now, - updatedAt: now, - lastError: null, - }, - events: Stream.fromQueue(events), - ensureThread: ({ threadId }) => - Effect.succeed({ - id: ProviderThreadId.make(`provider-thread:${threadId}`), + providerTurn: { + id: ProviderTurnId.make(`provider-turn:${turn.attemptId}`), + providerThreadId: turn.providerThread.id, + nodeId: turn.rootNodeId, + runAttemptId: turn.attemptId, + nativeTurnRef: { driver, - providerInstanceId: instanceId, - providerSessionId: input.providerSessionId, - appThreadId: threadId, - ownerNodeId: null, - nativeThreadRef: { driver, nativeId: "native-thread", strength: "strong" }, - nativeConversationHeadRef: null, - status: "idle", - firstRunOrdinal: null, - lastRunOrdinal: null, - handoffIds: [], - forkedFrom: null, - createdAt: now, - updatedAt: now, - }), - resumeThread: ({ providerThread }) => Effect.succeed(providerThread), - startTurn: (turn) => - Effect.gen(function* () { - started.push(turn); - yield* Queue.offer(events, { - type: "provider_turn.updated", - driver, - providerTurn: { - id: ProviderTurnId.make(`provider-turn:${turn.attemptId}`), - providerThreadId: turn.providerThread.id, - nodeId: turn.rootNodeId, - runAttemptId: turn.attemptId, - nativeTurnRef: { - driver, - nativeId: `native:${turn.attemptId}`, - strength: "strong", - }, - ordinal: turn.providerTurnOrdinal, - status: "running", - startedAt: now, - completedAt: null, - }, - }); - }), - steerTurn: (turn) => - Effect.gen(function* () { - steerCalls += 1; - if (timing === "after delivery") return; - yield* Deferred.succeed(steerEntered, undefined); - yield* Deferred.await(rejectSteer); - return yield* new ProviderAdapterSteerRunError({ - driver, - providerThreadId: turn.providerThread.id, - providerTurnId: turn.providerTurnId, - cause: "turn already completed", - }); - }), - interruptTurn: () => Effect.void, - respondToRuntimeRequest: () => Effect.void, - readThreadSnapshot: () => Effect.die("unused"), - rollbackThread: () => Effect.die("unused"), - forkThread: () => Effect.die("unused"), - }; - }), - }; - yield* Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; - const threadId = ThreadId.make("thread:steering-completion"); - const watch = (predicate: (event: OrchestrationV2DomainEvent) => boolean) => - orchestrator.streamDomainEvents.pipe( - Stream.filter(predicate), - Stream.take(1), - Stream.runDrain, - Effect.forkScoped, - ); - yield* orchestrator.dispatch({ - type: "thread.create", - commandId: CommandId.make("create"), - threadId, - projectId: ProjectId.make("project:steering-completion"), - title: "Steering race", - modelSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: cwd, - createdBy: "user", - creationSource: "web", - }); - yield* orchestrator.dispatch({ - type: "message.dispatch", - commandId: CommandId.make("first"), - threadId, - messageId: MessageId.make("message:first"), - text: "first", - attachments: [], - dispatchMode: { type: "start_immediately" }, - createdBy: "user", - creationSource: "web", - }); - const running = yield* watch( - (event) => - event.type === "provider-turn.updated" && event.payload.status === "running", - ); - yield* worker.drain(); - yield* Fiber.join(running); - const first = started[0]!; - const messageId = MessageId.make("message:steering"); - const taskId = NodeId.make("task:mailbox"); - if (mailbox) { - const sink = yield* EventSink.EventSinkV2; - const current = yield* orchestrator.getThreadProjection(threadId); - const parentRun = current.runs.find((run) => run.id === first.runId)!; - const now = yield* DateTime.now; - yield* sink.write({ - events: [ - { - id: EventId.make("mailbox:cohort"), - type: "run.updated", - threadId, - runId: first.runId, - occurredAt: now, - payload: { - ...parentRun, - delegatedCompletion: { - disposition: "open", - nextGeneration: 2, - delivery: { generation: 1, messageId, taskIds: [taskId] }, - }, + nativeId: `native:${turn.attemptId}`, + strength: "strong", }, + ordinal: turn.providerTurnOrdinal, + status: "running", + startedAt: now, + completedAt: null, }, - { - id: EventId.make("mailbox:task"), - type: "subagent.updated", - threadId, - runId: first.runId, - nodeId: taskId, - occurredAt: now, - payload: { - id: taskId, - threadId, - runId: first.runId, - parentNodeId: first.rootNodeId, - origin: "app_owned", - createdBy: "agent", - driver, - providerInstanceId: instanceId, - providerThreadId: null, - childThreadId: null, - nativeTaskRef: null, - prompt: "Do background work", - title: "Background test", - model: null, - completionWake: timing === "settled only" ? "settled_only" : "always", - completionDelivery: { state: "claimed", observedByRunId: null }, - status: "completed", - result: "done", - startedAt: now, - completedAt: now, - updatedAt: now, - }, - }, - ], - }); - } - const dispatchSteer = orchestrator.dispatch({ - type: "message.dispatch", - commandId: CommandId.make("steer"), - threadId, - messageId, - text: "fix the popover", - attachments: [ - { - type: "image", - id: "steering-screenshot", - name: "image.png", - mimeType: "image/png", - sizeBytes: 10, - }, - ], - dispatchMode: mailbox - ? { type: "queue_after_active" } - : { type: "steer_active", targetRunId: first.runId }, - createdBy: mailbox ? "agent" : "user", - creationSource: mailbox ? "server" : "web", - ...(mailbox - ? { - delegatedCompletion: { - parentRunId: first.runId, - generation: 1, - taskIds: [taskId], - }, - } - : {}), - }); - if (timing !== "before dispatch") yield* dispatchSteer; - if (timing === "after delivery") yield* worker.drain(); - const delivery = - timing === "during delivery" ? yield* worker.runOnce.pipe(Effect.forkScoped) : null; - if (delivery !== null) yield* Deferred.await(steerEntered); - const completed = yield* watch( - (event) => - event.type === "run.updated" && - event.payload.id === first.runId && - event.payload.status === "waiting", - ); - const projection = yield* orchestrator.getThreadProjection(threadId); - const turn = projection.providerTurns[0]!; - yield* Queue.offer(events, { - type: "provider_turn.updated", - driver, - providerTurn: { ...turn, status: "completed", completedAt: yield* DateTime.now }, - }); - yield* Queue.offer(events, { - type: "turn.terminal", - driver, - providerThreadId: turn.providerThreadId, - providerTurnId: turn.id, - runOrdinal: first.runOrdinal, - status: "completed", - failure: null, - threadDisposition: "reusable", - }); - yield* Fiber.join(completed); - if (delivery !== null) { - yield* Deferred.succeed(rejectSteer, undefined); - yield* Fiber.join(delivery); - } - if (timing === "before dispatch") yield* dispatchSteer; - yield* worker.drain(); - yield* orchestrator.resumeQueuedRuns; - yield* worker.drain(); - if (timing === "after delivery") { - assert.equal(steerCalls, 1); - assert.equal(started.length, 1); - if (mailbox) { - const delivered = yield* orchestrator.getThreadProjection(threadId); - assert.equal(delivered.subagents[0]?.completionDelivery?.state, "delivered"); - assert.equal(delivered.subagents[0]?.completionDelivery?.observedByRunId, null); - assert.equal(delivered.runs[0]?.delegatedCompletion?.delivery, null); - assert.equal( - delivered.turnItems.filter((item) => item.type === "notification").length, - 1, - ); - yield* orchestrator.dispatch({ - type: "notification.delivery.accept", - commandId: CommandId.make("duplicate-acceptance"), - threadId, - messageId, }); - yield* worker.drain(); - assert.equal(steerCalls, 1); - yield* orchestrator.dispatch({ - type: "delegated_task.completion-delivery.acknowledge", - commandId: CommandId.make("read-result"), - parentThreadId: threadId, - taskId, - observedByRunId: first.runId, + }), + steerTurn: (turn) => + Effect.gen(function* () { + steerCalls += 1; + if (timing === "after delivery") return; + yield* Deferred.succeed(steerEntered, undefined); + yield* Deferred.await(rejectSteer); + return yield* new ProviderAdapterSteerRunError({ + driver, + providerThreadId: turn.providerThread.id, + providerTurnId: turn.providerTurnId, + cause: "turn already completed", }); - const acknowledged = yield* orchestrator.getThreadProjection(threadId); - assert.equal( - acknowledged.subagents[0]?.completionDelivery?.state, - "acknowledged", - ); - } - return; - } - assert.equal(started.length, 2); - assert.equal(started[1]?.message.messageId, messageId); - if (mailbox) assert.include(started[1]?.message.text ?? "", String(taskId)); - else assert.equal(started[1]?.message.text, "fix the popover"); - assert.deepEqual(started[1]?.message.attachments, [ - { - type: "image", - id: "steering-screenshot", - name: "image.png", - mimeType: "image/png", - sizeBytes: 10, + }), + interruptTurn: () => Effect.void, + respondToRuntimeRequest: () => Effect.void, + readThreadSnapshot: () => Effect.die("unused"), + rollbackThread: () => Effect.die("unused"), + forkThread: () => Effect.die("unused"), + }; + }), + }; + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = ThreadId.make("thread:steering-completion"); + const watch = (predicate: (event: OrchestrationV2DomainEvent) => boolean) => + orchestrator.streamDomainEvents.pipe( + Stream.filter(predicate), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped, + ); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create"), + threadId, + projectId: ProjectId.make("project:steering-completion"), + title: "Steering race", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + createdBy: "user", + creationSource: "web", + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("first"), + threadId, + messageId: MessageId.make("message:first"), + text: "first", + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + const running = yield* watch( + (event) => event.type === "provider-turn.updated" && event.payload.status === "running", + ); + yield* worker.drain(); + yield* Fiber.join(running); + const first = started[0]!; + const messageId = MessageId.make("message:steering"); + const taskId = NodeId.make("task:mailbox"); + if (mailbox) { + const sink = yield* EventSink.EventSinkV2; + const current = yield* orchestrator.getThreadProjection(threadId); + const parentRun = current.runs.find((run) => run.id === first.runId)!; + const now = yield* DateTime.now; + yield* sink.write({ + events: [ + { + id: EventId.make("mailbox:cohort"), + type: "run.updated", + threadId, + runId: first.runId, + occurredAt: now, + payload: { + ...parentRun, + delegatedCompletion: { + disposition: "open", + nextGeneration: 2, + delivery: { generation: 1, messageId, taskIds: [taskId] }, + }, + }, + }, + { + id: EventId.make("mailbox:task"), + type: "subagent.updated", + threadId, + runId: first.runId, + nodeId: taskId, + occurredAt: now, + payload: { + id: taskId, + threadId, + runId: first.runId, + parentNodeId: first.rootNodeId, + origin: "app_owned", + createdBy: "agent", + driver, + providerInstanceId: instanceId, + providerThreadId: null, + childThreadId: null, + nativeTaskRef: null, + prompt: "Do background work", + title: "Background test", + model: null, + completionWake: timing === "settled only" ? "settled_only" : "always", + completionDelivery: { state: "claimed", observedByRunId: null }, + status: "completed", + result: "done", + startedAt: now, + completedAt: now, + updatedAt: now, }, - ]); - assert.equal(steerCalls, timing === "during delivery" ? 1 : 0); - const final = yield* orchestrator.getThreadProjection(threadId); - assert.equal(final.messages.filter((message) => message.id === messageId).length, 1); - assert.equal( - final.messages.find((message) => message.id === messageId)?.runId, - started[1]?.runId, - ); - assert.equal( - final.turnItems.filter((item) => - mailbox - ? item.type === "notification" - : item.type === "user_message" && item.messageId === messageId, - ).length, - 1, - ); - yield* worker.drain(); - assert.equal(started.length, 2); - }).pipe( - Effect.provide( - makeOrchestratorV2ReplayLayerWithRegistry( - { name: `steering-completion-${timing}` }, - ProviderAdapterRegistry.makeSingleLayer(adapter), - { runEffectWorker: false }, - ), - ), + }, + ], + }); + } + const dispatchSteer = orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("steer"), + threadId, + messageId, + text: "fix the popover", + attachments: [ + { + type: "image", + id: "steering-screenshot", + name: "image.png", + mimeType: "image/png", + sizeBytes: 10, + }, + ], + dispatchMode: mailbox + ? { type: "queue_after_active" } + : { type: "steer_active", targetRunId: first.runId }, + createdBy: mailbox ? "agent" : "user", + creationSource: mailbox ? "server" : "web", + ...(mailbox + ? { + delegatedCompletion: { + parentRunId: first.runId, + generation: 1, + taskIds: [taskId], + }, + } + : {}), + }); + if (timing !== "before dispatch") yield* dispatchSteer; + if (timing === "after delivery") yield* worker.drain(); + const delivery = + timing === "during delivery" ? yield* worker.runOnce.pipe(Effect.forkScoped) : null; + if (delivery !== null) yield* Deferred.await(steerEntered); + const completed = yield* watch( + (event) => + event.type === "run.updated" && + event.payload.id === first.runId && + event.payload.status === "waiting", + ); + const projection = yield* orchestrator.getThreadProjection(threadId); + const turn = projection.providerTurns[0]!; + yield* Queue.offer(events, { + type: "provider_turn.updated", + driver, + providerTurn: { ...turn, status: "completed", completedAt: yield* DateTime.now }, + }); + yield* Queue.offer(events, { + type: "turn.terminal", + driver, + providerThreadId: turn.providerThreadId, + providerTurnId: turn.id, + runOrdinal: first.runOrdinal, + status: "completed", + failure: null, + threadDisposition: "reusable", + }); + yield* Fiber.join(completed); + if (delivery !== null) { + yield* Deferred.succeed(rejectSteer, undefined); + yield* Fiber.join(delivery); + } + if (timing === "before dispatch") yield* dispatchSteer; + yield* worker.drain(); + yield* orchestrator.resumeQueuedRuns; + yield* worker.drain(); + if (timing === "after delivery") { + assert.equal(steerCalls, 1); + assert.equal(started.length, 1); + if (mailbox) { + const delivered = yield* orchestrator.getThreadProjection(threadId); + assert.equal(delivered.subagents[0]?.completionDelivery?.state, "delivered"); + assert.equal(delivered.subagents[0]?.completionDelivery?.observedByRunId, null); + assert.equal(delivered.runs[0]?.delegatedCompletion?.delivery, null); + assert.equal( + delivered.turnItems.filter((item) => item.type === "notification").length, + 1, ); - }), + yield* orchestrator.dispatch({ + type: "notification.delivery.accept", + commandId: CommandId.make("duplicate-acceptance"), + threadId, + messageId, + }); + yield* worker.drain(); + assert.equal(steerCalls, 1); + yield* orchestrator.dispatch({ + type: "delegated_task.completion-delivery.acknowledge", + commandId: CommandId.make("read-result"), + parentThreadId: threadId, + taskId, + observedByRunId: first.runId, + }); + const acknowledged = yield* orchestrator.getThreadProjection(threadId); + assert.equal(acknowledged.subagents[0]?.completionDelivery?.state, "acknowledged"); + } + return; + } + assert.equal(started.length, 2); + assert.equal(started[1]?.message.messageId, messageId); + if (mailbox) assert.include(started[1]?.message.text ?? "", String(taskId)); + else assert.equal(started[1]?.message.text, "fix the popover"); + assert.deepEqual(started[1]?.message.attachments, [ + { + type: "image", + id: "steering-screenshot", + name: "image.png", + mimeType: "image/png", + sizeBytes: 10, + }, + ]); + assert.equal(steerCalls, timing === "during delivery" ? 1 : 0); + const final = yield* orchestrator.getThreadProjection(threadId); + assert.equal(final.messages.filter((message) => message.id === messageId).length, 1); + assert.equal( + final.messages.find((message) => message.id === messageId)?.runId, + started[1]?.runId, + ); + assert.equal( + final.turnItems.filter((item) => + mailbox + ? item.type === "notification" + : item.type === "user_message" && item.messageId === messageId, + ).length, + 1, + ); + yield* worker.drain(); + assert.equal(started.length, 2); + }).pipe( + Effect.provide( + makeOrchestratorV2ReplayLayerWithRegistry( + { name: `steering-completion-${timing}` }, + ProviderAdapterRegistry.makeSingleLayer(adapter), + { runEffectWorker: false }, + ), ), - ); - } -} + ); + }), + ), +); // Claude and Pi steer live but cannot interrupt-and-restart, so a changed // selection waits for the next turn as the thread's saved selection. diff --git a/apps/server/src/orchestration-v2/SubagentProjection.test.ts b/apps/server/src/orchestration-v2/SubagentProjection.test.ts index dac4345c92dc..23d180102cd3 100644 --- a/apps/server/src/orchestration-v2/SubagentProjection.test.ts +++ b/apps/server/src/orchestration-v2/SubagentProjection.test.ts @@ -166,6 +166,28 @@ function taskFixture() { return { projection: { ...projection, runs: [run] }, run }; } +it("reports the run that ended last, not the highest ordinal", () => { + const { projection, run } = taskFixture(); + // A restart continuation (ordinal 4) ran ahead of held queued runs 2 and 3. + const ended = (ordinal: number, completedAt: string): OrchestrationV2Run => ({ + ...run, + id: RunId.make(`run:${ordinal}`), + ordinal, + completedAt: DateTime.makeUnsafe(completedAt), + }); + const progress = delegatedTaskProgress({ + ...projection, + runs: [ + { ...run, status: "cancelled" }, + ended(4, "2026-07-24T10:00:00.000Z"), + ended(2, "2026-07-24T10:05:00.000Z"), + ended(3, "2026-07-24T10:10:00.000Z"), + ], + }); + assert.equal(progress.state, "result_available"); + assert.equal(progress.resultRun?.ordinal, 3); +}); + it("waits for nested work and retains the report across monitor acknowledgements", () => { const { projection, run } = taskFixture(); assert.equal( diff --git a/apps/server/src/orchestration-v2/SubagentProjection.ts b/apps/server/src/orchestration-v2/SubagentProjection.ts index 8b3abf78fe85..fa912dfda0ba 100644 --- a/apps/server/src/orchestration-v2/SubagentProjection.ts +++ b/apps/server/src/orchestration-v2/SubagentProjection.ts @@ -16,6 +16,7 @@ import type { ThreadId, TurnItemId, } from "@t3tools/contracts"; +import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; import * as DateTime from "effect/DateTime"; import { isOrchestrationV2WorkActive } from "@t3tools/contracts"; @@ -239,7 +240,7 @@ export function delegatedTaskProgress(projection: { projection.providerThreads.some((thread) => (thread.pendingBackgroundTasks?.length ?? 0) > 0); const resultRun = workRuns .filter((run) => terminal(run.status) && (run.startedAt !== null || run.ordinal === 1)) - .toSorted((a, b) => b.ordinal - a.ordinal)[0]; + .toSorted((a, b) => (runRanAfter(a, b) ? -1 : runRanAfter(b, a) ? 1 : 0))[0]; return { state: active || resultRun === undefined diff --git a/apps/server/src/orchestration-v2/TODO.md b/apps/server/src/orchestration-v2/TODO.md deleted file mode 100644 index a4ac59fc8c5f..000000000000 --- a/apps/server/src/orchestration-v2/TODO.md +++ /dev/null @@ -1,172 +0,0 @@ -# Orchestration V2 TODO - -This file tracks remaining backend-oriented V2 work. Architecture-level intent lives in -[`docs/orchestration-v2`](../../../../docs/orchestration-v2); this file is the local -implementation checklist for `apps/server/src/orchestration-v2`. - -## Current Baseline - -- V2 commands/events/projections are server-owned and replayable. -- Message dispatch supports start, steer, queue, queue reorder, promote queued message to steer, - interrupt, and provider switch command shapes. -- Checkpoint rollback is currently a full revert: filesystem checkpoint restore, provider thread - rollback, stale checkpoint marking, and later run/node `rolled_back` projection state. -- Codex same-provider fork is lazy: `thread.fork` records lineage and pending transfer, and first - dispatch resolves native Codex fork. Earlier source-point forks pass the source turn's native id - to `thread/fork` as `lastTurnId`; the fork-then-rollback fallback remains only for source turns - without a native turn reference and only on legacy-history threads. -- Codex provider conversation rollback supports only legacy-history threads. The adapter probes - `historyMode` and fails explicitly on paginated threads. Closing the gap means paging - `thread/turns/list` to find the first removed turn, then `thread/revert` with `beforeTurnId`. -- Native Codex fork-from-earlier-run has a real replay-backed test fixture: - `testkit/fixtures/thread_fork_native_prior_turn`. -- Merge-back from a fork into its source thread records a `merge_back` context transfer, materializes - a `fork_delta_summary` context handoff, and injects that handoff into the next source-thread run. - -## Projection Hardening - -Target docs: - -- [`core-graph-and-data-model.md`](../../../../docs/orchestration-v2/core-graph-and-data-model.md) -- [`thread-lineage-and-context-transfer.md`](../../../../docs/orchestration-v2/thread-lineage-and-context-transfer.md) -- [`testing-strategy.md`](../../../../docs/orchestration-v2/testing-strategy.md) - -TODO: - -- [x] Add end-to-end projection assertions for forked threads, not only provider-context behavior. - The fork projection should show inherited user-visible items through the source point plus an - explicit fork marker. -- [x] Decide and implement the projection representation for inherited fork history: - referenced lineage overlay vs physically duplicated projection items. Prefer referenced overlay - unless product requirements need independent editable history. -- [x] Add local visible projection assertions to non-fork replay fixtures: - `visibleTurnItems` mirrors canonical local `turnItems` for simple, multi-turn, queue, - steering, interrupt, rollback, planning, tool, and web-search fixtures. -- [ ] Ensure projections render rollback state consistently: - rolled-back runs/nodes/items, stale checkpoints, and active provider thread cursor. -- [ ] Add projection tests for interrupt edge cases: - provider emits chunks after interrupt requested, provider ignores/delays interrupt, interrupt is - immediately followed by queue/steer/start. -- [ ] Add projection tests for queue/steer flows: - queued message visibility, queue reorder, promote queued message to steer, and post-interrupt - dispatch visibility. - -## Context Transfer And Merge-Back - -Target docs: - -- [`thread-lineage-and-context-transfer.md`](../../../../docs/orchestration-v2/thread-lineage-and-context-transfer.md) -- [`provider-switching-and-context.md`](../../../../docs/orchestration-v2/provider-switching-and-context.md) - -TODO: - -- [x] Implement merge-back from a fork into its source thread: - source fork point as `basePoint`, fork latest stable point as `sourcePoint`, and source-thread - next user message as the consuming run. -- [x] Materialize delta context artifacts for merge-back and persist them as auditable - `ContextHandoff` records. -- [x] Add replay-backed integration coverage for merge-back: - fork, explore in fork, merge back, then assert source provider receives only the fork delta plus - the new user message. -- [ ] Implement portable context handoff for cross-provider forks and same-thread provider switches. -- [ ] Define explicit failure states for unresolved context transfers: - missing source point, unsupported provider capability, context too large, source projection not - stable, and adapter resolution failure. - -## Capability And Policy Model - -Target docs: - -- [`provider-capability-system.md`](../../../../docs/orchestration-v2/provider-capability-system.md) -- [`feature-lifecycles.md`](../../../../docs/orchestration-v2/feature-lifecycles.md) - -TODO: - -- [ ] Audit capability use against the documented nested `OrchestrationV2ProviderCapabilities` - shape and fill any behavior gaps. -- [ ] Keep orchestration decisions capability/policy driven. Shared runtime code should not branch on - provider name except at adapter registration/resolution boundaries. -- [ ] Make optional adapter methods impossible to call without going through a policy wrapper or a - capability-checked branch. -- [ ] Add typed capability/policy errors for: - native fork unavailable, rollback unavailable, steering unavailable, interrupt unavailable, - context handoff unavailable, and weak terminal status. -- [ ] Add capability-aware tests using real adapter/test layers at provider boundaries only. Do not mock - core orchestration policy. - -## Checkpoint And Rollback - -Target docs: - -- [`feature-lifecycles.md`](../../../../docs/orchestration-v2/feature-lifecycles.md) -- [`core-graph-and-data-model.md`](../../../../docs/orchestration-v2/core-graph-and-data-model.md) - -TODO: - -- [ ] Document current `checkpoint.rollback` command semantics in contracts/docs as full revert: - filesystem restore plus provider conversation rollback. -- [ ] Decide whether we need separate commands for conversation-only rollback and filesystem-only - restore. Do not add them until a real product flow needs them. -- [ ] Add tests for rollback after fork and rollback inside fork: - source rollback should not corrupt child lineage, and fork rollback should not mutate source - provider state. -- [ ] Validate rollback behavior when no active provider thread exists. Current behavior fails; decide - whether a filesystem-only rollback fallback is useful or too surprising. - -## Provider Switching And Second Adapter - -Target docs: - -- [`provider-switching-and-context.md`](../../../../docs/orchestration-v2/provider-switching-and-context.md) -- [`provider-capability-system.md`](../../../../docs/orchestration-v2/provider-capability-system.md) -- [`testing-strategy.md`](../../../../docs/orchestration-v2/testing-strategy.md) - -TODO: - -- [x] Add Claude replay fixture definitions for recorded fixtures, with TODO fixture slots that - reference the corresponding Codex transcripts and V2 docs. -- [ ] Promote Claude `simple` from the replay adapter to a real `ClaudeAdapterV2` replay test: - live and replay both consume an injected Agent SDK `query()` async iterable. -- [ ] Record Claude `multi_turn` from real usage and prove native session/thread continuation. -- [ ] Record Claude `tool_call_read_only` from real usage and prove read-only tool projection without - approvals. -- [ ] Keep unrecorded Claude fixtures out of `testkit/fixtures/index.ts` until each has a real - transcript and real adapter assertions. -- [ ] Add the second adapter once these vv0 Claude slices are stable enough to validate - cross-provider behavior. -- [ ] Use the second adapter to test: - cross-provider fork, same-thread provider switch, returning to a previous provider thread with - delta handoff, and unsupported capability fallback paths. - -## Subagents - -Target docs: - -- [`thread-lineage-and-context-transfer.md`](../../../../docs/orchestration-v2/thread-lineage-and-context-transfer.md) -- [`provider-capability-system.md`](../../../../docs/orchestration-v2/provider-capability-system.md) - -TODO: - -- [x] Add provider-native subagent observation for Codex and Claude replay-backed fixtures. -- [ ] Model native subagents and app-owned cross-provider subagents as related thread/subthread graph - entries with different creator/lifecycle policy. -- [ ] Preserve native provider subagent refs where available, but do not make the app graph depend on - provider-native ids as primary ids. -- [ ] Add deeper tests for subagent wait, close, result transfer, pending approvals, failed/stopped - tasks, and fork-from-subagent behavior. - -FOOD FOR THOUGHT: - -- Custom t3code tools/mcp_server that lets agents spawn subagents of other providers powered by the T3 Orchestrator - -## Debugger-Only Work - -- Keep debugger UI useful but temporary. Backend semantics and projections should be the source of - truth. -- [x] Wire the debugger thread tree to persisted V2 shell state through websocket RPC instead of - debugger-local thread discovery state. -- Continue exposing lightweight controls for new backend surfaces: - fork from response, new thread, full revert from user message checkpoint, merge-back, and provider - switch. -- Avoid adding mock backend behavior for debugger convenience. In-memory debugger state is fine for - layout affordances, but backend behavior must route through V2 commands/projections. diff --git a/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts b/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts index 63bc1550b205..1badbe8dccd7 100644 --- a/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts +++ b/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts @@ -25,7 +25,7 @@ import type { ProviderAdapterV2Shape } from "./ProviderAdapter.ts"; import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; import { makeOrchestratorV2ReplayLayerWithRegistry } from "./testkit/ProviderReplayHarness.ts"; -for (const driverName of ["codex", "claudeAgent"] as const) { +const forkCases = (["codex", "claudeAgent"] as const).flatMap((driverName) => { const driver = ProviderDriverKind.make(driverName); const instanceId = ProviderInstanceId.make(driver); const modelSelection = { instanceId, model: "test-model" }; @@ -45,199 +45,207 @@ for (const driverName of ["codex", "claudeAgent"] as const) { { runEffectWorker: false }, ); - for (const status of ["failed", "interrupted", "cancelled"] as const) { - it.effect(`bounds ${driver} context when continuing a fork of a ${status} run`, () => - Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const eventSink = yield* EventSink.EventSinkV2; - const now = yield* DateTime.now; - const sourceThreadId = ThreadId.make("fork-boundary-source"); - const targetThreadId = ThreadId.make("fork-boundary-target"); - const providerThreadId = ProviderThreadId.make("fork-boundary-native-thread"); - const sourceRunId = RunId.make("fork-boundary-source-run"); - const attemptId = RunAttemptId.make("interrupted-source-attempt"); - const providerTurnId = ProviderTurnId.make("interrupted-source-turn"); - const rootNodeId = NodeId.make("interrupted-source-root"); + return (["failed", "interrupted", "cancelled"] as const).map((status) => ({ + driver, + status, + instanceId, + modelSelection, + layer, + })); +}); - yield* orchestrator.dispatch({ - type: "thread.create", - commandId: CommandId.make("create-source"), - threadId: sourceThreadId, - projectId: ProjectId.make("fork-boundary-project"), - title: "Fork boundary source", - modelSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - createdBy: "user", - creationSource: "web", - }); +it.effect.each(forkCases)( + "bounds $driver context when continuing a fork of a $status run", + ({ driver, status, instanceId, modelSelection, layer }) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + const sourceThreadId = ThreadId.make("fork-boundary-source"); + const targetThreadId = ThreadId.make("fork-boundary-target"); + const providerThreadId = ProviderThreadId.make("fork-boundary-native-thread"); + const sourceRunId = RunId.make("fork-boundary-source-run"); + const attemptId = RunAttemptId.make("interrupted-source-attempt"); + const providerTurnId = ProviderTurnId.make("interrupted-source-turn"); + const rootNodeId = NodeId.make("interrupted-source-root"); + + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-source"), + threadId: sourceThreadId, + projectId: ProjectId.make("fork-boundary-project"), + title: "Fork boundary source", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + yield* eventSink.write({ + events: [ + { + id: EventId.make("source-provider-thread"), + type: "provider-thread.updated", + threadId: sourceThreadId, + occurredAt: now, + payload: { + id: providerThreadId, + driver, + providerInstanceId: instanceId, + providerSessionId: null, + appThreadId: sourceThreadId, + ownerNodeId: null, + nativeThreadRef: { driver, nativeId: "native-source", strength: "strong" }, + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: 1, + lastRunOrdinal: 2, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + }, + }, + ], + }); + // A cancelled queue entry has no provider turn; an early interruption + // can have a turn but no native assistant cursor. + if (status === "interrupted") { yield* eventSink.write({ events: [ { - id: EventId.make("source-provider-thread"), - type: "provider-thread.updated", + id: EventId.make("source-attempt"), + type: "run-attempt.created", threadId: sourceThreadId, + runId: sourceRunId, occurredAt: now, payload: { - id: providerThreadId, - driver, + id: attemptId, + runId: sourceRunId, + attemptOrdinal: 1, + rootNodeId, providerInstanceId: instanceId, - providerSessionId: null, - appThreadId: sourceThreadId, - ownerNodeId: null, - nativeThreadRef: { driver, nativeId: "native-source", strength: "strong" }, - nativeConversationHeadRef: null, - status: "idle", - firstRunOrdinal: 1, - lastRunOrdinal: 2, - handoffIds: [], - forkedFrom: null, - createdAt: now, - updatedAt: now, + providerThreadId, + providerTurnId, + reason: "initial", + status, + startedAt: now, + completedAt: now, + }, + }, + { + id: EventId.make("source-provider-turn"), + type: "provider-turn.updated", + threadId: sourceThreadId, + occurredAt: now, + payload: { + id: providerTurnId, + providerThreadId, + nodeId: rootNodeId, + runAttemptId: attemptId, + nativeTurnRef: { driver, nativeId: "turn:synthetic", strength: "weak" }, + ordinal: 1, + status, + startedAt: now, + completedAt: now, }, }, ], }); - // A cancelled queue entry has no provider turn; an early interruption - // can have a turn but no native assistant cursor. - if (status === "interrupted") { - yield* eventSink.write({ - events: [ - { - id: EventId.make("source-attempt"), - type: "run-attempt.created", - threadId: sourceThreadId, - runId: sourceRunId, - occurredAt: now, - payload: { - id: attemptId, - runId: sourceRunId, - attemptOrdinal: 1, - rootNodeId, - providerInstanceId: instanceId, - providerThreadId, - providerTurnId, - reason: "initial", - status, - startedAt: now, - completedAt: now, - }, - }, - { - id: EventId.make("source-provider-turn"), - type: "provider-turn.updated", - threadId: sourceThreadId, - occurredAt: now, - payload: { - id: providerTurnId, - providerThreadId, - nodeId: rootNodeId, - runAttemptId: attemptId, - nativeTurnRef: { driver, nativeId: "turn:synthetic", strength: "weak" }, - ordinal: 1, - status, - startedAt: now, - completedAt: now, - }, - }, - ], - }); - } - for (const ordinal of [1, 2]) { - const runId = ordinal === 1 ? sourceRunId : RunId.make("later-run"); - const messageId = MessageId.make(`source-message-${ordinal}`); - yield* eventSink.write({ - events: [ - { - id: EventId.make(`run-${ordinal}`), - type: "run.created", + } + for (const ordinal of [1, 2]) { + const runId = ordinal === 1 ? sourceRunId : RunId.make("later-run"); + const messageId = MessageId.make(`source-message-${ordinal}`); + yield* eventSink.write({ + events: [ + { + id: EventId.make(`run-${ordinal}`), + type: "run.created", + threadId: sourceThreadId, + runId, + occurredAt: now, + payload: { + id: runId, threadId: sourceThreadId, - runId, - occurredAt: now, - payload: { - id: runId, - threadId: sourceThreadId, - ordinal, - providerInstanceId: instanceId, - modelSelection, - providerThreadId, - userMessageId: messageId, - rootNodeId: null, - activeAttemptId: ordinal === 1 && status === "interrupted" ? attemptId : null, - status: ordinal === 1 ? status : "completed", - queuePosition: null, - requestedAt: now, - startedAt: now, - completedAt: now, - checkpointId: null, - contextHandoffId: null, - }, + ordinal, + providerInstanceId: instanceId, + modelSelection, + providerThreadId, + userMessageId: messageId, + rootNodeId: null, + activeAttemptId: ordinal === 1 && status === "interrupted" ? attemptId : null, + status: ordinal === 1 ? status : "completed", + queuePosition: null, + requestedAt: now, + startedAt: now, + completedAt: now, + checkpointId: null, + contextHandoffId: null, }, - { - id: EventId.make(`item-${ordinal}`), - type: "turn-item.updated", + }, + { + id: EventId.make(`item-${ordinal}`), + type: "turn-item.updated", + threadId: sourceThreadId, + runId, + occurredAt: now, + payload: { + id: TurnItemId.make(`item-${ordinal}`), threadId: sourceThreadId, runId, - occurredAt: now, - payload: { - id: TurnItemId.make(`item-${ordinal}`), - threadId: sourceThreadId, - runId, - nodeId: null, - providerThreadId, - providerTurnId: null, - nativeItemRef: null, - parentItemId: null, - ordinal, - status: "completed", - title: null, - startedAt: now, - completedAt: now, - updatedAt: now, - type: "user_message", - createdBy: "user", - creationSource: "web", - inputIntent: "turn_start", - messageId, - text: ordinal === 1 ? "INCLUDED_SOURCE_MARKER" : "EXCLUDED_LATER_MARKER", - attachments: [], - }, + nodeId: null, + providerThreadId, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal, + status: "completed", + title: null, + startedAt: now, + completedAt: now, + updatedAt: now, + type: "user_message", + createdBy: "user", + creationSource: "web", + inputIntent: "turn_start", + messageId, + text: ordinal === 1 ? "INCLUDED_SOURCE_MARKER" : "EXCLUDED_LATER_MARKER", + attachments: [], }, - ], - }); - } - yield* orchestrator.dispatch({ - type: "thread.fork", - commandId: CommandId.make("fork-source"), - sourceThreadId, - targetThreadId, - sourcePoint: { type: "run", runId: sourceRunId }, - createdBy: "user", - creationSource: "web", - }); - yield* orchestrator.dispatch({ - type: "message.dispatch", - commandId: CommandId.make("continue-fork"), - threadId: targetThreadId, - messageId: MessageId.make("continue-fork"), - text: "Continue from the selected source run", - attachments: [], - modelSelection, - dispatchMode: { type: "start_immediately" }, - createdBy: "user", - creationSource: "web", + }, + ], }); - const target = yield* orchestrator.getThreadProjection(targetThreadId); - assert.equal(target.contextTransfers[0]?.resolution?.strategy, "portable_context"); - assert.lengthOf(target.contextHandoffs, 1); - const handoff = target.contextHandoffs[0]!; - const history = handoff.history?.messages.map((message) => message.text).join("\n") ?? ""; - assert.include(`${handoff.summaryText}\n${history}`, "INCLUDED_SOURCE_MARKER"); - assert.notInclude(`${handoff.summaryText}\n${history}`, "EXCLUDED_LATER_MARKER"); - assert.isNull(target.providerThreads[0]?.forkedFrom); - }).pipe(Effect.provide(layer)), - ); - } -} + } + yield* orchestrator.dispatch({ + type: "thread.fork", + commandId: CommandId.make("fork-source"), + sourceThreadId, + targetThreadId, + sourcePoint: { type: "run", runId: sourceRunId }, + createdBy: "user", + creationSource: "web", + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("continue-fork"), + threadId: targetThreadId, + messageId: MessageId.make("continue-fork"), + text: "Continue from the selected source run", + attachments: [], + modelSelection, + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + const target = yield* orchestrator.getThreadProjection(targetThreadId); + assert.equal(target.contextTransfers[0]?.resolution?.strategy, "portable_context"); + assert.lengthOf(target.contextHandoffs, 1); + const handoff = target.contextHandoffs[0]!; + const history = handoff.history?.messages.map((message) => message.text).join("\n") ?? ""; + assert.include(`${handoff.summaryText}\n${history}`, "INCLUDED_SOURCE_MARKER"); + assert.notInclude(`${handoff.summaryText}\n${history}`, "EXCLUDED_LATER_MARKER"); + assert.isNull(target.providerThreads[0]?.forkedFrom); + }).pipe(Effect.provide(layer)), +); diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index 7b33d6f6ec91..10eb4ac9485e 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -271,64 +271,64 @@ function waitUntil(predicate: () => Effect.Effect): Effect. }); } -for (const target of ["new", "existing"] as const) { - for (const createdBy of ["user", "agent"] as const) { - it.effect( - `attributes ${createdBy}-configured automations in ${target} threads without changing their prompt`, - () => { - const harness = makeHarness(); - const scheduledTasks = ScheduledTasks.layer.pipe( - Layer.provide(Layer.mergeAll(harness.layer, NodeCrypto.layer, Scheduler.layer)), - ); - return Effect.gen(function* () { - const tasks = yield* ScheduledTasks.ScheduledTaskService; - const launches = yield* ThreadLaunch.ThreadLaunchService; - const threads = yield* ThreadManagement.ThreadManagementService; - const existing = - target === "existing" - ? yield* launches.launch( - launchInput({ command: "command:existing", thread: "thread:existing" }), - ) - : null; - const { task } = yield* tasks.upsert({ - id: ScheduledTaskId.make("scheduled-task:attribution"), - title: "Daily audit", - prompt: "Audit performance and crashes.", - enabled: false, - schedule: { type: "interval", everyMs: 60_000 }, - projectId, - threadId: existing?.threadId ?? null, - workspaceStrategy: { type: "root" }, - modelSelection, - runtimeMode: "full-access", - interactionMode: "default", - createdBy, - creationSource: createdBy === "agent" ? "mcp" : "web", - }); - const result = yield* tasks.runNow({ id: task.id }); - assert.equal(result.task.lastRunStatus, "succeeded"); - const projectThreads = yield* threads.listProjectThreads({ - projectId, - includeSubagents: false, - }); - const thread = - projectThreads.find((candidate) => candidate.id === existing?.threadId) ?? - projectThreads[0]; - assert.isDefined(thread); - const projection = yield* threads.getThreadProjection(thread!.id); - // Encoding the persisted projection exercises both message and turn-item wire schemas. - const wire = yield* encodeThreadProjection(projection); - assert.equal(wire.messages[0]?.text, task.prompt); - assert.equal(wire.messages[0]?.scheduledTaskId, task.id); - assert.equal(wire.messages[0]?.createdBy, createdBy); - const turnItem = wire.turnItems.find((item) => item.type === "user_message"); - assert.equal(turnItem?.text, task.prompt); - assert.equal(turnItem?.scheduledTaskId, task.id); - }).pipe(Effect.provide(Layer.mergeAll(harness.layer, scheduledTasks))); - }, +it.effect.each( + (["new", "existing"] as const).flatMap((target) => + (["user", "agent"] as const).map((createdBy) => ({ target, createdBy })), + ), +)( + "attributes $createdBy-configured automations in $target threads without changing their prompt", + ({ target, createdBy }) => { + const harness = makeHarness(); + const scheduledTasks = ScheduledTasks.layer.pipe( + Layer.provide(Layer.mergeAll(harness.layer, NodeCrypto.layer, Scheduler.layer)), ); - } -} + return Effect.gen(function* () { + const tasks = yield* ScheduledTasks.ScheduledTaskService; + const launches = yield* ThreadLaunch.ThreadLaunchService; + const threads = yield* ThreadManagement.ThreadManagementService; + const existing = + target === "existing" + ? yield* launches.launch( + launchInput({ command: "command:existing", thread: "thread:existing" }), + ) + : null; + const { task } = yield* tasks.upsert({ + id: ScheduledTaskId.make("scheduled-task:attribution"), + title: "Daily audit", + prompt: "Audit performance and crashes.", + enabled: false, + schedule: { type: "interval", everyMs: 60_000 }, + projectId, + threadId: existing?.threadId ?? null, + workspaceStrategy: { type: "root" }, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + createdBy, + creationSource: createdBy === "agent" ? "mcp" : "web", + }); + const result = yield* tasks.runNow({ id: task.id }); + assert.equal(result.task.lastRunStatus, "succeeded"); + const projectThreads = yield* threads.listProjectThreads({ + projectId, + includeSubagents: false, + }); + const thread = + projectThreads.find((candidate) => candidate.id === existing?.threadId) ?? + projectThreads[0]; + assert.isDefined(thread); + const projection = yield* threads.getThreadProjection(thread!.id); + // Encoding the persisted projection exercises both message and turn-item wire schemas. + const wire = yield* encodeThreadProjection(projection); + assert.equal(wire.messages[0]?.text, task.prompt); + assert.equal(wire.messages[0]?.scheduledTaskId, task.id); + assert.equal(wire.messages[0]?.createdBy, createdBy); + const turnItem = wire.turnItems.find((item) => item.type === "user_message"); + assert.equal(turnItem?.text, task.prompt); + assert.equal(turnItem?.scheduledTaskId, task.id); + }).pipe(Effect.provide(Layer.mergeAll(harness.layer, scheduledTasks))); + }, +); it.effect("retains automation and sender attribution while a message waits in the queue", () => { const harness = makeHarness({ runSetup: () => Effect.never }); @@ -603,8 +603,9 @@ it.effect( }), ); -for (const nativeCommand of [" /COMPACT ", "/logout"]) { - it.effect(`uses the first conversation message for a title after ${nativeCommand}`, () => +it.effect.each([" /COMPACT ", "/logout"])( + "uses the first conversation message for a title after %s", + (nativeCommand) => Effect.gen(function* () { const harness = makeHarness(); yield* Effect.gen(function* () { @@ -653,8 +654,7 @@ for (const nativeCommand of [" /COMPACT ", "/logout"]) { ); }).pipe(Effect.provide(harness.layer)); }), - ); -} +); it.effect("keeps native maintenance commands out of steering and restart messages", () => Effect.gen(function* () { @@ -1293,47 +1293,45 @@ it.effect("shows the fetch diagnosis when preparing a worktree from origin fails }).pipe(Effect.provide(harness.layer)); }); -for (const failurePoint of ["worktree", "setup"] as const) { - it.effect( - `${failurePoint} failure keeps the thread and message visible and emits failure items`, - () => - Effect.gen(function* () { - const failure = new Error(`${failurePoint} failed`); - const harness = makeHarness( - failurePoint === "worktree" - ? { createWorktree: () => Effect.fail(failure as never) } - : { runSetup: () => Effect.fail(failure as never) }, +it.effect.each(["worktree", "setup"] as const)( + "%s failure keeps the thread and message visible and emits failure items", + (failurePoint) => + Effect.gen(function* () { + const failure = new Error(`${failurePoint} failed`); + const harness = makeHarness( + failurePoint === "worktree" + ? { createWorktree: () => Effect.fail(failure as never) } + : { runSetup: () => Effect.fail(failure as never) }, + ); + yield* Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const threads = yield* ThreadManagement.ThreadManagementService; + const input = launchInput({ + command: `command:launch:${failurePoint}-failure`, + thread: `thread:launch:${failurePoint}-failure`, + message: `Fail during ${failurePoint}`, + workspace: { type: "worktree", baseRef: "main" }, + }); + const launched = yield* launches.launch(input); + yield* waitUntil(() => + threads + .getThreadProjection(launched.threadId) + .pipe(Effect.map((projection) => projection.runs[0]?.status === "failed")), ); - yield* Effect.gen(function* () { - const launches = yield* ThreadLaunch.ThreadLaunchService; - const threads = yield* ThreadManagement.ThreadManagementService; - const input = launchInput({ - command: `command:launch:${failurePoint}-failure`, - thread: `thread:launch:${failurePoint}-failure`, - message: `Fail during ${failurePoint}`, - workspace: { type: "worktree", baseRef: "main" }, - }); - const launched = yield* launches.launch(input); - yield* waitUntil(() => - threads - .getThreadProjection(launched.threadId) - .pipe(Effect.map((projection) => projection.runs[0]?.status === "failed")), - ); - const projection = yield* threads.getThreadProjection(launched.threadId); - assert.equal(projection.messages[0]?.text, `Fail during ${failurePoint}`); - assert.equal(projection.runs[0]?.status, "failed"); - assert.equal( - projection.turnItems.find((item) => item.type === "command_execution")?.status, - "failed", - ); - assert.match( - projection.turnItems.find((item) => item.type === "error")?.failure.message ?? "", - new RegExp(`${failurePoint} failed`, "u"), - ); - }).pipe(Effect.provide(harness.layer)); - }), - ); -} + const projection = yield* threads.getThreadProjection(launched.threadId); + assert.equal(projection.messages[0]?.text, `Fail during ${failurePoint}`); + assert.equal(projection.runs[0]?.status, "failed"); + assert.equal( + projection.turnItems.find((item) => item.type === "command_execution")?.status, + "failed", + ); + assert.match( + projection.turnItems.find((item) => item.type === "error")?.failure.message ?? "", + new RegExp(`${failurePoint} failed`, "u"), + ); + }).pipe(Effect.provide(harness.layer)); + }), +); it.effect("replays a server-allocated launch", () => Effect.gen(function* () { @@ -1989,61 +1987,59 @@ it.effect("cancels tracked setup before provider work is released", () => }), ); -for (const exitCode of [0, 1]) { - it.effect(`releases an async setup before its completion with exit ${exitCode}`, () => - Effect.gen(function* () { - const completion = yield* Deferred.make<{ exitCode: number | null; durationMs: number }>(); - const harness = makeHarness({ - runSetup: () => - Effect.succeed({ - status: "started" as const, - async: true, - scriptId: "setup", - scriptName: "Setup", - scriptCommand: "vp install", - terminalId: "setup", - cwd: "/repo-worktrees/feature", - completion: Deferred.await(completion), - }), - }); - yield* Effect.gen(function* () { - const launches = yield* ThreadLaunch.ThreadLaunchService; - const threads = yield* ThreadManagement.ThreadManagementService; - const tracker = yield* WorktreeSetupTracker.WorktreeSetupTracker; - const launched = yield* launches.launch( - launchInput({ - command: `command:launch:async-${exitCode}`, - thread: `thread:launch:async-${exitCode}`, - message: "Start during setup", - workspace: { type: "worktree", baseRef: "main" }, - }), - ); - yield* tracker.stream(launched.threadId).pipe( - Stream.filter( - (snapshot) => - snapshot?.stages.some((stage) => stage.id === "agent" && stage.status === "done") === - true, - ), - Stream.runHead, - ); - const running = yield* threads.getThreadProjection(launched.threadId); - assert.equal(running.runs[0]?.status, "starting"); - assert.equal((yield* tracker.get(launched.threadId))?.phase, "running"); - yield* Deferred.succeed(completion, { exitCode, durationMs: 1 }); - yield* tracker.stream(launched.threadId).pipe( - Stream.filter((snapshot) => snapshot?.phase === "done"), - Stream.runHead, - ); - const settled = yield* tracker.get(launched.threadId); - assert.equal( - settled?.stages.find((stage) => stage.id === "setup-script")?.status, - exitCode === 0 ? "done" : "failed", - ); - assert.equal( - (yield* threads.getThreadProjection(launched.threadId)).runs[0]?.status, - "starting", - ); - }).pipe(Effect.provide(harness.layer)); - }), - ); -} +it.effect.each([0, 1])("releases an async setup before its completion with exit %s", (exitCode) => + Effect.gen(function* () { + const completion = yield* Deferred.make<{ exitCode: number | null; durationMs: number }>(); + const harness = makeHarness({ + runSetup: () => + Effect.succeed({ + status: "started" as const, + async: true, + scriptId: "setup", + scriptName: "Setup", + scriptCommand: "vp install", + terminalId: "setup", + cwd: "/repo-worktrees/feature", + completion: Deferred.await(completion), + }), + }); + yield* Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const threads = yield* ThreadManagement.ThreadManagementService; + const tracker = yield* WorktreeSetupTracker.WorktreeSetupTracker; + const launched = yield* launches.launch( + launchInput({ + command: `command:launch:async-${exitCode}`, + thread: `thread:launch:async-${exitCode}`, + message: "Start during setup", + workspace: { type: "worktree", baseRef: "main" }, + }), + ); + yield* tracker.stream(launched.threadId).pipe( + Stream.filter( + (snapshot) => + snapshot?.stages.some((stage) => stage.id === "agent" && stage.status === "done") === + true, + ), + Stream.runHead, + ); + const running = yield* threads.getThreadProjection(launched.threadId); + assert.equal(running.runs[0]?.status, "starting"); + assert.equal((yield* tracker.get(launched.threadId))?.phase, "running"); + yield* Deferred.succeed(completion, { exitCode, durationMs: 1 }); + yield* tracker.stream(launched.threadId).pipe( + Stream.filter((snapshot) => snapshot?.phase === "done"), + Stream.runHead, + ); + const settled = yield* tracker.get(launched.threadId); + assert.equal( + settled?.stages.find((stage) => stage.id === "setup-script")?.status, + exitCode === 0 ? "done" : "failed", + ); + assert.equal( + (yield* threads.getThreadProjection(launched.threadId)).runs[0]?.status, + "starting", + ); + }).pipe(Effect.provide(harness.layer)); + }), +); diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts index 1df1c242700f..7ff557c338dd 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts @@ -344,7 +344,7 @@ it.effect("preserves failed legacy materialization when reading checkpoint conte }).pipe(Effect.provide(testLayer)); }); -for (const scenario of [ +it.effect.each([ { finalStatus: "completed" as const, timedOut: false }, { finalStatus: "failed" as const, timedOut: false }, { finalStatus: "cancelled" as const, timedOut: false }, @@ -352,72 +352,70 @@ for (const scenario of [ { finalStatus: "rolled_back" as const, timedOut: false }, { finalStatus: "running" as const, timedOut: true }, { finalStatus: "missing" as const }, -]) { - it.effect(`waitForThread timeout final read when selected run is ${scenario.finalStatus}`, () => - Effect.gen(function* () { - const projectId = ProjectId.make("project:thread-management:wait-timeout"); - const threadId = ThreadId.make("thread:thread-management:wait-timeout"); - const runId = RunId.make("run:thread-management:wait-timeout"); - const loopRead = yield* Deferred.make(); - let reads = 0; - const projection = (status: OrchestrationV2Run["status"] | "missing") => - ({ - thread: { id: threadId, projectId, deletedAt: null }, - runs: status === "missing" ? [] : [{ id: runId, status }], - }) as unknown as OrchestrationV2ThreadProjection; - const testLayer = ThreadManagementService.layer.pipe( - Layer.provide( - Layer.mock(Orchestrator.OrchestratorV2)({ - getThreadRecords: () => - Effect.gen(function* () { - reads += 1; - if (reads === 1) { - return projection("running"); - } - if (reads === 2) { - // Park inside the wait loop so the timeout path runs while a - // final projection read can still observe a terminal run. - yield* Deferred.succeed(loopRead, undefined); - return yield* Effect.never; - } - return projection(scenario.finalStatus); - }), - }), - ), - ); - const service = yield* ThreadManagementService.ThreadManagementService.pipe( - Effect.provide(testLayer), - ); - const fiber = yield* service - .waitForThread({ - projectId, +])("waitForThread timeout final read when selected run is $finalStatus", (scenario) => + Effect.gen(function* () { + const projectId = ProjectId.make("project:thread-management:wait-timeout"); + const threadId = ThreadId.make("thread:thread-management:wait-timeout"); + const runId = RunId.make("run:thread-management:wait-timeout"); + const loopRead = yield* Deferred.make(); + let reads = 0; + const projection = (status: OrchestrationV2Run["status"] | "missing") => + ({ + thread: { id: threadId, projectId, deletedAt: null }, + runs: status === "missing" ? [] : [{ id: runId, status }], + }) as unknown as OrchestrationV2ThreadProjection; + const testLayer = ThreadManagementService.layer.pipe( + Layer.provide( + Layer.mock(Orchestrator.OrchestratorV2)({ + getThreadRecords: () => + Effect.gen(function* () { + reads += 1; + if (reads === 1) { + return projection("running"); + } + if (reads === 2) { + // Park inside the wait loop so the timeout path runs while a + // final projection read can still observe a terminal run. + yield* Deferred.succeed(loopRead, undefined); + return yield* Effect.never; + } + return projection(scenario.finalStatus); + }), + }), + ), + ); + const service = yield* ThreadManagementService.ThreadManagementService.pipe( + Effect.provide(testLayer), + ); + const fiber = yield* service + .waitForThread({ + projectId, + threadId, + runId, + timeoutMs: 1, + }) + .pipe(Effect.result, Effect.forkChild); + yield* Deferred.await(loopRead); + yield* TestClock.adjust(Duration.millis(1)); + const result = yield* Fiber.join(fiber); + + if (scenario.finalStatus === "missing") { + expect(result._tag).toBe("Failure"); + expect(result).toMatchObject({ + failure: expect.any(ThreadManagementService.ThreadManagementRunNotFoundError), + }); + expect(result).toMatchObject({ + failure: { threadId, runId }, + }); + } else { + expect(result._tag).toBe("Success"); + expect(result).toMatchObject({ + success: { threadId, - runId, - timeoutMs: 1, - }) - .pipe(Effect.result, Effect.forkChild); - yield* Deferred.await(loopRead); - yield* TestClock.adjust(Duration.millis(1)); - const result = yield* Fiber.join(fiber); - - if (scenario.finalStatus === "missing") { - expect(result._tag).toBe("Failure"); - expect(result).toMatchObject({ - failure: expect.any(ThreadManagementService.ThreadManagementRunNotFoundError), - }); - expect(result).toMatchObject({ - failure: { threadId, runId }, - }); - } else { - expect(result._tag).toBe("Success"); - expect(result).toMatchObject({ - success: { - threadId, - timedOut: scenario.timedOut, - run: { id: runId, status: scenario.finalStatus }, - }, - }); - } - }), - ); -} + timedOut: scenario.timedOut, + run: { id: runId, status: scenario.finalStatus }, + }, + }); + } + }), +); diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index b489f6890967..3bb397728060 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -313,6 +313,8 @@ export interface ThreadManagementServiceShape { input: ThreadManagementInterruptInput, ) => Effect.Effect; readonly getThreadEventSequence: Orchestrator.OrchestratorV2["Service"]["getThreadEventSequence"]; + readonly recoverDelegatedTask: Orchestrator.OrchestratorV2["Service"]["recoverDelegatedTask"]; + readonly delegatedTaskResultPending: Orchestrator.OrchestratorV2["Service"]["delegatedTaskResultPending"]; readonly streamStoredEvents: Orchestrator.OrchestratorV2["Service"]["streamStoredEvents"]; readonly streamStoredEventsFrom: Orchestrator.OrchestratorV2["Service"]["streamStoredEventsFrom"]; readonly streamDomainEvents: Orchestrator.OrchestratorV2["Service"]["streamDomainEvents"]; @@ -736,6 +738,8 @@ const make = Effect.gen(function* () { waitForThread, interruptThread, getThreadEventSequence: orchestrator.getThreadEventSequence, + recoverDelegatedTask: orchestrator.recoverDelegatedTask, + delegatedTaskResultPending: orchestrator.delegatedTaskResultPending, streamStoredEvents: orchestrator.streamStoredEvents, streamStoredEventsFrom: orchestrator.streamStoredEventsFrom, streamDomainEvents: orchestrator.streamDomainEvents, diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts index 03933d9d0048..ef12204541b6 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts @@ -46,7 +46,12 @@ function at(offsetMs: number): DateTime.Utc { return DateTime.makeUnsafe(NOW_MS + offsetMs); } -function shell(overrides: Partial = {}): OrchestrationV2ThreadShell { +type SettlementShell = OrchestrationV2ThreadShell & + Pick; + +// A fixture's user message is one the user wrote unless the test sets +// latestUserAuthoredMessageAt on its own. +function shell(overrides: Partial = {}): SettlementShell { return { id: ThreadId.make("thread-1"), projectId: ProjectId.make("project-1"), @@ -83,6 +88,7 @@ function shell(overrides: Partial = {}): Orchestrati latestRunStartedAt: null, latestRunCompletedAt: null, latestUserMessageAt: null, + latestUserAuthoredMessageAt: overrides.latestUserMessageAt ?? null, createdAt: at(-30 * DAY_MS), updatedAt: at(-10 * DAY_MS), archivedAt: null, @@ -138,12 +144,25 @@ describe("isAutoSettlementCandidate", () => { ).toBe(false); expect( ThreadSettlementService.isAutoSettlementCandidate( - shell({ pendingBackgroundTasks: [{ label: "task" }] as never }), + shell({ pendingBackgroundTasks: [{ taskId: "review", kind: "subagent" }] }), NOW_MS, ), ).toBe(false); }); + it("settles a thread whose only background work is a command left running", () => { + expect( + ThreadSettlementService.isAutoSettlementCandidate( + shell({ + pendingBackgroundTasks: [ + { taskId: "dev", kind: "command", description: "vp run dev --share" }, + ], + }), + NOW_MS, + ), + ).toBe(true); + }); + it("keeps snoozed threads parked until they wake early on error or completion", () => { const snoozed = shell({ snoozedUntil: at(60 * 60 * 1_000), @@ -292,6 +311,31 @@ describe("resolveAutoSettlementAt", () => { ).toEqual(shell().createdAt); }); + it("settles on merge after agent-started runs, but not after the user writes again", () => { + // A background command stopped after the merge and its notification + // started a run. Only the user's own messages hold a merged thread open. + const woken = shell({ + latestUserMessageAt: at(-30 * 60 * 1_000), + latestUserAuthoredMessageAt: at(-2 * 60 * 60 * 1_000), + latestRunRequestedAt: at(-30 * 60 * 1_000), + latestRunCompletedAt: at(-29 * 60 * 1_000), + }); + const input = { + thread: woken, + pullRequest: { state: "merged" as const, mergedAt: DateTime.formatIso(at(-60 * 60 * 1_000)) }, + nowMs: NOW_MS, + autoSettleAfterDays: null, + autoSettleOnMerge: true, + }; + expect(ThreadSettlementService.resolveAutoSettlementAt(input)).toEqual(at(-29 * 60 * 1_000)); + expect( + ThreadSettlementService.resolveAutoSettlementAt({ + ...input, + thread: { ...woken, latestUserAuthoredMessageAt: at(-30 * 60 * 1_000) }, + }), + ).toBeNull(); + }); + it("settles inactive threads even when their pull request remains open", () => { const input = { thread: shell({ latestUserMessageAt: at(-30 * DAY_MS) }), @@ -370,10 +414,7 @@ function makeProject( }; } -function makeThread( - id: string, - overrides: Partial = {}, -): OrchestrationV2ThreadShell { +function makeThread(id: string, overrides: Partial = {}): SettlementShell { return shell({ id: ThreadId.make(id), projectId: PROJECT_ID, @@ -385,10 +426,14 @@ function makeThread( }); } +type SettlementSnapshot = Omit & { + readonly threads: ReadonlyArray; +}; + function makeSnapshot( - threads: ReadonlyArray, + threads: ReadonlyArray, projects: ReadonlyArray = [makeProject()], -): OrchestrationV2ShellSnapshot { +): SettlementSnapshot { return { schemaVersion: 1, snapshotSequence: 1, @@ -435,7 +480,7 @@ function makeBranchPullRequest(state: "open" | "closed" | "merged") { } interface HarnessOptions { - readonly snapshot: OrchestrationV2ShellSnapshot; + readonly snapshot: SettlementSnapshot; readonly settings?: ContractServerSettings; readonly branchPullRequest?: GitManager.GitManager["Service"]["branchPullRequest"]; readonly pullRequestSummary?: PullRequestService.PullRequestService["Service"]["summary"]; diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.ts index 74c9de8d6b37..7c64ce02804a 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.ts @@ -1,3 +1,4 @@ +import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; import { @@ -105,10 +106,15 @@ export function threadHasQueuedTurnStart( ].every((value) => value === null || value < messageAtMs); } +/** + * A merged or closed pull request settles the thread unless the user wrote to + * it afterwards. Runs that background work, a PR watch, or another agent + * started do not count, so they cannot hold a merged thread open. + */ function pullRequestSettles( thread: Pick< - OrchestrationV2ThreadShell, - "createdAt" | "latestUserMessageAt" | "latestRunRequestedAt" + ProjectionStore.ProjectionSettlementCandidate, + "createdAt" | "latestUserAuthoredMessageAt" >, pullRequest: SettlementPullRequest, autoSettleOnMerge: boolean, @@ -120,8 +126,7 @@ function pullRequestSettles( if (terminalAt == null) return false; const userAnchorMs = latestMillis([ toMillis(thread.createdAt), - toMillis(thread.latestUserMessageAt), - toMillis(thread.latestRunRequestedAt), + toMillis(thread.latestUserAuthoredMessageAt), ]); if (userAnchorMs === null) return false; const pullRequestAtMs = Date.parse(terminalAt); @@ -131,16 +136,17 @@ function pullRequestSettles( /** Cheap checks that run before any source control lookup. */ export function isAutoSettlementCandidate( - thread: ProjectionStore.ProjectionSettlementCandidate, + thread: Omit, nowMs: number, ): boolean { if (thread.archivedAt !== null || thread.settledOverride !== null) return false; if (thread.pinnedAt != null || thread.autoSettleDisabledAt != null) return false; // Blocked-on-you work must never park behind a settled override. if (thread.pendingRuntimeRequest !== null) return false; - // A live run — or post-settlement background work — is not staleness. + // A live run, or background work that will wake the agent, is not + // staleness. A dev server left running is: the agent is done. if (thread.activityRunStatus != null) return false; - if ((thread.pendingBackgroundTasks?.length ?? 0) > 0) 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; diff --git a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts index 6b9d4a43cc27..21a22a60dd94 100644 --- a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts @@ -438,8 +438,9 @@ describe("ThreadTitleRegenerationService", () => { ); }); -for (const outcome of ["success", "exhausted", "stale", "interrupted"] as const) { - it.effect(`initial title retry: ${outcome}`, () => +it.effect.each(["success", "exhausted", "stale", "interrupted"] as const)( + "initial title retry: %s", + (outcome) => Effect.gen(function* () { const attempted = yield* Deferred.make(); let attempts = 0; @@ -502,5 +503,4 @@ for (const outcome of ["success", "exhausted", "stale", "interrupted"] as const) else assert.isNotOk(projection.thread.titleRegeneration); }).pipe(Effect.provide(harness.layer)); }), - ); -} +); diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts new file mode 100644 index 000000000000..c247d78fd332 --- /dev/null +++ b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts @@ -0,0 +1,217 @@ +import type { + PullRequestCheck, + PullRequestComment, + PullRequestDetail, + ThreadPullRequestWatch, +} from "@t3tools/contracts"; +import { assert, describe, it } from "@effect/vitest"; + +import { + PULL_REQUEST_WATCH_WAKE_LIMIT, + evaluatePullRequestWatch, + pullRequestWatchMessage, +} from "./pullRequestWatch.ts"; + +const STARTED = "2026-10-02T12:00:00.000Z"; + +const watch = (overrides: Partial = {}): ThreadPullRequestWatch => ({ + startedAt: STARTED, + headSha: null, + failedChecks: [], + passed: false, + remarksThrough: STARTED, + remarkIds: [], + conflicting: false, + wakes: 0, + ...overrides, +}); + +const check = (name: string, status: PullRequestCheck["status"]): PullRequestCheck => ({ + name, + status, + description: null, + url: `https://ci.example/${name}`, +}); + +type Detail = Parameters[1]; + +const detail = (overrides: Partial = {}): Detail => ({ + headSha: "aaaaaaaaaa", + checks: [check("lint", "success"), check("test", "pending")], + mergeability: "mergeable", + viewer: "agent-user", + author: { login: "agent-user", name: null, avatarUrl: null }, + ...overrides, +}); + +const remark = ( + login: string, + createdAt: string, + body = "Please rename this.", +): PullRequestComment => ({ + id: `${login}-${createdAt}`, + kind: "review-comment", + author: { login, name: null, avatarUrl: null }, + body, + createdAt, + url: `https://github.com/o/r/pull/1#${login}`, + path: "src/index.ts", + reviewState: null, +}); + +const noRemarks: ReadonlyArray = []; + +describe("evaluatePullRequestWatch", () => { + it("reports each failure at once, even while another check never finishes", () => { + const bot = check("CodeRabbit", "pending"); + const first = detail({ checks: [check("lint", "failure"), check("test", "pending"), bot] }); + const lint = evaluatePullRequestWatch(watch(), first, noRemarks); + assert.deepEqual(lint.changes, [{ kind: "checks-failed", failed: [check("lint", "failure")] }]); + assert.deepEqual(evaluatePullRequestWatch(lint.next, first, noRemarks).changes, []); + + // A different job failing later is news of its own. + const second = detail({ checks: [check("lint", "failure"), check("test", "failure"), bot] }); + const test = evaluatePullRequestWatch(lint.next, second, noRemarks); + assert.deepEqual(test.changes, [{ kind: "checks-failed", failed: [check("test", "failure")] }]); + + // A rerun leaves the list while it runs, so failing again is reported again. + const rerun = evaluatePullRequestWatch(test.next, first, noRemarks); + assert.equal(evaluatePullRequestWatch(rerun.next, second, noRemarks).changes.length, 1); + + // A push reports its failures, even ones that failed between two passes. + const pushed = detail({ ...second, headSha: "bbbbbbbbbb" }); + assert.equal(evaluatePullRequestWatch(test.next, pushed, noRemarks).changes.length, 1); + }); + + it("reports passed once the required checks pass, whatever the others do", () => { + const required = (name: string, status: PullRequestCheck["status"]) => ({ + ...check(name, status), + required: true, + }); + const green = detail({ + checks: [required("test", "success"), required("lint", "success"), check("bot", "pending")], + }); + const passed = evaluatePullRequestWatch(watch(), green, noRemarks); + assert.deepEqual(passed.changes, [{ kind: "checks-passed", count: 2, required: true }]); + assert.deepEqual(evaluatePullRequestWatch(passed.next, green, noRemarks).changes, []); + + // Where nothing is marked required, every check has to pass. + const plain = detail({ checks: [check("test", "success"), check("bot", "pending")] }); + assert.deepEqual(evaluatePullRequestWatch(watch(), plain, noRemarks).changes, []); + }); + + it("keeps remarks for a later pass when the conversation was not read whole", () => { + const comments = [remark("reviewer", "2026-10-02T12:06:00Z")]; + const partial = evaluatePullRequestWatch(watch(), detail(), null); + assert.deepEqual(partial.changes, []); + assert.equal( + evaluatePullRequestWatch(partial.next, detail(), comments).changes[0]?.kind, + "remarks", + ); + }); + + it("reports a remark that shows up late with the same time as a reported one", () => { + const first = remark("reviewer", "2026-10-02T12:06:00Z"); + const late = { ...remark("bot", "2026-10-02T12:06:00Z"), id: "late" }; + const reported = evaluatePullRequestWatch(watch(), detail(), [first]); + const again = evaluatePullRequestWatch(reported.next, detail(), [first, late]); + assert.deepEqual(again.changes, [{ kind: "remarks", remarks: [late] }]); + assert.deepEqual(again.next.remarkIds, [first.id, "late"]); + }); + + it("does not treat a failed check read as a rerun", () => { + const failed = detail({ checks: [check("lint", "failure")] }); + const reported = evaluatePullRequestWatch(watch(), failed, noRemarks); + assert.equal(reported.changes.length, 1); + const unreadable = evaluatePullRequestWatch(reported.next, detail({ checks: [] }), noRemarks); + assert.deepEqual(evaluatePullRequestWatch(unreadable.next, failed, noRemarks).changes, []); + }); + + it("wakes for the pull request's author when the agent is someone else", () => { + const contributor = detail({ author: { login: "contributor", name: null, avatarUrl: null } }); + const reply = remark("contributor", "2026-10-02T12:06:00Z"); + assert.deepEqual(evaluatePullRequestWatch(watch(), contributor, [reply]).changes, [ + { kind: "remarks", remarks: [reply] }, + ]); + // Without a viewer, the author is taken to be the agent. + const noViewer = detail({ viewer: undefined, author: contributor.author }); + assert.deepEqual(evaluatePullRequestWatch(watch(), noViewer, [reply]).changes, []); + }); + + it("reports remarks from others once and never the agent's own", () => { + const comments = [ + remark("agent-user", "2026-10-02T12:05:00Z", "Fixed in the latest push."), + remark("macroscope-app[bot]", "2026-10-02T12:06:00Z"), + remark("reviewer", "2026-10-02T11:00:00Z", "Older than the watch."), + ]; + const report = evaluatePullRequestWatch(watch(), detail(), comments); + assert.deepEqual(report.changes, [{ kind: "remarks", remarks: [comments[1]!] }]); + assert.equal(report.next.remarksThrough, "2026-10-02T12:06:00Z"); + assert.deepEqual(evaluatePullRequestWatch(report.next, detail(), comments).changes, []); + }); + + it("reports a conflict once, until the branch is clean again", () => { + const conflicting = detail({ mergeability: "conflicting" }); + const first = evaluatePullRequestWatch(watch(), conflicting, noRemarks); + assert.deepEqual(first.changes, [{ kind: "conflicting" }]); + // GitHub answers "unknown" while it recomputes after a push; that is not a resolution. + const recomputing = evaluatePullRequestWatch( + first.next, + detail({ mergeability: "unknown" }), + noRemarks, + ); + assert.deepEqual( + evaluatePullRequestWatch(recomputing.next, conflicting, noRemarks).changes, + [], + ); + const clean = evaluatePullRequestWatch(first.next, detail(), noRemarks); + assert.deepEqual(evaluatePullRequestWatch(clean.next, conflicting, noRemarks).changes, [ + { kind: "conflicting" }, + ]); + }); + + it("does not spend the comment wake limit on check results", () => { + const tired = watch({ headSha: "aaaaaaaaaa", wakes: PULL_REQUEST_WATCH_WAKE_LIMIT - 1 }); + const result = evaluatePullRequestWatch( + tired, + detail({ checks: [check("lint", "failure")] }), + noRemarks, + ); + assert.isFalse(result.exhausted); + assert.equal(result.next.wakes, 0); + }); + + it("stops after the wake limit unless the head moves", () => { + const comments = [remark("reviewer", "2026-10-02T12:10:00Z")]; + const tired = watch({ headSha: "aaaaaaaaaa", wakes: PULL_REQUEST_WATCH_WAKE_LIMIT - 1 }); + assert.isTrue(evaluatePullRequestWatch(tired, detail(), comments).exhausted); + const pushed = evaluatePullRequestWatch(tired, detail({ headSha: "cccccccccc" }), comments); + assert.isFalse(pushed.exhausted); + assert.equal(pushed.next.wakes, 1); + }); +}); + +describe("pullRequestWatchMessage", () => { + it("tells the agent what changed and marks failures for the timeline", () => { + const report = evaluatePullRequestWatch( + watch(), + detail({ checks: [check("lint", "failure")] }), + [remark("reviewer", "2026-10-02T12:10:00Z", "Needs a test.")], + ); + const message = pullRequestWatchMessage({ + number: 12, + url: "https://github.com/o/r/pull/12", + baseBranch: "main", + headSha: report.next.headSha, + report, + }); + assert.include(message.text, "- Checks failed on aaaaaaa:\n - lint https://ci.example/lint"); + assert.include(message.text, ' - reviewer on src/index.ts: "Needs a test."'); + assert.include(message.text, "unwatch_pull_request"); + assert.deepEqual(message.notification, { + source: { kind: "monitor" }, + outcome: "failed", + summary: "#12: checks failed, new comments", + }); + }); +}); diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.ts b/apps/server/src/orchestration-v2/pullRequestWatch.ts new file mode 100644 index 000000000000..af9ce27636ee --- /dev/null +++ b/apps/server/src/orchestration-v2/pullRequestWatch.ts @@ -0,0 +1,209 @@ +import type { + OrchestrationV2Notification, + PullRequestCheck, + PullRequestComment, + PullRequestDetail, + ThreadPullRequestWatch, +} from "@t3tools/contracts"; + +/** + * Wakes in a row that bring only comments. Check, conflict, or push news resets the count, so + * this only stops a chatty bot looping an agent that is replying to it. + */ +export const PULL_REQUEST_WATCH_WAKE_LIMIT = 10; +const LISTED_ITEMS = 10; +const SNIPPET_LENGTH = 200; + +export type PullRequestWatchChange = + | { readonly kind: "checks-failed"; readonly failed: ReadonlyArray } + | { readonly kind: "checks-passed"; readonly count: number; readonly required: boolean } + | { readonly kind: "remarks"; readonly remarks: ReadonlyArray } + | { readonly kind: "conflicting" }; + +export interface PullRequestWatchReport { + /** What the agent has not been told yet. Empty means no wake. */ + readonly changes: ReadonlyArray; + /** The watch to record, whether or not anything is reported. */ + readonly next: ThreadPullRequestWatch; + /** This report spends the last wake before the limit, so watching stops after it. */ + readonly exhausted: boolean; +} + +// "action-required" is a finished check that needs someone, so the agent hears about it. +const isFailedCheck = (check: PullRequestCheck) => + check.status === "failure" || check.status === "cancelled" || check.status === "action-required"; + +/** + * Compares a watched pull request with what its agent was last told. Each check is reported as + * soon as it fails, so a check that never finishes (an advisory review bot) cannot hold the + * news back. "Passed" is reported once the checks the base branch requires all passed, or all + * checks where the host marks none required. Remarks count when someone other than the agent's + * own account wrote them, so its own replies never wake it. `remarks` is null when the + * conversation could not be read; remarks then wait for a later pass. + */ +export function evaluatePullRequestWatch( + watch: ThreadPullRequestWatch, + detail: Pick, + remarks: ReadonlyArray | null, +): PullRequestWatchReport { + const changes: Array = []; + const headSha = detail.headSha ?? null; + const headMoved = headSha !== watch.headSha; + + // An empty list keeps the last state: a host can answer with one when its check read fails. + let failedChecks = headMoved ? [] : watch.failedChecks; + let passed = headMoved ? false : watch.passed; + if (detail.checks.length > 0) { + const failed = detail.checks.filter(isFailedCheck); + const newlyFailed = failed.filter((check) => !failedChecks.includes(check.name)); + if (newlyFailed.length > 0) changes.push({ kind: "checks-failed", failed: newlyFailed }); + // A check that runs again leaves the list, so a rerun that fails again is reported. + failedChecks = failed.map((check) => check.name); + + const required = detail.checks.filter((check) => check.required === true); + const gate = required.length > 0 ? required : detail.checks; + const passedNow = gate.every((check) => check.status !== "pending" && !isFailedCheck(check)); + if (passedNow && !passed) { + changes.push({ kind: "checks-passed", count: gate.length, required: required.length > 0 }); + } + passed = passedNow; + } + + const own = (detail.viewer ?? detail.author?.login)?.toLowerCase(); + const through = Date.parse(watch.remarksThrough); + // GitHub times are per second, so remarks at the boundary time are told apart by ID. + const fresh = (remarks ?? []).filter((remark) => { + const at = Date.parse(remark.createdAt); + return ( + (at > through || (at === through && !watch.remarkIds.includes(remark.id))) && + remark.author?.login.toLowerCase() !== own + ); + }); + if (fresh.length > 0) changes.push({ kind: "remarks", remarks: fresh }); + const latest = Math.max(through, ...fresh.map((remark) => Date.parse(remark.createdAt))); + const atLatest = fresh.filter((remark) => Date.parse(remark.createdAt) === latest); + const remarksThrough = latest === through ? watch.remarksThrough : atLatest[0]!.createdAt; + const remarkIds = [ + ...(latest === through ? watch.remarkIds : []), + ...atLatest.map((remark) => remark.id), + ]; + + if (detail.mergeability === "conflicting" && !watch.conflicting) { + changes.push({ kind: "conflicting" }); + } + // "unknown" is GitHub still computing after a push; only a clean answer clears a conflict. + const conflicting = + detail.mergeability === "unknown" ? watch.conflicting : detail.mergeability === "conflicting"; + + const commentsOnly = changes.length > 0 && changes.every((change) => change.kind === "remarks"); + const progress = headMoved || (changes.length > 0 && !commentsOnly); + const wakes = (progress ? 0 : watch.wakes) + (commentsOnly ? 1 : 0); + return { + changes, + next: { + startedAt: watch.startedAt, + headSha, + failedChecks, + passed, + remarksThrough, + remarkIds, + conflicting, + wakes, + }, + exhausted: commentsOnly && wakes >= PULL_REQUEST_WATCH_WAKE_LIMIT, + }; +} + +function snippet(body: string): string { + const text = body + .replaceAll(//g, " ") + .replaceAll(/\s+/g, " ") + .trim(); + return text.length <= SNIPPET_LENGTH ? text : `${text.slice(0, SNIPPET_LENGTH - 3)}...`; +} + +function listed(items: ReadonlyArray, line: (item: T) => string): Array { + const lines = items.slice(0, LISTED_ITEMS).map(line); + if (items.length > LISTED_ITEMS) lines.push(` - and ${items.length - LISTED_ITEMS} more`); + return lines; +} + +function changeLines( + change: PullRequestWatchChange, + context: { readonly baseBranch: string; readonly commit: string }, +): Array { + switch (change.kind) { + case "checks-failed": + return [ + `- Checks failed${context.commit}:`, + ...listed( + change.failed, + (check) => + ` - ${check.name}${check.status === "failure" ? "" : ` (${check.status})`}${check.url ? ` ${check.url}` : ""}`, + ), + ]; + case "checks-passed": + return [ + `- All ${change.count} ${change.required ? "required " : ""}${change.count === 1 ? "check" : "checks"} passed${context.commit}.`, + ]; + case "remarks": + return [ + `- ${change.remarks.length} new ${change.remarks.length === 1 ? "comment" : "comments"}:`, + ...listed(change.remarks, (remark) => { + const where = remark.path === null ? "" : ` on ${remark.path}`; + const body = snippet(remark.body); + const said = body.length === 0 ? (remark.reviewState ?? "reviewed") : `"${body}"`; + return ` - ${remark.author?.login ?? "someone"}${where}: ${said}${remark.url ? ` ${remark.url}` : ""}`; + }), + ]; + case "conflicting": + return [`- The branch now conflicts with ${context.baseBranch}.`]; + } +} + +const SUMMARY: Record = { + "checks-failed": "checks failed", + "checks-passed": "checks passed", + remarks: "new comments", + conflicting: "merge conflict", +}; + +/** The wake the agent reads and the timeline notification the user sees. */ +export function pullRequestWatchMessage(input: { + readonly number: number; + readonly url: string; + readonly baseBranch: string; + readonly headSha: string | null; + readonly report: PullRequestWatchReport; +}): { readonly text: string; readonly notification: OrchestrationV2Notification } { + const { changes, exhausted } = input.report; + const context = { + baseBranch: input.baseBranch, + commit: input.headSha === null ? "" : ` on ${input.headSha.slice(0, 7)}`, + }; + const text = [ + `Update on pull request #${input.number} (${input.url}), which T3 Code is watching for you:`, + ...changes.flatMap((change) => changeLines(change, context)), + "", + exhausted + ? `T3 Code stopped watching after ${PULL_REQUEST_WATCH_WAKE_LIMIT} comment-only updates in a row. Call watch_pull_request to watch it again.` + : "Look into each item and act on it as your task requires. T3 Code keeps watching and wakes you on the next change, so end your turn when you are done. Call unwatch_pull_request when you no longer need updates.", + ].join("\n"); + const failed = changes.some( + (change) => change.kind === "checks-failed" || change.kind === "conflicting", + ); + const summary = changes.map((change) => SUMMARY[change.kind]); + if (exhausted) summary.push("stopped watching"); + return { + text, + notification: { + source: { kind: "monitor" }, + outcome: failed + ? "failed" + : changes.every((change) => change.kind === "checks-passed") + ? "completed" + : "updated", + summary: `#${input.number}: ${summary.join(", ")}`, + }, + }; +} diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index d2aa88fb9210..a7e4687fc349 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -18,6 +18,7 @@ import { type ModelSelection, type OrchestrationV2Run, ProjectId, + type PullRequestDetail, ProviderDriverKind, ProviderInstanceId, ProviderThreadId, @@ -59,6 +60,9 @@ import * as EffectOutbox from "./EffectOutbox.ts"; import * as EventSink from "./EventSink.ts"; import * as ProviderRuntimeRecoveryService from "./ProviderRuntimeRecoveryService.ts"; import * as ProjectionMaintenance from "./ProjectionMaintenance.ts"; +import * as ProjectionStore from "./ProjectionStore.ts"; +import * as PullRequestWatchReactor from "./PullRequestWatchReactor.ts"; +import * as PullRequestService from "../pullRequest/PullRequestService.ts"; import * as ProjectStore from "./ProjectStore.ts"; import type { ProviderAdapterV2SessionRuntime, ProviderAdapterV2Shape } from "./ProviderAdapter.ts"; import * as ProviderSessionManager from "./ProviderSessionManager.ts"; @@ -215,6 +219,7 @@ const TestLayer = Layer.mergeAll( OrchestrationV2LayerLive, OrchestrationV2EventSinkLayerLive, ProjectStore.layer, + ProjectionStore.layer, EffectOutbox.layer, ThreadCommandExecutor.layer, ).pipe( @@ -2151,6 +2156,298 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + it.effect("starts, records, and stops a pull request watch", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; + const threadId = ThreadId.make("runtime-pull-request-watch"); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("pr-watch-create"), + threadId, + projectId: ProjectId.make("pr-watch-project"), + title: "Watch", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + const key = { host: "github.com", repository: "pingdotgg/t3code", number: 7 }; + const url = "https://github.com/pingdotgg/t3code/pull/7"; + const watchOf = Effect.map( + orchestrator.getThreadShell(threadId), + (thread) => thread?.pullRequests?.[0]?.watch, + ); + + // Watching an unlinked pull request links it in the same command. + yield* orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: CommandId.make("pr-watch-start"), + threadId, + ...key, + watching: true, + link: { url, source: "agent" }, + }); + assert.equal( + (yield* orchestrator.getThreadShell(threadId))?.pullRequests?.[0]?.source, + "agent", + ); + const started = yield* watchOf; + assert.isDefined(started); + if (started === undefined) return; + + // A legacy client re-linking the same pull request keeps its watch. + yield* orchestrator.dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make("pr-watch-legacy-relink"), + threadId, + linkedPullRequest: { projectId: ProjectId.make("pr-watch-project"), ...key, url }, + }); + assert.deepEqual(yield* watchOf, started); + + const recorded = { ...started, headSha: "abc123", failedChecks: ["lint"], wakes: 1 }; + yield* orchestrator.dispatch({ + type: "thread.pull-request-watch.sync", + commandId: CommandId.make("pr-watch-record"), + threadId, + ...key, + startedAt: started.startedAt, + watch: recorded, + }); + assert.deepEqual(yield* watchOf, recorded); + assert.isTrue((yield* maintenance.rebuild).valid); + assert.deepEqual(yield* watchOf, recorded); + + yield* orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: CommandId.make("pr-watch-stop"), + threadId, + ...key, + watching: false, + }); + // A wake read before the stop must neither wake the agent nor bring the watch back. + const late = yield* orchestrator + .dispatch({ + type: "thread.pull-request-watch.sync", + commandId: CommandId.make("pr-watch-late-record"), + threadId, + ...key, + startedAt: started.startedAt, + watch: { ...recorded, wakes: 2 }, + wake: { + messageId: MessageId.make("pr-watch-late-wake"), + text: "Update", + notification: { source: { kind: "monitor" }, outcome: "updated", summary: "#7" }, + }, + }) + .pipe(Effect.flip); + assert.equal(late._tag, "OrchestratorDispatchError"); + assert.isUndefined(yield* watchOf); + const { messages } = yield* orchestrator.getThreadRecords(threadId, ["messages"]); + assert.deepEqual(messages, []); + }), + ); + + it.effect("ends a watch it cannot read, and tells the agent", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadId = ThreadId.make("runtime-pull-request-watch-unreadable"); + const projectId = ProjectId.make("pr-watch-unreadable-project"); + yield* seedProject({ + projectId, + title: "Watch unreadable", + workspaceRoot: "/workspace/watch-unreadable", + defaultModelSelection: null, + createdAt: "2026-10-01T00:00:00.000Z", + }); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("pr-watch-unreadable-create"), + threadId, + projectId, + title: "Watch unreadable", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + yield* orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: CommandId.make("pr-watch-unreadable-start"), + threadId, + host: "github.com", + repository: "pingdotgg/t3code", + number: 8, + watching: true, + link: { url: "https://github.com/pingdotgg/t3code/pull/8", source: "agent" }, + }); + const reactor = yield* PullRequestWatchReactor.make.pipe( + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + Layer.mock(PullRequestService.PullRequestService)({ + detail: () => Effect.die("host unreachable"), + activity: () => Effect.die("host unreachable"), + }), + ), + ), + ); + for (let pass = 0; pass < 15; pass += 1) yield* reactor.sweep; + + const thread = yield* orchestrator.getThreadShell(threadId); + assert.isUndefined(thread?.pullRequests?.[0]?.watch); + const { messages } = yield* orchestrator.getThreadRecords(threadId, ["messages"]); + assert.deepEqual( + messages.flatMap((message) => message.notification?.summary ?? []), + ["#8: stopped watching, could not read it"], + ); + }), + ); + + it.effect("wakes a watched thread once for failed checks and a review comment", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadId = ThreadId.make("runtime-pull-request-watch-wake"); + const projectId = ProjectId.make("pr-watch-wake-project"); + yield* seedProject({ + projectId, + title: "Watch wake", + workspaceRoot: "/workspace/watch", + defaultModelSelection: null, + createdAt: "2026-10-01T00:00:00.000Z", + }); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("pr-watch-wake-create"), + threadId, + projectId, + title: "Watch wake", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + const key = { host: "github.com", repository: "pingdotgg/t3code", number: 7 }; + const url = "https://github.com/pingdotgg/t3code/pull/7"; + yield* orchestrator.dispatch({ + type: "thread.pull-request.link", + commandId: CommandId.make("pr-watch-wake-link"), + threadId, + ...key, + url, + source: "agent", + }); + yield* orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: CommandId.make("pr-watch-wake-start"), + threadId, + ...key, + watching: true, + }); + + const at = "2026-10-02T12:00:00.000Z"; + const detail: PullRequestDetail = { + provider: "github", + capabilities: { + diff: true, + comment: true, + actions: [], + mergeMethods: [], + search: false, + review: { inlineComment: false, reply: false, resolve: false, verdicts: [] }, + reviewers: { request: false, listCandidates: false }, + }, + viewerPermissions: { + actions: [], + comment: true, + resolve: true, + verdicts: [], + requestReviewers: false, + }, + projectId, + projectTitle: "Watch wake", + workspaceRoot: "/workspace/watch", + repository: key.repository, + number: key.number, + title: "Watched pull request", + body: "", + url, + author: { login: "agent-user", name: null, avatarUrl: null }, + state: "open", + isDraft: false, + mergeability: "mergeable", + additions: 1, + deletions: 0, + changedFiles: 1, + headBranch: "feature", + headSha: "abc1234def", + baseBranch: "main", + createdAt: at, + updatedAt: at, + mergedAt: null, + closedAt: null, + reviewers: [], + labels: [], + checks: [{ name: "lint", status: "failure", description: null, url: null }], + mergeCapabilities: { merge: true, squash: true, rebase: true }, + viewer: "agent-user", + }; + const reactor = yield* PullRequestWatchReactor.make.pipe( + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + Layer.mock(PullRequestService.PullRequestService)({ + detail: () => Effect.succeed(detail), + activity: () => + Effect.succeed({ + comments: [ + { + id: "review-1", + kind: "review-comment", + author: { login: "reviewer", name: null, avatarUrl: null }, + body: "One more thing.", + createdAt: "2999-01-01T00:00:00.000Z", + url: null, + path: "src/index.ts", + reviewState: null, + }, + ], + commentCount: 1, + commentsTruncated: false, + reviewThreads: [], + commits: [], + }), + }), + ), + ), + ); + yield* reactor.sweep; + yield* reactor.sweep; + + const { messages } = yield* orchestrator.getThreadRecords(threadId, ["messages"]); + assert.deepEqual( + messages.flatMap((message) => + message.notification === undefined ? [] : [message.notification.summary], + ), + ["#7: checks failed, new comments"], + ); + const watch = (yield* orchestrator.getThreadShell(threadId))?.pullRequests?.[0]?.watch; + assert.deepEqual( + { headSha: watch?.headSha, failedChecks: watch?.failedChecks, wakes: watch?.wakes }, + { headSha: "abc1234def", failedChecks: ["lint"], wakes: 0 }, + ); + }), + ); + it.effect("persists rejected command receipts across retries", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; @@ -2602,210 +2899,324 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); - for (const automatic of [false, true]) { - it.effect( - `promotes only one queued run after each terminal run (notification: ${automatic})`, - () => - Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const eventSink = yield* EventSink.EventSinkV2; - const threadId = ThreadId.make(`runtime-layer-serialized-queue-thread-${automatic}`); + it.effect.each([false, true])( + "promotes only one queued run after each terminal run (notification: %s)", + (automatic) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const threadId = ThreadId.make(`runtime-layer-serialized-queue-thread-${automatic}`); - yield* orchestrator.dispatch({ - type: "thread.create", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make(`runtime-layer-serialized-queue-create-${automatic}`), - threadId, - projectId: ProjectId.make(`runtime-layer-serialized-queue-project-${automatic}`), - title: "Serialized queue", - modelSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: process.cwd(), - }); - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make(`runtime-layer-serialized-queue-active-${automatic}`), - threadId, - messageId: MessageId.make(`runtime-layer-serialized-queue-active-${automatic}`), - text: "Active", - attachments: [], - modelSelection, - dispatchMode: { type: "start_immediately" }, - }); - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: automatic ? "agent" : "user", - creationSource: automatic ? "provider" : "web", - ...(automatic - ? { - notification: { - source: { kind: "monitor" as const }, - outcome: "updated" as const, - summary: "Monitor updated", - detail: "Build is green", - }, - } - : {}), - commandId: CommandId.make(`runtime-layer-serialized-queue-first-${automatic}`), - threadId, - messageId: MessageId.make(`runtime-layer-serialized-queue-first-${automatic}`), - text: "First queued", - attachments: [], - modelSelection, - dispatchMode: { type: "queue_after_active" }, - }); - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make(`runtime-layer-serialized-queue-second-${automatic}`), - threadId, - messageId: MessageId.make(`runtime-layer-serialized-queue-second-${automatic}`), - text: "Second queued", - attachments: [], - modelSelection, - dispatchMode: { type: "queue_after_active" }, - }); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`runtime-layer-serialized-queue-create-${automatic}`), + threadId, + projectId: ProjectId.make(`runtime-layer-serialized-queue-project-${automatic}`), + title: "Serialized queue", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: process.cwd(), + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`runtime-layer-serialized-queue-active-${automatic}`), + threadId, + messageId: MessageId.make(`runtime-layer-serialized-queue-active-${automatic}`), + text: "Active", + attachments: [], + modelSelection, + dispatchMode: { type: "start_immediately" }, + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: automatic ? "agent" : "user", + creationSource: automatic ? "provider" : "web", + ...(automatic + ? { + notification: { + source: { kind: "monitor" as const }, + outcome: "updated" as const, + summary: "Monitor updated", + detail: "Build is green", + }, + } + : {}), + commandId: CommandId.make(`runtime-layer-serialized-queue-first-${automatic}`), + threadId, + messageId: MessageId.make(`runtime-layer-serialized-queue-first-${automatic}`), + text: "First queued", + attachments: [], + modelSelection, + dispatchMode: { type: "queue_after_active" }, + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`runtime-layer-serialized-queue-second-${automatic}`), + threadId, + messageId: MessageId.make(`runtime-layer-serialized-queue-second-${automatic}`), + text: "Second queued", + attachments: [], + modelSelection, + dispatchMode: { type: "queue_after_active" }, + }); - const before = yield* orchestrator.getThreadProjection(threadId); - const activeRun = before.runs.find((run) => run.status === "starting"); - const queuedRuns = before.runs - .filter((run) => run.status === "queued") - .toSorted((left, right) => left.ordinal - right.ordinal); - const firstQueuedRun = queuedRuns[0]; - const secondQueuedRun = queuedRuns[1]; - assert.isDefined(activeRun); - assert.isDefined(firstQueuedRun); - assert.isDefined(secondQueuedRun); - assert.isFalse( - before.turnItems.some( - (item) => - item.type === "user_message" && - (item.messageId === firstQueuedRun.userMessageId || - item.messageId === secondQueuedRun.userMessageId), - ), - "queued messages must not exist as turn items before dispatch", - ); + const before = yield* orchestrator.getThreadProjection(threadId); + const activeRun = before.runs.find((run) => run.status === "starting"); + const queuedRuns = before.runs + .filter((run) => run.status === "queued") + .toSorted((left, right) => left.ordinal - right.ordinal); + const firstQueuedRun = queuedRuns[0]; + const secondQueuedRun = queuedRuns[1]; + assert.isDefined(activeRun); + assert.isDefined(firstQueuedRun); + assert.isDefined(secondQueuedRun); + assert.isFalse( + before.turnItems.some( + (item) => + item.type === "user_message" && + (item.messageId === firstQueuedRun.userMessageId || + item.messageId === secondQueuedRun.userMessageId), + ), + "queued messages must not exist as turn items before dispatch", + ); - const promotedRunIds = yield* Queue.unbounded(); - const afterSequence = yield* orchestrator.getThreadEventSequence(threadId); - yield* eventSink.stream({ threadId, afterSequence }).pipe( - Stream.runForEach((stored) => - stored.event.type === "run.updated" && stored.event.payload.status === "starting" - ? Queue.offer(promotedRunIds, stored.event.payload.id) - : Effect.void, - ), - Effect.forkScoped, - ); - yield* Effect.yieldNow; + const promotedRunIds = yield* Queue.unbounded(); + const afterSequence = yield* orchestrator.getThreadEventSequence(threadId); + yield* eventSink.stream({ threadId, afterSequence }).pipe( + Stream.runForEach((stored) => + stored.event.type === "run.updated" && stored.event.payload.status === "starting" + ? Queue.offer(promotedRunIds, stored.event.payload.id) + : Effect.void, + ), + Effect.forkScoped, + ); + yield* Effect.yieldNow; - const activeCompletedAt = yield* DateTime.now; - yield* eventSink.write({ - events: [ - { - id: EventId.make(`runtime-layer-serialized-queue-active-completed-${automatic}`), - type: "run.updated", - threadId, - runId: activeRun.id, - ...(activeRun.rootNodeId === null ? {} : { nodeId: activeRun.rootNodeId }), - providerInstanceId: activeRun.providerInstanceId, - occurredAt: activeCompletedAt, - payload: { - ...activeRun, - status: "completed", - completedAt: activeCompletedAt, - }, + const activeCompletedAt = yield* DateTime.now; + yield* eventSink.write({ + events: [ + { + id: EventId.make(`runtime-layer-serialized-queue-active-completed-${automatic}`), + type: "run.updated", + threadId, + runId: activeRun.id, + ...(activeRun.rootNodeId === null ? {} : { nodeId: activeRun.rootNodeId }), + providerInstanceId: activeRun.providerInstanceId, + occurredAt: activeCompletedAt, + payload: { + ...activeRun, + status: "completed", + completedAt: activeCompletedAt, }, - ], - }); + }, + ], + }); - assert.equal(yield* Queue.take(promotedRunIds), firstQueuedRun.id); - const afterFirstPromotion = yield* orchestrator.getThreadProjection(threadId); + assert.equal(yield* Queue.take(promotedRunIds), firstQueuedRun.id); + const afterFirstPromotion = yield* orchestrator.getThreadProjection(threadId); + assert.equal( + afterFirstPromotion.runs.find((run) => run.id === firstQueuedRun.id)?.status, + "starting", + ); + assert.equal( + afterFirstPromotion.runs.find((run) => run.id === secondQueuedRun.id)?.status, + "queued", + ); + const promotedMessageItem = afterFirstPromotion.turnItems.find( + (item) => + item.runId === firstQueuedRun.id && + (item.type === "user_message" || item.type === "notification"), + ); + assert.isDefined(promotedMessageItem); + if (automatic) { + assert.equal(promotedMessageItem.type, "notification"); assert.equal( - afterFirstPromotion.runs.find((run) => run.id === firstQueuedRun.id)?.status, - "starting", + afterFirstPromotion.messages.find( + (message) => message.id === firstQueuedRun.userMessageId, + )?.text, + "First queued", ); assert.equal( - afterFirstPromotion.runs.find((run) => run.id === secondQueuedRun.id)?.status, - "queued", + afterFirstPromotion.messages.find( + (message) => message.id === firstQueuedRun.userMessageId, + )?.notification?.summary, + "Monitor updated", ); - const promotedMessageItem = afterFirstPromotion.turnItems.find( - (item) => - item.runId === firstQueuedRun.id && - (item.type === "user_message" || item.type === "notification"), - ); - assert.isDefined(promotedMessageItem); - if (automatic) { - assert.equal(promotedMessageItem.type, "notification"); - assert.equal( - afterFirstPromotion.messages.find( - (message) => message.id === firstQueuedRun.userMessageId, - )?.text, - "First queued", - ); - assert.equal( - afterFirstPromotion.messages.find( - (message) => message.id === firstQueuedRun.userMessageId, - )?.notification?.summary, - "Monitor updated", - ); - assert.isFalse( - afterFirstPromotion.turnItems.some( - (item) => - item.type === "user_message" && item.messageId === firstQueuedRun.userMessageId, - ), - ); - } else { - assert.equal(promotedMessageItem.type, "user_message"); - } - assert.isTrue( - promotedMessageItem.startedAt !== null && - DateTime.toEpochMillis(promotedMessageItem.startedAt) >= - DateTime.toEpochMillis(activeCompletedAt), + assert.isFalse( + afterFirstPromotion.turnItems.some( + (item) => + item.type === "user_message" && item.messageId === firstQueuedRun.userMessageId, + ), ); + } else { + assert.equal(promotedMessageItem.type, "user_message"); + } + assert.isTrue( + promotedMessageItem.startedAt !== null && + DateTime.toEpochMillis(promotedMessageItem.startedAt) >= + DateTime.toEpochMillis(activeCompletedAt), + ); - const promotedFirst = afterFirstPromotion.runs.find( - (run) => run.id === firstQueuedRun.id, - ); - assert.isDefined(promotedFirst); - const firstCompletedAt = yield* DateTime.now; + const promotedFirst = afterFirstPromotion.runs.find((run) => run.id === firstQueuedRun.id); + assert.isDefined(promotedFirst); + const firstCompletedAt = yield* DateTime.now; + yield* eventSink.write({ + events: [ + { + id: EventId.make(`runtime-layer-serialized-queue-first-completed-${automatic}`), + type: "run.updated", + threadId, + runId: promotedFirst.id, + ...(promotedFirst.rootNodeId === null ? {} : { nodeId: promotedFirst.rootNodeId }), + providerInstanceId: promotedFirst.providerInstanceId, + occurredAt: firstCompletedAt, + payload: { + ...promotedFirst, + status: "completed", + completedAt: firstCompletedAt, + }, + }, + ], + }); + + assert.equal(yield* Queue.take(promotedRunIds), secondQueuedRun.id); + const afterSecondPromotion = yield* orchestrator.getThreadProjection(threadId); + assert.equal( + afterSecondPromotion.runs.find((run) => run.id === firstQueuedRun.id)?.status, + "completed", + ); + assert.equal( + afterSecondPromotion.runs.find((run) => run.id === secondQueuedRun.id)?.status, + "starting", + ); + }), + ); + + it.effect("starts a wake's work clock from the run that ran before it", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const threadId = ThreadId.make("runtime-layer-wake-work-start-thread"); + const messageId = (key: string) => MessageId.make(`runtime-layer-wake-work-start-${key}`); + + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("runtime-layer-wake-work-start-create"), + threadId, + projectId: ProjectId.make("runtime-layer-wake-work-start-project"), + title: "Wake work start", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: process.cwd(), + }); + const dispatch = (key: string, wake: boolean) => + orchestrator.dispatch({ + type: "message.dispatch", + createdBy: wake ? "agent" : "user", + creationSource: wake ? "provider" : "web", + ...(wake + ? { + notification: { + source: { kind: "background_task" as const }, + outcome: "updated" as const, + summary: "Background activity updated", + }, + } + : {}), + commandId: CommandId.make(`runtime-layer-wake-work-start-${key}`), + threadId, + messageId: messageId(key), + text: key, + attachments: [], + modelSelection, + dispatchMode: + key === "prompt" ? { type: "start_immediately" } : { type: "queue_after_active" }, + }); + const runFor = (key: string) => + Effect.map(orchestrator.getThreadProjection(threadId), ({ runs }) => { + const run = runs.find((candidate) => candidate.userMessageId === messageId(key)); + assert.isDefined(run); + return run; + }); + yield* dispatch("prompt", false); + yield* dispatch("queued", false); + yield* dispatch("early-wake", true); + // The early wake now runs ahead of the older queued prompt, as a + // delegated result does when it jumps the queue. + yield* orchestrator.dispatch({ + type: "queued-run.reorder", + commandId: CommandId.make("runtime-layer-wake-work-start-reorder"), + threadId, + runId: (yield* runFor("queued")).id, + beforeRunId: null, + }); + yield* dispatch("late-wake", true); + // A queued wake has no clock yet: what runs before it is still unknown. + assert.isUndefined((yield* runFor("early-wake")).workStartedAt); + + const startedRunIds = yield* Queue.unbounded(); + const afterSequence = yield* orchestrator.getThreadEventSequence(threadId); + yield* eventSink.stream({ threadId, afterSequence }).pipe( + Stream.runForEach((stored) => + stored.event.type === "run.updated" && stored.event.payload.status === "starting" + ? Queue.offer(startedRunIds, stored.event.payload.id) + : Effect.void, + ), + Effect.forkScoped, + ); + yield* Effect.yieldNow; + + const now = yield* DateTime.now; + // Runs start and settle the way the provider would report them. + const settle = (key: string, startedAt: DateTime.Utc) => + Effect.gen(function* () { + const run = yield* runFor(key); yield* eventSink.write({ events: [ { - id: EventId.make(`runtime-layer-serialized-queue-first-completed-${automatic}`), + id: EventId.make(`runtime-layer-wake-work-start-${key}-completed`), type: "run.updated", threadId, - runId: promotedFirst.id, - ...(promotedFirst.rootNodeId === null ? {} : { nodeId: promotedFirst.rootNodeId }), - providerInstanceId: promotedFirst.providerInstanceId, - occurredAt: firstCompletedAt, - payload: { - ...promotedFirst, - status: "completed", - completedAt: firstCompletedAt, - }, + runId: run.id, + ...(run.rootNodeId === null ? {} : { nodeId: run.rootNodeId }), + providerInstanceId: run.providerInstanceId, + occurredAt: startedAt, + payload: { ...run, status: "completed", startedAt, completedAt: startedAt }, }, ], }); - - assert.equal(yield* Queue.take(promotedRunIds), secondQueuedRun.id); - const afterSecondPromotion = yield* orchestrator.getThreadProjection(threadId); - assert.equal( - afterSecondPromotion.runs.find((run) => run.id === firstQueuedRun.id)?.status, - "completed", - ); - assert.equal( - afterSecondPromotion.runs.find((run) => run.id === secondQueuedRun.id)?.status, - "starting", - ); - }), - ); - } + }); + const millis = (value: DateTime.Utc | undefined) => + value === undefined ? undefined : DateTime.toEpochMillis(value); + + yield* settle("prompt", now); + assert.equal(yield* Queue.take(startedRunIds), (yield* runFor("early-wake")).id); + assert.equal(millis((yield* runFor("early-wake")).workStartedAt), millis(now)); + + yield* settle("early-wake", DateTime.add(now, { seconds: 1 })); + assert.equal(yield* Queue.take(startedRunIds), (yield* runFor("queued")).id); + assert.isUndefined((yield* runFor("queued")).workStartedAt); + + // The queued prompt starts long after it was requested; the wake after it + // counts from that start, not from the request. + const queuedStartedAt = DateTime.add(now, { minutes: 10 }); + yield* settle("queued", queuedStartedAt); + assert.equal(yield* Queue.take(startedRunIds), (yield* runFor("late-wake")).id); + assert.equal(millis((yield* runFor("late-wake")).workStartedAt), millis(queuedStartedAt)); + }), + ); it.effect.each(["usage_limit", "provider_error"] as const)( "handles a queued message after a %s failure", @@ -3335,9 +3746,11 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => { after.messages.find((message) => message.id === streamingMessageId)?.streaming, false, ); + // A delegated task's own child thread settles it, so reconciliation + // of the parent run leaves it untouched. assert.equal( after.subagents.find((subagent) => subagent.id === subagentId)?.status, - "cancelled", + "running", ); const interruptResult = after.turnItems.find( (item) => item.type === "run_interrupt_result" && item.runId === activeRun.id, @@ -3369,112 +3782,106 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); - for (const trigger of ["startup", "shutdown"] as const) { - it.effect( - `preserves and holds queued messages across ${trigger} until explicitly resumed`, - () => - Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const recovery = yield* ProviderRuntimeRecoveryService.ProviderRuntimeRecoveryService; - const threadId = ThreadId.make(`queue-hold-${trigger}`); + it.effect.each(["startup", "shutdown"] as const)( + "preserves and holds queued messages across %s until explicitly resumed", + (trigger) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const recovery = yield* ProviderRuntimeRecoveryService.ProviderRuntimeRecoveryService; + const threadId = ThreadId.make(`queue-hold-${trigger}`); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`${threadId}:create`), + threadId, + projectId: ProjectId.make(`${threadId}:project`), + title: "Recover queue", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: process.cwd(), + }); + for (const [index, text] of ["Active", "First queued", "Second queued"].entries()) { yield* orchestrator.dispatch({ - type: "thread.create", + type: "message.dispatch", createdBy: "user", creationSource: "web", - commandId: CommandId.make(`${threadId}:create`), + commandId: CommandId.make(`${threadId}:message:${index}`), threadId, - projectId: ProjectId.make(`${threadId}:project`), - title: "Recover queue", + messageId: MessageId.make(`${threadId}:message:${index}`), + text, + attachments: [], modelSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: process.cwd(), + dispatchMode: { type: index === 0 ? "start_immediately" : "queue_after_active" }, }); - for (const [index, text] of ["Active", "First queued", "Second queued"].entries()) { - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make(`${threadId}:message:${index}`), - threadId, - messageId: MessageId.make(`${threadId}:message:${index}`), - text, - attachments: [], - modelSelection, - dispatchMode: { type: index === 0 ? "start_immediately" : "queue_after_active" }, - }); - } - const before = yield* orchestrator.getThreadProjection(threadId); - const queued = before.runs.filter((run) => run.status === "queued"); - assert.equal(queued.length, 2); - yield* recovery.reconcile(trigger); - // A second boot must preserve the hold, even when only queued work remains. - yield* recovery.reconcile("startup"); - const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; - assert.isTrue((yield* maintenance.rebuild).valid); - assert.equal(yield* orchestrator.resumeQueuedRuns, 0); - const held = yield* orchestrator.getThreadProjection(threadId); + } + const before = yield* orchestrator.getThreadProjection(threadId); + const queued = before.runs.filter((run) => run.status === "queued"); + assert.equal(queued.length, 2); + yield* recovery.reconcile(trigger); + // A second boot must preserve the hold, even when only queued work remains. + yield* recovery.reconcile("startup"); + const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; + assert.isTrue((yield* maintenance.rebuild).valid); + assert.equal(yield* orchestrator.resumeQueuedRuns, 0); + const held = yield* orchestrator.getThreadProjection(threadId); + assert.deepEqual( + held.runs.map((run) => run.status), + ["cancelled", "queued", "queued"], + ); + for (const run of queued) { assert.deepEqual( - held.runs.map((run) => run.status), - ["cancelled", "queued", "queued"], + held.runs.find((row) => row.id === run.id), + { ...run, queueHeld: true }, ); - for (const run of queued) { - assert.deepEqual( - held.runs.find((row) => row.id === run.id), - { ...run, queueHeld: true }, - ); - assert.deepEqual( - held.messages.find((row) => row.id === run.userMessageId), - before.messages.find((row) => row.id === run.userMessageId), - ); - assert.equal( - held.attempts.find((row) => row.id === run.activeAttemptId)?.status, - "pending", - ); - assert.equal(held.nodes.find((row) => row.id === run.rootNodeId)?.status, "pending"); - } - // Editing and reordering are allowed without releasing the hold. - const first = queued[0]!; - const second = queued[1]!; - yield* orchestrator.dispatch({ - type: "queued-run.edit", - commandId: CommandId.make(`${threadId}:edit`), - threadId, - runId: second.id, - text: "Edited second message", - }); - yield* orchestrator.dispatch({ - type: "queued-run.reorder", - commandId: CommandId.make(`${threadId}:reorder`), - threadId, - runId: second.id, - beforeRunId: first.id, - }); - assert.equal(yield* orchestrator.resumeQueuedRuns, 0); - const resume = { - type: "queue.resume" as const, - commandId: CommandId.make(`${threadId}:resume`), - threadId, - }; - yield* orchestrator.dispatch(resume); - yield* orchestrator.dispatch(resume); - const resumed = yield* orchestrator.getThreadProjection(threadId); - assert.equal(resumed.runs.find((run) => run.id === second.id)?.status, "starting"); - assert.equal(resumed.runs.find((run) => run.id === first.id)?.status, "queued"); - assert.isFalse(resumed.runs.some((run) => run.status === "queued" && run.queueHeld)); - assert.equal( - resumed.messages.find((row) => row.id === second.userMessageId)?.text, - "Edited second message", + assert.deepEqual( + held.messages.find((row) => row.id === run.userMessageId), + before.messages.find((row) => row.id === run.userMessageId), ); assert.equal( - resumed.runs.length, - 3, - "resume retries must not duplicate messages or runs", + held.attempts.find((row) => row.id === run.activeAttemptId)?.status, + "pending", ); - }), - ); - } + assert.equal(held.nodes.find((row) => row.id === run.rootNodeId)?.status, "pending"); + } + // Editing and reordering are allowed without releasing the hold. + const first = queued[0]!; + const second = queued[1]!; + yield* orchestrator.dispatch({ + type: "queued-run.edit", + commandId: CommandId.make(`${threadId}:edit`), + threadId, + runId: second.id, + text: "Edited second message", + }); + yield* orchestrator.dispatch({ + type: "queued-run.reorder", + commandId: CommandId.make(`${threadId}:reorder`), + threadId, + runId: second.id, + beforeRunId: first.id, + }); + assert.equal(yield* orchestrator.resumeQueuedRuns, 0); + const resume = { + type: "queue.resume" as const, + commandId: CommandId.make(`${threadId}:resume`), + threadId, + }; + yield* orchestrator.dispatch(resume); + yield* orchestrator.dispatch(resume); + const resumed = yield* orchestrator.getThreadProjection(threadId); + assert.equal(resumed.runs.find((run) => run.id === second.id)?.status, "starting"); + assert.equal(resumed.runs.find((run) => run.id === first.id)?.status, "queued"); + assert.isFalse(resumed.runs.some((run) => run.status === "queued" && run.queueHeld)); + assert.equal( + resumed.messages.find((row) => row.id === second.userMessageId)?.text, + "Edited second message", + ); + assert.equal(resumed.runs.length, 3, "resume retries must not duplicate messages or runs"); + }), + ); it.effect("edits and removes queued runs", () => Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/runtimeLayer.ts b/apps/server/src/orchestration-v2/runtimeLayer.ts index 7ecbf57f204a..8573c6570795 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.ts @@ -35,6 +35,7 @@ import { layer as providerContinuationRequestsLayer } from "./ProviderContinuati import { workerLive as providerContinuationWorkerLive } from "./ProviderContinuationService.ts"; import { layer as threadTitleRegenerationServiceLayer } from "./ThreadTitleRegenerationService.ts"; import { layer as providerEventIngestorLayer } from "./ProviderEventIngestor.ts"; +import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; import { layer as providerSessionManagerLayer } from "./ProviderSessionManager.ts"; import { layer as providerRuntimeRecoveryLayer } from "./ProviderRuntimeRecoveryService.ts"; import { layer as providerSwitchServiceLayer } from "./ProviderSwitchService.ts"; @@ -98,7 +99,14 @@ export const ProjectServiceLayerLive = projectServiceLayer.pipe( ); const providerEventIngestorProvided = providerEventIngestorLayer.pipe( - Layer.provide(Layer.mergeAll(eventSinkProvided, idAllocatorLayer, projectionStoreLayer)), + Layer.provide( + Layer.mergeAll( + eventSinkProvided, + idAllocatorLayer, + projectionStoreLayer, + ThreadCommandExecutor.layer, + ), + ), ); const checkpointServiceProvided = checkpointServiceLayer.pipe(Layer.provide(idAllocatorLayer)); diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts index b60a5b553cbb..dd417ef162e0 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts @@ -221,19 +221,19 @@ function runFixtureProviderWithRegisteredHarness(input: { } describe("orchestrator replay fixtures", () => { - for (const fixture of ORCHESTRATOR_REPLAY_FIXTURES) { - for (const provider of fixture.providers) { - it.effect( - `runs ${fixture.name}/${provider.driver} through OrchestratorV2 using deterministic replay`, - () => - runFixtureProviderWithRegisteredHarness({ - fixtureName: fixture.name, - buildInput: fixture.buildInput, - driver: provider, - }), - ); - } - } + it.effect.each( + ORCHESTRATOR_REPLAY_FIXTURES.flatMap((fixture) => + fixture.providers.map( + (provider) => [fixture.name, provider.driver, fixture, provider] as const, + ), + ), + )("runs %s/%s through OrchestratorV2 using deterministic replay", ([, , fixture, provider]) => + runFixtureProviderWithRegisteredHarness({ + fixtureName: fixture.name, + buildInput: fixture.buildInput, + driver: provider, + }), + ); const steeringFixture = ORCHESTRATOR_REPLAY_FIXTURES.find( (fixture) => fixture.name === "message_steering", @@ -254,33 +254,38 @@ describe("orchestrator replay fixtures", () => { // A later OpenCode may change an execution start's shape; the client then // reads it as `unreadable.execution.started`. A subagent's turn and a // background follow-up must still start from it. - for (const [fixtureName, label] of [ - ["opencode2_subagent", "session.execution.started.2"], - ["opencode2_background", "session.execution.started.3"], - ] as const) { - const fixture = ORCHESTRATOR_REPLAY_FIXTURES.find( - (candidate) => candidate.name === fixtureName, - ); - const provider = fixture?.providers[0]; - if (fixture === undefined || provider === undefined) continue; - it.effect( - `runs ${fixtureName} when ${label} is an execution start this build cannot decode`, - () => - runFixtureProviderWithRegisteredHarness({ - fixtureName, - buildInput: fixture.buildInput, - driver: provider, - transformTranscript: (transcript) => ({ - ...transcript, - entries: transcript.entries.map((entry) => - entry.type === "emit_inbound" && entry.label === label - ? undecodableEvent(entry) - : entry, - ), - }), + it.effect.each( + ( + [ + ["opencode2_subagent", "session.execution.started.2"], + ["opencode2_background", "session.execution.started.3"], + ] as const + ).flatMap(([fixtureName, label]) => { + const fixture = ORCHESTRATOR_REPLAY_FIXTURES.find( + (candidate) => candidate.name === fixtureName, + ); + const provider = fixture?.providers[0]; + return fixture === undefined || provider === undefined + ? [] + : [[fixtureName, label, fixture, provider] as const]; + }), + )( + "runs %s when %s is an execution start this build cannot decode", + ([fixtureName, label, fixture, provider]) => + runFixtureProviderWithRegisteredHarness({ + fixtureName, + buildInput: fixture.buildInput, + driver: provider, + transformTranscript: (transcript) => ({ + ...transcript, + entries: transcript.entries.map((entry) => + entry.type === "emit_inbound" && entry.label === label + ? undecodableEvent(entry) + : entry, + ), }), - ); - } + }), + ); }); /** The same event with an envelope this build cannot decode, as a newer OpenCode may send. */ diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts index 4cc834dddd62..b9cad107409a 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts @@ -153,6 +153,7 @@ function commandThreadIds(command: OrchestrationV2Command): ReadonlyArray( ); const commandReceiptStoreProvided = CommandReceiptStore.layer.pipe(Layer.provide(databaseLayer)); const providerEventIngestorProvided = ProviderEventIngestor.layer.pipe( - Layer.provide(Layer.mergeAll(storesLayer, eventSinkProvided, IdAllocator.layer)), + Layer.provide( + Layer.mergeAll( + storesLayer, + eventSinkProvided, + IdAllocator.layer, + ThreadCommandExecutor.layer, + ), + ), ); const vcsDriverRegistryLayer = VcsDriverRegistry.layer.pipe( Layer.provide(VcsProcess.layer), @@ -425,6 +433,8 @@ export function makeOrchestratorV2ReplayLayerWithRegistry( dispatch: orchestrator.dispatch, getThreadRecords: orchestrator.getThreadRecords, getThreadProjection: orchestrator.getThreadProjection, + recoverDelegatedTask: orchestrator.recoverDelegatedTask, + delegatedTaskResultPending: orchestrator.delegatedTaskResultPending, }); }), ).pipe(Layer.provide(orchestratorProvided)); @@ -489,6 +499,8 @@ export function makeOrchestratorV2ReplayLayerWithRegistry( Orchestrator.OrchestratorV2, Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; + // As in serverRuntimeStartup: after runtime recovery, before the worker. + yield* orchestrator.recoverDelegatedTasks; yield* EffectWorker.runDaemon.pipe(Effect.forkScoped); return orchestrator; }), diff --git a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts index cba4cc6fbf19..9d01f22fe6a0 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts @@ -374,7 +374,7 @@ const waitForIdle = Effect.fn("ProviderSwitchTest.waitForIdle")(function* ( }); describe("orchestration v2 provider switching", () => { - for (const scenario of [ + it.live.each([ "compact-native", "compact-fallback", "compact-legacy", @@ -408,35 +408,496 @@ describe("orchestration v2 provider switching", () => { "prior-images-replacement-native", "prior-images-legacy-replacement-native", "delivery-write-failure", - ] as const) { - it.live(`preserves handoffs through ${scenario}`, () => + ] as const)("preserves handoffs through %s", (scenario) => + Effect.scoped( + Effect.gen(function* () { + const cwd = yield* checkpointWorkspace(`handoff-${scenario}`); + const capturedTurns = yield* Ref.make>([]); + const injectedHistory = yield* Ref.make>([]); + const reasoningScenario = scenario.includes("reasoning-change"); + const turnUsageScenario = reasoningScenario || scenario.includes("turn-usage"); + const modelScenario = + scenario.includes("model") || scenario.includes("option-change") || reasoningScenario; + const failStartOnce = yield* Ref.make(false); + const failResumeOnce = yield* Ref.make(false); + const generation = yield* Ref.make(0); + const priorImages = scenario.includes("prior-images"); + const replaceNative = scenario.includes("replacement"); + const capacityScenario = modelScenario || scenario.includes("reported-capacity"); + const returning = + scenario === "large-current-input" || scenario.includes("-change-") || priorImages; + const targetSelection: ModelSelection = !modelScenario + ? CLAUDE_MODEL_SELECTION + : { + ...CLAUDE_MODEL_SELECTION, + ...(reasoningScenario + ? { options: [{ id: "reasoningEffort", value: "low" }] } + : scenario.includes("option-change") + ? { options: [{ id: "contextWindow", value: "1m" }] } + : { model: `${CLAUDE_MODEL_SELECTION.model}-large` }), + }; + const registry = ProviderAdapterRegistry.makeLayer([ + makeTestAdapter({ + instanceId: CODEX_MODEL_SELECTION.instanceId, + driver: CODEX_DRIVER, + capabilities: CodexProviderCapabilitiesV2, + modelSelection: CODEX_MODEL_SELECTION, + responseByRunOrdinal: { 1: "Original partial work" }, + capturedTurns, + }), + makeTestAdapter({ + instanceId: CLAUDE_MODEL_SELECTION.instanceId, + driver: CLAUDE_DRIVER, + capabilities: ClaudeProviderCapabilitiesV2, + modelSelection: CLAUDE_MODEL_SELECTION, + responseByRunOrdinal: {}, + capturedTurns, + ...(scenario.includes("retry") || scenario.includes("unsent") ? { failStartOnce } : {}), + ...(replaceNative ? { failResumeOnce, nativeThreadGeneration: generation } : {}), + ...(scenario.includes("reported-capacity") + ? { initialContextUsage: { usedTokens: 999_999, maxTokens: 1_000_000 } } + : {}), + ...(turnUsageScenario + ? { + canReuseContextUsage: canReuseCodexContextUsage, + tokenUsageByRunOrdinal: { + 2: { + usedTokens: replaceNative ? 30_000 : 37_321, + maxTokens: replaceNative + ? scenario.includes("small") + ? 32_000 + : 64_000 + : 258_400, + }, + }, + } + : modelScenario + ? { + getModelContextWindow: (selection: ModelSelection) => + selection.model.endsWith("-large") || + selection.options?.some( + (option) => option.id === "contextWindow" && option.value === "1m", + ) + ? 1_000_000 + : 32_000, + } + : scenario === "large-current-input" || priorImages + ? { getModelContextWindow: () => 32_000 } + : {}), + ...(scenario.endsWith("-native") || scenario === "large-current-input" + ? { injectedHistory } + : {}), + }), + ]); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const eventSink = yield* EventSink.EventSinkV2; + const screenshot: ChatAttachment = { + type: "image", + id: "screenshot", + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: 100_000, + }; + const screenshots = Array.from( + { + length: + scenario.includes("eight") || capacityScenario + ? 8 + : scenario.includes("pair") + ? 2 + : 1, + }, + (_, index) => ({ ...screenshot, id: `screenshot-${index}` }), + ); + const targetOrdinal = returning ? 4 : 2; + const dispatch = (ordinal: number, text: string, selection: ModelSelection) => + orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`regression:${ordinal}`), + threadId, + messageId: MessageId.make(`regression:${ordinal}`), + createdBy: "user", + creationSource: "web", + text, + attachments: + turnUsageScenario && ordinal === 2 + ? Array.from({ length: 8 }, (_, index) => ({ + ...screenshot, + id: `prior-${index}`, + })) + : turnUsageScenario && ordinal >= targetOrdinal + ? [screenshot, { ...screenshot, id: "current-2" }] + : scenario.startsWith("screenshot") && ordinal >= targetOrdinal + ? screenshots + : priorImages && ordinal === (scenario.startsWith("imported") ? 1 : 2) + ? [screenshot] + : [], + modelSelection: selection, + dispatchMode: { type: "start_immediately" }, + }); + const wait = (ordinal: number) => + orchestrator.streamStoredEvents.pipe( + Stream.filter( + ({ event }) => + event.type === "run.updated" && + event.payload.ordinal === ordinal && + (event.payload.status === "completed" || event.payload.status === "failed"), + ), + Stream.runHead, + Effect.andThen(worker.drain()), + ); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("regression:create"), + threadId, + projectId, + createdBy: "user", + creationSource: "web", + title: "Handoff regression", + modelSelection: CODEX_MODEL_SELECTION, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + yield* dispatch(1, "Original request with constraints", CODEX_MODEL_SELECTION); + yield* wait(1); + const write = eventSink.write; + const spy = + scenario === "delivery-write-failure" + ? vi.spyOn(eventSink, "write").mockImplementation((input) => + input.events.some( + (event) => + event.type === "context-handoff.updated" && + event.payload.delivery?.status === "inline", + ) + ? Effect.fail( + new EventSink.EventSinkWriteError({ + eventCount: input.events.length, + cause: "bookkeeping unavailable", + }), + ) + : write(input), + ) + : undefined; + yield* Effect.addFinalizer(() => Effect.sync(() => spy?.mockRestore())); + const current = scenario.startsWith("compact") + ? "/compact" + : capacityScenario && !turnUsageScenario + ? "x".repeat(70_000) + : scenario === "large-current-input" + ? "x".repeat(9_000) + : "Continue work"; + // First establish the returning native thread: the current request must + // not be charged as existing context on the subsequent handoff. + if (returning) { + if (scenario.includes("unsent")) yield* Ref.set(failStartOnce, true); + yield* dispatch( + 2, + turnUsageScenario ? "x".repeat(27_460) : "Establish target", + CLAUDE_MODEL_SELECTION, + ); + yield* wait(2); + if (modelScenario && !turnUsageScenario) { + const target = (yield* orchestrator.getThreadProjection( + threadId, + )).providerThreads.find( + (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId, + )!; + yield* eventSink.write({ + events: [ + { + id: EventId.make("old-model-usage"), + type: "provider-thread.updated", + threadId, + occurredAt: yield* DateTime.now, + payload: { + ...target, + contextUsage: { + usedTokens: 30_000, + maxTokens: 32_000, + autoCompactThreshold: 31_000, + }, + }, + }, + ], + }); + } + if (scenario.includes("legacy-replacement")) { + const existing = yield* orchestrator.getThreadProjection(threadId); + yield* eventSink.write({ + events: existing.attempts.map( + ({ nativeThreadId: _nativeThreadId, ...legacy }, index) => ({ + id: EventId.make(`legacy-attempt:${index}`), + type: "run-attempt.updated" as const, + threadId, + occurredAt: existing.thread.createdAt, + payload: legacy, + }), + ), + }); + } + if (scenario.includes("telemetry")) { + const target = (yield* orchestrator.getThreadProjection( + threadId, + )).providerThreads.find( + (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId, + )!; + yield* eventSink.write({ + events: [ + { + id: EventId.make("compacted-context-usage"), + type: "provider-thread.updated", + threadId, + occurredAt: yield* DateTime.now, + payload: { ...target, contextUsage: { usedTokens: 100, maxTokens: 32_000 } }, + }, + ], + }); + } + yield* dispatch( + 3, + priorImages && !replaceNative + ? "New source constraint " + "q".repeat(9_000) + : "New source constraint", + CODEX_MODEL_SELECTION, + ); + yield* wait(3); + } + if (replaceNative) { + const target = (yield* orchestrator.getThreadProjection(threadId)).providerThreads.find( + (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId, + )!; + yield* orchestrator.dispatch({ + type: "provider-session.detach", + commandId: CommandId.make("detach-for-native-replacement"), + threadId, + providerSessionId: target.providerSessionId!, + }); + yield* worker.drain(); + yield* Ref.set(failResumeOnce, true); + } + if (scenario.includes("retry")) yield* Ref.set(failStartOnce, true); + yield* dispatch(targetOrdinal, current, targetSelection); + yield* wait(targetOrdinal); + if (scenario.includes("retry")) { + const failed = yield* orchestrator.getThreadProjection(threadId); + assert.equal(failed.runs.at(-1)?.status, "failed"); + const failedUsage = failed.providerThreads.find( + (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId, + )!.contextUsage; + if (reasoningScenario || scenario.includes("turn-usage")) { + assert.deepEqual(failedUsage, { usedTokens: 37_321, maxTokens: 258_400 }); + } else { + assert.deepEqual(failedUsage, { usedTokens: 30_000, maxTokens: 1_000_000 }); + } + if (reasoningScenario) { + const target = failed.providerThreads.find( + (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId, + )!; + yield* orchestrator.dispatch({ + type: "provider-session.detach", + commandId: CommandId.make("detach-before-reasoning-retry"), + threadId, + providerSessionId: target.providerSessionId!, + }); + yield* worker.drain(); + } + yield* dispatch(targetOrdinal + 1, current, targetSelection); + yield* wait(targetOrdinal + 1); + } + if (reasoningScenario && replaceNative) { + const replaced = yield* orchestrator.getThreadProjection(threadId); + assert.equal( + replaced.runs.at(-1)?.status, + scenario.includes("small") ? "failed" : "completed", + ); + const target = replaced.providerThreads.find( + (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId, + )!; + assert.isNull(target.contextUsage); + assert.equal(yield* Ref.get(generation), 2); + assert.equal( + (yield* Ref.get(capturedTurns)).at(-1)!.driver, + scenario.includes("small") ? CODEX_DRIVER : CLAUDE_DRIVER, + ); + return; + } + if (replaceNative) { + if (scenario.includes("legacy-replacement")) { + const recovered = yield* orchestrator.getThreadProjection(threadId); + const handoff = recovered.contextHandoffs.at(-1)!; + const { history: _history, ...legacyHandoff } = handoff; + yield* eventSink.write({ + events: [ + { + id: EventId.make("legacy-replacement-handoff"), + type: "context-handoff.updated", + threadId, + occurredAt: yield* DateTime.now, + payload: { + ...legacyHandoff, + delivery: { + nativeThreadId: handoff.delivery!.nativeThreadId, + status: handoff.delivery!.status, + itemIds: [], + }, + }, + }, + ], + }); + } + yield* dispatch( + targetOrdinal + 1, + "Continue after native replacement", + CLAUDE_MODEL_SELECTION, + ); + yield* wait(targetOrdinal + 1); + yield* dispatch( + targetOrdinal + 2, + "Another source constraint " + "q".repeat(9_000), + CODEX_MODEL_SELECTION, + ); + yield* wait(targetOrdinal + 2); + yield* dispatch(targetOrdinal + 3, current, CLAUDE_MODEL_SELECTION); + yield* wait(targetOrdinal + 3); + } + const projection = yield* orchestrator.getThreadProjection(threadId); + assert.equal(projection.runs.at(-1)?.status, "completed"); + if (priorImages) { + const handoff = projection.contextHandoffs.at(-1)!; + const context = scenario.endsWith("-native") + ? yield* encodeJson(yield* Ref.get(injectedHistory)) + : (yield* Ref.get(capturedTurns)).at(-1)!.text; + const shouldFit = + turnUsageScenario || + scenario.startsWith("imported") || + scenario.includes("telemetry") || + scenario.includes("unsent") || + replaceNative; + const sourceText = + `${replaceNative ? "Another" : "New"} source constraint ` + "q".repeat(9_000); + const sourceItem = projection.turnItems.find( + (item) => item.type === "user_message" && item.text === sourceText, + )!; + assert.isDefined(sourceItem); + if (shouldFit) { + assert.include(context, sourceText); + assert.include( + projection.contextHandoffs + .filter( + (record) => + record.targetRunId === + (scenario.includes("retry") + ? projection.runs.find((run) => run.ordinal === targetOrdinal)!.id + : projection.runs.at(-1)!.id), + ) + .flatMap((record) => record.delivery?.itemIds ?? []), + sourceItem.id, + ); + } else { + assert.notInclude(context, "q".repeat(9_000)); + assert.isAbove(handoff.delivery!.omittedItemIds!.length, 0); + } + const latestRun = projection.runs.at(-1)!; + const latestAttempt = projection.attempts.find( + (attempt) => attempt.id === latestRun.activeAttemptId, + )!; + const target = projection.providerThreads.find( + (thread) => thread.id === latestAttempt.providerThreadId, + )!; + assert.equal(latestAttempt.nativeThreadId, target.nativeThreadRef!.nativeId); + if (turnUsageScenario) { + if (replaceNative) assert.isNull(target.contextUsage); + else + assert.deepEqual(target.contextUsage, { usedTokens: 37_321, maxTokens: 258_400 }); + assert.lengthOf((yield* Ref.get(capturedTurns)).at(-1)!.attachments, 2); + } + if (replaceNative) assert.equal(yield* Ref.get(generation), 2); + return; + } + const handoff = projection.contextHandoffs.at(-1)!; + if (scenario.startsWith("screenshot")) { + assert.deepEqual((yield* Ref.get(capturedTurns)).at(-1)!.attachments, screenshots); + } + if (scenario === "compact-fallback" || scenario === "compact-legacy") { + if (scenario === "compact-legacy") { + const { history: _history, ...legacyHandoff } = handoff; + yield* eventSink.write({ + events: [ + { + id: EventId.make("legacy-handoff-shape"), + type: "context-handoff.updated", + threadId, + runId: handoff.targetRunId, + occurredAt: yield* DateTime.now, + payload: legacyHandoff, + }, + ], + }); + } + assert.isUndefined(handoff.delivery); + yield* dispatch(3, "Continue after compact", CLAUDE_MODEL_SELECTION); + yield* wait(3); + assert.include( + (yield* Ref.get(capturedTurns)).at(-1)!.text, + "Original request with constraints", + ); + assert.equal( + (yield* orchestrator.getThreadProjection(threadId)).contextHandoffs.at(-1)?.delivery + ?.status, + "inline", + ); + } else if ( + scenario === "delivery-write-failure" || + (scenario.startsWith("screenshot") && scenario.endsWith("fallback")) + ) { + assert.equal( + handoff.delivery?.status, + scenario.startsWith("screenshot") ? "inline" : "pending", + ); + assert.include( + (yield* Ref.get(capturedTurns)).at(-1)!.text, + "Original request with constraints", + ); + } else { + assert.equal(handoff.delivery?.status, "injected"); + assert.equal((yield* Ref.get(capturedTurns)).at(-1)!.text, current); + assert.include( + yield* encodeJson(yield* Ref.get(injectedHistory)), + "Original request with constraints", + ); + } + }).pipe( + Effect.provide( + makeOrchestratorV2ReplayLayerWithRegistry( + { + name: `handoff-${scenario}`, + runtimePolicyOverride: { + cwd, + approvalPolicy: "never", + sandboxPolicy: { type: "readOnly" }, + }, + }, + registry, + ), + ), + ); + }), + ), + ); + it.live.each(["turn-start", "injection", "large-missed-request"] as const)( + "recovers %s failure without duplicating history in the same native thread", + (failure) => Effect.scoped( Effect.gen(function* () { - const cwd = yield* checkpointWorkspace(`handoff-${scenario}`); + const cwd = yield* checkpointWorkspace(`handoff-retry-${failure}`); const capturedTurns = yield* Ref.make>([]); const injectedHistory = yield* Ref.make>([]); - const reasoningScenario = scenario.includes("reasoning-change"); - const turnUsageScenario = reasoningScenario || scenario.includes("turn-usage"); - const modelScenario = - scenario.includes("model") || scenario.includes("option-change") || reasoningScenario; - const failStartOnce = yield* Ref.make(false); - const failResumeOnce = yield* Ref.make(false); + const failOnce = yield* Ref.make(true); const generation = yield* Ref.make(0); - const priorImages = scenario.includes("prior-images"); - const replaceNative = scenario.includes("replacement"); - const capacityScenario = modelScenario || scenario.includes("reported-capacity"); - const returning = - scenario === "large-current-input" || scenario.includes("-change-") || priorImages; - const targetSelection: ModelSelection = !modelScenario - ? CLAUDE_MODEL_SELECTION - : { - ...CLAUDE_MODEL_SELECTION, - ...(reasoningScenario - ? { options: [{ id: "reasoningEffort", value: "low" }] } - : scenario.includes("option-change") - ? { options: [{ id: "contextWindow", value: "1m" }] } - : { model: `${CLAUDE_MODEL_SELECTION.model}-large` }), - }; const registry = ProviderAdapterRegistry.makeLayer([ makeTestAdapter({ instanceId: CODEX_MODEL_SELECTION.instanceId, @@ -453,94 +914,31 @@ describe("orchestration v2 provider switching", () => { modelSelection: CLAUDE_MODEL_SELECTION, responseByRunOrdinal: {}, capturedTurns, - ...(scenario.includes("retry") || scenario.includes("unsent") - ? { failStartOnce } - : {}), - ...(replaceNative ? { failResumeOnce, nativeThreadGeneration: generation } : {}), - ...(scenario.includes("reported-capacity") - ? { initialContextUsage: { usedTokens: 999_999, maxTokens: 1_000_000 } } - : {}), - ...(turnUsageScenario - ? { - canReuseContextUsage: canReuseCodexContextUsage, - tokenUsageByRunOrdinal: { - 2: { - usedTokens: replaceNative ? 30_000 : 37_321, - maxTokens: replaceNative - ? scenario.includes("small") - ? 32_000 - : 64_000 - : 258_400, - }, - }, - } - : modelScenario - ? { - getModelContextWindow: (selection: ModelSelection) => - selection.model.endsWith("-large") || - selection.options?.some( - (option) => option.id === "contextWindow" && option.value === "1m", - ) - ? 1_000_000 - : 32_000, - } - : scenario === "large-current-input" || priorImages - ? { getModelContextWindow: () => 32_000 } - : {}), - ...(scenario.endsWith("-native") || scenario === "large-current-input" - ? { injectedHistory } - : {}), + injectedHistory, + nativeThreadGeneration: generation, + getModelContextWindow: () => 32_000, + ...(failure !== "injection" + ? { failStartOnce: failOnce } + : { failInjectionOnce: failOnce }), }), ]); yield* Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; - const eventSink = yield* EventSink.EventSinkV2; - const screenshot: ChatAttachment = { - type: "image", - id: "screenshot", - name: "screenshot.png", - mimeType: "image/png", - sizeBytes: 100_000, - }; - const screenshots = Array.from( - { - length: - scenario.includes("eight") || capacityScenario - ? 8 - : scenario.includes("pair") - ? 2 - : 1, - }, - (_, index) => ({ ...screenshot, id: `screenshot-${index}` }), - ); - const targetOrdinal = returning ? 4 : 2; const dispatch = (ordinal: number, text: string, selection: ModelSelection) => orchestrator.dispatch({ type: "message.dispatch", - commandId: CommandId.make(`regression:${ordinal}`), + commandId: CommandId.make(`retry:${ordinal}`), threadId, - messageId: MessageId.make(`regression:${ordinal}`), + messageId: MessageId.make(`retry:${ordinal}`), createdBy: "user", creationSource: "web", text, - attachments: - turnUsageScenario && ordinal === 2 - ? Array.from({ length: 8 }, (_, index) => ({ - ...screenshot, - id: `prior-${index}`, - })) - : turnUsageScenario && ordinal >= targetOrdinal - ? [screenshot, { ...screenshot, id: "current-2" }] - : scenario.startsWith("screenshot") && ordinal >= targetOrdinal - ? screenshots - : priorImages && ordinal === (scenario.startsWith("imported") ? 1 : 2) - ? [screenshot] - : [], + attachments: [], modelSelection: selection, dispatchMode: { type: "start_immediately" }, }); - const wait = (ordinal: number) => + const wait = (ordinal: number, status: "failed" | "completed") => orchestrator.streamStoredEvents.pipe( Stream.filter( ({ event }) => @@ -550,15 +948,23 @@ describe("orchestration v2 provider switching", () => { ), Stream.runHead, Effect.andThen(worker.drain()), + Effect.andThen( + Effect.gen(function* () { + assert.equal( + (yield* orchestrator.getThreadProjection(threadId)).runs.at(-1)?.status, + status, + ); + }), + ), ); yield* orchestrator.dispatch({ type: "thread.create", - commandId: CommandId.make("regression:create"), + commandId: CommandId.make("retry:create"), threadId, projectId, createdBy: "user", creationSource: "web", - title: "Handoff regression", + title: "Handoff retry", modelSelection: CODEX_MODEL_SELECTION, runtimeMode: "full-access", interactionMode: "default", @@ -566,320 +972,71 @@ describe("orchestration v2 provider switching", () => { worktreePath: null, }); yield* dispatch(1, "Original request with constraints", CODEX_MODEL_SELECTION); - yield* wait(1); - const write = eventSink.write; - const spy = - scenario === "delivery-write-failure" - ? vi.spyOn(eventSink, "write").mockImplementation((input) => - input.events.some( - (event) => - event.type === "context-handoff.updated" && - event.payload.delivery?.status === "inline", - ) - ? Effect.fail( - new EventSink.EventSinkWriteError({ - eventCount: input.events.length, - cause: "bookkeeping unavailable", - }), - ) - : write(input), - ) - : undefined; - yield* Effect.addFinalizer(() => Effect.sync(() => spy?.mockRestore())); - const current = scenario.startsWith("compact") - ? "/compact" - : capacityScenario && !turnUsageScenario - ? "x".repeat(70_000) - : scenario === "large-current-input" - ? "x".repeat(9_000) - : "Continue work"; - // First establish the returning native thread: the current request must - // not be charged as existing context on the subsequent handoff. - if (returning) { - if (scenario.includes("unsent")) yield* Ref.set(failStartOnce, true); - yield* dispatch( - 2, - turnUsageScenario ? "x".repeat(27_460) : "Establish target", - CLAUDE_MODEL_SELECTION, + yield* wait(1, "completed"); + yield* dispatch( + 2, + failure === "large-missed-request" ? "x".repeat(7_000) : "First target request", + CLAUDE_MODEL_SELECTION, + ); + yield* wait(2, "failed"); + const failed = yield* orchestrator.getThreadProjection(threadId); + const failedHandoff = failed.contextHandoffs.at(-1)!; + assert.equal( + failedHandoff.delivery?.status, + failure !== "injection" ? "injected" : "pending", + ); + const historyBeforeRetry = (yield* Ref.get(injectedHistory)).length; + const retryText = + failure === "large-missed-request" ? "y".repeat(10_000) : "Retry target request"; + yield* dispatch(3, retryText, CLAUDE_MODEL_SELECTION); + yield* wait(3, "completed"); + const retried = yield* orchestrator.getThreadProjection(threadId); + const lastTurn = (yield* Ref.get(capturedTurns)).at(-1)!; + assert.equal(lastTurn.text, retryText); + if (failure !== "injection") { + const delta = yield* encodeJson( + (yield* Ref.get(injectedHistory)).slice(historyBeforeRetry), ); - yield* wait(2); - if (modelScenario && !turnUsageScenario) { - const target = (yield* orchestrator.getThreadProjection( - threadId, - )).providerThreads.find( - (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId, + if (failure === "large-missed-request") { + assert.include(delta, "omitted 1 items"); + const missedRequest = retried.turnItems.find( + (item) => item.type === "user_message" && item.text === "x".repeat(7_000), )!; - yield* eventSink.write({ - events: [ - { - id: EventId.make("old-model-usage"), - type: "provider-thread.updated", - threadId, - occurredAt: yield* DateTime.now, - payload: { - ...target, - contextUsage: { - usedTokens: 30_000, - maxTokens: 32_000, - autoCompactThreshold: 31_000, - }, - }, - }, - ], - }); - } - if (scenario.includes("legacy-replacement")) { - const existing = yield* orchestrator.getThreadProjection(threadId); - yield* eventSink.write({ - events: existing.attempts.map( - ({ nativeThreadId: _nativeThreadId, ...legacy }, index) => ({ - id: EventId.make(`legacy-attempt:${index}`), - type: "run-attempt.updated" as const, - threadId, - occurredAt: existing.thread.createdAt, - payload: legacy, - }), - ), - }); - } - if (scenario.includes("telemetry")) { - const target = (yield* orchestrator.getThreadProjection( - threadId, - )).providerThreads.find( - (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId, - )!; - yield* eventSink.write({ - events: [ - { - id: EventId.make("compacted-context-usage"), - type: "provider-thread.updated", - threadId, - occurredAt: yield* DateTime.now, - payload: { ...target, contextUsage: { usedTokens: 100, maxTokens: 32_000 } }, - }, - ], - }); - } - yield* dispatch( - 3, - priorImages && !replaceNative - ? "New source constraint " + "q".repeat(9_000) - : "New source constraint", - CODEX_MODEL_SELECTION, - ); - yield* wait(3); - } - if (replaceNative) { - const target = (yield* orchestrator.getThreadProjection( - threadId, - )).providerThreads.find( - (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId, - )!; - yield* orchestrator.dispatch({ - type: "provider-session.detach", - commandId: CommandId.make("detach-for-native-replacement"), - threadId, - providerSessionId: target.providerSessionId!, - }); - yield* worker.drain(); - yield* Ref.set(failResumeOnce, true); - } - if (scenario.includes("retry")) yield* Ref.set(failStartOnce, true); - yield* dispatch(targetOrdinal, current, targetSelection); - yield* wait(targetOrdinal); - if (scenario.includes("retry")) { - const failed = yield* orchestrator.getThreadProjection(threadId); - assert.equal(failed.runs.at(-1)?.status, "failed"); - const failedUsage = failed.providerThreads.find( - (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId, - )!.contextUsage; - if (reasoningScenario || scenario.includes("turn-usage")) { - assert.deepEqual(failedUsage, { usedTokens: 37_321, maxTokens: 258_400 }); - } else { - assert.deepEqual(failedUsage, { usedTokens: 30_000, maxTokens: 1_000_000 }); - } - if (reasoningScenario) { - const target = failed.providerThreads.find( - (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId, - )!; - yield* orchestrator.dispatch({ - type: "provider-session.detach", - commandId: CommandId.make("detach-before-reasoning-retry"), - threadId, - providerSessionId: target.providerSessionId!, - }); - yield* worker.drain(); - } - yield* dispatch(targetOrdinal + 1, current, targetSelection); - yield* wait(targetOrdinal + 1); - } - if (reasoningScenario && replaceNative) { - const replaced = yield* orchestrator.getThreadProjection(threadId); - assert.equal( - replaced.runs.at(-1)?.status, - scenario.includes("small") ? "failed" : "completed", - ); - const target = replaced.providerThreads.find( - (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId, - )!; - assert.isNull(target.contextUsage); + const delivery = retried.contextHandoffs.at(-1)!.delivery!; + assert.include(delivery.omittedItemIds ?? [], missedRequest.id); + assert.notInclude(delivery.itemIds, missedRequest.id); + } else assert.include(delta, "First target request"); + assert.notInclude(delta, "Original request with constraints"); + assert.notInclude(delta, "Original partial work"); + assert.equal(yield* Ref.get(generation), 1); + } else { assert.equal(yield* Ref.get(generation), 2); - assert.equal( - (yield* Ref.get(capturedTurns)).at(-1)!.driver, - scenario.includes("small") ? CODEX_DRIVER : CLAUDE_DRIVER, - ); - return; - } - if (replaceNative) { - if (scenario.includes("legacy-replacement")) { - const recovered = yield* orchestrator.getThreadProjection(threadId); - const handoff = recovered.contextHandoffs.at(-1)!; - const { history: _history, ...legacyHandoff } = handoff; - yield* eventSink.write({ - events: [ - { - id: EventId.make("legacy-replacement-handoff"), - type: "context-handoff.updated", - threadId, - occurredAt: yield* DateTime.now, - payload: { - ...legacyHandoff, - delivery: { - nativeThreadId: handoff.delivery!.nativeThreadId, - status: handoff.delivery!.status, - itemIds: [], - }, - }, - }, - ], - }); - } - yield* dispatch( - targetOrdinal + 1, - "Continue after native replacement", - CLAUDE_MODEL_SELECTION, - ); - yield* wait(targetOrdinal + 1); - yield* dispatch( - targetOrdinal + 2, - "Another source constraint " + "q".repeat(9_000), - CODEX_MODEL_SELECTION, - ); - yield* wait(targetOrdinal + 2); - yield* dispatch(targetOrdinal + 3, current, CLAUDE_MODEL_SELECTION); - yield* wait(targetOrdinal + 3); - } - const projection = yield* orchestrator.getThreadProjection(threadId); - assert.equal(projection.runs.at(-1)?.status, "completed"); - if (priorImages) { - const handoff = projection.contextHandoffs.at(-1)!; - const context = scenario.endsWith("-native") - ? yield* encodeJson(yield* Ref.get(injectedHistory)) - : (yield* Ref.get(capturedTurns)).at(-1)!.text; - const shouldFit = - turnUsageScenario || - scenario.startsWith("imported") || - scenario.includes("telemetry") || - scenario.includes("unsent") || - replaceNative; - const sourceText = - `${replaceNative ? "Another" : "New"} source constraint ` + "q".repeat(9_000); - const sourceItem = projection.turnItems.find( - (item) => item.type === "user_message" && item.text === sourceText, - )!; - assert.isDefined(sourceItem); - if (shouldFit) { - assert.include(context, sourceText); - assert.include( - projection.contextHandoffs - .filter( - (record) => - record.targetRunId === - (scenario.includes("retry") - ? projection.runs.find((run) => run.ordinal === targetOrdinal)!.id - : projection.runs.at(-1)!.id), - ) - .flatMap((record) => record.delivery?.itemIds ?? []), - sourceItem.id, - ); - } else { - assert.notInclude(context, "q".repeat(9_000)); - assert.isAbove(handoff.delivery!.omittedItemIds!.length, 0); - } - const latestRun = projection.runs.at(-1)!; - const latestAttempt = projection.attempts.find( - (attempt) => attempt.id === latestRun.activeAttemptId, - )!; - const target = projection.providerThreads.find( - (thread) => thread.id === latestAttempt.providerThreadId, - )!; - assert.equal(latestAttempt.nativeThreadId, target.nativeThreadRef!.nativeId); - if (turnUsageScenario) { - if (replaceNative) assert.isNull(target.contextUsage); - else - assert.deepEqual(target.contextUsage, { usedTokens: 37_321, maxTokens: 258_400 }); - assert.lengthOf((yield* Ref.get(capturedTurns)).at(-1)!.attachments, 2); - } - if (replaceNative) assert.equal(yield* Ref.get(generation), 2); - return; - } - const handoff = projection.contextHandoffs.at(-1)!; - if (scenario.startsWith("screenshot")) { - assert.deepEqual((yield* Ref.get(capturedTurns)).at(-1)!.attachments, screenshots); - } - if (scenario === "compact-fallback" || scenario === "compact-legacy") { - if (scenario === "compact-legacy") { - const { history: _history, ...legacyHandoff } = handoff; - yield* eventSink.write({ - events: [ - { - id: EventId.make("legacy-handoff-shape"), - type: "context-handoff.updated", - threadId, - runId: handoff.targetRunId, - occurredAt: yield* DateTime.now, - payload: legacyHandoff, - }, - ], - }); - } - assert.isUndefined(handoff.delivery); - yield* dispatch(3, "Continue after compact", CLAUDE_MODEL_SELECTION); - yield* wait(3); - assert.include( - (yield* Ref.get(capturedTurns)).at(-1)!.text, - "Original request with constraints", - ); - assert.equal( - (yield* orchestrator.getThreadProjection(threadId)).contextHandoffs.at(-1)?.delivery - ?.status, - "inline", - ); - } else if ( - scenario === "delivery-write-failure" || - (scenario.startsWith("screenshot") && scenario.endsWith("fallback")) - ) { - assert.equal( - handoff.delivery?.status, - scenario.startsWith("screenshot") ? "inline" : "pending", - ); - assert.include( - (yield* Ref.get(capturedTurns)).at(-1)!.text, - "Original request with constraints", + assert.notEqual( + retried.contextHandoffs.at(-1)?.delivery?.nativeThreadId, + failedHandoff.delivery?.nativeThreadId, ); - } else { - assert.equal(handoff.delivery?.status, "injected"); - assert.equal((yield* Ref.get(capturedTurns)).at(-1)!.text, current); - assert.include( - yield* encodeJson(yield* Ref.get(injectedHistory)), - "Original request with constraints", + const newHistory = yield* encodeJson( + (yield* Ref.get(injectedHistory)).slice(historyBeforeRetry), ); + assert.include(newHistory, "Original request with constraints"); + assert.include(newHistory, "Original partial work"); + assert.notInclude(newHistory, "Retry target request"); } + const beforeFollowup = yield* Ref.get(injectedHistory); + const followup = "z".repeat(6_000); + yield* dispatch(4, followup, CLAUDE_MODEL_SELECTION); + yield* wait(4, "completed"); + assert.equal((yield* Ref.get(capturedTurns)).at(-1)!.text, followup); + assert.deepEqual(yield* Ref.get(injectedHistory), beforeFollowup); + assert.equal( + (yield* orchestrator.getThreadProjection(threadId)).contextHandoffs.length, + retried.contextHandoffs.length, + ); }).pipe( Effect.provide( makeOrchestratorV2ReplayLayerWithRegistry( { - name: `handoff-${scenario}`, + name: `handoff-retry-${failure}`, runtimePolicyOverride: { cwd, approvalPolicy: "never", @@ -892,368 +1049,194 @@ describe("orchestration v2 provider switching", () => { ); }), ), - ); - } - for (const failure of ["turn-start", "injection", "large-missed-request"] as const) { - it.live( - `recovers ${failure} failure without duplicating history in the same native thread`, - () => - Effect.scoped( - Effect.gen(function* () { - const cwd = yield* checkpointWorkspace(`handoff-retry-${failure}`); - const capturedTurns = yield* Ref.make>([]); - const injectedHistory = yield* Ref.make>([]); - const failOnce = yield* Ref.make(true); - const generation = yield* Ref.make(0); - const registry = ProviderAdapterRegistry.makeLayer([ - makeTestAdapter({ - instanceId: CODEX_MODEL_SELECTION.instanceId, - driver: CODEX_DRIVER, - capabilities: CodexProviderCapabilitiesV2, - modelSelection: CODEX_MODEL_SELECTION, - responseByRunOrdinal: { 1: "Original partial work" }, - capturedTurns, - }), - makeTestAdapter({ - instanceId: CLAUDE_MODEL_SELECTION.instanceId, - driver: CLAUDE_DRIVER, - capabilities: ClaudeProviderCapabilitiesV2, - modelSelection: CLAUDE_MODEL_SELECTION, - responseByRunOrdinal: {}, - capturedTurns, - injectedHistory, - nativeThreadGeneration: generation, - getModelContextWindow: () => 32_000, - ...(failure !== "injection" - ? { failStartOnce: failOnce } - : { failInjectionOnce: failOnce }), - }), - ]); - yield* Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; - const dispatch = (ordinal: number, text: string, selection: ModelSelection) => - orchestrator.dispatch({ - type: "message.dispatch", - commandId: CommandId.make(`retry:${ordinal}`), - threadId, - messageId: MessageId.make(`retry:${ordinal}`), - createdBy: "user", - creationSource: "web", - text, - attachments: [], - modelSelection: selection, - dispatchMode: { type: "start_immediately" }, - }); - const wait = (ordinal: number, status: "failed" | "completed") => - orchestrator.streamStoredEvents.pipe( - Stream.filter( - ({ event }) => - event.type === "run.updated" && - event.payload.ordinal === ordinal && - (event.payload.status === "completed" || event.payload.status === "failed"), - ), - Stream.runHead, - Effect.andThen(worker.drain()), - Effect.andThen( - Effect.gen(function* () { - assert.equal( - (yield* orchestrator.getThreadProjection(threadId)).runs.at(-1)?.status, - status, - ); - }), - ), - ); - yield* orchestrator.dispatch({ - type: "thread.create", - commandId: CommandId.make("retry:create"), - threadId, - projectId, - createdBy: "user", - creationSource: "web", - title: "Handoff retry", - modelSelection: CODEX_MODEL_SELECTION, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - }); - yield* dispatch(1, "Original request with constraints", CODEX_MODEL_SELECTION); - yield* wait(1, "completed"); - yield* dispatch( - 2, - failure === "large-missed-request" ? "x".repeat(7_000) : "First target request", - CLAUDE_MODEL_SELECTION, - ); - yield* wait(2, "failed"); - const failed = yield* orchestrator.getThreadProjection(threadId); - const failedHandoff = failed.contextHandoffs.at(-1)!; - assert.equal( - failedHandoff.delivery?.status, - failure !== "injection" ? "injected" : "pending", - ); - const historyBeforeRetry = (yield* Ref.get(injectedHistory)).length; - const retryText = - failure === "large-missed-request" ? "y".repeat(10_000) : "Retry target request"; - yield* dispatch(3, retryText, CLAUDE_MODEL_SELECTION); - yield* wait(3, "completed"); - const retried = yield* orchestrator.getThreadProjection(threadId); - const lastTurn = (yield* Ref.get(capturedTurns)).at(-1)!; - assert.equal(lastTurn.text, retryText); - if (failure !== "injection") { - const delta = yield* encodeJson( - (yield* Ref.get(injectedHistory)).slice(historyBeforeRetry), - ); - if (failure === "large-missed-request") { - assert.include(delta, "omitted 1 items"); - const missedRequest = retried.turnItems.find( - (item) => item.type === "user_message" && item.text === "x".repeat(7_000), - )!; - const delivery = retried.contextHandoffs.at(-1)!.delivery!; - assert.include(delivery.omittedItemIds ?? [], missedRequest.id); - assert.notInclude(delivery.itemIds, missedRequest.id); - } else assert.include(delta, "First target request"); - assert.notInclude(delta, "Original request with constraints"); - assert.notInclude(delta, "Original partial work"); - assert.equal(yield* Ref.get(generation), 1); - } else { - assert.equal(yield* Ref.get(generation), 2); - assert.notEqual( - retried.contextHandoffs.at(-1)?.delivery?.nativeThreadId, - failedHandoff.delivery?.nativeThreadId, - ); - const newHistory = yield* encodeJson( - (yield* Ref.get(injectedHistory)).slice(historyBeforeRetry), - ); - assert.include(newHistory, "Original request with constraints"); - assert.include(newHistory, "Original partial work"); - assert.notInclude(newHistory, "Retry target request"); - } - const beforeFollowup = yield* Ref.get(injectedHistory); - const followup = "z".repeat(6_000); - yield* dispatch(4, followup, CLAUDE_MODEL_SELECTION); - yield* wait(4, "completed"); - assert.equal((yield* Ref.get(capturedTurns)).at(-1)!.text, followup); - assert.deepEqual(yield* Ref.get(injectedHistory), beforeFollowup); - assert.equal( - (yield* orchestrator.getThreadProjection(threadId)).contextHandoffs.length, - retried.contextHandoffs.length, - ); - }).pipe( - Effect.provide( - makeOrchestratorV2ReplayLayerWithRegistry( - { - name: `handoff-retry-${failure}`, - runtimePolicyOverride: { - cwd, - approvalPolicy: "never", - sandboxPolicy: { type: "readOnly" }, - }, - }, - registry, - ), - ), - ); - }), - ), - ); - } + ); - for (const status of ["failed", "interrupted"] as const) { - for (const queued of [false, true]) { - for (const returning of [false, true]) { - for (const native of [false, true]) { - it.live( - `hands off ${status} context ${queued ? "through the queue" : "immediately"} to ${returning ? "a returning" : "a new"} provider via ${native ? "native history" : "text"}`, - () => - Effect.scoped( - Effect.gen(function* () { - const cwd = yield* checkpointWorkspace( - `handoff-${status}-${queued}-${returning}`, - ); - const capturedTurns = yield* Ref.make>([]); - const injectedHistory = yield* Ref.make>([]); - const started = yield* Deferred.make(); - const release = yield* Deferred.make(); - const sourceOrdinal = returning ? 2 : 1; - const sourceSelection = returning - ? CLAUDE_MODEL_SELECTION - : CODEX_MODEL_SELECTION; - const targetSelection = returning - ? CODEX_MODEL_SELECTION - : CLAUDE_MODEL_SELECTION; - const originalPrompt = - "Keep the release marker violet and preserve the existing API."; - const partialResponse = "I checked the API and found the release configuration."; - const registryLayer = ProviderAdapterRegistry.makeLayer( - ( - [ - [CODEX_MODEL_SELECTION, CODEX_DRIVER, CodexProviderCapabilitiesV2], - [CLAUDE_MODEL_SELECTION, CLAUDE_DRIVER, ClaudeProviderCapabilitiesV2], - ] as const - ).map(([modelSelection, driver, capabilities]) => - makeTestAdapter({ - instanceId: modelSelection.instanceId, - driver, - capabilities, - modelSelection, - responseByRunOrdinal: { [sourceOrdinal]: partialResponse }, - capturedTurns, - ...(native && modelSelection.instanceId === targetSelection.instanceId - ? { injectedHistory } - : {}), - ...(modelSelection.instanceId === sourceSelection.instanceId - ? { - failedRunOrdinals: new Set( - status === "failed" ? [sourceOrdinal] : [], - ), - interruptedRunOrdinals: new Set( - status === "interrupted" ? [sourceOrdinal] : [], - ), - holdRunOrdinal: sourceOrdinal, - holdFirstTurn: started, - releaseFirstTurn: release, - } - : {}), - }), - ), - ); - yield* Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; - const waitForRun = ( - ordinal: number, - expectedStatus: "completed" | "failed" | "interrupted", - ) => - orchestrator.streamStoredEvents.pipe( - Stream.filter( - ({ event }) => - event.type === "run.updated" && - event.threadId === threadId && - event.payload.ordinal === ordinal && - event.payload.status === expectedStatus, - ), - Stream.runHead, - Effect.andThen(worker.drain()), - ); - const dispatch = ( - key: string, - text: string, - modelSelection: ModelSelection, - queue = false, - ) => - orchestrator.dispatch({ - type: "message.dispatch", - commandId: CommandId.make(`command:handoff:${key}`), - threadId, - messageId: MessageId.make(`message:handoff:${key}`), - createdBy: "user", - creationSource: "web", - text, - attachments: [], - modelSelection, - dispatchMode: { type: queue ? "queue_after_active" : "start_immediately" }, - }); - yield* orchestrator.dispatch({ - type: "thread.create", - commandId: CommandId.make("command:handoff:create"), - threadId, - projectId, - createdBy: "user", - creationSource: "web", - title: "Interrupted provider handoff", - modelSelection: CODEX_MODEL_SELECTION, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - }); - if (returning) { - yield* dispatch( - "initial", - "Earlier successful request.", - CODEX_MODEL_SELECTION, - ); - yield* waitForRun(1, "completed"); - } - yield* dispatch("source", originalPrompt, sourceSelection); - yield* Deferred.await(started); - if (queued) { - yield* dispatch("target", "Continue", targetSelection, true); - assert.equal( - (yield* orchestrator.getThreadProjection(threadId)).runs.at(-1)?.status, - "queued", - ); - } - yield* Deferred.succeed(release, undefined); - yield* waitForRun(sourceOrdinal, status); - if (!queued) { - yield* dispatch("target", "Continue", targetSelection); - } - yield* waitForRun(sourceOrdinal + 1, "completed"); - const projection = yield* orchestrator.getThreadProjection(threadId); - const targetRun = projection.runs.at(-1)!; - const handoff = projection.contextHandoffs.find( - (candidate) => candidate.id === targetRun.contextHandoffId, - ); - assert.isDefined(handoff); - assert.equal( - handoff?.strategy, - returning ? "delta_since_target_last_seen" : "full_thread_summary", - ); - assert.deepEqual(handoff?.coveredRunOrdinals, { - from: sourceOrdinal, - to: sourceOrdinal, - }); - const delivered = (yield* Ref.get(capturedTurns)).at(-1)!; - const history = yield* Ref.get(injectedHistory); - const deliveredHistory = native ? yield* encodeJson(history) : delivered.text; - assert.include(deliveredHistory, originalPrompt); - assert.include(deliveredHistory, partialResponse); - assert.include(deliveredHistory, `run-status=${status}`); - if (native) { - assert.equal(delivered.text, "Continue"); - assert.notInclude(deliveredHistory, '"text":"Continue"'); - assert.include(deliveredHistory, '"role":"assistant"'); - assert.include(deliveredHistory, '"role":"user"'); - assert.equal(handoff?.delivery?.status, "injected"); - } else { - assert.include(delivered.text, "User message:\nContinue"); - assert.equal(handoff?.delivery?.status, "inline"); - } - assert.notInclude(deliveredHistory, "Earlier successful request."); - // The returning source already has its own failed/interrupted native turn. - yield* dispatch("back", "Finish the remaining work", sourceSelection); - yield* waitForRun(sourceOrdinal + 2, "completed"); - const back = (yield* Ref.get(capturedTurns)).at(-1)!; - assert.notInclude(back.text, originalPrompt); - assert.notInclude(back.text, partialResponse); - }).pipe( - Effect.provide( - makeOrchestratorV2ReplayLayerWithRegistry( - { - name: `handoff-${status}-${queued}-${returning}`, - runtimePolicyOverride: { - cwd, - approvalPolicy: "never", - sandboxPolicy: { - type: "readOnly", - access: { type: "fullAccess" }, - networkAccess: false, - }, - }, - }, - registryLayer, - ), + it.live.each( + (["failed", "interrupted"] as const).flatMap((status) => + [false, true].flatMap((queued) => + [false, true].flatMap((returning) => + [false, true].map( + (native) => + [ + `${status} context ${queued ? "through the queue" : "immediately"} to ${returning ? "a returning" : "a new"} provider via ${native ? "native history" : "text"}`, + { status, queued, returning, native }, + ] as const, + ), + ), + ), + ), + )("hands off %s", ([, { status, queued, returning, native }]) => + Effect.scoped( + Effect.gen(function* () { + const cwd = yield* checkpointWorkspace(`handoff-${status}-${queued}-${returning}`); + const capturedTurns = yield* Ref.make>([]); + const injectedHistory = yield* Ref.make>([]); + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + const sourceOrdinal = returning ? 2 : 1; + const sourceSelection = returning ? CLAUDE_MODEL_SELECTION : CODEX_MODEL_SELECTION; + const targetSelection = returning ? CODEX_MODEL_SELECTION : CLAUDE_MODEL_SELECTION; + const originalPrompt = "Keep the release marker violet and preserve the existing API."; + const partialResponse = "I checked the API and found the release configuration."; + const registryLayer = ProviderAdapterRegistry.makeLayer( + ( + [ + [CODEX_MODEL_SELECTION, CODEX_DRIVER, CodexProviderCapabilitiesV2], + [CLAUDE_MODEL_SELECTION, CLAUDE_DRIVER, ClaudeProviderCapabilitiesV2], + ] as const + ).map(([modelSelection, driver, capabilities]) => + makeTestAdapter({ + instanceId: modelSelection.instanceId, + driver, + capabilities, + modelSelection, + responseByRunOrdinal: { [sourceOrdinal]: partialResponse }, + capturedTurns, + ...(native && modelSelection.instanceId === targetSelection.instanceId + ? { injectedHistory } + : {}), + ...(modelSelection.instanceId === sourceSelection.instanceId + ? { + failedRunOrdinals: new Set(status === "failed" ? [sourceOrdinal] : []), + interruptedRunOrdinals: new Set( + status === "interrupted" ? [sourceOrdinal] : [], ), - ); - }), + holdRunOrdinal: sourceOrdinal, + holdFirstTurn: started, + releaseFirstTurn: release, + } + : {}), + }), + ), + ); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const waitForRun = ( + ordinal: number, + expectedStatus: "completed" | "failed" | "interrupted", + ) => + orchestrator.streamStoredEvents.pipe( + Stream.filter( + ({ event }) => + event.type === "run.updated" && + event.threadId === threadId && + event.payload.ordinal === ordinal && + event.payload.status === expectedStatus, ), + Stream.runHead, + Effect.andThen(worker.drain()), + ); + const dispatch = ( + key: string, + text: string, + modelSelection: ModelSelection, + queue = false, + ) => + orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`command:handoff:${key}`), + threadId, + messageId: MessageId.make(`message:handoff:${key}`), + createdBy: "user", + creationSource: "web", + text, + attachments: [], + modelSelection, + dispatchMode: { type: queue ? "queue_after_active" : "start_immediately" }, + }); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("command:handoff:create"), + threadId, + projectId, + createdBy: "user", + creationSource: "web", + title: "Interrupted provider handoff", + modelSelection: CODEX_MODEL_SELECTION, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + if (returning) { + yield* dispatch("initial", "Earlier successful request.", CODEX_MODEL_SELECTION); + yield* waitForRun(1, "completed"); + } + yield* dispatch("source", originalPrompt, sourceSelection); + yield* Deferred.await(started); + if (queued) { + yield* dispatch("target", "Continue", targetSelection, true); + assert.equal( + (yield* orchestrator.getThreadProjection(threadId)).runs.at(-1)?.status, + "queued", + ); + } + yield* Deferred.succeed(release, undefined); + yield* waitForRun(sourceOrdinal, status); + if (!queued) { + yield* dispatch("target", "Continue", targetSelection); + } + yield* waitForRun(sourceOrdinal + 1, "completed"); + const projection = yield* orchestrator.getThreadProjection(threadId); + const targetRun = projection.runs.at(-1)!; + const handoff = projection.contextHandoffs.find( + (candidate) => candidate.id === targetRun.contextHandoffId, ); - } - } - } - } + assert.isDefined(handoff); + assert.equal( + handoff?.strategy, + returning ? "delta_since_target_last_seen" : "full_thread_summary", + ); + assert.deepEqual(handoff?.coveredRunOrdinals, { + from: sourceOrdinal, + to: sourceOrdinal, + }); + const delivered = (yield* Ref.get(capturedTurns)).at(-1)!; + const history = yield* Ref.get(injectedHistory); + const deliveredHistory = native ? yield* encodeJson(history) : delivered.text; + assert.include(deliveredHistory, originalPrompt); + assert.include(deliveredHistory, partialResponse); + assert.include(deliveredHistory, `run-status=${status}`); + if (native) { + assert.equal(delivered.text, "Continue"); + assert.notInclude(deliveredHistory, '"text":"Continue"'); + assert.include(deliveredHistory, '"role":"assistant"'); + assert.include(deliveredHistory, '"role":"user"'); + assert.equal(handoff?.delivery?.status, "injected"); + } else { + assert.include(delivered.text, "User message:\nContinue"); + assert.equal(handoff?.delivery?.status, "inline"); + } + assert.notInclude(deliveredHistory, "Earlier successful request."); + // The returning source already has its own failed/interrupted native turn. + yield* dispatch("back", "Finish the remaining work", sourceSelection); + yield* waitForRun(sourceOrdinal + 2, "completed"); + const back = (yield* Ref.get(capturedTurns)).at(-1)!; + assert.notInclude(back.text, originalPrompt); + assert.notInclude(back.text, partialResponse); + }).pipe( + Effect.provide( + makeOrchestratorV2ReplayLayerWithRegistry( + { + name: `handoff-${status}-${queued}-${returning}`, + runtimePolicyOverride: { + cwd, + approvalPolicy: "never", + sandboxPolicy: { + type: "readOnly", + access: { type: "fullAccess" }, + networkAccess: false, + }, + }, + }, + registryLayer, + ), + ), + ); + }), + ), + ); it.live("checks the queued provider's capability while the current provider stays running", () => Effect.scoped( @@ -2998,229 +2981,226 @@ describe("orchestration v2 provider switching", () => { ), ); - for (const failResume of [false, true]) { - it.live( - `switches providers while consuming a pending cross-provider merge-back (resume failure: ${failResume})`, - () => - Effect.scoped( - Effect.gen(function* () { - const sourceThreadId = ThreadId.make("thread:cross-provider-merge:source"); - const forkThreadId = ThreadId.make("thread:cross-provider-merge:fork"); - const firstSourcePrompt = "Remember that the first source marker is amber."; - const secondSourcePrompt = "Remember that the second source marker is violet."; - const forkPrompt = "Remember that the fork marker is cobalt."; - const mergePrompt = "Report all three remembered markers."; - const cwd = yield* checkpointWorkspace("cross-provider-merge"); - const capturedTurns = yield* Ref.make>([]); - const registryLayer = ProviderAdapterRegistry.makeLayer([ - makeTestAdapter({ - instanceId: ProviderInstanceId.make("codex"), - driver: CODEX_DRIVER, - capabilities: CodexProviderCapabilitiesV2, - modelSelection: CODEX_MODEL_SELECTION, - responseByRunOrdinal: {}, - responseByThreadId: { - [sourceThreadId]: { - 1: "I will remember amber.", - 3: "The markers are amber, violet, and cobalt.", - }, - [forkThreadId]: { - 1: "I will remember cobalt.", - }, + it.live.each([false, true])( + "switches providers while consuming a pending cross-provider merge-back (resume failure: %s)", + (failResume) => + Effect.scoped( + Effect.gen(function* () { + const sourceThreadId = ThreadId.make("thread:cross-provider-merge:source"); + const forkThreadId = ThreadId.make("thread:cross-provider-merge:fork"); + const firstSourcePrompt = "Remember that the first source marker is amber."; + const secondSourcePrompt = "Remember that the second source marker is violet."; + const forkPrompt = "Remember that the fork marker is cobalt."; + const mergePrompt = "Report all three remembered markers."; + const cwd = yield* checkpointWorkspace("cross-provider-merge"); + const capturedTurns = yield* Ref.make>([]); + const registryLayer = ProviderAdapterRegistry.makeLayer([ + makeTestAdapter({ + instanceId: ProviderInstanceId.make("codex"), + driver: CODEX_DRIVER, + capabilities: CodexProviderCapabilitiesV2, + modelSelection: CODEX_MODEL_SELECTION, + responseByRunOrdinal: {}, + responseByThreadId: { + [sourceThreadId]: { + 1: "I will remember amber.", + 3: "The markers are amber, violet, and cobalt.", + }, + [forkThreadId]: { + 1: "I will remember cobalt.", }, - capturedTurns, - failResume, - }), - makeTestAdapter({ - instanceId: ProviderInstanceId.make("claudeAgent"), - driver: CLAUDE_DRIVER, - capabilities: ClaudeProviderCapabilitiesV2, - modelSelection: CLAUDE_MODEL_SELECTION, - responseByRunOrdinal: { 2: "I will remember violet." }, - capturedTurns, - }), - ]); - const commands = [ - { - type: "thread.create", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:cross-provider-merge:create"), - threadId: sourceThreadId, - projectId, - title: "Cross-provider merge source", - modelSelection: CODEX_MODEL_SELECTION, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - }, - { - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:cross-provider-merge:first-source"), - threadId: sourceThreadId, - messageId: MessageId.make("message:cross-provider-merge:first-source"), - text: firstSourcePrompt, - attachments: [], - modelSelection: CODEX_MODEL_SELECTION, - dispatchMode: { type: "start_immediately" }, - }, - { - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:cross-provider-merge:second-source"), - threadId: sourceThreadId, - messageId: MessageId.make("message:cross-provider-merge:second-source"), - text: secondSourcePrompt, - attachments: [], - modelSelection: CLAUDE_MODEL_SELECTION, - dispatchMode: { type: "start_immediately" }, - }, - { - type: "thread.fork", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:cross-provider-merge:fork"), - sourceThreadId, - targetThreadId: forkThreadId, - sourcePoint: { type: "latest_stable" }, - title: "Cross-provider merge fork", - }, - { - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:cross-provider-merge:fork-turn"), - threadId: forkThreadId, - messageId: MessageId.make("message:cross-provider-merge:fork-turn"), - text: forkPrompt, - attachments: [], - modelSelection: CODEX_MODEL_SELECTION, - dispatchMode: { type: "start_immediately" }, - }, - { - type: "thread.merge_back", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:cross-provider-merge:merge"), - sourceThreadId: forkThreadId, - targetThreadId: sourceThreadId, - sourcePoint: { type: "latest_stable" }, - }, - { - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:cross-provider-merge:consume"), - threadId: sourceThreadId, - messageId: MessageId.make("message:cross-provider-merge:consume"), - text: mergePrompt, - attachments: [], - modelSelection: CODEX_MODEL_SELECTION, - dispatchMode: { type: "start_immediately" }, }, - ] satisfies ReadonlyArray; + capturedTurns, + failResume, + }), + makeTestAdapter({ + instanceId: ProviderInstanceId.make("claudeAgent"), + driver: CLAUDE_DRIVER, + capabilities: ClaudeProviderCapabilitiesV2, + modelSelection: CLAUDE_MODEL_SELECTION, + responseByRunOrdinal: { 2: "I will remember violet." }, + capturedTurns, + }), + ]); + const commands = [ + { + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:cross-provider-merge:create"), + threadId: sourceThreadId, + projectId, + title: "Cross-provider merge source", + modelSelection: CODEX_MODEL_SELECTION, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }, + { + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:cross-provider-merge:first-source"), + threadId: sourceThreadId, + messageId: MessageId.make("message:cross-provider-merge:first-source"), + text: firstSourcePrompt, + attachments: [], + modelSelection: CODEX_MODEL_SELECTION, + dispatchMode: { type: "start_immediately" }, + }, + { + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:cross-provider-merge:second-source"), + threadId: sourceThreadId, + messageId: MessageId.make("message:cross-provider-merge:second-source"), + text: secondSourcePrompt, + attachments: [], + modelSelection: CLAUDE_MODEL_SELECTION, + dispatchMode: { type: "start_immediately" }, + }, + { + type: "thread.fork", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:cross-provider-merge:fork"), + sourceThreadId, + targetThreadId: forkThreadId, + sourcePoint: { type: "latest_stable" }, + title: "Cross-provider merge fork", + }, + { + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:cross-provider-merge:fork-turn"), + threadId: forkThreadId, + messageId: MessageId.make("message:cross-provider-merge:fork-turn"), + text: forkPrompt, + attachments: [], + modelSelection: CODEX_MODEL_SELECTION, + dispatchMode: { type: "start_immediately" }, + }, + { + type: "thread.merge_back", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:cross-provider-merge:merge"), + sourceThreadId: forkThreadId, + targetThreadId: sourceThreadId, + sourcePoint: { type: "latest_stable" }, + }, + { + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:cross-provider-merge:consume"), + threadId: sourceThreadId, + messageId: MessageId.make("message:cross-provider-merge:consume"), + text: mergePrompt, + attachments: [], + modelSelection: CODEX_MODEL_SELECTION, + dispatchMode: { type: "start_immediately" }, + }, + ] satisfies ReadonlyArray; - const projection = yield* Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - yield* orchestrator.dispatch(commands[0]!); - yield* orchestrator.dispatch(commands[1]!); - yield* waitForIdle(sourceThreadId); - yield* orchestrator.dispatch(commands[2]!); - yield* waitForIdle(sourceThreadId); - yield* orchestrator.dispatch(commands[3]!); - yield* orchestrator.dispatch(commands[4]!); - yield* waitForIdle(forkThreadId); - yield* orchestrator.dispatch(commands[5]!); - if (failResume) { - // A persisted native ref that is not loaded in this process must - // exercise resume rather than the session manager's warm cache. - const beforeResume = yield* orchestrator.getThreadProjection(sourceThreadId); - const codexThread = beforeResume.providerThreads.find( - (thread) => thread.id === beforeResume.runs[0]?.providerThreadId, - )!; - yield* (yield* EventSink.EventSinkV2).write({ - events: [ - { - id: EventId.make("unloaded-merge-target"), - type: "provider-thread.updated", - threadId: sourceThreadId, - occurredAt: yield* DateTime.now, - payload: { - ...codexThread, - nativeThreadRef: { - ...codexThread.nativeThreadRef!, - nativeId: "unloaded-native-merge-target", - }, - }, - }, - ], - }); - } - yield* orchestrator.dispatch(commands[6]!); - return yield* waitForIdle(sourceThreadId); - }).pipe( - Effect.provide( - makeOrchestratorV2ReplayLayerWithRegistry( + const projection = yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + yield* orchestrator.dispatch(commands[0]!); + yield* orchestrator.dispatch(commands[1]!); + yield* waitForIdle(sourceThreadId); + yield* orchestrator.dispatch(commands[2]!); + yield* waitForIdle(sourceThreadId); + yield* orchestrator.dispatch(commands[3]!); + yield* orchestrator.dispatch(commands[4]!); + yield* waitForIdle(forkThreadId); + yield* orchestrator.dispatch(commands[5]!); + if (failResume) { + // A persisted native ref that is not loaded in this process must + // exercise resume rather than the session manager's warm cache. + const beforeResume = yield* orchestrator.getThreadProjection(sourceThreadId); + const codexThread = beforeResume.providerThreads.find( + (thread) => thread.id === beforeResume.runs[0]?.providerThreadId, + )!; + yield* (yield* EventSink.EventSinkV2).write({ + events: [ { - name: "cross-provider-merge", - runtimePolicyOverride: { - cwd, - approvalPolicy: "never", - sandboxPolicy: { - type: "readOnly", - access: { type: "fullAccess" }, - networkAccess: false, + id: EventId.make("unloaded-merge-target"), + type: "provider-thread.updated", + threadId: sourceThreadId, + occurredAt: yield* DateTime.now, + payload: { + ...codexThread, + nativeThreadRef: { + ...codexThread.nativeThreadRef!, + nativeId: "unloaded-native-merge-target", }, }, }, - registryLayer, - ), + ], + }); + } + yield* orchestrator.dispatch(commands[6]!); + return yield* waitForIdle(sourceThreadId); + }).pipe( + Effect.provide( + makeOrchestratorV2ReplayLayerWithRegistry( + { + name: "cross-provider-merge", + runtimePolicyOverride: { + cwd, + approvalPolicy: "never", + sandboxPolicy: { + type: "readOnly", + access: { type: "fullAccess" }, + networkAccess: false, + }, + }, + }, + registryLayer, ), + ), + ); + const turns = yield* Ref.get(capturedTurns); + const mergedTurn = turns.findLast( + (turn) => turn.threadId === sourceThreadId && turn.driver === "codex", + ); + const mergeTransfer = projection.contextTransfers.find( + (transfer) => transfer.type === "merge_back", + ); + + if (failResume) + assert.isAtLeast( + projection.contextHandoffs.filter( + (handoff) => handoff.targetRunId === projection.runs.at(-1)?.id, + ).length, + 2, ); - const turns = yield* Ref.get(capturedTurns); - const mergedTurn = turns.findLast( - (turn) => turn.threadId === sourceThreadId && turn.driver === "codex", - ); - const mergeTransfer = projection.contextTransfers.find( - (transfer) => transfer.type === "merge_back", + assert.isDefined(mergedTurn); + if (failResume) + assert.notEqual( + projection.providerThreads.find((thread) => thread.id === mergedTurn.providerThreadId) + ?.nativeThreadRef?.nativeId, + "unloaded-native-merge-target", ); - - if (failResume) - assert.isAtLeast( - projection.contextHandoffs.filter( - (handoff) => handoff.targetRunId === projection.runs.at(-1)?.id, - ).length, - 2, - ); - assert.isDefined(mergedTurn); - if (failResume) - assert.notEqual( - projection.providerThreads.find( - (thread) => thread.id === mergedTurn.providerThreadId, - )?.nativeThreadRef?.nativeId, - "unloaded-native-merge-target", - ); - assert.include(mergedTurn.text, "Context handoff (full_thread_summary):"); - assert.include(mergedTurn.text, firstSourcePrompt); - assert.include(mergedTurn.text, "I will remember amber."); - assert.include(mergedTurn.text, secondSourcePrompt); - assert.include(mergedTurn.text, "I will remember violet."); - assert.include(mergedTurn.text, "Context handoff (merge_back / fork_delta_summary):"); - assert.include(mergedTurn.text, forkPrompt); - assert.include(mergedTurn.text, "I will remember cobalt."); - assert.include(mergedTurn.text, mergePrompt); - assert.isDefined(mergeTransfer); - assert.equal(mergeTransfer.status, "consumed"); - assert.equal(mergeTransfer.targetProviderInstanceId, "codex"); - assert.equal(mergeTransfer.resolution?.strategy, "fork_delta_context"); - }), - ), - ); - } + assert.include(mergedTurn.text, "Context handoff (full_thread_summary):"); + assert.include(mergedTurn.text, firstSourcePrompt); + assert.include(mergedTurn.text, "I will remember amber."); + assert.include(mergedTurn.text, secondSourcePrompt); + assert.include(mergedTurn.text, "I will remember violet."); + assert.include(mergedTurn.text, "Context handoff (merge_back / fork_delta_summary):"); + assert.include(mergedTurn.text, forkPrompt); + assert.include(mergedTurn.text, "I will remember cobalt."); + assert.include(mergedTurn.text, mergePrompt); + assert.isDefined(mergeTransfer); + assert.equal(mergeTransfer.status, "consumed"); + assert.equal(mergeTransfer.targetProviderInstanceId, "codex"); + assert.equal(mergeTransfer.resolution?.strategy, "fork_delta_context"); + }), + ), + ); it.live("routes two custom instances of the same driver independently", () => Effect.scoped( diff --git a/apps/server/src/orchestration-v2/testkit/ThreadMergeBack.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ThreadMergeBack.integration.test.ts index 92837e4974a7..e22d847037ab 100644 --- a/apps/server/src/orchestration-v2/testkit/ThreadMergeBack.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ThreadMergeBack.integration.test.ts @@ -257,8 +257,9 @@ function makeCreateCommand(input: { } describe("orchestration V2 merge-back provider replay", () => { - for (const variant of PROVIDERS) { - it.effect(`merges one fork delta back into the original ${variant.driver} thread`, () => + it.effect.each(PROVIDERS)( + "merges one fork delta back into the original $driver thread", + (variant) => Effect.gen(function* () { const rawTranscript = yield* readTranscript("thread_merge_back_continue", variant.driver); const materialized = yield* Effect.gen(function* () { @@ -452,9 +453,11 @@ describe("orchestration V2 merge-back provider replay", () => { assert.notInclude(visibleConversationText(source), "Context handoff ("); assert.include(visibleConversationText(fork), "merge fork stored"); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); + ); - it.effect(`merges two sibling fork deltas into the original ${variant.driver} thread`, () => + it.effect.each(PROVIDERS)( + "merges two sibling fork deltas into the original $driver thread", + (variant) => Effect.gen(function* () { const rawTranscript = yield* readTranscript("thread_merge_back_siblings", variant.driver); const materialized = yield* Effect.gen(function* () { @@ -725,6 +728,5 @@ describe("orchestration V2 merge-back provider replay", () => { assert.include(visibleConversationText(secondFork), "second merge sibling stored"); assert.notInclude(visibleConversationText(secondFork), "first merge sibling stored"); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - } + ); }); diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_subagent_lifecycle/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_subagent_lifecycle/output.ts index 0828f7f9bbec..4c91460bed69 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_subagent_lifecycle/output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_subagent_lifecycle/output.ts @@ -180,6 +180,9 @@ export function assertClaudeBackgroundSubagentLifecycleOutput( const agentAChild = agentA?.childThreadId == null ? undefined : result.projections.get(agentA.childThreadId); assert.isDefined(agentAChild); + // The thread starts on the Agent call's "haiku"; the snapshot's model, which + // only arrives with the subagent's first reply, replaces it. + assert.equal(agentAChild.thread.modelSelection.model, AGENT_A_OBSERVED_MODEL); assert.deepEqual(assistantTexts(agentAChild), ["A_FIRST", "A_SECOND"]); assert.deepEqual(conversation(agentAChild), [ "user:Reply with exactly: A_FIRST", diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_task_interrupt/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_task_interrupt/output.ts index 120f1cfc435a..c58112b37202 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_task_interrupt/output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_task_interrupt/output.ts @@ -44,7 +44,7 @@ export function assertClaudeBackgroundTaskInterruptOutput( projection.turnItems.flatMap((item) => item.type === "command_execution" ? [item.status] : [], ), - ["completed", "failed"], - "the background launch completed; the interrupted foreground command did not", + ["completed", "interrupted"], + "the background launch completed; the foreground command was interrupted", ); } diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_mid_tool/claude_output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_mid_tool/claude_output.ts index 72884607068b..d90531499a80 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_mid_tool/claude_output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_mid_tool/claude_output.ts @@ -81,7 +81,7 @@ export function assertTurnInterruptMidToolClaudeOutput( assert.isDefined(commandItem); assert.isDefined(interruptRequest); assert.isDefined(interruptResult); - assert.equal(commandItem.status, "failed"); + assert.equal(commandItem.status, "interrupted"); assert.include(commandItem.input, "node -e"); assert.equal(interruptRequest.status, "completed"); assert.equal(interruptResult.status, "interrupted"); diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_restart/claude_output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_restart/claude_output.ts index 1da224a8485b..f05b470d32d8 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_restart/claude_output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_restart/claude_output.ts @@ -111,7 +111,7 @@ export function assertTurnInterruptRestartClaudeOutput( assert.equal(projection.providerThreads[0]?.status, "idle"); const commandItem = projection.turnItems.find((item) => item.type === "command_execution"); assert.isDefined(commandItem); - assert.equal(commandItem.status, "failed"); + assert.equal(commandItem.status, "interrupted"); assert.include(commandItem.input, "node -e"); const outboundFrames = transcript.entries.flatMap((entry) => diff --git a/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts b/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts index cccc826478b7..9c1115ca0df7 100644 --- a/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts +++ b/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts @@ -209,6 +209,36 @@ describe("threadHistoryPaging", () => { ); }); + it("pages agent-only child transcripts instead of dropping their earlier activity", () => { + const commandRows = Array.from({ length: 90 }, (_, index) => makeRow(index + 1)); + const first = makeRow(0); + const prompt = { + ...first, + item: { + ...first.item, + type: "user_message" as const, + createdBy: "agent" as const, + creationSource: "provider" as const, + inputIntent: "turn_start" as const, + messageId: MessageId.make("child-prompt"), + text: "Inspect this project", + attachments: [], + }, + } as OrchestrationV2ProjectedTurnItem; + const items = [prompt, ...commandRows]; + const recent = selectRecentTimelineWindow({ items, snapshotSequence: 1 }); + + expect(recent.items).toHaveLength(THREAD_HISTORY_PAGE_POLICY.maxItems); + expect(recent.hasMoreHistory).toBe(true); + const older = selectHistoryPageFromCursor({ + items, + cursor: recent.nextCursor!, + snapshotSequence: 1, + }); + expect(older.items[0]?.item.type).toBe("user_message"); + expect([...older.items, ...recent.items]).toHaveLength(items.length); + }); + it("encodes opaque cursors with stable source identity", () => { const cursor = encodeThreadHistoryCursor({ snapshotSequence: 9, diff --git a/apps/server/src/orchestration-v2/threadHistoryPaging.ts b/apps/server/src/orchestration-v2/threadHistoryPaging.ts index 98ae11afdad0..a2e6c87e20df 100644 --- a/apps/server/src/orchestration-v2/threadHistoryPaging.ts +++ b/apps/server/src/orchestration-v2/threadHistoryPaging.ts @@ -185,7 +185,7 @@ function selectOlderTimelinePage(input: { let encodedBytes = 0; let userTurns = 0; let rawTurns = 0; - const turnLimit = input.items.slice(0, end).some((row) => isThreadHistoryTurnStart(row.item)) + const turnLimit = input.items.slice(0, end).some((row) => isThreadHistoryUserTurn(row.item)) ? policy.maxUserTurns : undefined; for (let index = end - 1; index >= 0; index -= 1) { diff --git a/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts b/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts index b053c2403401..fc43700b06a4 100644 --- a/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts @@ -394,8 +394,9 @@ layer("OrchestrationEventStore", (it) => { ); }); -for (const phase of ["high-water", "replay"] as const) { - it.effect(`bounds application live events while the ${phase} query is blocked`, () => +it.effect.each(["high-water", "replay"] as const)( + "bounds application live events while the %s query is blocked", + (phase) => Effect.scoped( Effect.gen(function* () { const store = yield* OrchestrationEventStore.OrchestrationEventStore; @@ -464,8 +465,7 @@ for (const phase of ["high-water", "replay"] as const) { } }), ).pipe(Effect.provide(Layer.fresh(TestLayer))), - ); -} +); it.effect("releases consumed application replay pages", () => Effect.gen(function* () { diff --git a/apps/server/src/persistence/Migrations/OrchestrationV2/ApplicationEventSequenceIndexes.ts b/apps/server/src/persistence/Migrations/OrchestrationV2/ApplicationEventSequenceIndexes.ts index 0cb5b781cc5e..7872d416190f 100644 --- a/apps/server/src/persistence/Migrations/OrchestrationV2/ApplicationEventSequenceIndexes.ts +++ b/apps/server/src/persistence/Migrations/OrchestrationV2/ApplicationEventSequenceIndexes.ts @@ -1,7 +1,7 @@ import * as Effect from "effect/Effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; -// Index setup composed by migration 050. +// Index setup composed by migration 055. export default Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/persistence/Migrations/OrchestrationV2/ApplicationEventSource.ts b/apps/server/src/persistence/Migrations/OrchestrationV2/ApplicationEventSource.ts index 69cc856a8f34..1365d886a2d9 100644 --- a/apps/server/src/persistence/Migrations/OrchestrationV2/ApplicationEventSource.ts +++ b/apps/server/src/persistence/Migrations/OrchestrationV2/ApplicationEventSource.ts @@ -30,7 +30,7 @@ interface ProjectProjectionRow { const decodeJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); -// Event-store setup and V1 project baseline composed by migration 050. +// Event-store setup and V1 project baseline composed by migration 055. export default Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/persistence/Migrations/OrchestrationV2/EffectCancellation.ts b/apps/server/src/persistence/Migrations/OrchestrationV2/EffectCancellation.ts index 2d2185d8caa9..e04363684ab6 100644 --- a/apps/server/src/persistence/Migrations/OrchestrationV2/EffectCancellation.ts +++ b/apps/server/src/persistence/Migrations/OrchestrationV2/EffectCancellation.ts @@ -1,7 +1,7 @@ import * as Effect from "effect/Effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; -// Schema setup composed by migration 050. +// Schema setup composed by migration 055. export default Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/persistence/Migrations/OrchestrationV2/Foundation.ts b/apps/server/src/persistence/Migrations/OrchestrationV2/Foundation.ts index a04a576c8de7..3ee1cde819bf 100644 --- a/apps/server/src/persistence/Migrations/OrchestrationV2/Foundation.ts +++ b/apps/server/src/persistence/Migrations/OrchestrationV2/Foundation.ts @@ -2,7 +2,7 @@ import * as Effect from "effect/Effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; /** - * Production-facing V2 persistence setup composed by migration 050. + * Production-facing V2 persistence setup composed by migration 055. * * The original V2 schema used a single `provider` column for both configured * instance routing and driver identity. Keep those columns in place for diff --git a/apps/server/src/persistence/Migrations/OrchestrationV2/LegacyV1ImportState.ts b/apps/server/src/persistence/Migrations/OrchestrationV2/LegacyV1ImportState.ts index 4e3d94c57f1c..915aa31227ab 100644 --- a/apps/server/src/persistence/Migrations/OrchestrationV2/LegacyV1ImportState.ts +++ b/apps/server/src/persistence/Migrations/OrchestrationV2/LegacyV1ImportState.ts @@ -5,7 +5,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; * Tracks the incremental import of v1 materialized thread state into the v2 * event model. Shells are imported synchronously at startup; full transcripts * are hydrated on demand and by a low-priority background pass. This table - * setup is composed by migration 050. + * setup is composed by migration 055. */ export default Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/persistence/Migrations/OrchestrationV2/ProviderSessionBindings.ts b/apps/server/src/persistence/Migrations/OrchestrationV2/ProviderSessionBindings.ts index 10eb0132b6ed..769da3c4ca8f 100644 --- a/apps/server/src/persistence/Migrations/OrchestrationV2/ProviderSessionBindings.ts +++ b/apps/server/src/persistence/Migrations/OrchestrationV2/ProviderSessionBindings.ts @@ -1,7 +1,7 @@ import * as Effect from "effect/Effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; -// Schema setup composed by migration 050. +// Schema setup composed by migration 055. export default Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/persistence/Migrations/OrchestrationV2/RecoveryIndexes.ts b/apps/server/src/persistence/Migrations/OrchestrationV2/RecoveryIndexes.ts index 43db54a55676..4bd8f6251459 100644 --- a/apps/server/src/persistence/Migrations/OrchestrationV2/RecoveryIndexes.ts +++ b/apps/server/src/persistence/Migrations/OrchestrationV2/RecoveryIndexes.ts @@ -1,7 +1,7 @@ import * as Effect from "effect/Effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; -// Index setup composed by migration 050. +// Index setup composed by migration 055. export default Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/persistence/Migrations/OrchestrationV2/ScheduledTasks.ts b/apps/server/src/persistence/Migrations/OrchestrationV2/ScheduledTasks.ts index 2d2cbfba93c1..f35830314516 100644 --- a/apps/server/src/persistence/Migrations/OrchestrationV2/ScheduledTasks.ts +++ b/apps/server/src/persistence/Migrations/OrchestrationV2/ScheduledTasks.ts @@ -1,7 +1,7 @@ import * as Effect from "effect/Effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; -// Schema setup composed by migration 050. +// Schema setup composed by migration 055. export default Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/persistence/Migrations/OrchestrationV2/ShellIndexes.ts b/apps/server/src/persistence/Migrations/OrchestrationV2/ShellIndexes.ts index 577611201432..5a9ab2a5d6cd 100644 --- a/apps/server/src/persistence/Migrations/OrchestrationV2/ShellIndexes.ts +++ b/apps/server/src/persistence/Migrations/OrchestrationV2/ShellIndexes.ts @@ -1,7 +1,7 @@ import * as Effect from "effect/Effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; -// Index setup composed by migration 050. +// Index setup composed by migration 055. export default Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/persistence/Migrations/OrchestrationV2/Subagents.ts b/apps/server/src/persistence/Migrations/OrchestrationV2/Subagents.ts index 65641532ab38..a874987634c3 100644 --- a/apps/server/src/persistence/Migrations/OrchestrationV2/Subagents.ts +++ b/apps/server/src/persistence/Migrations/OrchestrationV2/Subagents.ts @@ -1,7 +1,7 @@ import * as Effect from "effect/Effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; -// Schema setup composed by migration 050. +// Schema setup composed by migration 055. export default Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/persistence/Migrations/OrchestrationV2/ThreadLaunchWorkflows.ts b/apps/server/src/persistence/Migrations/OrchestrationV2/ThreadLaunchWorkflows.ts index 5763312dd2fd..1e5557c60821 100644 --- a/apps/server/src/persistence/Migrations/OrchestrationV2/ThreadLaunchWorkflows.ts +++ b/apps/server/src/persistence/Migrations/OrchestrationV2/ThreadLaunchWorkflows.ts @@ -1,7 +1,7 @@ import * as Effect from "effect/Effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; -// Schema setup composed by migration 050. +// Schema setup composed by migration 055. export default Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; yield* sql` diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index 88a393f8989e..ecaec10e5b4c 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -60,8 +60,9 @@ describe("V2 preview upgrade", () => { }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); - for (const withIndexes of [false, true]) { - it.effect(`upgrades preview migration 54 with index cleanup ${withIndexes}`, () => + it.effect.each([false, true])( + "upgrades preview migration 54 with index cleanup %s", + (withIndexes) => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; yield* runMigrations({ toMigrationInclusive: 52 }); @@ -89,8 +90,7 @@ describe("V2 preview upgrade", () => { }>`PRAGMA table_info(projection_threads)`; assert.ok(columns.some((column) => column.name === "auto_settle_disabled_at")); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), - ); - } + ); it.effect("rolls back schema and ledger together on failure and can retry", () => Effect.gen(function* () { diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index aab4d78f4d71..9a5c8b99ce7d 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -6,6 +6,7 @@ import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as ConfigProvider from "effect/ConfigProvider"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; @@ -1186,26 +1187,24 @@ it.effect("memoizes editor discovery and refreshes after the cache window", () = ); }); -// A client that disconnects mid-scan interrupts the shared discovery effect on -// the connection fiber. The cache must not retain that interrupt: doing so -// replayed it to every later connect for the whole TTL, so `server.getConfig` -// failed and no client could reconnect until the server restarted. -it.effect("rescans after an interrupted discovery instead of caching the interrupt", () => { +// Connects run discovery under a timeout and may disconnect mid-scan. Neither +// may cancel the scan: on a busy host every connect would time out partway +// through, cache nothing, and leave every client without editors. +it.effect("keeps scanning after the caller is interrupted and shares that scan", () => { const fileInfo = { type: "File" } as FileSystem.File.Info; - let blockFirstScan = true; - let scans = 0; + const release = Deferred.makeUnsafe(); + let parkedStats = 0; const launcherLayer = ExternalLauncher.layer.pipe( Layer.provide( Layer.mergeAll( FileSystem.layerNoop({ - // The first scan parks inside `stat` so the interrupt lands while - // discovery is in flight, which is what a client disconnecting - // mid-connect does to the shared effect. + // Scans park inside `stat` until released, so the interrupt lands + // while discovery is in flight. stat: () => Effect.gen(function* () { - scans += 1; - if (blockFirstScan) { - return yield* Effect.never; + if (!Deferred.isDoneUnsafe(release)) { + parkedStats += 1; + yield* Deferred.await(release); } return fileInfo; }), @@ -1222,16 +1221,18 @@ it.effect("rescans after an interrupted discovery instead of caching the interru return Effect.gen(function* () { const launcher = yield* ExternalLauncher.ExternalLauncher; - const fiber = yield* Effect.forkChild(launcher.resolveAvailableEditors()); + const interrupted = yield* Effect.forkChild(launcher.resolveAvailableEditors()); yield* Effect.yieldNow; - yield* Fiber.interrupt(fiber); + yield* Fiber.interrupt(interrupted); - // The next connect must still get a real answer well inside the TTL. - blockFirstScan = false; - scans = 0; - const editors = yield* launcher.resolveAvailableEditors(); + // The next connect joins the running scan instead of starting its own. + const next = yield* Effect.forkChild(launcher.resolveAvailableEditors()); + yield* Effect.yieldNow; + assert.equal(parkedStats, 1); + + yield* Deferred.succeed(release, undefined); + const editors = yield* Fiber.join(next); assert.equal(editors.includes("vscode"), true); - assert.isAbove(scans, 0); }).pipe( Effect.provide( Layer.mergeAll( diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index ab287e4d78ee..058318ea9d55 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -28,13 +28,16 @@ import { import * as Clock from "effect/Clock"; import * as Config from "effect/Config"; import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; +import * as Exit from "effect/Exit"; 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 Ref from "effect/Ref"; +import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; @@ -462,21 +465,21 @@ const resolveFileManagerRevealKind = Effect.fn("externalLauncher.resolveFileMana // the discovered set for a bounded window so repeat connects skip even the // per-command cache lookups in @t3tools/shared/shell. // -// This deliberately does not use `Effect.cachedWithTTL`: that memoizes the -// first caller's Exit whatever it is, including an interrupt. Callers run this -// on the connection fiber under a timeout (`resolveAvailableEditorsForConfig`), -// so one client disconnecting mid-scan would cache the interrupt and replay it -// to every later connect for the whole TTL, breaking `server.getConfig` -// permanently. Storing only on success means an interrupted scan leaves the -// cache untouched and the next connect simply rescans. +// The scan runs on its own fiber in the service scope, and every caller awaits +// that one scan. Callers apply a timeout (`resolveAvailableEditorsForConfig`) +// and disconnect mid-connect; neither may cancel a scan other connects are +// waiting on, or throw away work a slow host (a busy server at startup, a +// long PATH) needs more than one connect to finish. A failed scan clears the +// entry so the next caller starts over rather than replaying the failure. // Expiry uses the monotonic clock (Clock.currentTimeNanos), matching the // command-resolution cache in @t3tools/shared/shell, so a backward wall-clock // adjustment cannot keep an expired entry alive. const EDITOR_DISCOVERY_CACHE_TTL_NANOS = 60_000_000_000n; interface EditorDiscoveryCacheEntry { - readonly editors: ReadonlyArray; - readonly expiresAtNanos: bigint; + readonly scan: Deferred.Deferred>; + /** Undefined while the scan is still running. */ + readonly expiresAtNanos: bigint | undefined; } /** @@ -760,27 +763,57 @@ export const make = Effect.gen(function* () { Effect.provideService(Path.Path, path), ); + const scope = yield* Scope.Scope; const editorDiscoveryCache = yield* Ref.make>( Option.none(), ); - const cachedAvailableEditors = Effect.gen(function* () { - const nowNanos = yield* Clock.currentTimeNanos; - const entry = yield* Ref.get(editorDiscoveryCache); - if (Option.isSome(entry) && entry.value.expiresAtNanos > nowNanos) { - return entry.value.editors; - } - const editors = yield* provideCommandResolutionServices(resolveAvailableEditors()).pipe( + const runEditorDiscovery = (scan: Deferred.Deferred>) => + provideCommandResolutionServices(resolveAvailableEditors()).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.onExit((exit) => + Effect.gen(function* () { + const expiresAtNanos = (yield* Clock.currentTimeNanos) + EDITOR_DISCOVERY_CACHE_TTL_NANOS; + yield* Ref.update(editorDiscoveryCache, (current) => + Option.isNone(current) || current.value.scan !== scan + ? current + : Exit.isSuccess(exit) + ? Option.some({ scan, expiresAtNanos }) + : Option.none(), + ); + yield* Deferred.done(scan, exit); + }), + ), + Effect.interruptible, + Effect.forkIn(scope), ); - yield* Ref.set( + // Claiming the cache entry and starting its scan must not be split by an + // interrupt, or the entry would wait on a scan that never runs. + const acquireEditorDiscovery = Effect.gen(function* () { + const nowNanos = yield* Clock.currentTimeNanos; + const [scan, isNewScan] = yield* Ref.modify( editorDiscoveryCache, - Option.some({ - editors, - expiresAtNanos: nowNanos + EDITOR_DISCOVERY_CACHE_TTL_NANOS, - }), + ( + current, + ): [ + [EditorDiscoveryCacheEntry["scan"], boolean], + Option.Option, + ] => { + if ( + Option.isSome(current) && + (current.value.expiresAtNanos === undefined || current.value.expiresAtNanos > nowNanos) + ) { + return [[current.value.scan, false], current]; + } + const scan = Deferred.makeUnsafe>(); + return [[scan, true], Option.some({ scan, expiresAtNanos: undefined })]; + }, ); - return editors; - }); + if (isNewScan) { + yield* runEditorDiscovery(scan); + } + return scan; + }).pipe(Effect.uninterruptible); + const cachedAvailableEditors = Effect.flatMap(acquireEditorDiscovery, Deferred.await); return ExternalLauncher.of({ resolveAvailableEditors: () => cachedAvailableEditors, diff --git a/apps/server/src/provider/CodexChatGptAuth.test.ts b/apps/server/src/provider/CodexChatGptAuth.test.ts index 725314b27e1a..1bad9db0df10 100644 --- a/apps/server/src/provider/CodexChatGptAuth.test.ts +++ b/apps/server/src/provider/CodexChatGptAuth.test.ts @@ -426,36 +426,34 @@ it.effect( }), ), ); -for (const failure of ["identity", "sharing"] as const) { - it.effect( - `failed account change preserves the original credentials and registration: ${failure}`, - () => - provision( - Effect.gen(function* () { - const h = yield* makeHarness; - yield* h.signIn; - yield* h.phase("succeeded"); - const before = h.storedRecords(); - h.setCallbackClientId("oaiapp_other_account"); - if (failure === "identity") h.setInvalidNonce(); - else h.declineSharing(); - yield* h.changeAccount; - yield* h.phase("failed"); - assert.strictEqual( - h.authorizationRequests[1]!.searchParams.get("client_id"), - "dynamic_agent_client", +it.effect.each(["identity", "sharing"] as const)( + "failed account change preserves the original credentials and registration: %s", + (failure) => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + const before = h.storedRecords(); + h.setCallbackClientId("oaiapp_other_account"); + if (failure === "identity") h.setInvalidNonce(); + else h.declineSharing(); + yield* h.changeAccount; + yield* h.phase("failed"); + assert.strictEqual( + h.authorizationRequests[1]!.searchParams.get("client_id"), + "dynamic_agent_client", + ); + if (failure === "sharing") { + assert.deepEqual( + Option.getOrThrow(yield* h.auth.read), + before.find((record) => record.accessToken), ); - if (failure === "sharing") { - assert.deepEqual( - Option.getOrThrow(yield* h.auth.read), - before.find((record) => record.accessToken), - ); - assert.lengthOf(h.storedRecords().find((record) => record.profiles).profiles, 2); - } else assert.deepEqual(h.storedRecords(), before); - }), - ), - ); -} + assert.lengthOf(h.storedRecords().find((record) => record.profiles).profiles, 2); + } else assert.deepEqual(h.storedRecords(), before); + }), + ), +); it.effect("retains the callback host and path after controller recreation and token removal", () => provision( Effect.gen(function* () { @@ -961,48 +959,42 @@ it.effect("rejects mismatched callback state before any token exchange or creden }), ), ); -for (const invalidClaim of ["issuer", "audience", "signature"] as const) { - it.effect( - `rejects ID token ${invalidClaim} verification before saving tokens or registration`, - () => - provision( - Effect.gen(function* () { - const h = yield* makeHarness; - h.setIdentityFailure(invalidClaim); - yield* h.signIn; - assert.include((yield* h.phase("failed")).message!, "could not be verified"); - assert.strictEqual(h.exchanges.length, 1); - assert.deepEqual(h.storedRecords(), []); - assert.isTrue(Option.isNone(yield* h.auth.read)); - }), - ), - ); -} -for (const revoked of [false, true]) { - it.effect( - `rejects conflicting callback client ID on reauthorization ${revoked ? "after token removal" : "without changing the current account"}`, - () => - provision( - Effect.gen(function* () { - const h = yield* makeHarness; - yield* h.signIn; - yield* h.phase("succeeded"); - if (revoked) yield* h.auth.revoke; - const before = h.storedRecords(); - h.setCallbackClientId("oaiapp_untrusted_callback"); - yield* h.signIn; - assert.include((yield* h.phase("failed")).message!, "registration is incomplete"); - assert.strictEqual( - h.authorizationRequests[1]?.searchParams.get("client_id"), - "oaiapp_test", - ); - assert.strictEqual(h.exchanges.length, 1); - assert.deepEqual(h.storedRecords(), before); - assert.strictEqual(Option.isNone(yield* h.auth.read), revoked); - }), - ), - ); -} +it.effect.each(["issuer", "audience", "signature"] as const)( + "rejects ID token %s verification before saving tokens or registration", + (invalidClaim) => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + h.setIdentityFailure(invalidClaim); + yield* h.signIn; + assert.include((yield* h.phase("failed")).message!, "could not be verified"); + assert.strictEqual(h.exchanges.length, 1); + assert.deepEqual(h.storedRecords(), []); + assert.isTrue(Option.isNone(yield* h.auth.read)); + }), + ), +); +it.effect.each([ + { revoked: false, label: "without changing the current account" }, + { revoked: true, label: "after token removal" }, +])("rejects conflicting callback client ID on reauthorization $label", ({ revoked }) => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + if (revoked) yield* h.auth.revoke; + const before = h.storedRecords(); + h.setCallbackClientId("oaiapp_untrusted_callback"); + yield* h.signIn; + assert.include((yield* h.phase("failed")).message!, "registration is incomplete"); + assert.strictEqual(h.authorizationRequests[1]?.searchParams.get("client_id"), "oaiapp_test"); + assert.strictEqual(h.exchanges.length, 1); + assert.deepEqual(h.storedRecords(), before); + assert.strictEqual(Option.isNone(yield* h.auth.read), revoked); + }), + ), +); it.effect("returns successful desktop sign-in to the original Welcome step", () => provision( @@ -1157,26 +1149,25 @@ it.effect( ), ); -for (const disconnected of [false, true]) { - it.effect( - `rejects a different verified identity during saved-profile reauth ${disconnected ? "after Disconnect" : "while connected"}`, - () => - provision( - Effect.gen(function* () { - const h = yield* makeHarness; - yield* h.signIn; - yield* h.phase("succeeded"); - if (disconnected) yield* h.auth.controller.logout(Effect.void); - const before = h.storedRecords(); - h.setIdentity("another-user", "another@example.test"); - yield* h.signIn; - assert.include((yield* h.phase("failed")).message!, "different ChatGPT account"); - assert.deepEqual(h.storedRecords(), before); - assert.strictEqual(Option.isNone(yield* h.auth.read), disconnected); - }), - ), - ); -} +it.effect.each([ + { disconnected: false, label: "while connected" }, + { disconnected: true, label: "after Disconnect" }, +])("rejects a different verified identity during saved-profile reauth $label", ({ disconnected }) => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + if (disconnected) yield* h.auth.controller.logout(Effect.void); + const before = h.storedRecords(); + h.setIdentity("another-user", "another@example.test"); + yield* h.signIn; + assert.include((yield* h.phase("failed")).message!, "different ChatGPT account"); + assert.deepEqual(h.storedRecords(), before); + assert.strictEqual(Option.isNone(yield* h.auth.read), disconnected); + }), + ), +); it.effect( "retains both profiles and reuses the original account's client and callback when returning from another account", @@ -1427,7 +1418,7 @@ it.effect( ), ); -for (const code of [ +it.effect.each([ "invalid_grant", "invalid_refresh_token", "token_expired", @@ -1436,33 +1427,31 @@ for (const code of [ "refresh_token_reused", "invalid_client", "invalid_token", -]) { - it.effect(`refresh recovery follows the machine-readable code: ${code}`, () => - provision( - Effect.gen(function* () { - const h = yield* makeHarness; - yield* h.signIn; - yield* h.phase("succeeded"); - yield* h.seedExpired; - h.setRefreshError(code); - yield* Effect.flip(h.auth.access); - assert.strictEqual( - Option.isNone(yield* h.auth.read), - !["invalid_client", "invalid_token"].includes(code), - ); - assert.isTrue( - h - .storedRecords() - .some((record) => - record.profiles?.some( - (profile: { clientId: string }) => profile.clientId === "oaiapp_test", - ), +])("refresh recovery follows the machine-readable code: %s", (code) => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + yield* h.seedExpired; + h.setRefreshError(code); + yield* Effect.flip(h.auth.access); + assert.strictEqual( + Option.isNone(yield* h.auth.read), + !["invalid_client", "invalid_token"].includes(code), + ); + assert.isTrue( + h + .storedRecords() + .some((record) => + record.profiles?.some( + (profile: { clientId: string }) => profile.clientId === "oaiapp_test", ), - ); - }), - ), - ); -} + ), + ); + }), + ), +); it.effect( "logout revokes the latest refresh token with the selected client and clears its ID hint", diff --git a/apps/server/src/provider/CodexInstallation.test.ts b/apps/server/src/provider/CodexInstallation.test.ts index a0d22decc0c2..2cc54cf83285 100644 --- a/apps/server/src/provider/CodexInstallation.test.ts +++ b/apps/server/src/provider/CodexInstallation.test.ts @@ -92,8 +92,9 @@ const terminalState = (installation: CodexInstallation.CodexInstallation["Servic Effect.map(Option.getOrThrow), ); -for (const version of ["0.156.0", "0.156.1", "0.156.2", "0.157.0"]) { - it.effect(`reuses installed Codex ${version} without downloading or taking ownership of it`, () => +it.effect.each(["0.156.0", "0.156.1", "0.156.2", "0.157.0"])( + "reuses installed Codex %s without downloading or taking ownership of it", + (version) => Effect.gen(function* () { const h = yield* makeHarness({ local: { version } }); expect(yield* h.installation.start).toMatchObject({ @@ -120,9 +121,8 @@ for (const version of ["0.156.0", "0.156.1", "0.156.2", "0.157.0"]) { expect(yield* h.fs.exists(h.localBinaryPath)).toBe(true); expect((yield* h.installation.state).source).toBe("local"); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); -} -for (const local of [ +); +it.effect.each([ { version: "0.128.9" }, { version: "0.145.0" }, { version: "0.155.1" }, @@ -131,24 +131,20 @@ for (const local of [ { version: "unknown" }, { version: "0.156.1", appServerFails: true }, { version: "0.156.1", versionFails: true }, -]) { - it.effect( - `downloads the pinned release when the local CLI is unsupported or broken: ${JSON.stringify(local)}`, - () => - Effect.gen(function* () { - const h = yield* makeHarness({ local }); - expect((yield* h.installation.state).installedVersion).toBeNull(); - yield* h.installation.start; - const installed = yield* terminalState(h.installation); - expect(installed.phase).toBe("succeeded"); - const executable = yield* h.installation.resolve(); - expect(executable.source).toBe("managed"); - expect(installed.executablePath).toBe(executable.executablePath); - expect(h.downloads()).toBe(1); - expect(yield* h.fs.exists(h.localBinaryPath)).toBe(true); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); -} +])("downloads the pinned release when the local CLI is unsupported or broken: %j", (local) => + Effect.gen(function* () { + const h = yield* makeHarness({ local }); + expect((yield* h.installation.state).installedVersion).toBeNull(); + yield* h.installation.start; + const installed = yield* terminalState(h.installation); + expect(installed.phase).toBe("succeeded"); + const executable = yield* h.installation.resolve(); + expect(executable.source).toBe("managed"); + expect(installed.executablePath).toBe(executable.executablePath); + expect(h.downloads()).toBe(1); + expect(yield* h.fs.exists(h.localBinaryPath)).toBe(true); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); it.effect("falls back to a managed download when the reused local executable disappears", () => Effect.gen(function* () { const h = yield* makeHarness({ local: { version: "0.156.1" } }); diff --git a/apps/server/src/provider/CodexManagedRuntime.test.ts b/apps/server/src/provider/CodexManagedRuntime.test.ts index 33ccaa13ea8c..494ff88a2299 100644 --- a/apps/server/src/provider/CodexManagedRuntime.test.ts +++ b/apps/server/src/provider/CodexManagedRuntime.test.ts @@ -48,178 +48,174 @@ it.effect("managed home defaults to the global Codex home and honors configured assert.equal(configured.effectiveHomePath, "/custom/shadow"); }).pipe(Effect.provide(NodeServices.layer)), ); -for (const source of ["managed", "local"] as const) - for (const account of ["primary", "additional"] as const) - it.effect( - `${source} Codex ${account} account shares home state without routing owned tokens through ambient CLI overrides`, - () => - Effect.gen(function* () { - const instanceId = ProviderInstanceId.make( - account === "primary" ? "codex" : "codex-personal", - ); - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const sharedHome = yield* fs.makeTempDirectoryScoped({ prefix: "codex-shared-home-" }); - yield* fs.writeFileString(path.join(sharedHome, "auth.json"), "native-auth-unchanged"); - yield* fs.writeFileString(path.join(sharedHome, "config.toml"), "# shared config\n"); - const data = new Map(); - const secrets = ServerSecretStore.ServerSecretStore.of({ - get: (key) => Effect.sync(() => Option.fromUndefinedOr(data.get(key))), - set: (key, value) => - Effect.sync(() => { - data.set(key, value); - }), - remove: (key) => +it.effect.each( + (["managed", "local"] as const).flatMap((source) => + (["primary", "additional"] as const).map((account) => ({ source, account })), + ), +)( + "$source Codex $account account shares home state without routing owned tokens through ambient CLI overrides", + ({ source, account }) => + Effect.gen(function* () { + const instanceId = ProviderInstanceId.make( + account === "primary" ? "codex" : "codex-personal", + ); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sharedHome = yield* fs.makeTempDirectoryScoped({ prefix: "codex-shared-home-" }); + yield* fs.writeFileString(path.join(sharedHome, "auth.json"), "native-auth-unchanged"); + yield* fs.writeFileString(path.join(sharedHome, "config.toml"), "# shared config\n"); + const data = new Map(); + const secrets = ServerSecretStore.ServerSecretStore.of({ + get: (key) => Effect.sync(() => Option.fromUndefinedOr(data.get(key))), + set: (key, value) => + Effect.sync(() => { + data.set(key, value); + }), + remove: (key) => + Effect.sync(() => { + data.delete(key); + }), + create: () => Effect.die("unused"), + getOrCreateRandom: () => Effect.die("unused"), + }); + let leases = 0; + const executable = { + executablePath: + source === "managed" ? "/isolated/tools/codex/0.156.1/bin/codex" : "/user/bin/codex", + managedVersionDirectory: source === "managed" ? "/isolated/tools/codex/0.156.1" : null, + source, + version: "0.156.1", + }; + const installerLayer = Layer.mock(CodexInstallation.CodexInstallation)({ + managedDirectory: "/isolated/tools/codex", + resolve: () => Effect.succeed(executable), + acquire: () => + Effect.gen(function* () { + leases++; + yield* Effect.addFinalizer(() => Effect.sync(() => { - data.delete(key); + leases--; }), - create: () => Effect.die("unused"), - getOrCreateRandom: () => Effect.die("unused"), - }); - let leases = 0; - const executable = { - executablePath: - source === "managed" ? "/isolated/tools/codex/0.156.1/bin/codex" : "/user/bin/codex", - managedVersionDirectory: source === "managed" ? "/isolated/tools/codex/0.156.1" : null, - source, - version: "0.156.1", - }; - const installerLayer = Layer.mock(CodexInstallation.CodexInstallation)({ - managedDirectory: "/isolated/tools/codex", - resolve: () => Effect.succeed(executable), - acquire: () => - Effect.gen(function* () { - leases++; - yield* Effect.addFinalizer(() => - Effect.sync(() => { - leases--; - }), - ); - return executable; - }), - }); - yield* Effect.gen(function* () { - const store = yield* ProviderCredentialStore.make("codex-chatgpt", instanceId); - const json = yield* encodeJson({ - clientId: "oaiapp_test", - accessToken: "dummy-owned-access", - refreshToken: "dummy-refresh", - expiresAt: (yield* Clock.currentTimeMillis) + 3_600_000, - earliestRefreshAt: null, - scopes: ["chatgpt.tokens.use.direct"], - subject: "test-user", - email: null, - }); - yield* store.set(new TextEncoder().encode(json)); - const ambient = { - CODEX_HOME: "/user/.codex", - OPENAI_API_KEY: "dummy-global-key", - OPENAI_BASE_URL: "https://user-proxy.test", - T3CODE_CODEX_LAUNCH_ARGS: "--config model_provider=global-proxy", - PATH: "/usr/bin", - }; - const runtime = yield* makeCodexManagedRuntime({ - instanceId, - enabled: true, - config: decodeSettings({ setupMode: "managed", homePath: sharedHome }), - environment: ambient, - }); - yield* Effect.gen(function* () { - const effective = yield* runtime.resolve; - assert.strictEqual(leases, 1); - assert.strictEqual(effective.config.binaryPath, executable.executablePath); - assert.notStrictEqual(effective.config.homePath, ambient.CODEX_HOME); - assert.equal(runtime.homeLayout.sharedHomePath, sharedHome); - if (account === "primary") { - assert.equal(effective.config.homePath, sharedHome); - assert.equal(runtime.homeLayout.mode, "direct"); - } else { - assert.include(effective.config.homePath, instanceId); - assert.include(effective.config.homePath, "userdata/providers/codex"); - assert.equal(runtime.homeLayout.mode, "authOverlay"); - assert.equal( - yield* fs.readLink(path.join(effective.config.homePath, "sessions")), - path.join(sharedHome, "sessions"), - ); - assert.equal( - yield* fs.readLink(path.join(effective.config.homePath, "config.toml")), - path.join(sharedHome, "config.toml"), - ); - assert.isFalse(yield* fs.exists(path.join(effective.config.homePath, "auth.json"))); - } - assert.strictEqual(effective.environment.ACCESS_TOKEN, "dummy-owned-access"); - assert.isUndefined(effective.environment.OPENAI_API_KEY); - assert.isUndefined(effective.environment.OPENAI_BASE_URL); - assert.isUndefined(effective.environment.T3CODE_CODEX_LAUNCH_ARGS); - const args = codexAppServerArgs(effective.config.launchArgs); - assert.include( - args, - 'model_providers.openai_token_sharing.base_url="https://api.openai.com/v1"', - ); - assert.include( - args, - 'model_providers.openai_token_sharing.model_catalog_url="https://api.openai.com/v1/models"', - ); - assert.include(args, "features.api_key_model_discovery=true"); - assert.notInclude(effective.config.launchArgs, "model_catalog_json"); - assert.notInclude(effective.config.launchArgs, "x-openai-chatpass-test"); - assert.include( - args, - "model_providers.openai_token_sharing.supports_websockets=false", - ); - assert.include( - args, - "model_providers.openai_token_sharing.requires_openai_auth=false", - ); - assert.notInclude(effective.config.launchArgs, "dummy-owned-access"); - assert.strictEqual(ambient.CODEX_HOME, "/user/.codex"); - }).pipe(Effect.scoped); - assert.strictEqual(leases, 0); - yield* runtime.auth.controller.logout(Effect.void); + ); + return executable; + }), + }); + yield* Effect.gen(function* () { + const store = yield* ProviderCredentialStore.make("codex-chatgpt", instanceId); + const json = yield* encodeJson({ + clientId: "oaiapp_test", + accessToken: "dummy-owned-access", + refreshToken: "dummy-refresh", + expiresAt: (yield* Clock.currentTimeMillis) + 3_600_000, + earliestRefreshAt: null, + scopes: ["chatgpt.tokens.use.direct"], + subject: "test-user", + email: null, + }); + yield* store.set(new TextEncoder().encode(json)); + const ambient = { + CODEX_HOME: "/user/.codex", + OPENAI_API_KEY: "dummy-global-key", + OPENAI_BASE_URL: "https://user-proxy.test", + T3CODE_CODEX_LAUNCH_ARGS: "--config model_provider=global-proxy", + PATH: "/usr/bin", + }; + const runtime = yield* makeCodexManagedRuntime({ + instanceId, + enabled: true, + config: decodeSettings({ setupMode: "managed", homePath: sharedHome }), + environment: ambient, + }); + yield* Effect.gen(function* () { + const effective = yield* runtime.resolve; + assert.strictEqual(leases, 1); + assert.strictEqual(effective.config.binaryPath, executable.executablePath); + assert.notStrictEqual(effective.config.homePath, ambient.CODEX_HOME); + assert.equal(runtime.homeLayout.sharedHomePath, sharedHome); + if (account === "primary") { + assert.equal(effective.config.homePath, sharedHome); + assert.equal(runtime.homeLayout.mode, "direct"); + } else { + assert.include(effective.config.homePath, instanceId); + assert.include(effective.config.homePath, "userdata/providers/codex"); + assert.equal(runtime.homeLayout.mode, "authOverlay"); assert.equal( - yield* fs.readFileString(path.join(sharedHome, "auth.json")), - "native-auth-unchanged", + yield* fs.readLink(path.join(effective.config.homePath, "sessions")), + path.join(sharedHome, "sessions"), ); - }).pipe( - Effect.provideService(ServerSecretStore.ServerSecretStore, secrets), - Effect.provideService(ServerEnvironment.ServerEnvironmentIdentity, { - getEnvironmentId: Effect.succeed( - EnvironmentId.make("00000000-0000-4000-8000-000000000001"), - ), - }), - Effect.provide(installerLayer), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make((request) => - Effect.sync(() => { - assert.isTrue( - [ - "https://auth.openai.com/.well-known/openid-configuration", - "https://auth.openai.com/revoke", - ].includes(request.url), - ); - return HttpClientResponse.fromWeb( - request, - request.url.endsWith("/revoke") - ? new Response(null, { status: 200 }) - : Response.json({ - issuer: "https://auth.openai.com", - authorization_endpoint: "https://auth.openai.com/api/accounts/authorize", - token_endpoint: "https://auth.openai.com/api/accounts/oauth/token", - jwks_uri: "https://auth.openai.com/jwks", - revocation_endpoint: "https://auth.openai.com/revoke", - }), - ); - }), - ), - ), + assert.equal( + yield* fs.readLink(path.join(effective.config.homePath, "config.toml")), + path.join(sharedHome, "config.toml"), + ); + assert.isFalse(yield* fs.exists(path.join(effective.config.homePath, "auth.json"))); + } + assert.strictEqual(effective.environment.ACCESS_TOKEN, "dummy-owned-access"); + assert.isUndefined(effective.environment.OPENAI_API_KEY); + assert.isUndefined(effective.environment.OPENAI_BASE_URL); + assert.isUndefined(effective.environment.T3CODE_CODEX_LAUNCH_ARGS); + const args = codexAppServerArgs(effective.config.launchArgs); + assert.include( + args, + 'model_providers.openai_token_sharing.base_url="https://api.openai.com/v1"', + ); + assert.include( + args, + 'model_providers.openai_token_sharing.model_catalog_url="https://api.openai.com/v1/models"', ); - }).pipe( - Effect.scoped, - Effect.provide( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-managed-runtime-", - }).pipe(Layer.provideMerge(NodeServices.layer)), + assert.include(args, "features.api_key_model_discovery=true"); + assert.notInclude(effective.config.launchArgs, "model_catalog_json"); + assert.notInclude(effective.config.launchArgs, "x-openai-chatpass-test"); + assert.include(args, "model_providers.openai_token_sharing.supports_websockets=false"); + assert.include(args, "model_providers.openai_token_sharing.requires_openai_auth=false"); + assert.notInclude(effective.config.launchArgs, "dummy-owned-access"); + assert.strictEqual(ambient.CODEX_HOME, "/user/.codex"); + }).pipe(Effect.scoped); + assert.strictEqual(leases, 0); + yield* runtime.auth.controller.logout(Effect.void); + assert.equal( + yield* fs.readFileString(path.join(sharedHome, "auth.json")), + "native-auth-unchanged", + ); + }).pipe( + Effect.provideService(ServerSecretStore.ServerSecretStore, secrets), + Effect.provideService(ServerEnvironment.ServerEnvironmentIdentity, { + getEnvironmentId: Effect.succeed( + EnvironmentId.make("00000000-0000-4000-8000-000000000001"), + ), + }), + Effect.provide(installerLayer), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => { + assert.isTrue( + [ + "https://auth.openai.com/.well-known/openid-configuration", + "https://auth.openai.com/revoke", + ].includes(request.url), + ); + return HttpClientResponse.fromWeb( + request, + request.url.endsWith("/revoke") + ? new Response(null, { status: 200 }) + : Response.json({ + issuer: "https://auth.openai.com", + authorization_endpoint: "https://auth.openai.com/api/accounts/authorize", + token_endpoint: "https://auth.openai.com/api/accounts/oauth/token", + jwks_uri: "https://auth.openai.com/jwks", + revocation_endpoint: "https://auth.openai.com/revoke", + }), + ); + }), ), ), - ); + ); + }).pipe( + Effect.scoped, + Effect.provide( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-managed-runtime-", + }).pipe(Layer.provideMerge(NodeServices.layer)), + ), + ), +); diff --git a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts index 0e7c08ce2c81..5ecdcc9d3662 100644 --- a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts @@ -58,7 +58,9 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-probe-sdk-" }); - const executablePath = path.join(tempDir, "fake-claude.mjs"); + const executablePath = yield* path.fromFileUrl( + new URL("./testing/ClaudeCapabilitiesProbe.fixture.mjs", import.meta.url), + ); const invocationPath = path.join(tempDir, "invocation.json"); // The probe aborts the SDK without awaiting the child's exit, and on // Windows a directory that is still some process's cwd cannot be @@ -80,68 +82,6 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { ), ); - yield* fs.writeFileString( - executablePath, - [ - "#!/usr/bin/env node", - 'import { existsSync, readFileSync, writeFileSync } from "node:fs";', - 'import { createInterface } from "node:readline";', - "const args = process.argv.slice(2);", - 'const mcpConfigIndex = args.indexOf("--mcp-config");', - "const rawMcpConfig = mcpConfigIndex >= 0 ? args[mcpConfigIndex + 1] : undefined;", - "let mcpConfig;", - "if (rawMcpConfig) {", - ' const contents = existsSync(rawMcpConfig) ? readFileSync(rawMcpConfig, "utf8") : rawMcpConfig;', - " try { mcpConfig = JSON.parse(contents); } catch { mcpConfig = contents; }", - "}", - "writeFileSync(process.env.T3_PROBE_INVOCATION_PATH, JSON.stringify({", - " args,", - " cwd: process.cwd(),", - " connectorEnv: process.env.ENABLE_CLAUDEAI_MCP_SERVERS,", - " mcpConfig,", - "}));", - "const lines = createInterface({ input: process.stdin });", - 'lines.on("line", (line) => {', - " const message = JSON.parse(line);", - ' if (message.type !== "control_request") return;', - " const reply = (response) => process.stdout.write(JSON.stringify({", - ' type: "control_response",', - ' response: { subtype: "success", request_id: message.request_id, response },', - ' }) + "\\n");', - ' if (message.request?.subtype === "initialize") {', - " reply({", - ' commands: [{ name: "review", description: "Review changes", argumentHint: "[path]" }],', - " agents: [],", - ' output_style: "default",', - ' available_output_styles: ["default"],', - " models: [],", - ' account: { email: "dev@example.com", subscriptionType: "pro", tokenSource: "oauth" },', - " });", - " }", - " // The probe follows initialize with get_usage on the same process.", - ' if (message.request?.subtype === "get_usage") {', - " reply({", - " session: {},", - ' subscription_type: "pro",', - " rate_limits_available: true,", - ' rate_limits: { five_hour: { utilization: 12, resets_at: "2026-07-18T14:39:00Z" } },', - " behaviors: null,", - " });", - " }", - "});", - "// Stay alive for follow-up control requests, but never outlive the", - "// parent: the probe aborts the SDK without awaiting the child, so an", - "// unconditional interval would strand this process until reboot.", - "const keepAlive = setInterval(() => {}, 1_000);", - 'lines.on("close", () => {', - " clearInterval(keepAlive);", - " process.exit(0);", - "});", - "", - ].join("\n"), - ); - yield* fs.chmod(executablePath, 0o755); - const capabilities = yield* probeClaudeCapabilities( decodeClaudeSettings({ binaryPath: executablePath }), { diff --git a/apps/server/src/provider/Layers/testing/ClaudeCapabilitiesProbe.fixture.mjs b/apps/server/src/provider/Layers/testing/ClaudeCapabilitiesProbe.fixture.mjs new file mode 100755 index 000000000000..57fc1795cb65 --- /dev/null +++ b/apps/server/src/provider/Layers/testing/ClaudeCapabilitiesProbe.fixture.mjs @@ -0,0 +1,66 @@ +#!/usr/bin/env node +import * as NodeFS from "node:fs"; +import * as NodeReadline from "node:readline"; +const args = process.argv.slice(2); +const mcpConfigIndex = args.indexOf("--mcp-config"); +const rawMcpConfig = mcpConfigIndex >= 0 ? args[mcpConfigIndex + 1] : undefined; +let mcpConfig; +if (rawMcpConfig) { + const contents = NodeFS.existsSync(rawMcpConfig) + ? NodeFS.readFileSync(rawMcpConfig, "utf8") + : rawMcpConfig; + try { + mcpConfig = JSON.parse(contents); + } catch { + mcpConfig = contents; + } +} +NodeFS.writeFileSync( + process.env.T3_PROBE_INVOCATION_PATH, + JSON.stringify({ + args, + cwd: process.cwd(), + connectorEnv: process.env.ENABLE_CLAUDEAI_MCP_SERVERS, + mcpConfig, + }), +); +const lines = NodeReadline.createInterface({ input: process.stdin }); +lines.on("line", (line) => { + const message = JSON.parse(line); + if (message.type !== "control_request") return; + const reply = (response) => + process.stdout.write( + JSON.stringify({ + type: "control_response", + response: { subtype: "success", request_id: message.request_id, response }, + }) + "\n", + ); + if (message.request?.subtype === "initialize") { + reply({ + commands: [{ name: "review", description: "Review changes", argumentHint: "[path]" }], + agents: [], + output_style: "default", + available_output_styles: ["default"], + models: [], + account: { email: "dev@example.com", subscriptionType: "pro", tokenSource: "oauth" }, + }); + } + // The probe follows initialize with get_usage on the same process. + if (message.request?.subtype === "get_usage") { + reply({ + session: {}, + subscription_type: "pro", + rate_limits_available: true, + rate_limits: { five_hour: { utilization: 12, resets_at: "2026-07-18T14:39:00Z" } }, + behaviors: null, + }); + } +}); +// Stay alive for follow-up control requests, but never outlive the +// parent: the probe aborts the SDK without awaiting the child, so an +// unconditional interval would strand this process until reboot. +const keepAlive = setInterval(() => {}, 1_000); +lines.on("close", () => { + clearInterval(keepAlive); + process.exit(0); +}); diff --git a/apps/server/src/provider/RuntimeInstructions.ts b/apps/server/src/provider/RuntimeInstructions.ts index afaba915d785..545e8be58443 100644 --- a/apps/server/src/provider/RuntimeInstructions.ts +++ b/apps/server/src/provider/RuntimeInstructions.ts @@ -1,5 +1,5 @@ const PULL_REQUEST_LINKING_INSTRUCTIONS = ` -When the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. +When the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. `; /** diff --git a/apps/server/src/provider/T3OrchestrationInstructions.test.ts b/apps/server/src/provider/T3OrchestrationInstructions.test.ts index dbcd209a136f..e625e40c1125 100644 --- a/apps/server/src/provider/T3OrchestrationInstructions.test.ts +++ b/apps/server/src/provider/T3OrchestrationInstructions.test.ts @@ -13,6 +13,11 @@ describe("T3 orchestration provider instructions", () => { assert.include(T3_CODE_ORCHESTRATION_INSTRUCTIONS, "ordinary top-level T3 conversations"); assert.include(T3_CODE_ORCHESTRATION_INSTRUCTIONS, "Never use them merely"); assert.include(T3_CODE_ORCHESTRATION_INSTRUCTIONS, "cross-provider"); + assert.include(T3_CODE_ORCHESTRATION_INSTRUCTIONS, "call `delegate_task` again"); + assert.include( + T3_CODE_ORCHESTRATION_INSTRUCTIONS, + "Do not use `t3_thread_send` on `childThreadId`", + ); }); it("documents structured schedules instead of JSON strings", () => { diff --git a/apps/server/src/provider/T3OrchestrationInstructions.ts b/apps/server/src/provider/T3OrchestrationInstructions.ts index ed510b879c0f..a70ef35bffcd 100644 --- a/apps/server/src/provider/T3OrchestrationInstructions.ts +++ b/apps/server/src/provider/T3OrchestrationInstructions.ts @@ -6,8 +6,9 @@ export const T3_CODE_ORCHESTRATION_INSTRUCTIONS = ` The \`t3-code\` MCP server provides app-owned orchestration. Treat these concepts distinctly: -- A delegated task/subagent is child work owned by the current thread. Use \`orchestrator_capabilities\` to discover the current provider/model IDs from the same live catalog as the composer, including configured custom models. Do not treat a native tool's model list as the full list of available subagent models. Prefer native subagent tools for same-provider work only when they support the chosen model. Use \`delegate_task\` with that provider instance and model when native tools cannot, including for same-provider work. Also use \`delegate_task\` for cross-provider or explicitly T3-owned child tasks. Retain each returned \`taskId\`, and use \`task_status\` or \`task_cancel\` to manage it. The returned \`childThreadId\` is backing storage for the subagent; do not replace delegation with ordinary thread creation. +- A delegated task/subagent is child work owned by the current thread. Use \`orchestrator_capabilities\` to discover the current provider/model IDs from the same live catalog as the composer, including configured custom models. Do not treat a native tool's model list as the full list of available subagent models. Prefer native subagent tools for same-provider work only when they support the chosen model. Use \`delegate_task\` with that provider instance and model when native tools cannot, including for same-provider work. Also use \`delegate_task\` for cross-provider or explicitly T3-owned child tasks. Retain each returned \`taskId\`, and use \`task_status\` or \`task_cancel\` to manage it. The returned \`childThreadId\` is backing storage for the subagent, not the target for starting another delegated review round. - \`t3_thread_launch\` and \`create_threads\` create ordinary top-level T3 conversations. Use them only when the user explicitly asks for separate/new/top-level threads or conversations. Never use them merely because the user said "subagent" or requested parallel delegated work. +- For every T3 delegated review round, call \`delegate_task\` again. Include the original brief, prior findings, responses, and unresolved objections in each new task prompt. Track each round by its own \`taskId\`. Use a distinct \`clientRequestId\` per round, stable across retries of that round. Do not use \`t3_thread_send\` on \`childThreadId\` to continue a delegated review. - \`schedule_task\` creates persistent recurring work in the app scheduler. Pass \`schedule\` as a structured object, never as JSON text: \`{"type":"interval","everyMs":3600000}\` for an interval, or \`{"type":"fixed_time","timeOfDay":"09:00","weekdays":[1,2,3,4,5]}\` for a wall-clock schedule. By default runs return to the current thread; set \`bindToCurrentThread=false\` only when the user wants a fresh thread for every run. After scheduling, report the returned cadence and next run time. ### Choose the workspace before starting a new thread diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index d881b5f22a40..32f207e2b62b 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -33,8 +33,9 @@ const mockRuntimeOptions = { } satisfies AcpSessionRuntime.AcpSessionRuntimeOptions; describe("AcpSessionRuntime", () => { - for (const setupMethod of ["session/new", "session/resume"] as const) { - it.effect(`buffers root metadata while ${setupMethod} startup is still pending`, () => + it.effect.each(["session/new", "session/resume"] as const)( + "buffers root metadata while %s startup is still pending", + (setupMethod) => Effect.gen(function* () { const setupReplied = yield* Deferred.make(); const allowStartup = yield* Deferred.make(); @@ -91,8 +92,7 @@ describe("AcpSessionRuntime", () => { (yield* runtime.getConfigOptions).find((option) => option.category === "model"), ).toMatchObject({ currentValue: "gpt-5.4" }); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - } + ); it.effect("publishes model changes returned by a config request and live notifications", () => Effect.gen(function* () { diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts index b11ba6e20563..2cdf78c56a14 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts @@ -117,6 +117,7 @@ export function azureDevOpsProviderFailure( if (error._tag === "AzureDevOpsCliUnavailableError") return { reason: "missing-tool" }; if (error._tag === "AzureDevOpsCliAuthenticationError") return { reason: "unauthenticated" }; if (error._tag === "AzureDevOpsCliRateLimitError") return { reason: "rate-limited" }; + if (error._tag === "AzureDevOpsPullRequestNotFoundError") return { reason: "not-found" }; return { reason: "failed" }; } diff --git a/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts b/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts index e0ee4c0a85b7..f553dfd13bc1 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts @@ -82,6 +82,12 @@ export function bitbucketProviderFailure( ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }), }; } + if ( + (error._tag === "BitbucketResponseError" || error._tag === "BitbucketResponseBodyReadError") && + error.status === 404 + ) { + return { reason: "not-found" }; + } return { reason: "failed" }; } diff --git a/apps/server/src/pullRequest/ForgejoPullRequestProvider.ts b/apps/server/src/pullRequest/ForgejoPullRequestProvider.ts index 73d582c9042c..12d931279a74 100644 --- a/apps/server/src/pullRequest/ForgejoPullRequestProvider.ts +++ b/apps/server/src/pullRequest/ForgejoPullRequestProvider.ts @@ -89,7 +89,9 @@ export const make = Effect.gen(function* () { ? "unauthenticated" : error.reason === "rate-limit" ? "rate-limited" - : "failed", + : error.reason === "not-found" + ? "not-found" + : "failed", }), ), ); diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts index 5d3de87104f4..4bfd2a42e414 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -52,7 +52,7 @@ import { decodePullRequestActivityJson, decodePullRequestDetailJson, decodePullRequestCoreJson, - PULL_REQUEST_CORE_GRAPHQL_QUERY, + pullRequestCoreGraphQlQuery, type GitHubPullRequestCore, type GitHubPullRequestSummary, decodePullRequestPreviewJson, @@ -1601,7 +1601,7 @@ export const make = Effect.gen(function* () { ["-F", `number=${input.number}`], ["-f", `headRef=refs/pull/${input.number}/head`], ], - query: PULL_REQUEST_CORE_GRAPHQL_QUERY, + query: pullRequestCoreGraphQlQuery(input.host), decode: decodePullRequestCoreJson, }), ), diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts index a66daaac21ff..41d344834225 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts @@ -120,6 +120,7 @@ export function gitHubProviderFailure( if (error._tag === "SourceControlRateLimitPausedError") { return { reason: "rate-limited", retryAt: error.retryAt }; } + if (error._tag === "GitHubPullRequestNotFoundError") return { reason: "not-found" }; // A refusal is still a failed request; what it adds is which one, so the page can offer the // way out where there is one rather than leave the reader with a sentence and no button. if (error._tag === "GitHubCliRefusedError") { diff --git a/apps/server/src/pullRequest/GitLabPullRequestProvider.ts b/apps/server/src/pullRequest/GitLabPullRequestProvider.ts index ad089382352a..45034b84787c 100644 --- a/apps/server/src/pullRequest/GitLabPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GitLabPullRequestProvider.ts @@ -102,6 +102,7 @@ export function gitLabProviderFailure( if (error._tag === "GitLabCliUnavailableError") return { reason: "missing-tool" }; if (error._tag === "GitLabCliAuthenticationError") return { reason: "unauthenticated" }; if (error._tag === "GitLabCliRateLimitError") return { reason: "rate-limited" }; + if (error._tag === "GitLabMergeRequestNotFoundError") return { reason: "not-found" }; return { reason: "failed" }; } diff --git a/apps/server/src/pullRequest/PullRequestProvider.ts b/apps/server/src/pullRequest/PullRequestProvider.ts index a21701ff296a..7caf788f24f0 100644 --- a/apps/server/src/pullRequest/PullRequestProvider.ts +++ b/apps/server/src/pullRequest/PullRequestProvider.ts @@ -55,7 +55,13 @@ export class PullRequestProviderError extends Schema.TaggedError - Effect.gen(function* () { - const seen: string[] = []; - const target = project({ - id: "target", - title: "target", - workspaceRoot: "/target", - provider: "azure-devops", - ...checkout, - }); - const service = yield* makeService({ - projects: [ - ...["org-a/project/_git/web", "org-b/other-project/_git/web"].map((repository) => - project({ - id: repository, - title: repository, - workspaceRoot: `/${repository}`, - provider: "azure-devops", - host: "dev.azure.com", - repository, - }), - ), - target, - ], - providers: [ - fakeProvider("azure-devops", { - getChangeRequestSummary: (input) => - Effect.sync(() => { - seen.push(`read ${input.cwd} ${input.repository}`); - return changeRequest(7, "2026-07-02T00:00:00Z"); - }), - runAction: (input) => - Effect.sync(() => { - seen.push(`write ${input.cwd} ${input.repository}`); - }), +])("routes Azure URL reads and writes through a $host checkout", (checkout) => + Effect.gen(function* () { + const seen: string[] = []; + const target = project({ + id: "target", + title: "target", + workspaceRoot: "/target", + provider: "azure-devops", + ...checkout, + }); + const service = yield* makeService({ + projects: [ + ...["org-a/project/_git/web", "org-b/other-project/_git/web"].map((repository) => + project({ + id: repository, + title: repository, + workspaceRoot: `/${repository}`, + provider: "azure-devops", + host: "dev.azure.com", + repository, }), - ], - }); - const reference = { - projectId: "org-a/project/_git/web" as ProjectId, - host: "dev.azure.com", - repository: "org-b/project/_git/web", - number: 7, - }; - yield* service.summary(reference, { recoverTransientFailure: false }); - yield* service.runAction({ ...reference, action: "merge" }); - assert.deepStrictEqual(seen, ["read /target web", "write /target web", "read /target web"]); - }), - ); -} + ), + target, + ], + providers: [ + fakeProvider("azure-devops", { + getChangeRequestSummary: (input) => + Effect.sync(() => { + seen.push(`read ${input.cwd} ${input.repository}`); + return changeRequest(7, "2026-07-02T00:00:00Z"); + }), + runAction: (input) => + Effect.sync(() => { + seen.push(`write ${input.cwd} ${input.repository}`); + }), + }), + ], + }); + const reference = { + projectId: "org-a/project/_git/web" as ProjectId, + host: "dev.azure.com", + repository: "org-b/project/_git/web", + number: 7, + }; + yield* service.summary(reference, { recoverTransientFailure: false }); + yield* service.runAction({ ...reference, action: "merge" }); + assert.deepStrictEqual(seen, ["read /target web", "write /target web", "read /target web"]); + }), +); it.effect("refuses Azure cross-organization reads and writes without its checkout", () => Effect.gen(function* () { @@ -4948,6 +4946,33 @@ it.effect("does not let a still-cached detail overwrite a fresher linked summary }), ); +it.effect("tells the client when the host has no pull request under that number", () => + Effect.gen(function* () { + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 121 }; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + getChangeRequest: () => + Effect.fail( + new PullRequestProviderError({ + provider: "github", + operation: "getChangeRequest", + reason: "not-found", + detail: "Pull request not found. Check the PR number or URL and try again.", + }), + ), + }), + ], + }); + + const error = yield* Effect.flip(service.detail(reference)); + + assert.strictEqual(error._tag, "PullRequestOperationError"); + assert.strictEqual(error._tag === "PullRequestOperationError" && error.reason, "not-found"); + }), +); + it.effect("keeps recent detail on a transient refresh failure but not after invalidation", () => Effect.gen(function* () { let failing = false; @@ -5173,104 +5198,102 @@ it.effect('resolves an author filter of "me" to the viewer before narrowing a ho }), ); -for (const crossHost of [false, true]) { - it.effect( - `authorizes stack rebases and refreshes sibling layers (cross-host: ${crossHost})`, - () => - Effect.gen(function* () { - let taken = 0; - let summaryReads = 0; - let mutationFails = false; - let stackRebase = true; - let stackActions = true; - const capabilities = { - diff: true, - comment: true, - actions: ["update-branch"] as const, - mergeMethods: ["merge"] as const, - updateMethods: ["rebase"] as const, - get stackActions() { - return stackActions; - }, - search: true, - reactions: true, - review: FULL_REVIEW, - reviewers: FULL_REVIEWERS, - }; - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), - project({ - id: "p2", - title: "enterprise", - workspaceRoot: "/b", - repository: "acme/web", - host: "enterprise.test", - }), - ], - providers: [ - fakeProvider("github", { - capabilities, - getViewerPermissions: () => - Effect.succeed({ - actions: [], - stackRebase, - comment: true, - resolve: false, - verdicts: [], - requestReviewers: false, - }), - getChangeRequestSummary: () => - Effect.sync(() => { - summaryReads++; - return changeRequest(8, "2026-07-01T00:00:00Z"); - }), - runAction: () => - Effect.gen(function* () { - taken++; - if (mutationFails) return yield* requestFailed; - }), - }), - ], - }); - const input = { - ...(crossHost ? { host: "enterprise.test" } : {}), - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 3, - action: "update-branch" as const, - updateMethod: "rebase" as const, - stackNumber: 50, - expectedStackHeads: [{ number: 3, headSha: "ccc" }], - }; - yield* service.runAction(input); - assert.strictEqual(taken, 1); - const unrelated = { ...input, number: 8 }; - yield* service.summary(unrelated); - assert.strictEqual(summaryReads, 1); - stackRebase = false; - assert.strictEqual( - (yield* Effect.flip(service.runAction(input)))._tag, - "PullRequestOperationError", - ); - stackRebase = true; - stackActions = false; - assert.strictEqual( - (yield* Effect.flip(service.runAction(input)))._tag, - "PullRequestOperationError", - ); - assert.strictEqual(taken, 1); - yield* service.summary(unrelated); - assert.strictEqual(summaryReads, 1); - stackActions = true; - mutationFails = true; - yield* Effect.flip(service.runAction(input)); - assert.strictEqual(taken, 2); - yield* service.summary(unrelated); - assert.strictEqual(summaryReads, 2); - }), - ); -} +it.effect.each([false, true])( + "authorizes stack rebases and refreshes sibling layers (cross-host: %s)", + (crossHost) => + Effect.gen(function* () { + let taken = 0; + let summaryReads = 0; + let mutationFails = false; + let stackRebase = true; + let stackActions = true; + const capabilities = { + diff: true, + comment: true, + actions: ["update-branch"] as const, + mergeMethods: ["merge"] as const, + updateMethods: ["rebase"] as const, + get stackActions() { + return stackActions; + }, + search: true, + reactions: true, + review: FULL_REVIEW, + reviewers: FULL_REVIEWERS, + }; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + project({ + id: "p2", + title: "enterprise", + workspaceRoot: "/b", + repository: "acme/web", + host: "enterprise.test", + }), + ], + providers: [ + fakeProvider("github", { + capabilities, + getViewerPermissions: () => + Effect.succeed({ + actions: [], + stackRebase, + comment: true, + resolve: false, + verdicts: [], + requestReviewers: false, + }), + getChangeRequestSummary: () => + Effect.sync(() => { + summaryReads++; + return changeRequest(8, "2026-07-01T00:00:00Z"); + }), + runAction: () => + Effect.gen(function* () { + taken++; + if (mutationFails) return yield* requestFailed; + }), + }), + ], + }); + const input = { + ...(crossHost ? { host: "enterprise.test" } : {}), + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 3, + action: "update-branch" as const, + updateMethod: "rebase" as const, + stackNumber: 50, + expectedStackHeads: [{ number: 3, headSha: "ccc" }], + }; + yield* service.runAction(input); + assert.strictEqual(taken, 1); + const unrelated = { ...input, number: 8 }; + yield* service.summary(unrelated); + assert.strictEqual(summaryReads, 1); + stackRebase = false; + assert.strictEqual( + (yield* Effect.flip(service.runAction(input)))._tag, + "PullRequestOperationError", + ); + stackRebase = true; + stackActions = false; + assert.strictEqual( + (yield* Effect.flip(service.runAction(input)))._tag, + "PullRequestOperationError", + ); + assert.strictEqual(taken, 1); + yield* service.summary(unrelated); + assert.strictEqual(summaryReads, 1); + stackActions = true; + mutationFails = true; + yield* Effect.flip(service.runAction(input)); + assert.strictEqual(taken, 2); + yield* service.summary(unrelated); + assert.strictEqual(summaryReads, 2); + }), +); it.effect("refuses a way of updating a branch that the host or the viewer does not allow", () => Effect.gen(function* () { diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index ab4c49c69847..4410f8e6503a 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -488,6 +488,7 @@ function toPullRequestError( : new PullRequestOperationError({ operation, detail: error.detail, + ...(error.reason === "not-found" ? { reason: "not-found" as const } : {}), ...(error.refusal === undefined ? {} : { refusal: error.refusal }), cause: error, }); @@ -1710,6 +1711,7 @@ export const make = Effect.gen(function* () { ...(changeRequest.headRepositoryNameWithOwner === undefined ? {} : { headRepositoryNameWithOwner: changeRequest.headRepositoryNameWithOwner }), + ...(changeRequest.headSha ? { headSha: changeRequest.headSha } : {}), baseBranch: changeRequest.baseBranch, createdAt: changeRequest.createdAt, updatedAt: changeRequest.updatedAt, diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts index 6f8ea5874c3d..4be798c447ad 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts @@ -26,6 +26,7 @@ import { decodeWorkflowRunApprovalsJson, reviewThreadConversation, REVIEW_THREADS_GRAPHQL_QUERY, + pullRequestCoreGraphQlQuery, pullRequestSearchGraphQlQuery, } from "./gitHubPullRequestJson.ts"; @@ -285,6 +286,25 @@ describe("pull request detail decoding", () => { ]); }); + it("keeps what branch protection requires, and asks for it on github.com only", () => { + const raw = JSON.parse(detailJson) as Record; + const detail = expectSuccess( + decodePullRequestDetailJson( + JSON.stringify({ + ...raw, + statusCheckRollup: [ + { __typename: "CheckRun", name: "test", status: "IN_PROGRESS", isRequired: true }, + { __typename: "StatusContext", context: "bot", state: "PENDING", isRequired: false }, + { __typename: "StatusContext", context: "legacy", state: "SUCCESS" }, + ], + }), + ), + ); + expect(detail.checks.map((check) => check.required)).toEqual([true, false, undefined]); + expect(pullRequestCoreGraphQlQuery("github.com")).toContain("isRequired"); + expect(pullRequestCoreGraphQlQuery("github.example.com")).not.toContain("isRequired"); + }); + it("keeps a workflow waiting for approval out of the passing state", () => { const raw = JSON.parse(detailJson) as Record; const detail = expectSuccess( diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.ts index 4c7c0ff8b393..7d2800558370 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.ts @@ -93,6 +93,8 @@ const RawCheckSchema = Schema.Struct({ workflowName: Schema.optional(Schema.NullOr(Schema.String)), startedAt: Schema.optional(Schema.NullOr(Schema.String)), completedAt: Schema.optional(Schema.NullOr(Schema.String)), + /** Branch protection requires this check; read by the detail query on github.com only. */ + isRequired: Schema.optional(Schema.NullOr(Schema.Boolean)), }); const RawListItemSchema = Schema.Struct({ @@ -706,8 +708,15 @@ export const PULL_REQUEST_LIST_JSON_FIELDS = export const PULL_REQUEST_DETAIL_JSON_FIELDS = `${PULL_REQUEST_LIST_JSON_FIELDS},body,changedFiles,closedAt,isCrossRepository,headRepositoryOwner,headRefOid,autoMergeRequest`; -/** Pull refs let the comparison share the detail read without first resolving a fork branch. */ -export const PULL_REQUEST_CORE_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $headRef: String!) { +/** + * Pull refs let the comparison share the detail read without first resolving a fork branch. + * `isRequired` is asked for on github.com only: an older Enterprise server may not know it, and + * an unknown field fails the whole read. + */ +export const pullRequestCoreGraphQlQuery = (host: string) => { + const required = + host.toLowerCase() === "github.com" ? " isRequired(pullRequestNumber: $number)" : ""; + return `query($owner: String!, $name: String!, $number: Int!, $headRef: String!) { repository(owner: $owner, name: $name) { mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed viewerPermission pullRequest(number: $number) { @@ -727,9 +736,9 @@ export const PULL_REQUEST_CORE_GRAPHQL_QUERY = `query($owner: String!, $name: St nodes { commit { statusCheckRollup { contexts(first: 100) { nodes { __typename - ... on StatusContext { context state targetUrl createdAt description } + ... on StatusContext { context state targetUrl createdAt description${required} } ... on CheckRun { - name status conclusion startedAt completedAt detailsUrl + name status conclusion startedAt completedAt detailsUrl${required} checkSuite { workflowRun { workflow { name } } } } } @@ -739,6 +748,7 @@ export const PULL_REQUEST_CORE_GRAPHQL_QUERY = `query($owner: String!, $name: St } } }`; +}; export const PULL_REQUEST_PREVIEW_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { repository(owner: $owner, name: $name) { @@ -1468,6 +1478,7 @@ function toCheckEntries( status: toCheckStatus(check), description: trimmed(check.description), url: trimmed(check.detailsUrl) ?? trimmed(check.targetUrl), + ...(typeof check.isRequired === "boolean" ? { required: check.isRequired } : {}), }, workflowName: trimmed(check.workflowName), at: realTimestamp(check.completedAt) ?? realTimestamp(check.startedAt), diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 30ada288bd76..003403fc5c09 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -206,6 +206,8 @@ const makeTestRelay = Effect.fnUntraced(function* ( waitForThread: unused, interruptThread: unused, getThreadEventSequence: unused, + recoverDelegatedTask: unused, + delegatedTaskResultPending: unused, streamStoredEvents: Stream.empty, streamStoredEventsFrom: () => Stream.empty, streamDomainEvents: options.domainEvents ?? Stream.empty, @@ -659,6 +661,30 @@ describe("AgentAwarenessRelay", () => { }), ); + it.effect.each([ + { label: "live", archived: false }, + { label: "archived", archived: true }, + ])("never publishes tombstones for $label subagent threads", ({ archived }) => + Effect.gen(function* () { + const { relay, currentShell, publications } = yield* makeTestRelay(); + yield* Ref.set( + currentShell, + shell({ + lineage: { + rootThreadId: THREAD_ID, + parentThreadId: THREAD_ID, + relationshipToParent: "subagent", + }, + ...(archived ? { archivedAt: yield* DateTime.now } : {}), + }), + ); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.equal(publications.length, 0); + }), + ); + 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 71e74d11b913..bc0e3d233119 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -522,6 +522,16 @@ export const make = Effect.gen(function* () { // domain event, so materializing the full shell here would make the cost // of one thread's activity proportional to how many threads exist. const threadShell = yield* threads.getThreadShell(threadId); + if ( + threadShell?.lineage.relationshipToParent === "subagent" && + !(yield* Ref.get(publishedStateByThreadRef)).has(threadId) + ) { + // Subagents never project activity, so the relay holds no row to clear. + // Their events would otherwise publish a tombstone each, and every + // publish re-delivers the user's aggregate. Checked before the archive + // filter so archiving one stays quiet too. + return; + } const thread = threadShell === null || threadShell.archivedAt !== null ? Option.none() diff --git a/apps/server/src/scheduling/Scheduler.test.ts b/apps/server/src/scheduling/Scheduler.test.ts index dd3ac3948a12..fbbf15ced75e 100644 --- a/apps/server/src/scheduling/Scheduler.test.ts +++ b/apps/server/src/scheduling/Scheduler.test.ts @@ -6,6 +6,7 @@ import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as TestClock from "effect/testing/TestClock"; +import * as ServerActivation from "../serverActivation.ts"; import * as Scheduler from "./Scheduler.ts"; it.effect("keeps other sources running after a source defects", () => @@ -84,3 +85,26 @@ it.effect("unregisters closed sources and interrupts their in-flight work", () = assert.equal(yield* Ref.get(runs), 1); }).pipe(Effect.provide(Scheduler.layer)), ); + +it.effect("holds due work and the clock until startup recovery activates the server", () => + Effect.gen(function* () { + const activation = yield* Deferred.make(); + const runs = yield* Ref.make(0); + const ran = yield* Deferred.make(); + yield* Effect.gen(function* () { + const scheduler = yield* Scheduler.Scheduler; + yield* scheduler.register( + "due", + Ref.update(runs, (n) => n + 1).pipe(Effect.andThen(Deferred.succeed(ran, undefined))), + ); + yield* TestClock.adjust("10 seconds"); + assert.equal(yield* Ref.get(runs), 0); + yield* Deferred.succeed(activation, undefined); + yield* Deferred.await(ran); + }).pipe( + Effect.provide(Scheduler.layer), + Effect.provideService(ServerActivation.ServerActivation, Deferred.await(activation)), + ); + assert.isAtLeast(yield* Ref.get(runs), 1); + }), +); diff --git a/apps/server/src/scheduling/Scheduler.ts b/apps/server/src/scheduling/Scheduler.ts index 957ba11e2ea0..e2214094cffb 100644 --- a/apps/server/src/scheduling/Scheduler.ts +++ b/apps/server/src/scheduling/Scheduler.ts @@ -6,6 +6,8 @@ import * as Ref from "effect/Ref"; import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; +import { forkParked } from "../serverActivation.ts"; + /** Sources load due work from their durable state; registration owns its execution lifetime. */ export class Scheduler extends Context.Service< Scheduler, @@ -47,14 +49,15 @@ const make = Effect.gen(function* () { return next; }), ); - yield* run; + // Due work waits for startup recovery, which would cancel runs it started. + yield* forkParked(run); }); const tick = Ref.get(sources).pipe( Effect.flatMap((current) => Effect.forEach(current.values(), (run) => run, { discard: true })), ); // One clock for all due-work sources. A slow source cannot block another // source or overlap itself, and no extra missed-tick backlog is queued. - yield* Effect.sleep("5 seconds").pipe(Effect.andThen(tick), Effect.forever, Effect.forkScoped); + yield* forkParked(Effect.sleep("5 seconds").pipe(Effect.andThen(tick), Effect.forever)); return Scheduler.of({ register }); }); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 989202f285fe..0bf5aa293b56 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -4,6 +4,7 @@ import * as Random from "effect/Random"; import * as Semaphore from "effect/Semaphore"; import * as StorageCleanup from "./storageCleanup.ts"; import * as PullRequestSyncReactor from "./orchestration-v2/PullRequestSyncReactor.ts"; +import * as PullRequestWatchReactor from "./orchestration-v2/PullRequestWatchReactor.ts"; // @effect-diagnostics nodeBuiltinImport:off import * as NodeHttp from "node:http"; @@ -522,6 +523,16 @@ const RuntimeCoreDependenciesBaseLive = Layer.mergeAll( Layer.provide(PullRequestServiceLive), Layer.provide(ProjectionStoreV2.layer), ), + Layer.effectDiscard( + Effect.gen(function* () { + const service = yield* PullRequestWatchReactor.PullRequestWatchReactor; + yield* service.start(); + }), + ).pipe( + Layer.provide(PullRequestWatchReactor.layer), + Layer.provide(PullRequestServiceLive), + Layer.provide(ProjectionStoreV2.layer), + ), // Subscribes to `account.rate-limits.updated` so usage bars track live // telemetry instead of waiting for the next status probe. ProviderUsageLimitsIngestionLive, diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index a89d010fab75..9aa6ebfaa45f 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -31,11 +31,20 @@ it.effect("starts without scanning or rebuilding projection history", () => const result = yield* ServerRuntimeStartup.runOrderedV2StartupPhases({ importLegacyShells: record("import"), recover: record("recover").pipe(Effect.as({ closedRequests: 2 })), + recoverDelegatedTasks: record("delegated"), startEffectWorker: record("worker"), autoBootstrap: record("bootstrap").pipe(Effect.as({ projectId: "project-1" })), }); - assert.deepEqual(yield* Ref.get(calls), ["import", "recover", "worker", "bootstrap"]); + // Delegated recovery reads the runs recovery terminalizes, and settles them + // before the worker runs restart continuations that would otherwise race it. + assert.deepEqual(yield* Ref.get(calls), [ + "import", + "recover", + "delegated", + "worker", + "bootstrap", + ]); assert.deepEqual(result, { recovery: { closedRequests: 2 }, bootstrap: { projectId: "project-1" }, diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index e3f92058e70c..c06ff1159d87 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -32,6 +32,7 @@ import * as Keybindings from "./keybindings.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; import * as EffectWorker from "./orchestration-v2/EffectWorker.ts"; import * as LegacyV1ThreadImporter from "./orchestration-v2/legacy/LegacyV1ThreadImporter.ts"; +import * as Orchestrator from "./orchestration-v2/Orchestrator.ts"; import * as ProviderRuntimeRecovery from "./orchestration-v2/ProviderRuntimeRecoveryService.ts"; import * as ProviderSessionManager from "./orchestration-v2/ProviderSessionManager.ts"; import * as ThreadLaunch from "./orchestration-v2/ThreadLaunchService.ts"; @@ -386,21 +387,26 @@ export function runOrderedV2StartupPhases< Bootstrap, ImportError, RecoveryError, + DelegationError, WorkerError, BootstrapError, ImportContext, RecoveryContext, + DelegationContext, WorkerContext, BootstrapContext, >(input: { readonly importLegacyShells: Effect.Effect; readonly recover: Effect.Effect; + /** Settles delegated tasks whose runs recovery just terminalized. */ + readonly recoverDelegatedTasks: Effect.Effect; 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; @@ -413,6 +419,7 @@ const make = (options?: StartupOptions) => 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; @@ -509,6 +516,10 @@ const make = (options?: StartupOptions) => ), ), recover: runStartupPhase("orchestration-v2.recovery", providerRuntimeRecovery.recover), + recoverDelegatedTasks: runStartupPhase( + "orchestration-v2.delegated-tasks.recover", + orchestrator.recoverDelegatedTasks, + ), startEffectWorker: runStartupPhase( "orchestration-v2.effect-worker.start", startEffectWorkerWithRelay({ diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index bc0c35574bfe..7dc5c58c8a92 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -182,6 +182,41 @@ it.layer(NodeServices.layer)("server settings", (it) => { ).pipe(TestClock.withLive, Effect.provide(makeServerSettingsLayer())), ); + it.effect("follows a settings link that is repointed to another directory", () => + Effect.scoped( + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const service = yield* ServerSettingsModule.ServerSettingsService; + const dotfiles = yield* fs.makeTempDirectoryScoped({ prefix: "t3-settings-dotfiles-" }); + const firstSettingsPath = path.join(dotfiles, "first", "settings.json"); + const secondSettingsPath = path.join(dotfiles, "second", "settings.json"); + yield* fs.makeDirectory(path.dirname(firstSettingsPath), { recursive: true }); + yield* fs.makeDirectory(path.dirname(secondSettingsPath), { recursive: true }); + yield* fs.writeFileString(firstSettingsPath, `{ "responseStreamingMode": "turn" }`); + yield* fs.writeFileString(secondSettingsPath, `{ "responseStreamingMode": "paragraph" }`); + yield* fs.remove(config.settingsPath, { force: true }); + yield* fs.symlink(firstSettingsPath, config.settingsPath); + yield* service.start; + + const repointChanges = yield* service.subscribeChanges; + yield* fs.remove(config.settingsPath); + yield* fs.symlink(secondSettingsPath, config.settingsPath); + const repointed = yield* repointChanges.pipe(Stream.runHead, Effect.timeout("2 seconds")); + assert.equal(Option.getOrUndefined(repointed)?.responseStreamingMode, "paragraph"); + + const editChanges = yield* service.subscribeChanges; + yield* writeFileStringAtomically({ + filePath: secondSettingsPath, + contents: `{ "responseStreamingMode": "turn" }`, + }); + const edited = yield* editChanges.pipe(Stream.runHead, Effect.timeout("2 seconds")); + assert.equal(Option.getOrUndefined(edited)?.responseStreamingMode, "turn"); + }), + ).pipe(TestClock.withLive, Effect.provide(makeServerSettingsLayer())), + ); + it.effect("reloads when a dangling settings link gets its destination", () => Effect.scoped( Effect.gen(function* () { @@ -1321,77 +1356,78 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(settingsLayer)); }); - for (const { label, variable, expected, duplicate } of [ - { - label: "preserves an inline secret on a redacted settings save", - variable: { name: "API_TOKEN", value: "", sensitive: true, valueRedacted: true }, - expected: "inline-test-token", - }, - { - label: "preserves the effective last inline secret when names are duplicated", - variable: { name: "API_TOKEN", value: "", sensitive: true, valueRedacted: true }, - expected: "last-inline-test-token", - duplicate: true, - }, - { - label: "replaces an inline secret with an explicit value", - variable: { name: "API_TOKEN", value: "replacement-test-token", sensitive: true }, - expected: "replacement-test-token", - }, - { - label: "clears an inline secret with an explicit empty value", - variable: { name: "API_TOKEN", value: "", sensitive: true }, - expected: "", - }, - ]) { - it.effect(label, () => - Effect.gen(function* () { - const instanceId = ProviderInstanceId.make("codex_personal"); - const serverSettings = yield* ServerSettingsModule.ServerSettingsService; - const serverConfig = yield* ServerConfig.ServerConfig; - const fileSystem = yield* FileSystem.FileSystem; - yield* fileSystem.writeFileString( - serverConfig.settingsPath, - duplicate - ? '{"providerInstances":{"codex_personal":{"driver":"codex","environment":[{"name":"API_TOKEN","value":"inline-test-token","sensitive":true},{"name":"API_TOKEN","value":"last-inline-test-token","sensitive":true}],"config":{}}}}' - : '{"providerInstances":{"codex_personal":{"driver":"codex","environment":[{"name":"API_TOKEN","value":"inline-test-token","sensitive":true}],"config":{}}}}', - ); - const initial = yield* serverSettings.getSettings; - assert.equal( - initial.providerInstances[instanceId]?.environment?.[0]?.value, - "inline-test-token", - ); + it.effect.each( + [ + { + label: "preserves an inline secret on a redacted settings save", + variable: { name: "API_TOKEN", value: "", sensitive: true, valueRedacted: true }, + expected: "inline-test-token", + }, + { + label: "preserves the effective last inline secret when names are duplicated", + variable: { name: "API_TOKEN", value: "", sensitive: true, valueRedacted: true }, + expected: "last-inline-test-token", + duplicate: true, + }, + { + label: "replaces an inline secret with an explicit value", + variable: { name: "API_TOKEN", value: "replacement-test-token", sensitive: true }, + expected: "replacement-test-token", + }, + { + label: "clears an inline secret with an explicit empty value", + variable: { name: "API_TOKEN", value: "", sensitive: true }, + expected: "", + }, + ].map((testCase) => [testCase.label, testCase] as const), + )("%s", ([, { variable, expected, duplicate }]) => + Effect.gen(function* () { + const instanceId = ProviderInstanceId.make("codex_personal"); + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.writeFileString( + serverConfig.settingsPath, + duplicate + ? '{"providerInstances":{"codex_personal":{"driver":"codex","environment":[{"name":"API_TOKEN","value":"inline-test-token","sensitive":true},{"name":"API_TOKEN","value":"last-inline-test-token","sensitive":true}],"config":{}}}}' + : '{"providerInstances":{"codex_personal":{"driver":"codex","environment":[{"name":"API_TOKEN","value":"inline-test-token","sensitive":true}],"config":{}}}}', + ); + const initial = yield* serverSettings.getSettings; + assert.equal( + initial.providerInstances[instanceId]?.environment?.[0]?.value, + "inline-test-token", + ); - const next = yield* serverSettings.updateSettings({ - providerInstances: { - [instanceId]: { - driver: ProviderDriverKind.make("codex"), - displayName: "Renamed provider", - environment: duplicate ? [variable, variable] : [variable], - config: {}, - }, + const next = yield* serverSettings.updateSettings({ + providerInstances: { + [instanceId]: { + driver: ProviderDriverKind.make("codex"), + displayName: "Renamed provider", + environment: duplicate ? [variable, variable] : [variable], + config: {}, }, - }); - assert.equal(next.providerInstances[instanceId]?.environment?.[0]?.value, expected); - const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); - assert.notInclude(raw, "inline-test-token"); - assert.notInclude(raw, "replacement-test-token"); - - const reloaded = yield* Effect.gen(function* () { - const fresh = yield* ServerSettingsModule.ServerSettingsService; - return yield* fresh.getSettings; - }).pipe( - Effect.provide( - Layer.fresh(ServerSettingsModule.layer).pipe(Layer.provide(ServerSecretStore.layer)), - ), - ); - assert.equal(reloaded.providerInstances[instanceId]?.environment?.[0]?.value, expected); - }).pipe(Effect.provide(makeServerSettingsLayer())), - ); - } + }, + }); + assert.equal(next.providerInstances[instanceId]?.environment?.[0]?.value, expected); + const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); + assert.notInclude(raw, "inline-test-token"); + assert.notInclude(raw, "replacement-test-token"); + + const reloaded = yield* Effect.gen(function* () { + const fresh = yield* ServerSettingsModule.ServerSettingsService; + return yield* fresh.getSettings; + }).pipe( + Effect.provide( + Layer.fresh(ServerSettingsModule.layer).pipe(Layer.provide(ServerSecretStore.layer)), + ), + ); + assert.equal(reloaded.providerInstances[instanceId]?.environment?.[0]?.value, expected); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); - for (const sensitiveLast of [true, false]) { - it.effect(`preserves duplicate secret operation order (sensitive last: ${sensitiveLast})`, () => + it.effect.each([true, false])( + "preserves duplicate secret operation order (sensitive last: %s)", + (sensitiveLast) => Effect.gen(function* () { const service = yield* ServerSettingsModule.ServerSettingsService; const instanceId = ProviderInstanceId.make("codex_duplicate"); @@ -1415,8 +1451,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { sensitiveLast ? "secret-last" : "", ); }).pipe(Effect.provide(makeServerSettingsLayer())), - ); - } + ); it.effect("stores 1Password secret sources as written and never as sensitive", () => Effect.gen(function* () { @@ -1747,8 +1782,9 @@ it.layer(NodeServices.layer)("server settings", (it) => { }), ); - for (const failure of ["response materialization", "partially committed write"] as const) { - it.effect(`rolls back provider secret changes after ${failure} fails`, () => { + it.effect.each(["response materialization", "partially committed write"] as const)( + "rolls back provider secret changes after %s fails", + (failure) => { const textDecoder = new TextDecoder(); const secrets = new Map(); let rejectNewSecret = false; @@ -1851,8 +1887,8 @@ it.layer(NodeServices.layer)("server settings", (it) => { "sk-kept", ); }).pipe(Effect.provide(settingsLayer)); - }); - } + }, + ); it.effect("folds legacy project overrides into projectSettingsOverrides once", () => Effect.gen(function* () { diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index c71570ae1657..4e4c57c9996e 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -1229,20 +1229,34 @@ const make = Effect.gen(function* () { const revalidateAndEmitSafely = revalidateAndEmit.pipe(Effect.ignoreCause({ log: true })); // A symlinked settings file is rewritten in its destination's directory, - // which a watch on the link's directory never sees. - const linkTargetPath = yield* resolveSymlinkTarget(settingsPath).pipe( - Effect.provideService(FileSystem.FileSystem, fs), - Effect.provideService(Path.Path, pathService), - ); - const isLinked = linkTargetPath !== pathService.resolve(settingsPath); - if (isLinked) { + // which a watch on the link's directory never sees. The link is resolved + // again whenever it changes, so repointing it moves the watch along. + const watchLinkTarget = Effect.gen(function* () { + const linkTargetPath = yield* resolveSymlinkTarget(settingsPath).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, pathService), + ); + if (linkTargetPath === pathService.resolve(settingsPath)) { + return Option.none(); + } yield* fs .makeDirectory(pathService.dirname(linkTargetPath), { recursive: true }) .pipe(Effect.ignore({ log: true })); - } - const linkTargetEvents = isLinked - ? watchFileChanges(linkTargetPath).pipe(Stream.ignore({ log: true })) - : Stream.empty; + return Option.some(linkTargetPath); + }).pipe(Effect.orElseSucceed(() => Option.none())); + + const initialLinkTarget = yield* watchLinkTarget; + const linkTargetEvents = Stream.make(initialLinkTarget).pipe( + Stream.concat(watchFileChanges(settingsPath).pipe(Stream.mapEffect(() => watchLinkTarget))), + Stream.changes, + Stream.switchMap( + Option.match({ + onNone: () => Stream.empty, + onSome: (linkTargetPath) => + watchFileChanges(linkTargetPath).pipe(Stream.ignore({ log: true })), + }), + ), + ); // Debounce watch events so the file is fully written before we read it. // Editors emit multiple events per save (truncate, write, rename) and diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index 2a214fc800ff..751420dfc5a1 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -408,8 +408,9 @@ it("reports an update hint instead of unauthenticated when gh predates --json", ); }); -for (const kind of ["pull", "issues"]) { - it.effect(`resolves ${kind} subjects on the linked host without using the checkout`, () => +it.effect.each(["pull", "issues"])( + "resolves %s subjects on the linked host without using the checkout", + (kind) => Effect.gen(function* () { const provider = yield* makeProvider({ execute: (input) => { @@ -449,11 +450,11 @@ for (const kind of ["pull", "issues"]) { undefined, ); }), - ); -} +); -for (const stage of ["read", "decode"] as const) { - it.effect(`retains the ${stage} failure without exposing its raw contents`, () => +it.effect.each(["read", "decode"] as const)( + "retains the %s failure without exposing its raw contents", + (stage) => Effect.gen(function* () { const cause = new GitHubCli.GitHubCliCommandError({ command: "gh", @@ -484,5 +485,4 @@ for (const stage of ["read", "decode"] as const) { if (stage === "read") assert.strictEqual(error.cause, cause); else assert.propertyVal(error.cause, "_tag", "SchemaError"); }), - ); -} +); diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts index deca59c48b90..de0e6bad76c0 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts @@ -227,8 +227,9 @@ selfhosted ); }); -for (const kind of ["merge_requests", "issues"]) { - it.effect(`resolves ${kind} subjects on the linked host without using the checkout`, () => +it.effect.each(["merge_requests", "issues"])( + "resolves %s subjects on the linked host without using the checkout", + (kind) => Effect.gen(function* () { const provider = yield* makeProvider({ execute: (input) => { @@ -269,11 +270,11 @@ for (const kind of ["merge_requests", "issues"]) { undefined, ); }), - ); -} +); -for (const stage of ["read", "decode"] as const) { - it.effect(`retains the ${stage} failure without exposing its raw contents`, () => +it.effect.each(["read", "decode"] as const)( + "retains the %s failure without exposing its raw contents", + (stage) => Effect.gen(function* () { const cause = new GitLabCli.GitLabCliCommandError({ command: "glab", @@ -305,5 +306,4 @@ for (const stage of ["read", "decode"] as const) { if (stage === "read") assert.strictEqual(error.cause, cause); else assert.propertyVal(error.cause, "_tag", "SchemaError"); }), - ); -} +); diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index 960e2459fb30..ec7a10e403cc 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -371,7 +371,7 @@ export const make = Effect.gen(function* () { return; yield* git.removeWorktree({ cwd: project.workspaceRoot, path: worktreePath, force: false }); yield* gitManager.invalidateStatus(project.workspaceRoot); - // Preserve branch and path: ProviderCommandReactor recreates the checkout + // Preserve branch and path: ProviderTurnStartService recreates the checkout // from that branch when the thread is resumed. yield* Effect.logInfo("storage cleanup removed worktree", { threadId: thread.id }); }).pipe( diff --git a/apps/server/src/telemetry/AnalyticsService.test.ts b/apps/server/src/telemetry/AnalyticsService.test.ts index c135467efffa..e2966306329d 100644 --- a/apps/server/src/telemetry/AnalyticsService.test.ts +++ b/apps/server/src/telemetry/AnalyticsService.test.ts @@ -4,6 +4,10 @@ import { assert, it } from "@effect/vitest"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientError from "effect/unstable/http/HttpClientError"; import * as HttpServer from "effect/unstable/http/HttpServer"; import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; @@ -46,7 +50,86 @@ interface RecordedBatchBody { }>; } +const SentBatch = Schema.fromJsonString( + Schema.Struct({ + batch: Schema.Array(Schema.Struct({ uuid: Schema.String })), + }), +); + +/** + * HTTP client that reads each batch, then fails as if the connection dropped + * before the response arrived. PostHog stores these batches, so the server + * must not send them forever. + */ +const acceptThenFailClient = (batches: Array>) => + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.gen(function* () { + if (request.body._tag === "Uint8Array") { + const body = yield* Schema.decodeEffect(SentBatch)( + new TextDecoder().decode(request.body.body), + ).pipe(Effect.orDie); + batches.push(body.batch); + } + return yield* new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ request, cause: "connection reset" }), + }); + }), + ), + ); + +it("retryDelayMs doubles from 2s and stays under the 5 minute cap", () => { + assert.equal(AnalyticsService.retryDelayMs(1, 0), 1_000); + assert.equal(AnalyticsService.retryDelayMs(2, 0.999_999), 4_000); + assert.equal(AnalyticsService.retryDelayMs(30, 0), 150_000); + assert.equal(AnalyticsService.retryDelayMs(30, 0.999_999), 300_000); +}); + it.layer(NodeServices.layer)("AnalyticsService test", (it) => { + it.effect("a batch that keeps failing is retried with backoff, then dropped", () => + Effect.gen(function* () { + const batches: Array> = []; + const runtimeLayer = AnalyticsService.layer.pipe( + Layer.provideMerge( + ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-telemetry-retry-" }), + ), + Layer.provide( + ConfigProvider.layer( + ConfigProvider.fromUnknown({ + T3CODE_TELEMETRY_ENABLED: true, + T3CODE_POSTHOG_KEY: "phc_test_key", + T3CODE_POSTHOG_HOST: "http://localhost", + }), + ), + ), + Layer.provide( + Layer.mergeAll( + Layer.succeed(HostProcessPlatform, "win32"), + Layer.succeed(HostProcessArchitecture, "x64"), + acceptThenFailClient(batches), + ), + ), + ); + + yield* Effect.gen(function* () { + const analytics = yield* AnalyticsService.AnalyticsService; + for (let index = 0; index < 20; index += 1) { + yield* analytics.record("test.retry", { index }); + } + // Before the fix this loop sent the batch about once a second. + for (let second = 0; second < 600; second += 1) { + yield* TestClock.adjust("1 second"); + } + }).pipe(Effect.provide(runtimeLayer)); + + assert.equal(batches.length, 5); + const uuids = batches.map((batch) => batch.map((event) => event.uuid).join(",")); + assert.equal(new Set(uuids).size, 1, "every retry carries the same uuids"); + assert.equal(new Set(batches[0]?.map((event) => event.uuid)).size, 20); + }), + ); + it.effect("flush drains all buffered events across multiple batches", () => Effect.gen(function* () { const capturedRequests: Array = []; diff --git a/apps/server/src/telemetry/AnalyticsService.ts b/apps/server/src/telemetry/AnalyticsService.ts index d663a8f321e9..4d1db714eb96 100644 --- a/apps/server/src/telemetry/AnalyticsService.ts +++ b/apps/server/src/telemetry/AnalyticsService.ts @@ -2,19 +2,27 @@ * Anonymous PostHog telemetry service. * * Persists an installation-scoped anonymous identifier, buffers events in - * memory, and flushes batches over Effect's HTTP client. + * memory, and flushes batches over Effect's HTTP client. A failed batch is + * retried with backoff and dropped after a few tries. Each event carries a + * uuid, so PostHog can tell a retried copy from a new event. * * @module AnalyticsService */ import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import type { ClientOs } from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; import * as Config from "effect/Config"; import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Random from "effect/Random"; import * as Ref from "effect/Ref"; +import * as Result from "effect/Result"; +import * as Semaphore from "effect/Semaphore"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; @@ -24,11 +32,40 @@ import * as ServerConfig from "../config.ts"; import { getTelemetryIdentifier } from "./Identify.ts"; interface BufferedAnalyticsEvent { + readonly uuid: string; readonly event: string; readonly properties?: Readonly>; readonly capturedAt: string; } +interface DeliveryState { + /** Batch that failed last. It is sent again before newer events. */ + readonly failedBatch: ReadonlyArray; + /** Failed sends of `failedBatch`. */ + readonly batchAttempts: number; + /** Failed sends since the last success. Sets the backoff delay. */ + readonly failures: number; + /** The background flush does not send before this time (epoch ms). */ + readonly retryAt: number; +} + +const FLUSH_INTERVAL_MS = 1_000; +// A hung send would hold the flush lock, and with it the shutdown flush. +const SEND_TIMEOUT = "10 seconds"; +const MAX_BATCH_ATTEMPTS = 5; +const RETRY_BASE_DELAY_MS = 2_000; +const RETRY_MAX_DELAY_MS = 300_000; + +/** + * Delay before the next send after `failures` consecutive failed sends. The + * ceiling doubles from 2s up to 5 minutes, and the delay is a random point in + * its upper half. `random` is in [0, 1). + */ +export function retryDelayMs(failures: number, random: number): number { + const ceiling = Math.min(RETRY_MAX_DELAY_MS, RETRY_BASE_DELAY_MS * 2 ** (failures - 1)); + return Math.round(ceiling / 2 + (ceiling / 2) * random); +} + const TelemetryEnvConfig = Config.all({ posthogKey: Config.String("T3CODE_POSTHOG_KEY").pipe( Config.withDefault("phc_XOWci4oZP4VvLiEyrFqkFjP4CZn55mjYYBMREK5Wd6m"), @@ -88,17 +125,31 @@ export const make = Effect.gen(function* () { const httpClient = yield* HttpClient.HttpClient; const serverConfig = yield* ServerConfig.ServerConfig; const identifier = yield* getTelemetryIdentifier; + const crypto = yield* Crypto.Crypto; const bufferRef = yield* Ref.make>([]); + const deliveryRef = yield* Ref.make({ + failedBatch: [], + batchAttempts: 0, + failures: 0, + retryAt: 0, + }); + // The background flush and the shutdown flush must not send the same batch at once. + const flushLock = yield* Semaphore.make(1); const clientType = serverConfig.mode === "desktop" ? "desktop-app" : "cli-web-client"; const hostPlatform = yield* HostProcessPlatform; const hostArchitecture = yield* HostProcessArchitecture; - const enqueueBufferedEvent = (event: string, properties?: Readonly>) => + const enqueueBufferedEvent = ( + uuid: string, + event: string, + properties?: Readonly>, + ) => Effect.flatMap(DateTime.now, (now) => Ref.modify(bufferRef, (current) => { const appended = [ ...current, { + uuid, event, ...(properties ? { properties } : {}), capturedAt: DateTime.formatIso(now), @@ -128,6 +179,7 @@ export const make = Effect.gen(function* () { const payload = { api_key: telemetryConfig.posthogKey, batch: events.map((event) => ({ + uuid: event.uuid, event: event.event, distinct_id: identifier, properties: { @@ -152,39 +204,69 @@ export const make = Effect.gen(function* () { HttpClientRequest.bodyJson(payload), Effect.flatMap(httpClient.execute), Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.timeout(SEND_TIMEOUT), ); }); + const takeBatch = Ref.modify(bufferRef, (current) => { + const nextBatch = current.slice(0, telemetryConfig.flushBatchSize); + return [nextBatch, current.slice(nextBatch.length)] as const; + }); + + // Sends batches until the buffer is empty or a send fails. A failed batch is + // kept for the next flush, and dropped after MAX_BATCH_ATTEMPTS failed sends. const flush: AnalyticsService["Service"]["flush"] = Effect.gen(function* () { while (true) { - const batch = yield* Ref.modify(bufferRef, (current) => { - if (current.length === 0) { - return [[] as ReadonlyArray, current] as const; - } - const nextBatch = current.slice(0, telemetryConfig.flushBatchSize); - const remaining = current.slice(nextBatch.length); - return [nextBatch, remaining] as const; - }); - + const delivery = yield* Ref.get(deliveryRef); + const batch = delivery.failedBatch.length > 0 ? delivery.failedBatch : yield* takeBatch; if (batch.length === 0) { return; } - yield* sendBatch(batch).pipe( - Effect.catch((error) => - Ref.update(bufferRef, (current) => [...batch, ...current]).pipe( - Effect.flatMap(() => Effect.fail(error)), - ), - ), - ); + const sent = yield* Effect.result(sendBatch(batch)); + if (Result.isSuccess(sent)) { + yield* Ref.set(deliveryRef, { failedBatch: [], batchAttempts: 0, failures: 0, retryAt: 0 }); + continue; + } + + const failures = delivery.failures + 1; + const batchAttempts = delivery.batchAttempts + 1; + const retryAt = (yield* Clock.currentTimeMillis) + retryDelayMs(failures, yield* Random.next); + if (batchAttempts < MAX_BATCH_ATTEMPTS) { + yield* Ref.set(deliveryRef, { failedBatch: batch, batchAttempts, failures, retryAt }); + yield* Effect.logDebug("Failed to send telemetry batch; will retry", { + attempt: batchAttempts, + cause: sent.failure, + }); + return; + } + yield* Ref.set(deliveryRef, { failedBatch: [], batchAttempts: 0, failures, retryAt }); + yield* Effect.logWarning("Dropped telemetry batch after repeated send failures", { + events: batch.length, + attempts: batchAttempts, + cause: sent.failure, + }); + return; } - }).pipe(Effect.catch((cause) => Effect.logError("Failed to flush telemetry", { cause }))); + }).pipe(flushLock.withPermit); + + const flushWhenDue = Effect.gen(function* () { + const { retryAt } = yield* Ref.get(deliveryRef); + if ((yield* Clock.currentTimeMillis) >= retryAt) { + yield* flush; + } + }); const record: AnalyticsService["Service"]["record"] = Effect.fn("AnalyticsService.record")( function* (event, properties) { if (!telemetryConfig.enabled || !identifier) return; - const enqueueResult = yield* enqueueBufferedEvent(event, properties); + // Telemetry is best effort: an event without a uuid is not sent. The + // Node implementation throws (a defect) rather than failing, so catch both. + const uuid = yield* Effect.exit(crypto.randomUUIDv7); + if (Exit.isFailure(uuid)) return; + + const enqueueResult = yield* enqueueBufferedEvent(uuid.value, event, properties); if (enqueueResult.dropped) { yield* Effect.logDebug("analytics buffer full; dropping oldest event", { size: enqueueResult.size, @@ -194,7 +276,7 @@ export const make = Effect.gen(function* () { }, ); - yield* Effect.forever(Effect.sleep(1000).pipe(Effect.flatMap(() => flush)), { + yield* Effect.forever(Effect.sleep(FLUSH_INTERVAL_MS).pipe(Effect.flatMap(() => flushWhenDue)), { disableYield: true, }).pipe(Effect.forkScoped); diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 29edb395b908..b2b379edb85d 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -1461,8 +1461,9 @@ it.layer( }), ); - for (const source of ["current", "legacy"] as const) { - it.effect(`reads only a Unicode-safe tail from oversized ${source} history`, () => + it.effect.each(["current", "legacy"] as const)( + "reads only a Unicode-safe tail from oversized %s history", + (source) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -1513,8 +1514,7 @@ it.layer( yield* manager.close({ threadId: "thread-1" }); expect((yield* manager.open(openInput())).history).toBe("\uFEFFnewest\ré"); }), - ); - } + ); it.effect("strips replay-unsafe terminal query and reply sequences from persisted history", () => Effect.gen(function* () { diff --git a/apps/server/src/terminal/NodePtyAdapter.test.ts b/apps/server/src/terminal/NodePtyAdapter.test.ts index 8107e19e6165..7c6101dfa38e 100644 --- a/apps/server/src/terminal/NodePtyAdapter.test.ts +++ b/apps/server/src/terminal/NodePtyAdapter.test.ts @@ -110,8 +110,9 @@ it.effect("waits for the Windows PID without requiring output", () => }).pipe(Effect.provide(testLayer)), ); -for (const failure of ["exit", "close", "error", "invalid-pid"] as const) { - it.effect(`fails Windows startup on ${failure} and cleans up`, () => +it.effect.each(["exit", "close", "error", "invalid-pid"] as const)( + "fails Windows startup on %s and cleans up", + (failure) => Effect.gen(function* () { const { nativeProcess, subscribed } = preparePendingProcess(); const adapter = yield* PtyAdapter.PtyAdapter; @@ -129,8 +130,7 @@ for (const failure of ["exit", "close", "error", "invalid-pid"] as const) { assert.equal(nativeProcess.events.listenerCount("exit"), 0); assert.equal(nativeProcess._agent.kill.mock.calls.length, 1); }).pipe(Effect.provide(testLayer)), - ); -} +); it.effect("cancels the Windows connection without waiting for output", () => Effect.gen(function* () { @@ -160,8 +160,9 @@ it.effect("reports an incompatible Windows readiness API instead of hanging", () }).pipe(Effect.provide(testLayer)), ); -for (const platform of ["win32", "linux", "darwin"] as const) { - it.effect(`terminates through node-pty using ${platform} semantics`, () => +it.effect.each(["win32", "linux", "darwin"] as const)( + "terminates through node-pty using %s semantics", + (platform) => Effect.gen(function* () { const adapter = yield* PtyAdapter.PtyAdapter; const process = yield* adapter.spawn({ @@ -189,8 +190,7 @@ for (const platform of ["win32", "linux", "darwin"] as const) { : [["SIGTERM"], ["SIGKILL"], [undefined]], ); }).pipe(Effect.provide(makeTestLayer(platform))), - ); -} +); it.effect("spawns through the public adapter with the provided host references", () => Effect.gen(function* () { @@ -279,8 +279,9 @@ it.effect("reports native module load failures as structured startup defects", ( ), ); -for (const budget of [2048, 8]) { - it.effect(`preserves an exit during readiness handoff with scheduler budget ${budget}`, () => +it.effect.each([2048, 8])( + "preserves an exit during readiness handoff with scheduler budget %s", + (budget) => Effect.gen(function* () { const { nativeProcess, subscribed } = preparePendingProcess(); const adapter = yield* PtyAdapter.PtyAdapter; @@ -300,8 +301,7 @@ for (const budget of [2048, 8]) { yield* Fiber.join(fiber); assert.equal(exits.length, 1); }).pipe(Effect.provide(testLayer)), - ); -} +); it.effect("replays an exit to late subscribers and respects unsubscription", () => Effect.gen(function* () { @@ -320,8 +320,9 @@ it.effect("replays an exit to late subscribers and respects unsubscription", () }).pipe(Effect.provide(testLayer)), ); -for (const failure of ["spawn", "interrupt"] as const) { - it.effect(`logs cleanup failures without replacing ${failure}`, () => +it.effect.each(["spawn", "interrupt"] as const)( + "logs cleanup failures without replacing %s", + (failure) => Effect.gen(function* () { const { nativeProcess, subscribed } = preparePendingProcess(); const killError = new Error("native kill failed"); @@ -361,5 +362,4 @@ for (const failure of ["spawn", "interrupt"] as const) { assert.equal(nativeProcess.events.listenerCount("exit"), 0); assert.equal(nativeProcess._socket.listenerCount("ready_datapipe"), 0); }).pipe(Effect.provide(testLayer)), - ); -} +); diff --git a/apps/server/src/terminal/OutputProtocol.test.ts b/apps/server/src/terminal/OutputProtocol.test.ts index 76629cbf6181..58fa9e3d74d6 100644 --- a/apps/server/src/terminal/OutputProtocol.test.ts +++ b/apps/server/src/terminal/OutputProtocol.test.ts @@ -11,69 +11,67 @@ import { Rpc, RpcGroup, RpcMessage, RpcSerialization, RpcServer } from "effect/u import { withTerminalOutputWindow } from "./OutputProtocol.ts"; describe("terminal output window", () => { - for (const { tag, size, limit } of [ + it.effect.each([ { tag: WS_METHODS.terminalAttach, size: 1, limit: 8 }, { tag: WS_METHODS.subscribeTerminalEvents, size: 1, limit: 8 }, { tag: WS_METHODS.terminalAttach, size: 64 * 1024, limit: 1 }, { tag: WS_METHODS.subscribeTerminalMetadata, size: 1, limit: 1 }, - ]) { - it.effect(`limits ${tag} with ${size}-byte values to ${limit} pending chunks`, () => - Effect.gen(function* () { - const group = RpcGroup.make(Rpc.make(tag, { success: Schema.String, stream: true })); - const output = yield* Queue.unbounded(); - const responses = 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(responses, response), - end: () => Effect.void, - clientIds: Effect.succeed(new Set([0])), - initialMessage: Effect.succeedNone, - supportsAck: true, - supportsTransferables: false, - supportsSpanPropagation: false, - supportsNotifications: true, - codecFor: serialization.codecFor, - }; - }), - ); - yield* RpcServer.make(group).pipe( - Effect.provide(group.toLayerHandler(tag, () => Stream.fromQueue(output))), - Effect.provideService(RpcServer.Protocol, withTerminalOutputWindow(protocol)), - Effect.forkScoped, - ); - const write = yield* Deferred.await(receive); - yield* write(0, { _tag: "Request", id: "1", tag, payload: null, headers: [] }); + ])("limits $tag with $size-byte values to $limit pending chunks", ({ tag, size, limit }) => + Effect.gen(function* () { + const group = RpcGroup.make(Rpc.make(tag, { success: Schema.String, stream: true })); + const output = yield* Queue.unbounded(); + const responses = 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(responses, response), + end: () => Effect.void, + clientIds: Effect.succeed(new Set([0])), + initialMessage: Effect.succeedNone, + supportsAck: true, + supportsTransferables: false, + supportsSpanPropagation: false, + supportsNotifications: true, + codecFor: serialization.codecFor, + }; + }), + ); + yield* RpcServer.make(group).pipe( + Effect.provide(group.toLayerHandler(tag, () => Stream.fromQueue(output))), + Effect.provideService(RpcServer.Protocol, withTerminalOutputWindow(protocol)), + Effect.forkScoped, + ); + const write = yield* Deferred.await(receive); + yield* write(0, { _tag: "Request", id: "1", tag, payload: null, headers: [] }); - for (let index = 0; index < limit; index++) { - const value = String(index).repeat(size); - yield* Queue.offer(output, value); - yield* TestClock.adjust(0); - assert.equal(yield* Queue.size(responses), 1); - assert.deepEqual(yield* Queue.take(responses), { - _tag: "Chunk", - requestId: "1", - values: [value], - }); - } - yield* Queue.offer(output, "i".repeat(size)); + for (let index = 0; index < limit; index++) { + const value = String(index).repeat(size); + yield* Queue.offer(output, value); yield* TestClock.adjust(0); - assert.equal(yield* Queue.size(responses), 0); - yield* write(0, { _tag: "Ack", requestId: "1" }); - const resumed = yield* Queue.take(responses); - assert.equal(resumed._tag, "Chunk"); - if (resumed._tag === "Chunk") assert.deepEqual(resumed.values, ["i".repeat(size)]); + assert.equal(yield* Queue.size(responses), 1); + assert.deepEqual(yield* Queue.take(responses), { + _tag: "Chunk", + requestId: "1", + values: [value], + }); + } + yield* Queue.offer(output, "i".repeat(size)); + yield* TestClock.adjust(0); + assert.equal(yield* Queue.size(responses), 0); + yield* write(0, { _tag: "Ack", requestId: "1" }); + const resumed = yield* Queue.take(responses); + assert.equal(resumed._tag, "Chunk"); + if (resumed._tag === "Chunk") assert.deepEqual(resumed.values, ["i".repeat(size)]); - yield* Queue.offer(output, "j"); - yield* TestClock.adjust(0); - assert.equal(yield* Queue.size(responses), 0); - yield* write(0, { _tag: "Interrupt", requestId: "1" }); - assert.equal((yield* Queue.take(responses))._tag, "Exit"); - }).pipe(Effect.provide(RpcSerialization.layerJson), Effect.scoped), - ); - } + yield* Queue.offer(output, "j"); + yield* TestClock.adjust(0); + assert.equal(yield* Queue.size(responses), 0); + yield* write(0, { _tag: "Interrupt", requestId: "1" }); + assert.equal((yield* Queue.take(responses))._tag, "Exit"); + }).pipe(Effect.provide(RpcSerialization.layerJson), Effect.scoped), + ); }); diff --git a/apps/server/src/testUtils/shardWeights.json b/apps/server/src/testUtils/shardWeights.json index 443acb03658d..999edad92545 100644 --- a/apps/server/src/testUtils/shardWeights.json +++ b/apps/server/src/testUtils/shardWeights.json @@ -1,40 +1,62 @@ { - "integration/orchestrationEngine.integration.test.ts": 1.9, - "integration/providerService.integration.test.ts": 0.5, - "src/bin.test.ts": 0.8, - "src/checkpointing/CheckpointStore.test.ts": 0.6, - "src/device/sshDeviceScript.test.ts": 2.9, - "src/git/GitManager.test.ts": 18.7, - "src/orchestration/Layers/CheckpointReactor.test.ts": 7.1, - "src/orchestration/Layers/OrchestrationEngine.test.ts": 1.4, - "src/orchestration/Layers/ProjectionPipeline.test.ts": 0.5, - "src/orchestration/Layers/ProviderCommandReactor.test.ts": 3.8, - "src/orchestration/Layers/ProviderRuntimeIngestion.test.ts": 2.4, - "src/persistence/Layers/OrchestrationEventStore.test.ts": 2.2, - "src/process/externalLauncher.test.ts": 2.2, - "src/project/AgentSessionScanner.test.ts": 4.3, - "src/provider/CodexChatGptAuth.test.ts": 5.9, - "src/provider/Drivers/AntigravityDriver.test.ts": 2, - "src/provider/Layers/AntigravityAdapter.test.ts": 0.5, - "src/provider/Layers/ClaudeAdapter.test.ts": 0.7, - "src/provider/Layers/CodexCollabRuntime.integration.test.ts": 3.4, - "src/provider/Layers/CursorAdapter.test.ts": 8, - "src/provider/Layers/CursorProvider.test.ts": 1.4, - "src/provider/Layers/GrokAdapter.test.ts": 9, - "src/provider/Layers/GrokProvider.test.ts": 0.7, - "src/provider/Layers/ProviderService.test.ts": 0.5, - "src/provider/acp/AcpJsonRpcConnection.test.ts": 6.2, - "src/provider/acp/XAiAcpExtension.test.ts": 0.6, + "integration/transferBudgetV2.integration.test.ts": 0.7, + "src/auth/SessionStore.test.ts": 0.5, + "src/checkpointing/CheckpointStore.test.ts": 0.7, + "src/cli/project.test.ts": 0.7, + "src/device/sshDeviceScript.test.ts": 3, + "src/git/GitManager.test.ts": 22.7, + "src/git/detachStackFrame.memory.test.ts": 0.5, + "src/mcp/OrchestratorMcpToolkit.integration.test.ts": 9.1, + "src/orchestration-v2/Adapters/AcpAdapterV2.test.ts": 52.3, + "src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts": 1.8, + "src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts": 1.1, + "src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts": 0.6, + "src/orchestration-v2/Adapters/CodexAdapterV2.test.ts": 0.6, + "src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts": 0.8, + "src/orchestration-v2/FoundationPersistence.test.ts": 2.6, + "src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts": 6, + "src/orchestration-v2/ProjectionStore.test.ts": 0.7, + "src/orchestration-v2/ProviderSessionManager.test.ts": 0.9, + "src/orchestration-v2/ProviderTurnStartService.memory.test.ts": 6.5, + "src/orchestration-v2/SelectionRestart.integration.test.ts": 2.5, + "src/orchestration-v2/SteeringCompletion.integration.test.ts": 2.7, + "src/orchestration-v2/ThreadLaunchService.test.ts": 2, + "src/orchestration-v2/ThreadTitleRegenerationService.test.ts": 0.5, + "src/orchestration-v2/legacy/LegacyV1Cutover.integration.test.ts": 0.5, + "src/orchestration-v2/runtimeLayer.test.ts": 0.9, + "src/orchestration-v2/testkit/ClaudeReplayFixtures.integration.test.ts": 0.6, + "src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts": 53.4, + "src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts": 1.4, + "src/orchestration-v2/testkit/OrchestratorReplayRestartBackgroundNote.integration.test.ts": 1.9, + "src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts": 25.4, + "src/orchestration-v2/testkit/ThreadFork.integration.test.ts": 3.7, + "src/orchestration-v2/testkit/ThreadMergeBack.integration.test.ts": 4.3, + "src/persistence/Layers/OrchestrationEventStore.test.ts": 1.9, + "src/persistence/Layers/Sqlite.test.ts": 0.5, + "src/process/externalLauncher.test.ts": 2.3, + "src/project/AgentSessionScanner.test.ts": 4.2, + "src/project/ManagedProjectFolders.test.ts": 0.5, + "src/provider/CodexChatGptAuth.test.ts": 5.4, + "src/provider/Drivers/AntigravityDriver.test.ts": 2.3, + "src/provider/Layers/GrokProvider.test.ts": 0.9, + "src/provider/Layers/ProviderInstanceRegistryLive.test.ts": 0.5, + "src/provider/OpenCodeServerLedger.test.ts": 1.7, + "src/provider/acp/AcpClientTerminals.test.ts": 1.3, + "src/provider/acp/AcpJsonRpcConnection.test.ts": 10.2, + "src/provider/acp/AcpRegistryProbe.test.ts": 1, + "src/provider/acp/AcpSessionRuntime.processTree.test.ts": 4.3, + "src/provider/cursorSdk.test.ts": 0.7, + "src/provider/opencode2/OpenCode2Server.test.ts": 1.2, "src/provider/opencodeRuntime.environment.test.ts": 1.1, - "src/pullRequest/PullRequestService.test.ts": 3.1, - "src/server.test.ts": 10.2, - "src/serverSettings.test.ts": 0.5, + "src/pullRequest/PullRequestService.test.ts": 5.1, + "src/rpcInitialItems.memory.test.ts": 5.3, + "src/serverSettings.test.ts": 1, "src/serviceLauncher.test.ts": 6.2, - "src/terminal/Manager.test.ts": 1.6, - "src/textGeneration/CursorTextGeneration.test.ts": 0.8, - "src/textGeneration/GrokTextGeneration.test.ts": 1.2, + "src/sourceControl/PrTemplateDetection.test.ts": 0.5, + "src/terminal/Manager.test.ts": 1.8, + "src/textGeneration/CodexTextGeneration.test.ts": 0.5, "src/usage/usageTranscriptStreaming.test.ts": 0.8, - "src/vcs/GitVcsDriver.test.ts": 3.7, - "src/vcs/GitVcsDriverCore.test.ts": 6.5, - "src/workspace/WorkspaceEntries.test.ts": 1.9 + "src/vcs/GitVcsDriver.test.ts": 4.5, + "src/vcs/GitVcsDriverCore.test.ts": 6.4, + "src/workspace/WorkspaceEntries.test.ts": 2.1 } diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts index 8fe5152d3450..fc51a7248ad3 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts @@ -461,69 +461,69 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => { }), ); - for (const verbose of [false, true]) { - it.effect(`unwraps a JSON title in ${verbose ? "verbose" : "normal"} Claude output`, () => { - const result = { - type: "result", - structured_output: { title: '{"title": "Refresh ev-stg APP ASG instances"}' }, - }; - return withFakeClaudeEnv( - { output: JSON.stringify(verbose ? [result] : result) }, - (textGeneration) => - Effect.gen(function* () { - const generated = yield* textGeneration.generateThreadTitle({ - cwd: process.cwd(), - message: "Refresh ev-stg APP ASG instances", - modelSelection: { - instanceId: ProviderInstanceId.make("claudeAgent"), - model: SYNTHETIC_CLAUDE_STANDARD_MODEL, - }, - }); + it.effect.each([ + { verbose: false, label: "normal" }, + { verbose: true, label: "verbose" }, + ])("unwraps a JSON title in $label Claude output", ({ verbose }) => { + const result = { + type: "result", + structured_output: { title: '{"title": "Refresh ev-stg APP ASG instances"}' }, + }; + return withFakeClaudeEnv( + { output: JSON.stringify(verbose ? [result] : result) }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Refresh ev-stg APP ASG instances", + modelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: SYNTHETIC_CLAUDE_STANDARD_MODEL, + }, + }); - expect(generated.title).toBe("Refresh ev-stg APP ASG instances"); - }), - ); - }); - } + expect(generated.title).toBe("Refresh ev-stg APP ASG instances"); + }), + ); + }); - for (const previousTitle of [undefined, "Old thread title"]) { - it.effect( - `reads the result from verbose Claude output when ${previousTitle ? "regenerating" : "generating"} a title`, - () => - withFakeClaudeEnv( + it.effect.each([ + { previousTitle: undefined, label: "generating" }, + { previousTitle: "Old thread title", label: "regenerating" }, + ])("reads the result from verbose Claude output when $label a title", ({ previousTitle }) => + withFakeClaudeEnv( + { + output: JSON.stringify([ + { type: "system", subtype: "init" }, + { type: "assistant", message: { content: [] } }, + { type: "user", message: { content: [] } }, + { type: "rate_limit_event" }, { - output: JSON.stringify([ - { type: "system", subtype: "init" }, - { type: "assistant", message: { content: [] } }, - { type: "user", message: { content: [] } }, - { type: "rate_limit_event" }, - { - type: "result", - subtype: "success", - result: '{"title":"Refresh ev-stg APP ASG Instances"}', - structured_output: { title: "Refresh ev-stg APP ASG Instances" }, - }, - ]), + type: "result", + subtype: "success", + result: '{"title":"Refresh ev-stg APP ASG Instances"}', + structured_output: { title: "Refresh ev-stg APP ASG Instances" }, }, - (textGeneration) => - Effect.gen(function* () { - const generated = yield* textGeneration.generateThreadTitle({ - cwd: process.cwd(), - message: "Refresh ev-stg APP ASG instances", - previousTitle, - modelSelection: { - instanceId: ProviderInstanceId.make("claudeAgent"), - model: SYNTHETIC_CLAUDE_STANDARD_MODEL, - }, - }); - - expect(generated.title).toBe("Refresh ev-stg APP ASG Instances"); - }), - ), - ); - } + ]), + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Refresh ev-stg APP ASG instances", + previousTitle, + modelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: SYNTHETIC_CLAUDE_STANDARD_MODEL, + }, + }); - for (const [name, output] of [ + expect(generated.title).toBe("Refresh ev-stg APP ASG Instances"); + }), + ), + ); + + it.effect.each([ ["empty message array", []], ["missing result", [{ type: "assistant", structured_output: { title: "Not a result" } }]], ["invalid title", [{ type: "result", structured_output: { title: 42 } }]], @@ -534,26 +534,24 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => { { type: "result", subtype: "error_max_structured_output_retries" }, ], ], - ] as const) { - it.effect(`rejects verbose Claude output with ${name}`, () => - withFakeClaudeEnv({ output: JSON.stringify(output) }, (textGeneration) => - Effect.gen(function* () { - const error = yield* Effect.flip( - textGeneration.generateThreadTitle({ - cwd: process.cwd(), - message: "Name this thread", - modelSelection: { - instanceId: ProviderInstanceId.make("claudeAgent"), - model: SYNTHETIC_CLAUDE_STANDARD_MODEL, - }, - }), - ); + ] as const)("rejects verbose Claude output with %s", ([, output]) => + withFakeClaudeEnv({ output: JSON.stringify(output) }, (textGeneration) => + Effect.gen(function* () { + const error = yield* Effect.flip( + textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Name this thread", + modelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: SYNTHETIC_CLAUDE_STANDARD_MODEL, + }, + }), + ); - expect(error._tag).toBe("TextGenerationError"); - }), - ), - ); - } + expect(error._tag).toBe("TextGenerationError"); + }), + ), + ); it.effect("falls back when Claude thread title normalization becomes whitespace-only", () => withFakeClaudeEnv( diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index 6d1ecbfdcf3f..98f0c9836e47 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -170,8 +170,9 @@ function withFakeCodexEnv( } it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => { - for (const selectedModel of ["gpt-5.6-luna", "openai.gpt-5.6-luna"]) { - it.effect(`dispatches the qualified live model for ${selectedModel}`, () => + it.effect.each(["gpt-5.6-luna", "openai.gpt-5.6-luna"])( + "dispatches the qualified live model for %s", + (selectedModel) => withFakeCodexEnv( { output: JSON.stringify({ title: "Bedrock title" }), @@ -189,8 +190,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => { expect(result.title).toBe("Bedrock title"); }), ), - ); - } + ); it.effect("generates and sanitizes commit messages without branch by default", () => withFakeCodexEnv( { @@ -406,7 +406,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => { ), ); - for (const example of [ + it.effect.each([ { mode: "static", output: "Add Search", @@ -425,30 +425,28 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => { expected: "Julius/ABC-123.v2", instruction: "Preserve the issue ID and capitalization.", }, - ] as const) { - it.effect(`generates a branch using ${example.mode} naming`, () => - withFakeCodexEnv( - { - output: JSON.stringify({ branch: example.output }), - stdinMustContain: example.instruction, - }, - (textGeneration) => - Effect.gen(function* () { - const generated = yield* textGeneration.generateBranchName({ - cwd: process.cwd(), - message: "Add search", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - naming: { - mode: example.mode, - prefix: "team/", - instructions: "Preserve the issue ID and capitalization.", - }, - }); - expect(generated.branch).toBe(example.expected); - }), - ), - ); - } + ] as const)("generates a branch using $mode naming", (example) => + withFakeCodexEnv( + { + output: JSON.stringify({ branch: example.output }), + stdinMustContain: example.instruction, + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateBranchName({ + cwd: process.cwd(), + message: "Add search", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + naming: { + mode: example.mode, + prefix: "team/", + instructions: "Preserve the issue ID and capitalization.", + }, + }); + expect(generated.branch).toBe(example.expected); + }), + ), + ); it.effect("generates branch names even when the ambient scope is already closed", () => withFakeCodexEnv( diff --git a/apps/server/src/textGeneration/CursorTextGeneration.test.ts b/apps/server/src/textGeneration/CursorTextGeneration.test.ts index fff76988e977..684d173463f1 100644 --- a/apps/server/src/textGeneration/CursorTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CursorTextGeneration.test.ts @@ -253,8 +253,9 @@ describe("CursorTextGeneration", () => { }).pipe(Effect.provide(fsLayer)), ); - for (const status of ["error", "cancelled"] as const) { - it.effect(`rejects a ${status} Cursor SDK run that includes valid title JSON`, () => + it.effect.each(["error", "cancelled"] as const)( + "rejects a %s Cursor SDK run that includes valid title JSON", + (status) => Effect.gen(function* () { const promptResult = { id: "run-cursor-partial-title-test", @@ -284,8 +285,7 @@ describe("CursorTextGeneration", () => { ); expect(cursorSdkMock.close).toHaveBeenCalledOnce(); }).pipe(Effect.provide(fsLayer)), - ); - } + ); it.effect("fails closed when ambient sandbox policy can expand write access", () => Effect.gen(function* () { @@ -329,8 +329,9 @@ describe("CursorTextGeneration", () => { }).pipe(Effect.provide(fsLayer), Effect.scoped), ); - for (const phase of ["create", "send"] as const) { - it.effect(`times out pending ${phase} and releases its late SDK resource`, () => + it.effect.each(["create", "send"] as const)( + "times out pending %s and releases its late SDK resource", + (phase) => Effect.gen(function* () { let started!: () => void; const called = new Promise((resolve) => { @@ -388,8 +389,7 @@ describe("CursorTextGeneration", () => { expect(wait).not.toHaveBeenCalled(); if (phase === "send") expect(cursorSdkMock.cancel).toHaveBeenCalledOnce(); }).pipe(Effect.provide(fsLayer), Effect.scoped), - ); - } + ); it.effect("requires CURSOR_API_KEY before calling the SDK", () => Effect.gen(function* () { diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index f7286a11b60d..62345d8472e1 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -1,5 +1,6 @@ // @effect-diagnostics nodeBuiltinImport:off - the suite seeds and grows real // transcript trees on disk, outside the service's Effect FileSystem. +import * as NodeChildProcess from "node:child_process"; import * as NodeFSP from "node:fs/promises"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; @@ -34,6 +35,7 @@ import * as UsageService from "./UsageService.ts"; const encodeUnknownJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); const encodeUnknownJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const decodeUnknownJsonString = Schema.decodeSync(Schema.fromJsonString(Schema.Unknown)); function claudeLine(id: number, outputTokens: number, model = "claude-fable-5"): string { return `${JSON.stringify({ @@ -116,100 +118,136 @@ const serviceLayers = (input: { ), ); +/** Outside `NARROW_WINDOW`, inside `WINDOW`. Seconds, as `utimes` takes them. */ +const BEFORE_NARROW_WINDOW = Date.parse("2026-08-01T10:00:00Z") / 1000; +const NARROW_WINDOW: UsageSummaryInput = { + timeZone: "UTC", + sinceDay: UsageDay.make("2026-09-01"), + untilDay: UsageDay.make("2026-09-02"), +}; + +/** + * A FIFO named like a transcript. A scan's read of it waits in `open` until + * `openGate`, then fails at once, so a gate holds that scan's directory reads + * in flight. Each waiting gate holds one libuv pool thread; keep at most three. + */ +const makeGate = (path: string, lastWriteSeconds?: number) => + Effect.promise(async () => { + NodeChildProcess.execFileSync("mkfifo", [path]); + if (lastWriteSeconds !== undefined) { + await NodeFSP.utimes(path, lastWriteSeconds, lastWriteSeconds); + } + }); + +/** + * Returns once a scan has opened the gate. A scan opens every file of a + * directory at once, so it then holds the directory's transcripts open too. + */ +const openGate = (path: string) => + Effect.promise(async () => (await NodeFSP.open(path, "w")).close()); + +/** Replaces a file by rename, so a scan holding the old one keeps reading it. */ +const replaceFile = (path: string, content: string) => + Effect.promise(async () => { + await NodeFSP.writeFile(path + ".next", content); + await NodeFSP.rename(path + ".next", path); + }); + function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens: number } }[] }) { return summary.buckets.reduce((sum, bucket) => sum + bucket.totals.outputTokens, 0); } describe("UsageService", () => { - for (const explicitDefault of [true, false]) { - it.live( - `reads shared managed ${explicitDefault ? "explicit" : "legacy"} default and disabled extra account history once`, - () => - Effect.gen(function* () { - const { home, settings } = yield* setup; - const summary = yield* Effect.gen(function* () { - for (const [id, output] of [ - ["codex", 17], - ["codex-personal", 23], - ] as const) { - const sessions = NodePath.join(home, "shared-codex", "sessions"); - yield* Effect.promise(async () => { - await NodeFSP.mkdir(sessions, { recursive: true }); - await NodeFSP.writeFile( - NodePath.join(sessions, `${id}-rollout.jsonl`), - [ - { type: "session_meta", payload: { id } }, - { type: "turn_context", payload: { model: "gpt-5.6-sol" } }, - { - type: "event_msg", - timestamp: "2026-08-01T10:00:00Z", - payload: { - type: "token_count", - info: { last_token_usage: { input_tokens: 10, output_tokens: output } }, - }, + it.live.each([ + { explicitDefault: true, label: "explicit" }, + { explicitDefault: false, label: "legacy" }, + ])( + "reads shared managed $label default and disabled extra account history once", + ({ explicitDefault }) => + Effect.gen(function* () { + const { home, settings } = yield* setup; + const summary = yield* Effect.gen(function* () { + for (const [id, output] of [ + ["codex", 17], + ["codex-personal", 23], + ] as const) { + const sessions = NodePath.join(home, "shared-codex", "sessions"); + yield* Effect.promise(async () => { + await NodeFSP.mkdir(sessions, { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(sessions, `${id}-rollout.jsonl`), + [ + { type: "session_meta", payload: { id } }, + { type: "turn_context", payload: { model: "gpt-5.6-sol" } }, + { + type: "event_msg", + timestamp: "2026-08-01T10:00:00Z", + payload: { + type: "token_count", + info: { last_token_usage: { input_tokens: 10, output_tokens: output } }, }, - ] - .map((line) => encodeUnknownJsonString(line)) - .join("\n") + "\n", - ); - }); - } - const service = yield* UsageService.make; - return yield* service.readSummary(WINDOW); - }).pipe( - Effect.provide( - serviceLayers({ - prefix: "usage-managed-accounts", - home, - settings: { - ...settings, - providers: { - ...settings.providers, - codex: { setupMode: "managed", homePath: NodePath.join(home, "shared-codex") }, }, - providerInstances: { - ...(explicitDefault - ? { - [ProviderInstanceId.make("codex")]: { - driver: ProviderDriverKind.make("codex"), - config: { - setupMode: "managed", - homePath: NodePath.join(home, "shared-codex"), - }, + ] + .map((line) => encodeUnknownJsonString(line)) + .join("\n") + "\n", + ); + }); + } + const service = yield* UsageService.make; + return yield* service.readSummary(WINDOW); + }).pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-managed-accounts", + home, + settings: { + ...settings, + providers: { + ...settings.providers, + codex: { setupMode: "managed", homePath: NodePath.join(home, "shared-codex") }, + }, + providerInstances: { + ...(explicitDefault + ? { + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + config: { + setupMode: "managed", + homePath: NodePath.join(home, "shared-codex"), }, - } - : {}), - [ProviderInstanceId.make("codex-personal")]: { - driver: ProviderDriverKind.make("codex"), - enabled: false, - config: { - setupMode: "managed", - homePath: NodePath.join(home, "shared-codex"), - shadowHomePath: NodePath.join(home, "personal-shadow"), - }, - environment: [ - { - name: "CODEX_HOME", - value: NodePath.join(home, "ignored-environment"), - sensitive: false, }, - ], + } + : {}), + [ProviderInstanceId.make("codex-personal")]: { + driver: ProviderDriverKind.make("codex"), + enabled: false, + config: { + setupMode: "managed", + homePath: NodePath.join(home, "shared-codex"), + shadowHomePath: NodePath.join(home, "personal-shadow"), }, + environment: [ + { + name: "CODEX_HOME", + value: NodePath.join(home, "ignored-environment"), + sensitive: false, + }, + ], }, }, - }), - ), - ); - assert.strictEqual(totalOutputTokens(summary), 40); - assert.strictEqual( - summary.sources.filter( - (source) => source.fingerprint.provider === "codex" && source.status === "ok", - ).length, - 1, - ); - }).pipe(Effect.scoped), - ); - } + }, + }), + ), + ); + assert.strictEqual(totalOutputTokens(summary), 40); + assert.strictEqual( + summary.sources.filter( + (source) => source.fingerprint.provider === "codex" && source.status === "ok", + ).length, + 1, + ); + }).pipe(Effect.scoped), + ); it.live("omits Cursor account usage when no file login is saved", () => Effect.gen(function* () { const { settings, home } = yield* setup; @@ -787,6 +825,98 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); + it.live( + "upgrades a v4 cache: reprices live Codex tiers, keeps deleted rollouts, leaves v4 intact", + () => + Effect.gen(function* () { + const { home, settings } = yield* setup; + const sessions = NodePath.join(home, "codex", "sessions"); + const rollout = (sessionId: string, outputTokens: number) => + [ + { type: "session_meta", payload: { id: sessionId } }, + { type: "turn_context", payload: { model: "gpt-6-astra" } }, + { + type: "event_msg", + payload: { + type: "thread_settings_applied", + thread_settings: { service_tier: "ultrafast" }, + }, + }, + { + type: "event_msg", + timestamp: "2026-08-01T10:00:00Z", + payload: { + type: "token_count", + info: { last_token_usage: { input_tokens: 0, output_tokens: outputTokens } }, + }, + }, + ] + .map((line) => encodeUnknownJsonString(line)) + .join("\n") + "\n"; + const live = NodePath.join(sessions, "live.jsonl"); + const deleted = NodePath.join(sessions, "deleted.jsonl"); + yield* Effect.promise(async () => { + await NodeFSP.mkdir(sessions, { recursive: true }); + await NodeFSP.writeFile(live, rollout("live", 10)); + await NodeFSP.writeFile(deleted, rollout("deleted", 20)); + }); + + yield* Effect.gen(function* () { + const { stateDir } = yield* ServerConfig.ServerConfig; + const cachePath = NodePath.join(stateDir, "usage-scan-cache-v5.json"); + const legacyPath = NodePath.join(stateDir, "usage-scan-cache.json"); + yield* (yield* UsageService.make).readSummary(WINDOW); + + // Rewrite the cache as a v4 server left it: every Codex record at + // speed 0 (standard), and no tier in the reducer state. + const legacy = yield* Effect.promise(async () => { + const document = decodeUnknownJsonString(await NodeFSP.readFile(cachePath, "utf8")) as { + files: Record; + }; + for (const file of Object.values(document.files)) { + file.r = file.r.map((row) => [...row.slice(0, 10), 0]); + delete file.cs.speed; + } + const text = encodeUnknownJsonString({ ...document, version: 4 }); + await NodeFSP.writeFile(legacyPath, text); + await NodeFSP.rm(cachePath); + await NodeFSP.rm(deleted); + return text; + }); + + const summary = yield* (yield* UsageService.make).readSummary(WINDOW); + // The live rollout re-parses at the ultrafast rate (10 x 6); the + // deleted one keeps its saved v4 usage at the standard rate (20 x 1). + assert.strictEqual(totalOutputTokens(summary), 30); + assert.strictEqual( + summary.buckets.reduce((sum, bucket) => sum + bucket.costUsd, 0), + 80, + ); + // A v4 server sharing this state directory still finds its own cache. + assert.strictEqual( + yield* Effect.promise(() => NodeFSP.readFile(legacyPath, "utf8")), + legacy, + ); + }).pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-service-v4-upgrade-test", + home, + settings, + ratesDocument: { + "gpt-6-astra": { + input_cost_per_token: 0, + output_cost_per_token: 1, + input_cost_per_token_ultrafast: 0, + output_cost_per_token_ultrafast: 6, + }, + }, + }), + ), + ); + }).pipe(Effect.scoped), + ); + it.live("preserves saved tokens, costs and sessions after transcript cleanup and restart", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; @@ -878,6 +1008,157 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); + it.live("credits the same copy of a duplicate after its transcripts are deleted", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + const dir = NodePath.dirname(transcript); + // The walk-first file is the original, padded so it finishes parsing + // after the small fork copy that repeats its record under a new session. + const [first = "", second = ""] = yield* Effect.promise(async () => { + await NodeFSP.writeFile(NodePath.join(dir, "a.jsonl"), ""); + await NodeFSP.writeFile(NodePath.join(dir, "b.jsonl"), ""); + return (await NodeFSP.readdir(dir)).map((name) => NodePath.join(dir, name)); + }); + const forked = (line: string) => line.replace('"session-1"', '"session-2"'); + yield* Effect.promise(async () => { + await NodeFSP.writeFile( + first, + claudeLine(1, 5).replace( + '"message":', + '"padding":' + encodeUnknownJsonString("x".repeat(9 * 1024 * 1024)) + ',"message":', + ), + ); + await NodeFSP.writeFile(second, forked(claudeLine(1, 5)) + forked(claudeLine(2, 7))); + }); + yield* Effect.gen(function* () { + const service = yield* UsageService.make; + const live = yield* service.readSummary(WINDOW); + assert.strictEqual(live.buckets[0]?.sessions, 2); + + yield* Effect.promise(() => Promise.all([NodeFSP.rm(first), NodeFSP.rm(second)])); + const saved = yield* service.readSummary(WINDOW); + assert.deepStrictEqual(saved.buckets, live.buckets); + assert.deepStrictEqual(saved.sources, live.sources); + const restored = yield* (yield* UsageService.make).readSummary(WINDOW); + assert.deepStrictEqual(restored.buckets, live.buckets); + }).pipe( + Effect.provide(serviceLayers({ prefix: "usage-service-copy-order-test", home, settings })), + ); + }).pipe(Effect.scoped), + ); + + it.live.skipIf(HostProcessPlatform.defaultValue() === "win32")( + "keeps a newer cached read when a slower scan of another window finishes later", + () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + const dir = NodePath.dirname(transcript); + const probe = NodePath.join(dir, "probe.jsonl"); + const hold = NodePath.join(dir, "hold.jsonl"); + yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); + yield* makeGate(probe, BEFORE_NARROW_WINDOW); + yield* makeGate(hold, BEFORE_NARROW_WINDOW); + yield* Effect.gen(function* () { + const service = yield* UsageService.make; + const wide = yield* service.readSummary(WINDOW).pipe(Effect.forkChild); + yield* openGate(probe); + yield* replaceFile(transcript, claudeLine(1, 5) + claudeLine(2, 7)); + // The narrow scan caches the newer read while the wide one waits. + yield* service.readSummary(NARROW_WINDOW); + yield* openGate(hold); + yield* Fiber.join(wide); + + yield* Effect.promise(() => + Promise.all([transcript, probe, hold].map((path) => NodeFSP.rm(path))), + ); + assert.strictEqual(totalOutputTokens(yield* service.readSummary(WINDOW)), 12); + }).pipe( + Effect.provide( + serviceLayers({ prefix: "usage-service-stale-read-test", home, settings }), + ), + ); + }).pipe(Effect.scoped), + ); + + it.live.skipIf(HostProcessPlatform.defaultValue() === "win32")( + "keeps the later read when a scan that read earlier finishes first", + () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + const dir = NodePath.dirname(transcript); + const gate = (name: string) => NodePath.join(dir, `${name}.jsonl`); + const wideProbe = gate("wide-probe"); + const wideHold = gate("wide-hold"); + const narrowProbe = gate("narrow-probe"); + const narrowHold = gate("narrow-hold"); + yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); + yield* makeGate(wideProbe, BEFORE_NARROW_WINDOW); + yield* makeGate(wideHold, BEFORE_NARROW_WINDOW); + yield* Effect.gen(function* () { + const service = yield* UsageService.make; + const wide = yield* service.readSummary(WINDOW).pipe(Effect.forkChild); + yield* openGate(wideProbe); + yield* replaceFile(transcript, claudeLine(1, 5) + claudeLine(2, 7)); + // Made after the wide scan's walk, so only the narrow scan waits on them. + yield* makeGate(narrowProbe); + yield* makeGate(narrowHold); + const narrow = yield* service.readSummary(NARROW_WINDOW).pipe(Effect.forkChild); + yield* openGate(narrowProbe); + // Both scans started from an empty cache entry; the earlier read lands first. + yield* openGate(wideHold); + yield* Fiber.join(wide); + yield* openGate(narrowHold); + yield* Fiber.join(narrow); + + yield* Effect.promise(() => + Promise.all( + [transcript, wideProbe, wideHold, narrowProbe, narrowHold].map((path) => + NodeFSP.rm(path), + ), + ), + ); + assert.strictEqual(totalOutputTokens(yield* service.readSummary(WINDOW)), 12); + }).pipe( + Effect.provide(serviceLayers({ prefix: "usage-service-late-read-test", home, settings })), + ); + }).pipe(Effect.scoped), + ); + + it.live("reports saved usage of a removed directory only for windows it reaches", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(async () => { + await NodeFSP.writeFile(transcript, claudeLine(1, 5)); + const lastWrite = Date.parse("2026-08-01T10:00:00Z") / 1000; + await NodeFSP.utimes(transcript, lastWrite, lastWrite); + }); + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ prefix: "usage-service-saved-window-test", home, settings }), + ), + ); + const first = yield* service.readSummary(WINDOW); + yield* Effect.promise(() => + NodeFSP.rm(NodePath.join(home, "claude", "projects"), { recursive: true }), + ); + + const reached = yield* service.readSummary(WINDOW); + assert.deepStrictEqual(reached.buckets, first.buckets); + assert.strictEqual(reached.sources[0]?.status, "ok"); + + // A missing source cannot claim this directory from another environment + // that still reads it, so it adds nothing to a window after its last write. + const later = yield* service.readSummary({ + timeZone: "UTC", + sinceDay: UsageDay.make("2026-08-10"), + untilDay: UsageDay.make("2026-08-12"), + }); + assert.deepStrictEqual(later.buckets, []); + assert.strictEqual(later.sources[0]?.status, "missing"); + assert.strictEqual(later.sources[0]?.scannedFiles, 0); + }).pipe(Effect.scoped), + ); + it.live("does not share an in-flight scan after custom prices change", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index f0e8d9b826fa..bf44e7325b51 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -56,7 +56,7 @@ import { import { readOpenCodeUsage } from "./opencodeUsageReader.ts"; import { readAntigravityUsage } from "./antigravityUsageReader.ts"; import { readCursorAccountUsage } from "./cursorUsageReader.ts"; -import { UsageAggregator } from "./usageAggregation.ts"; +import { resolveModelAliases, UsageAggregator } from "./usageAggregation.ts"; import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts"; import { listTranscriptFiles, @@ -66,8 +66,11 @@ import { import { decodeScanCache, dedupeWithinFile, - encodeScanCache, + LEGACY_SCAN_CACHE_FILE_NAME, + makeScanCacheWriter, pruneScanCache, + SCAN_CACHE_FILE_NAME, + type CachedFile, type ScanCache, } from "./usageScanCache.ts"; import type { UsageRecord } from "./usageTranscripts.ts"; @@ -91,6 +94,9 @@ const MAX_HOURLY_WINDOW_MS = 24 * 60 * 60 * 1000; /** Longest window the UI offers, plus slack. Older entries are pruned. */ const CACHE_RETENTION_DAYS = 90; +/** Transcripts parsed at once. More gains little once the disk stays busy. */ +const TRANSCRIPT_READ_CONCURRENCY = 4; + const decodeCodexSettings = Schema.decodeOption(CodexSettings); const decodeClaudeSettings = Schema.decodeOption(ClaudeSettings); @@ -109,9 +115,37 @@ const encodeRatesCache = Schema.encodeEffect( /** The scan cache is narrowed by hand in `usageScanCache`, so JSON is enough here. */ const ScanCacheJson = Schema.fromJsonString(Schema.Unknown as unknown as Schema.Codec); const decodeScanCacheFile = Schema.decodeUnknownEffect(ScanCacheJson); -const encodeScanCacheFile = Schema.encodeEffect(ScanCacheJson); const encodeUsageRecordKey = Schema.encodeSync(ScanCacheJson); const CachedSource = Schema.Struct({ dir: Schema.String, volumeId: Schema.String }); + +/** Whether `a` read a later state of its file than `b`. Transcripts only grow. */ +function isLaterRead(a: CachedFile, b: CachedFile): boolean { + return a.mtimeMs > b.mtimeMs || (a.mtimeMs === b.mtimeMs && a.size > b.size); +} + +/** + * Codex sessions with records in more than one file, such as a rollout that + * moved after it was read. Only these need cross-file dedupe keys: within one + * file the occurrence count already keeps every key unique, so keying the rest + * would only build and hash a string for each of their records. + */ +function sharedCodexSessions( + files: readonly { readonly records: readonly UsageRecord[] }[], +): ReadonlySet { + const firstFile = new Map(); + const shared = new Set(); + for (const [index, file] of files.entries()) { + let previous = ""; + for (const { provider, sessionId } of file.records) { + if (provider !== "codex" || sessionId === previous || sessionId.length === 0) continue; + previous = sessionId; + const first = firstFile.get(sessionId); + if (first === undefined) firstFile.set(sessionId, index); + else if (first !== index) shared.add(sessionId); + } + } + return shared; +} const decodeCachedSources = Schema.decodeUnknownOption( Schema.Struct({ sources: Schema.Record(Schema.String, CachedSource) }), ); @@ -171,7 +205,8 @@ export const make = Effect.gen(function* () { }; const ratesCachePath = path.join(config.stateDir, "usage-model-rates.json"); - const scanCachePath = path.join(config.stateDir, "usage-scan-cache.json"); + const scanCachePath = path.join(config.stateDir, SCAN_CACHE_FILE_NAME); + const legacyScanCachePath = path.join(config.stateDir, LEGACY_SCAN_CACHE_FILE_NAME); let rates: RateTable = new Map(); let ratesFetchedAtMs: number | null = null; let ratesStatus: UsagePricing["status"] = "unavailable"; @@ -368,10 +403,17 @@ export const make = Effect.gen(function* () { */ const ensureScanCacheLoaded = yield* Effect.cached( Effect.gen(function* () { - const document = yield* fileSystem.readFileString(scanCachePath).pipe( - Effect.flatMap((raw) => decodeScanCacheFile(raw)), - Effect.catchCause(() => Effect.succeed(null)), - ); + const readDocument = (filePath: string) => + fileSystem.readFileString(filePath).pipe( + Effect.flatMap((raw) => decodeScanCacheFile(raw)), + Effect.catchCause(() => Effect.succeed(null)), + ); + let document = yield* readDocument(scanCachePath); + if (document === null) { + document = yield* readDocument(legacyScanCachePath); + // Write the migrated cache to its own file on the next scan. + cacheDirty = document !== null; + } if (document === null) return; for (const [path, entry] of decodeScanCache(document)) fileCache.set(path, entry); const sources = decodeCachedSources(document); @@ -382,20 +424,28 @@ export const make = Effect.gen(function* () { }), ); + const writeScanCache = makeScanCacheWriter(); + // Scans with different windows can finish together; two writes interleaved + // in one file would corrupt it. + const persistLock = yield* Semaphore.make(1); + const persistScanCache = Effect.fn("UsageService.persistScanCache")(function* () { if (!cacheDirty) return; - // Cleared only after the write lands, so a failed persist is retried on - // the next scan instead of leaving disk permanently stale. - yield* encodeScanCacheFile({ - ...encodeScanCache(fileCache), - sources: Object.fromEntries(sourceCache), - }).pipe( + // Cleared before encoding, so a scan that changes the cache while this + // write is in flight marks it dirty again. A failed write restores the + // flag, so the next scan retries instead of leaving disk stale. + cacheDirty = false; + yield* Effect.sync(() => + writeScanCache(fileCache, { sources: Object.fromEntries(sourceCache) }), + ).pipe( Effect.flatMap((serialized) => fileSystem.writeFileString(scanCachePath, serialized)), - Effect.map(() => { - cacheDirty = false; - }), // A cache we cannot write is a slower next start, not a failed read. - Effect.ignoreCause, + Effect.catchCause(() => + Effect.sync(() => { + cacheDirty = true; + }), + ), + persistLock.withPermit, ); }); @@ -406,13 +456,22 @@ export const make = Effect.gen(function* () { * written multi-hundred-megabyte rollout costs its appended bytes per scan * rather than a full re-read. The reader verifies the position's guard bytes * and silently restarts from byte 0 when they no longer match. + * + * A fresh parse comes back as `update` for the caller to cache, with the + * entry it was built from. Reads run concurrently, and the caller stores + * updates in walk order rather than completion order: saved records of + * deleted transcripts aggregate in cache order, where the first copy of a + * duplicate wins. */ const readFileRecords = ( filePath: string, size: number, mtimeMs: number, provider: UsageProviderKind, - ): Effect.Effect => + ): Effect.Effect<{ + readonly records: readonly UsageRecord[]; + 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 @@ -423,9 +482,12 @@ export const make = Effect.gen(function* () { cached.mtimeMs === mtimeMs && cached.provider === provider ) { - return cached.tailRecords.length === 0 - ? cached.records - : [...cached.records, ...cached.tailRecords]; + 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 @@ -441,7 +503,9 @@ export const make = Effect.gen(function* () { // 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 cached?.provider === provider ? [...cached.records, ...cached.tailRecords] : []; + return { + records: cached?.provider === provider ? [...cached.records, ...cached.tailRecords] : [], + }; // Stored already de-duplicated within the file, which is 99% of all // duplicates. The aggregator still runs the cross-file dedupe pass. One @@ -452,16 +516,13 @@ export const make = Effect.gen(function* () { const records = dedupeWithinFile([...base, ...parsed.records], seen); const tailRecords = dedupeWithinFile(parsed.tailRecords, seen); - fileCache.set(filePath, { - size, - mtimeMs, - provider, - records, - tailRecords, - position: parsed.position, - }); - cacheDirty = true; - return tailRecords.length === 0 ? records : [...records, ...tailRecords]; + return { + records: tailRecords.length === 0 ? records : [...records, ...tailRecords], + update: { + entry: { size, mtimeMs, provider, records, tailRecords, position: parsed.position }, + replaces: cached, + }, + }; }); /** One provider directory's walk and parse, before rates are involved. */ @@ -501,11 +562,32 @@ export const make = Effect.gen(function* () { const files = yield* Effect.promise(() => listTranscriptFiles(dir, windowStartMs, fileName === undefined ? undefined : { fileName }), ); - const parsedFiles: { path: string; records: readonly UsageRecord[] }[] = []; - for (const file of files) { - const records = yield* readFileRecords(file.path, file.size, file.mtimeMs, provider); - parsedFiles.push({ path: file.path, records }); - } + // A cold parse waits on disk reads, so a few files in flight read + // close to twice as fast. Results keep walk order. + const read = yield* Effect.forEach( + files, + (file) => + readFileRecords(file.path, file.size, file.mtimeMs, provider).pipe( + Effect.map((result) => ({ path: file.path, ...result })), + ), + { concurrency: TRANSCRIPT_READ_CONCURRENCY }, + ); + const parsedFiles = read.map(({ path, records, update }) => { + if (update === undefined) return { path, records }; + // A scan of another window may have cached its own read of this file + // meanwhile. Then keep whichever read saw the later file, so a slower + // scan never replaces newer usage with older. + const current = fileCache.get(path); + if ( + current === update.replaces || + current === undefined || + !isLaterRead(current, update.entry) + ) { + fileCache.set(path, update.entry); + cacheDirty = true; + } + return { path, records }; + }); scanned.push({ provider, dir, volumeId, files: parsedFiles }); } @@ -745,41 +827,44 @@ export const make = Effect.gen(function* () { ...hourlyWindow, rates, priceOverrides: createOverrideRateTable(settings.usagePriceOverrides), + modelAliases: resolveModelAliases(settings.usageModelAliases), }); const sources: UsageSource[] = []; - for (const { - provider, - dir, - volumeId, - files, - status, - message, - action, - hostId: sourceHostId, - } of scannedDirs) { + // Cleanup may remove transcripts, but the usage we already saved still + // contributes to its source through the normal aggregation and dedupe + // path. Like the walk, skip files last written before the window: they + // cannot hold records inside it. + const retainedSinceMs = Math.max(windowStartMs, retentionCutoffMs); + const filesByDir = scannedDirs.map(({ provider, dir, files }) => { const retainedFiles = [...(files ?? [])]; const livePaths = new Set(retainedFiles.map((file) => file.path)); - // Cleanup may remove transcripts, but the usage we already saved still - // contributes to this source. Keep the normal aggregation and dedupe path. for (const [filePath, entry] of fileCache) { if ( entry.provider !== provider || - entry.mtimeMs < retentionCutoffMs || + entry.mtimeMs < retainedSinceMs || livePaths.has(filePath) || !isWithinDirectory(filePath, dir) ) continue; retainedFiles.push({ path: filePath, records: [...entry.records, ...entry.tailRecords] }); } + return retainedFiles; + }); + const sharedSessions = sharedCodexSessions(filesByDir.flat()); + + for (const [ + index, + { provider, dir, volumeId, files, status, message, action, hostId: sourceHostId }, + ] of scannedDirs.entries()) { let scannedFiles = 0; let skippedFiles = 0; // Distinct per directory. Buckets carry per-cell session counts, but a // session spans days and models, so clients total this figure instead. const sessionIds = new Set(); - for (const file of retainedFiles) { + for (const file of filesByDir[index] ?? []) { if (file.records.length === 0) { skippedFiles += 1; continue; @@ -788,9 +873,10 @@ export const make = Effect.gen(function* () { const codexEventOccurrences = new Map(); for (const record of file.records) { let usageRecord = record; - if (record.provider === "codex" && record.sessionId.length > 0) { + if (record.provider === "codex" && sharedSessions.has(record.sessionId)) { // Match moved rollout copies without collapsing repeated equal events // within one rollout (timestamps can have only second precision). + // Only sessions seen in several files can have a copy to match. const key = encodeUsageRecordKey([ record.provider, record.sessionId, @@ -846,17 +932,13 @@ export const make = Effect.gen(function* () { }); /** - * In-flight scans by window and custom prices, so concurrent identical requests (the usage + * In-flight scans by window and usage settings, so concurrent identical requests (the usage * page open on two clients at once) share one scan instead of racing over * the same corpus twice. */ const inflightScans = new Map>(); - const scanKey = ( - input: UsageSummaryInput, - priceOverrides: ServerSettingsValue["usagePriceOverrides"], - cursorKeychainUsageEnabled: boolean, - ): string => + const scanKey = (input: UsageSummaryInput, settings: ServerSettingsValue): string => JSON.stringify([ input.timeZone, input.sinceDay, @@ -864,13 +946,14 @@ export const make = Effect.gen(function* () { input.resolution ?? "day", input.sinceTime ?? null, input.untilTime ?? null, - priceOverrides, - cursorKeychainUsageEnabled, + settings.usagePriceOverrides, + settings.usageModelAliases, + settings.cursorKeychainUsageEnabled, ]); const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) { const settings = yield* readSettings; - const key = scanKey(input, settings.usagePriceOverrides, settings.cursorKeychainUsageEnabled); + const key = scanKey(input, settings); const deferred = yield* Effect.uninterruptible( Effect.gen(function* () { const existing = inflightScans.get(key); diff --git a/apps/server/src/usage/antigravityUsageReader.ts b/apps/server/src/usage/antigravityUsageReader.ts index 54c00b804b7a..34a88114f42e 100644 --- a/apps/server/src/usage/antigravityUsageReader.ts +++ b/apps/server/src/usage/antigravityUsageReader.ts @@ -240,7 +240,7 @@ async function readDatabase(path: string, fallbackTimestamp: number): Promise { }), ); - for (const [code, outcome] of [ + it.effect.each([ ["nothing_to_reset", "nothingToReset"], ["no_credit", "noCredit"], ["already_redeemed", "alreadyRedeemed"], - ] as const) { - it.effect(`reports ${code} accurately`, () => - Effect.gen(function* () { - const test = fixture({ upstream: () => ({ status: 200, body: { code } }) }); - const api = yield* test.api; - expect(yield* api.consume(config, "first.json", "credit")).toEqual({ outcome }); - expect(test.requests.some((request) => request.path.endsWith("/reset-quota"))).toBe( - code === "already_redeemed", - ); - }), - ); - } + ] as const)("reports %s accurately", ([code, outcome]) => + Effect.gen(function* () { + const test = fixture({ upstream: () => ({ status: 200, body: { code } }) }); + const api = yield* test.api; + expect(yield* api.consume(config, "first.json", "credit")).toEqual({ outcome }); + expect(test.requests.some((request) => request.path.endsWith("/reset-quota"))).toBe( + code === "already_redeemed", + ); + }), + ); it.effect("reports redemption success even if cooldown clearing fails", () => Effect.gen(function* () { diff --git a/apps/server/src/usage/cursorUsageReader.ts b/apps/server/src/usage/cursorUsageReader.ts index 31fae24c3cf5..0931718cea64 100644 --- a/apps/server/src/usage/cursorUsageReader.ts +++ b/apps/server/src/usage/cursorUsageReader.ts @@ -253,7 +253,7 @@ export async function readCursorAccountUsage( sessionId, totals, reportedCostUsd, - fast: false, + speed: "standard", dedupeKey: `cursor-account:${accountKey}:${key}:${occurrence}`, }); } diff --git a/apps/server/src/usage/opencodeUsageReader.ts b/apps/server/src/usage/opencodeUsageReader.ts index 45d6ef33a584..d4414ac7b9a6 100644 --- a/apps/server/src/usage/opencodeUsageReader.ts +++ b/apps/server/src/usage/opencodeUsageReader.ts @@ -64,7 +64,7 @@ function parseOpenCodeMessage( // OpenCode writes zero for models without a known rate, including paid // subscription models. Let the shared price table estimate those records. reportedCostUsd: typeof cost === "number" && Number.isFinite(cost) && cost > 0 ? cost : null, - fast: false, + speed: "standard", dedupeKey: id ? `opencode:${id}` : null, }; } diff --git a/apps/server/src/usage/usageAggregation.test.ts b/apps/server/src/usage/usageAggregation.test.ts index 75435de08ff7..a27a9b101cc3 100644 --- a/apps/server/src/usage/usageAggregation.test.ts +++ b/apps/server/src/usage/usageAggregation.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; -import { UsageAggregator } from "./usageAggregation.ts"; +import { resolveModelAliases, UsageAggregator } from "./usageAggregation.ts"; import type { RateTable } from "./usagePricing.ts"; import type { UsageRecord } from "./usageTranscripts.ts"; @@ -12,7 +12,13 @@ const rates: RateTable = new Map([ outputCostPerToken: 5e-5, cacheReadCostPerToken: 1e-6, cacheCreationCostPerToken: 1.25e-5, - fastMultiplier: 1, + fast: { + inputCostPerToken: 2e-5, + outputCostPerToken: 1e-4, + cacheReadCostPerToken: 2e-6, + cacheCreationCostPerToken: 2.5e-5, + }, + ultrafast: null, }, ], ]); @@ -32,7 +38,7 @@ function record(overrides: Partial = {}): UsageRecord { reasoningTokens: 0, }, reportedCostUsd: null, - fast: false, + speed: "standard", dedupeKey: null, ...overrides, }; @@ -76,6 +82,24 @@ describe("UsageAggregator", () => { ).toThrow("requires exact time bounds"); }); + it("splits a bucket's cost by category and speed", () => { + const [bucket] = aggregate([record(), record({ speed: "fast" })]).buckets; + + // Standard costs $0.005625 and fast twice that. + expect(bucket).toMatchObject({ + costUsd: expect.closeTo(0.016875), + categoryCostUsd: { + input: expect.closeTo(0.003), + cacheRead: expect.closeTo(0.003), + cacheWrite: expect.closeTo(0.000375), + output: expect.closeTo(0.0075), + }, + fastCostUsd: expect.closeTo(0.01125), + speedPremiumUsd: expect.closeTo(0.005625), + }); + expect(bucket).not.toHaveProperty("ultrafastCostUsd"); + }); + it("keeps only the first record for a repeated dedupe key", () => { const result = aggregate([ record({ dedupeKey: "msg_1:" }), @@ -104,6 +128,41 @@ describe("UsageAggregator", () => { expect(losAngeles.buckets[0]?.day).toBe("2026-08-06"); }); + it("finds the day at a quarter-hour zone's midnight across interleaved buckets", () => { + // Kathmandu is UTC+5:45, so its midnight falls at 18:15 UTC. + const result = aggregate( + [ + record({ timestampMs: Date.parse("2026-08-01T18:14:59.999Z") }), + record({ timestampMs: Date.parse("2026-08-01T18:15:00.000Z") }), + record({ timestampMs: Date.parse("2026-08-01T18:15:00.000Z"), model: "claude-opus-5" }), + record({ timestampMs: Date.parse("2026-08-01T18:16:00.000Z") }), + ], + "Asia/Kathmandu", + ); + + expect(result.buckets.map((bucket) => [bucket.day, bucket.model, bucket.records])).toEqual([ + ["2026-08-01", "claude-fable-5", 1], + ["2026-08-02", "claude-fable-5", 2], + ["2026-08-02", "claude-opus-5", 1], + ]); + }); + + it("finds the day at a fixed offset's midnight between quarter hours", () => { + // At +00:01, midnight falls at 23:59 UTC. + const result = aggregate( + [ + record({ timestampMs: Date.parse("2026-08-01T23:58:59.999Z") }), + record({ timestampMs: Date.parse("2026-08-01T23:59:00.000Z") }), + ], + "+00:01", + ); + + expect(result.buckets.map((bucket) => [bucket.day, bucket.records])).toEqual([ + ["2026-08-01", 1], + ["2026-08-02", 1], + ]); + }); + it("splits an hourly request into fixed buckets anchored to its exact start", () => { const result = aggregate( [ @@ -194,6 +253,25 @@ describe("UsageAggregator", () => { expect(aggregator.add(record({ timestampMs: Date.parse("2026-07-01T12:00:00Z") }))).toBe(false); }); + it("folds a mapped model into its target and prices it there", () => { + const aggregator = new UsageAggregator({ + timeZone: "UTC", + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + rates, + modelAliases: resolveModelAliases({ "example-preview": "claude-fable-5" }), + }); + aggregator.add(record()); + aggregator.add(record({ model: "example-preview", rateModel: "example-preview-high" })); + const result = aggregator.finish(); + + expect(result.buckets).toHaveLength(1); + expect(result.buckets[0]?.model).toBe("claude-fable-5"); + expect(result.buckets[0]?.records).toBe(2); + expect(result.buckets[0]?.costUsd).toBeCloseTo(0.00925, 9); + expect(result.buckets[0]?.unpricedRecords).toBe(0); + }); + it("separates providers and models into their own buckets", () => { const result = aggregate([ record(), @@ -204,3 +282,23 @@ describe("UsageAggregator", () => { expect(result.buckets).toHaveLength(3); }); }); + +describe("resolveModelAliases", () => { + it("follows chains to the final model and drops chains that enter a loop", () => { + expect( + resolveModelAliases({ + "preview[1m]": "preview", + preview: "example-model", + loop: "back", + back: "loop", + intoLoop: "loop", + self: "self", + }), + ).toEqual( + new Map([ + ["preview[1m]", "example-model"], + ["preview", "example-model"], + ]), + ); + }); +}); diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts index 92abfef74108..f2923871a323 100644 --- a/apps/server/src/usage/usageAggregation.ts +++ b/apps/server/src/usage/usageAggregation.ts @@ -12,9 +12,15 @@ * * @module usageAggregation */ -import type { UsageBucket, UsageDay, UsageResolution, UsageTokenTotals } from "@t3tools/contracts"; +import type { + UsageBucket, + UsageCategoryCost, + UsageDay, + UsageResolution, + UsageTokenTotals, +} from "@t3tools/contracts"; -import { addTotals, EMPTY_TOTALS, type UsageRecord } from "./usageTranscripts.ts"; +import { EMPTY_TOTALS, type UsageRecord } from "./usageTranscripts.ts"; import { cacheSavingsUsd, priceUsage, type RateTable } from "./usagePricing.ts"; /** @@ -41,15 +47,36 @@ function makeDayFormatter(timeZone: string): (timestampMs: number) => string { day: "2-digit", }); } - return (timestampMs) => format.format(new Date(timestampMs)); + // Formatting once per quarter-hour slot keeps `Intl` (about 2µs a call) off + // the per-record path. A slot whose two ends fall on one day lies wholly in + // that day. Named zones put midnight on a quarter hour, so their slots never + // split; a fixed offset such as `+00:01` can, and records in a split slot are + // formatted exactly. `null` marks a split slot. + const days = new Map(); + return (timestampMs) => { + const slot = Math.floor(timestampMs / QUARTER_HOUR_MS); + let day = days.get(slot); + if (day === undefined) { + const first = format.format(new Date(slot * QUARTER_HOUR_MS)); + const last = format.format(new Date((slot + 1) * QUARTER_HOUR_MS - 1)); + day = first === last ? first : null; + days.set(slot, day); + } + return day ?? format.format(new Date(timestampMs)); + }; } +const QUARTER_HOUR_MS = 15 * 60 * 1000; const HOUR_MS = 60 * 60 * 1000; interface MutableBucket { - totals: UsageTokenTotals; + totals: { -readonly [K in keyof UsageTokenTotals]: number }; costUsd: number; cacheSavingsUsd: number; + categoryCostUsd: UsageCategoryCost | null; + fastCostUsd: number; + ultrafastCostUsd: number; + speedPremiumUsd: number; records: number; unpricedRecords: number; providerReportedRecords: number; @@ -62,11 +89,35 @@ export interface AggregateOptions { readonly untilDay: string; readonly rates: RateTable; readonly priceOverrides?: RateTable; + /** From {@link resolveModelAliases}. Mapped records bucket and price as their target. */ + readonly modelAliases?: ReadonlyMap; readonly resolution?: UsageResolution; readonly sinceTimeMs?: number; readonly untilTimeMs?: number; } +/** + * Resolves user model mappings to their final target, so `a -> b` and + * `b -> c` both land on `c`. A model whose chain enters a loop is left + * unmapped. + */ +export function resolveModelAliases( + aliases: Readonly>, +): ReadonlyMap { + const resolved = new Map(); + for (const model of Object.keys(aliases)) { + const seen = new Set([model]); + let target = aliases[model]!; + while (Object.hasOwn(aliases, target) && !seen.has(target)) { + seen.add(target); + target = aliases[target]!; + } + // Stopping on a mapped model means the chain entered a loop. + if (!Object.hasOwn(aliases, target)) resolved.set(model, target); + } + return resolved; +} + export interface AggregateResult { readonly buckets: readonly UsageBucket[]; /** Records dropped because an earlier record carried the same dedupe key. */ @@ -88,6 +139,14 @@ export class UsageAggregator { readonly #toDay: (timestampMs: number) => string; readonly #hourlyWindow: { readonly sinceTimeMs: number; readonly untilTimeMs: number } | null; readonly #options: AggregateOptions; + #lastBucket: { + readonly day: string; + readonly hourIndex: number; + readonly provider: string; + readonly model: string; + readonly source: string; + readonly bucket: MutableBucket; + } | null = null; #duplicatesDropped = 0; #outOfWindow = 0; @@ -112,7 +171,8 @@ export class UsageAggregator { * can derive per-window facts (distinct sessions, for one) from the records * that landed rather than everything the mtime prefilter happened to admit. */ - add(record: UsageRecord, sourcePath?: string): boolean { + add(input: UsageRecord, sourcePath?: string): boolean { + const record = this.#mapModel(input); if (record.dedupeKey !== null) { if (this.#seen.has(record.dedupeKey)) { this.#duplicatesDropped += 1; @@ -139,32 +199,37 @@ export class UsageAggregator { return false; } - const hourStart = + const hourIndex = this.#hourlyWindow === null - ? "" - : new Date( - this.#hourlyWindow.sinceTimeMs + - Math.floor((record.timestampMs - this.#hourlyWindow.sinceTimeMs) / HOUR_MS) * HOUR_MS, - ).toISOString(); - const key = `${day}\u0000${hourStart}\u0000${record.provider}\u0000${record.model}\u0000${sourcePath ?? ""}`; - let bucket = this.#buckets.get(key); - if (bucket === undefined) { - bucket = { - totals: EMPTY_TOTALS, - costUsd: 0, - cacheSavingsUsd: 0, - records: 0, - unpricedRecords: 0, - providerReportedRecords: 0, - sessions: new Set(), - }; - this.#buckets.set(key, bucket); - } + ? -1 + : Math.floor((record.timestampMs - this.#hourlyWindow.sinceTimeMs) / HOUR_MS); + const bucket = this.#bucketFor(day, hourIndex, record.provider, record.model, sourcePath ?? ""); const priced = priceUsage(this.#options.rates, record, this.#options.priceOverrides); - bucket.totals = addTotals(bucket.totals, record.totals); + const totals = bucket.totals; + totals.uncachedInputTokens += record.totals.uncachedInputTokens; + totals.cachedInputTokens += record.totals.cachedInputTokens; + totals.cacheCreationTokens += record.totals.cacheCreationTokens; + totals.outputTokens += record.totals.outputTokens; + totals.reasoningTokens += record.totals.reasoningTokens; bucket.costUsd += priced.costUsd; + if (priced.categoryCostUsd !== null) { + const sum = bucket.categoryCostUsd; + const add = priced.categoryCostUsd; + bucket.categoryCostUsd = + sum === null + ? add + : { + input: sum.input + add.input, + cacheRead: sum.cacheRead + add.cacheRead, + cacheWrite: sum.cacheWrite + add.cacheWrite, + output: sum.output + add.output, + }; + } + if (record.speed === "fast") bucket.fastCostUsd += priced.costUsd; + if (record.speed === "ultrafast") bucket.ultrafastCostUsd += priced.costUsd; + bucket.speedPremiumUsd += priced.speedPremiumUsd; bucket.cacheSavingsUsd += cacheSavingsUsd( this.#options.rates, record, @@ -177,20 +242,94 @@ export class UsageAggregator { return true; } + /** The target's own rate applies, so a provider-specific `rateModel` is dropped. */ + #mapModel(record: UsageRecord): UsageRecord { + const model = this.#options.modelAliases?.get(record.model); + if (model === undefined) return record; + const { rateModel: _rateModel, ...rest } = record; + return { ...rest, model }; + } + + /** + * Records arrive file by file in time order, so most land in the bucket the + * previous record used. Checking that first skips building and hashing a + * key string per record. + */ + #bucketFor( + day: string, + hourIndex: number, + provider: string, + model: string, + source: string, + ): MutableBucket { + const last = this.#lastBucket; + if ( + last !== null && + last.day === day && + last.hourIndex === hourIndex && + last.provider === provider && + last.model === model && + last.source === source + ) { + return last.bucket; + } + const window = this.#hourlyWindow; + const hourStart = + window === null ? "" : new Date(window.sinceTimeMs + hourIndex * HOUR_MS).toISOString(); + const key = `${day}\u0000${hourStart}\u0000${provider}\u0000${model}\u0000${source}`; + let bucket = this.#buckets.get(key); + if (bucket === undefined) { + bucket = { + totals: { ...EMPTY_TOTALS }, + costUsd: 0, + cacheSavingsUsd: 0, + categoryCostUsd: null, + fastCostUsd: 0, + ultrafastCostUsd: 0, + speedPremiumUsd: 0, + records: 0, + unpricedRecords: 0, + providerReportedRecords: 0, + sessions: new Set(), + }; + this.#buckets.set(key, bucket); + } + this.#lastBucket = { day, hourIndex, provider, model, source, bucket }; + return bucket; + } + finish(): AggregateResult { const buckets: UsageBucket[] = []; for (const [key, bucket] of this.#buckets) { const [day = "", hourStart = "", provider = "", model = "", sourcePath = ""] = key.split("\u0000"); + const category = bucket.categoryCostUsd; + const fastCostUsd = roundUsd(bucket.fastCostUsd); + const ultrafastCostUsd = roundUsd(bucket.ultrafastCostUsd); + const speedPremiumUsd = roundUsd(bucket.speedPremiumUsd); buckets.push({ day: day as UsageDay, ...(hourStart === "" ? {} : { hourStart }), provider: provider as UsageBucket["provider"], model, ...(sourcePath === "" ? {} : { sourcePath }), - totals: bucket.totals, + totals: { ...bucket.totals }, costUsd: bucket.costUsd, cacheSavingsUsd: bucket.cacheSavingsUsd, + // Zero and unknown figures are omitted to keep payloads small. + ...(category === null + ? {} + : { + categoryCostUsd: { + input: roundUsd(category.input), + cacheRead: roundUsd(category.cacheRead), + cacheWrite: roundUsd(category.cacheWrite), + output: roundUsd(category.output), + }, + }), + ...(fastCostUsd === 0 ? {} : { fastCostUsd }), + ...(ultrafastCostUsd === 0 ? {} : { ultrafastCostUsd }), + ...(speedPremiumUsd === 0 ? {} : { speedPremiumUsd }), costSource: resolveCostSource(bucket), records: bucket.records, unpricedRecords: bucket.unpricedRecords, @@ -214,6 +353,14 @@ export class UsageAggregator { } } +/** + * Rounds to micro-dollars. The split and speed figures need no more precision, + * and shorter numbers keep them cheap on the wire. + */ +function roundUsd(value: number): number { + return Math.round(value * 1e6) / 1e6; +} + /** * A bucket mixes records from one model, but their cost provenance can differ * when only some records carried a reported cost. The weakest provenance in the diff --git a/apps/server/src/usage/usagePricing.test.ts b/apps/server/src/usage/usagePricing.test.ts index db4f68c2cb42..0a324dd734b4 100644 --- a/apps/server/src/usage/usagePricing.test.ts +++ b/apps/server/src/usage/usagePricing.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import { cursorRateModel } from "./cursorUsageReader.ts"; +import type { UsageSpeed } from "./usageTranscripts.ts"; import { cacheSavingsUsd, createOverrideRateTable, @@ -23,11 +24,15 @@ describe("usage pricing", () => { outputTokens: 1_000_000, reasoningTokens: 500_000, }; - const record = (model: string, reportedCostUsd: number | null = null, fast = false) => ({ + const record = ( + model: string, + reportedCostUsd: number | null = null, + speed: UsageSpeed = "standard", + ) => ({ model, totals, reportedCostUsd, - fast, + speed, }); it("uses custom token rates ahead of public and provider-reported costs", () => { @@ -42,7 +47,7 @@ describe("usage pricing", () => { }); for (const reportedCostUsd of [null, 99]) { - expect(priceUsage(table, record("example-model", reportedCostUsd), overrides)).toEqual({ + expect(priceUsage(table, record("example-model", reportedCostUsd), overrides)).toMatchObject({ costUsd: 13.5, costSource: "modelPriced", }); @@ -64,7 +69,7 @@ describe("usage pricing", () => { expect(cacheSavingsUsd(table, cursorRecord("claude-fable-5-1-thinking-high"))).toBeCloseTo(9); expect(cacheSavingsUsd(table, cursorRecord("cursor-grok-4.7-high-fast"))).toBeCloseTo(1.5); expect(cacheSavingsUsd(table, cursorRecord("default"))).toBe(0); - expect(priceUsage(table, cursorRecord("grok-4.7-xhigh-fast"))).toEqual({ + expect(priceUsage(table, cursorRecord("grok-4.7-xhigh-fast"))).toMatchObject({ costUsd: 0.25, costSource: "providerReported", }); @@ -76,7 +81,7 @@ describe("usage pricing", () => { "example-model": { inputCostPerMillionTokens: 2, outputCostPerMillionTokens: 8 }, }); - expect(priceUsage(table, record("example-model"), overrides)).toEqual({ + expect(priceUsage(table, record("example-model"), overrides)).toMatchObject({ costUsd: 14, costSource: "modelPriced", }); @@ -91,7 +96,7 @@ describe("usage pricing", () => { outputCostPerMillionTokens: 0, }, }); - expect(priceUsage(table, record(" vendor/example-model[1m] ", 99), overrides)).toEqual({ + expect(priceUsage(table, record(" vendor/example-model[1m] ", 99), overrides)).toMatchObject({ costUsd: 0, costSource: "modelPriced", }); @@ -105,6 +110,8 @@ describe("usage pricing", () => { expect(priceUsage(table, record(model, 99), overrides)).toEqual({ costUsd: 99, costSource: "providerReported", + categoryCostUsd: null, + speedPremiumUsd: 0, }); } }); @@ -117,20 +124,82 @@ describe("usage pricing", () => { const overrides = createOverrideRateTable({ "claude-opus-5-5": { inputCostPerMillionTokens: 4, outputCostPerMillionTokens: 20 }, }); - const cost = (model: string, fast: boolean, custom?: typeof overrides) => - priceUsage(table, record(model, null, fast), custom).costUsd; + const cost = (model: string, speed: UsageSpeed, custom?: typeof overrides) => + priceUsage(table, record(model, null, speed), custom).costUsd; - expect(cost("claude-opus-5-5", true)).toBeCloseTo(2 * cost("claude-opus-5-5", false)); - expect(cacheSavingsUsd(table, record("claude-opus-5-5", null, true))).toBeCloseTo( + expect(cost("claude-opus-5-5", "fast")).toBeCloseTo(2 * cost("claude-opus-5-5", "standard")); + expect(cacheSavingsUsd(table, record("claude-opus-5-5", null, "fast"))).toBeCloseTo( 2 * cacheSavingsUsd(table, record("claude-opus-5-5")), ); // No published fast tier, and custom prices, both stay at the standard rate. - expect(cost("claude-fable-5-1", true)).toBe(cost("claude-fable-5-1", false)); - expect(cost("claude-opus-5-5", true, overrides)).toBe( - cost("claude-opus-5-5", false, overrides), + expect(cost("claude-fable-5-1", "fast")).toBe(cost("claude-fable-5-1", "standard")); + expect(cost("claude-opus-5-5", "fast", overrides)).toBe( + cost("claude-opus-5-5", "standard", overrides), ); }); + it("splits cost by category and prices the speed premium", () => { + const table = parseRateTable({ + "claude-opus-5-5": { + ...rate(4e-6, 4e-7), + cache_creation_input_token_cost: 5e-6, + provider_specific_entry: { fast: 2 }, + }, + }); + const split = (input: number, cacheRead: number, cacheWrite: number, output: number) => ({ + input: expect.closeTo(input), + cacheRead: expect.closeTo(cacheRead), + cacheWrite: expect.closeTo(cacheWrite), + output: expect.closeTo(output), + }); + + expect(priceUsage(table, record("claude-opus-5-5", null, "fast"))).toEqual({ + costUsd: expect.closeTo(58.8), + costSource: "modelPriced", + categoryCostUsd: split(8, 0.8, 10, 40), + speedPremiumUsd: expect.closeTo(29.4), + }); + // A reported cost keeps its total and splits in proportion to list rates. + expect(priceUsage(table, record("claude-opus-5-5", 29.4, "fast"))).toEqual({ + costUsd: 29.4, + costSource: "providerReported", + categoryCostUsd: split(4, 0.4, 5, 20), + speedPremiumUsd: expect.closeTo(14.7), + }); + // Without rates there is nothing to split it by. + expect(priceUsage(table, record("unknown-model", 29.4, "fast"))).toMatchObject({ + costUsd: 29.4, + categoryCostUsd: null, + speedPremiumUsd: 0, + }); + }); + + it("prices Codex priority and ultrafast requests at their published tier rates", () => { + const table = parseRateTable({ + "gpt-6-astra": { + ...rate(1e-5, 1e-6), + input_cost_per_token_priority: 2e-5, + output_cost_per_token_priority: 1e-4, + cache_read_input_token_cost_priority: 2e-6, + input_cost_per_token_ultrafast: 6e-5, + output_cost_per_token_ultrafast: 3e-4, + // No ultrafast cache rate: keeps the standard 10:1 input-to-cache ratio. + }, + "gpt-6-sol": rate(2e-6, 2e-7), + }); + const cost = (model: string, speed: UsageSpeed) => + priceUsage(table, record(model, null, speed)).costUsd; + const standard = cost("gpt-6-astra", "standard"); + + expect(cost("gpt-6-astra", "fast")).toBeCloseTo(2 * standard); + expect(cost("gpt-6-astra", "ultrafast")).toBeCloseTo(6 * standard); + expect(cacheSavingsUsd(table, record("gpt-6-astra", null, "ultrafast"))).toBeCloseTo( + 6 * cacheSavingsUsd(table, record("gpt-6-astra")), + ); + // A tier the model does not publish bills at the standard rate. + expect(cost("gpt-6-sol", "ultrafast")).toBe(cost("gpt-6-sol", "standard")); + }); + it("keeps the canonical Fable rate separate from DeepInfra in either order", () => { const canonical = ["claude-fable-5", rate(1e-5, 1e-6)] as const; const deepInfra = ["deepinfra/anthropic/claude-fable-5", rate(1e-5)] as const; diff --git a/apps/server/src/usage/usagePricing.ts b/apps/server/src/usage/usagePricing.ts index 78f6cf2c5cd9..0ec7ef8d622f 100644 --- a/apps/server/src/usage/usagePricing.ts +++ b/apps/server/src/usage/usagePricing.ts @@ -7,35 +7,46 @@ * * @module usagePricing */ -import type { UsageCostSource, UsageModelPriceOverride } from "@t3tools/contracts"; +import type { + UsageCategoryCost, + UsageCostSource, + UsageModelPriceOverride, + UsageTokenTotals, +} from "@t3tools/contracts"; -import type { UsageRecord } from "./usageTranscripts.ts"; +import type { UsageRecord, UsageSpeed } from "./usageTranscripts.ts"; -/** - * The subset of a LiteLLM entry we price against. All values are USD per token. - * - * LiteLLM also publishes tiered variants (`*_above_272k_tokens`, `*_flex`, - * `*_priority`, `*_batches`). We deliberately price at the base tier: the - * transcripts don't record which tier served a request, so anything else would - * be a guess dressed up as precision. - */ -export interface ModelRate { +/** Token rates for one billing speed. All values are USD per token. */ +export interface TokenRates { readonly inputCostPerToken: number; readonly outputCostPerToken: number; readonly cacheReadCostPerToken: number; readonly cacheCreationCostPerToken: number; +} + +/** + * The subset of a LiteLLM entry we price against: standard rates, plus rates + * for each faster speed the model publishes. A request at a speed with no + * published rates bills at the standard rates. + * + * LiteLLM also publishes `*_above_272k_tokens`, `*_flex`, and `*_batches` + * variants. Transcripts don't record those, so we don't price them. + */ +export interface ModelRate extends TokenRates { /** - * Multiple of the rates above billed for a fast-mode request, from LiteLLM's - * `provider_specific_entry.fast`. `1` when the model publishes no fast tier. + * From LiteLLM's `provider_specific_entry.fast` multiple (Claude fast mode), + * or else its `*_priority` rates (Codex `priority`). */ - readonly fastMultiplier: number; + readonly fast: TokenRates | null; + /** From LiteLLM's `*_ultrafast` rates (Codex `ultrafast`). */ + readonly ultrafast: TokenRates | null; } export type RateTable = ReadonlyMap; /** * Custom IDs keep their case, provider prefix, and variant suffix. Custom rates - * apply as entered, fast-mode requests included. + * apply as entered, at every speed. */ export function createOverrideRateTable( overrides: Readonly>, @@ -50,31 +61,68 @@ export function createOverrideRateTable( (prices.cacheReadCostPerMillionTokens ?? prices.inputCostPerMillionTokens) / 1_000_000, cacheCreationCostPerToken: (prices.cacheWriteCostPerMillionTokens ?? prices.inputCostPerMillionTokens) / 1_000_000, - fastMultiplier: 1, + fast: null, + ultrafast: null, }, ]), ); } -/** Raw shape of one LiteLLM entry, narrowed to the fields we read. */ -interface LiteLlmEntry { - readonly input_cost_per_token?: unknown; - readonly output_cost_per_token?: unknown; - readonly cache_read_input_token_cost?: unknown; - readonly cache_creation_input_token_cost?: unknown; - readonly provider_specific_entry?: unknown; -} +/** One raw LiteLLM entry. Field names carry a tier suffix, e.g. `_priority`. */ +type LiteLlmEntry = Readonly>; function finiteNumber(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) ? value : null; } +/** + * Reads one rate set, `suffix` selecting a tier such as `_priority`. Returns + * `null` without both an input and an output rate. + * + * Anthropic bills cache reads at a discount and cache writes at a premium. + * When the standard tier omits them, cached input is priced as plain input + * rather than as free. A faster tier that omits them keeps the standard tier's + * cache-to-input ratio. + */ +function readTokenRates( + entry: LiteLlmEntry, + suffix: string, + standard?: TokenRates, +): TokenRates | null { + const input = finiteNumber(entry[`input_cost_per_token${suffix}`]); + const output = finiteNumber(entry[`output_cost_per_token${suffix}`]); + if (input === null || output === null) return null; + const cacheRate = (name: string, field: "cacheReadCostPerToken" | "cacheCreationCostPerToken") => + finiteNumber(entry[`${name}${suffix}`]) ?? + (standard !== undefined && standard.inputCostPerToken > 0 + ? (standard[field] / standard.inputCostPerToken) * input + : input); + return { + inputCostPerToken: input, + outputCostPerToken: output, + cacheReadCostPerToken: cacheRate("cache_read_input_token_cost", "cacheReadCostPerToken"), + cacheCreationCostPerToken: cacheRate( + "cache_creation_input_token_cost", + "cacheCreationCostPerToken", + ), + }; +} + +function scaleTokenRates(rates: TokenRates, multiple: number): TokenRates { + return { + inputCostPerToken: rates.inputCostPerToken * multiple, + outputCostPerToken: rates.outputCostPerToken * multiple, + cacheReadCostPerToken: rates.cacheReadCostPerToken * multiple, + cacheCreationCostPerToken: rates.cacheCreationCostPerToken * multiple, + }; +} + /** Reads `provider_specific_entry.fast`, e.g. `2` for Claude Opus 5.5. */ -function fastMultiplier(entry: LiteLlmEntry): number { - const specific = entry.provider_specific_entry; - if (typeof specific !== "object" || specific === null) return 1; +function fastMultiplier(entry: LiteLlmEntry): number | null { + const specific = entry["provider_specific_entry"]; + if (typeof specific !== "object" || specific === null) return null; const fast = finiteNumber((specific as Record)["fast"]); - return fast !== null && fast > 0 ? fast : 1; + return fast !== null && fast > 0 ? fast : null; } /** @@ -94,21 +142,19 @@ export function parseRateTable(document: unknown): RateTable { for (const [name, raw] of Object.entries(document as Record)) { if (typeof raw !== "object" || raw === null) continue; const entry = raw as LiteLlmEntry; - const input = finiteNumber(entry.input_cost_per_token); - const output = finiteNumber(entry.output_cost_per_token); - if (input === null || output === null) continue; + const standard = readTokenRates(entry, ""); + if (standard === null) continue; const key = normalizeRateKey(name); if (key.length === 0) continue; + const multiple = fastMultiplier(entry); table.set(key, { - inputCostPerToken: input, - outputCostPerToken: output, - // Anthropic bills cache reads at a discount and cache writes at a - // premium. When a model omits them, cached input is priced as plain - // input rather than as free. - cacheReadCostPerToken: finiteNumber(entry.cache_read_input_token_cost) ?? input, - cacheCreationCostPerToken: finiteNumber(entry.cache_creation_input_token_cost) ?? input, - fastMultiplier: fastMultiplier(entry), + ...standard, + fast: + multiple === null + ? readTokenRates(entry, "_priority", standard) + : scaleTokenRates(standard, multiple), + ultrafast: readTokenRates(entry, "_ultrafast", standard), }); } @@ -131,16 +177,29 @@ export function parseRateTable(document: unknown): RateTable { return table; } -function sameRate(a: ModelRate, b: ModelRate): boolean { +function sameTokenRates(a: TokenRates | null, b: TokenRates | null): boolean { + if (a === null || b === null) return a === b; return ( a.inputCostPerToken === b.inputCostPerToken && a.outputCostPerToken === b.outputCostPerToken && a.cacheReadCostPerToken === b.cacheReadCostPerToken && - a.cacheCreationCostPerToken === b.cacheCreationCostPerToken && - a.fastMultiplier === b.fastMultiplier + a.cacheCreationCostPerToken === b.cacheCreationCostPerToken + ); +} + +function sameRate(a: ModelRate, b: ModelRate): boolean { + return ( + sameTokenRates(a, b) && + sameTokenRates(a.fast, b.fast) && + sameTokenRates(a.ultrafast, b.ultrafast) ); } +/** The rates a request at `speed` bills at. */ +function ratesAt(rate: ModelRate, speed: UsageSpeed): TokenRates { + return (speed === "standard" ? null : rate[speed]) ?? rate; +} + function normalizeRateKey(model: string): string { return model.trim().toLowerCase(); } @@ -176,7 +235,27 @@ const UNPRICEABLE_MODELS = new Set([ "fable", ]); +/** + * Lookups per table, by raw model name. A scan prices every record twice + * against a few dozen models, and tables are never mutated once built. + */ +const resolvedRates = new WeakMap>(); + export function lookupRate(table: RateTable, model: string): ModelRate | null { + let resolved = resolvedRates.get(table); + if (resolved === undefined) { + resolved = new Map(); + resolvedRates.set(table, resolved); + } + let rate = resolved.get(model); + if (rate === undefined) { + rate = resolveRate(table, model); + resolved.set(model, rate); + } + return rate; +} + +function resolveRate(table: RateTable, model: string): ModelRate | null { const key = stripVariantSuffix(normalizeRateKey(model)); const bareName = bareModelName(key); if (bareName.length === 0 || UNPRICEABLE_MODELS.has(bareName)) return null; @@ -186,17 +265,37 @@ export function lookupRate(table: RateTable, model: string): ModelRate | null { /** The parts of a transcript record that decide its price. */ export type PricedRecord = Pick< UsageRecord, - "model" | "rateModel" | "totals" | "fast" | "reportedCostUsd" + "model" | "rateModel" | "totals" | "speed" | "reportedCostUsd" >; export interface PricedUsage { readonly costUsd: number; readonly costSource: UsageCostSource; + /** `costUsd` by token category, or `null` when no rates are known to split it. */ + readonly categoryCostUsd: UsageCategoryCost | null; + /** What `costUsd` exceeds the same tokens at standard rates. `0` without rates. */ + readonly speedPremiumUsd: number; +} + +function costByCategory(totals: UsageTokenTotals, rates: TokenRates): UsageCategoryCost { + return { + input: totals.uncachedInputTokens * rates.inputCostPerToken, + cacheRead: totals.cachedInputTokens * rates.cacheReadCostPerToken, + cacheWrite: totals.cacheCreationTokens * rates.cacheCreationCostPerToken, + output: totals.outputTokens * rates.outputCostPerToken, + }; +} + +function sumCategories(cost: UsageCategoryCost): number { + return cost.input + cost.cacheRead + cost.cacheWrite + cost.output; } /** * Prices one record's tokens. * + * A provider-reported cost is kept as is, and split by category and speed in + * proportion to the model's list rates when those are known. + * * `reasoningTokens` is intentionally not charged separately: it is already * counted inside `outputTokens`. */ @@ -207,22 +306,37 @@ export function priceUsage( ): PricedUsage { const { model, totals, reportedCostUsd } = record; const override = overrides?.get(model.trim()); - if (override === undefined && reportedCostUsd !== null && Number.isFinite(reportedCostUsd)) { - return { costUsd: reportedCostUsd, costSource: "providerReported" }; - } - + const reported = + override === undefined && reportedCostUsd !== null && Number.isFinite(reportedCostUsd) + ? reportedCostUsd + : null; + const unsplit = (costUsd: number, costSource: UsageCostSource): PricedUsage => ({ + costUsd, + costSource, + categoryCostUsd: null, + speedPremiumUsd: 0, + }); const rate = override ?? lookupRate(table, record.rateModel ?? model); - if (rate === null) return { costUsd: 0, costSource: "unpriced" }; - - const standardCostUsd = - totals.uncachedInputTokens * rate.inputCostPerToken + - totals.cachedInputTokens * rate.cacheReadCostPerToken + - totals.cacheCreationTokens * rate.cacheCreationCostPerToken + - totals.outputTokens * rate.outputCostPerToken; + if (rate === null) { + return reported === null ? unsplit(0, "unpriced") : unsplit(reported, "providerReported"); + } + const listCost = costByCategory(totals, ratesAt(rate, record.speed)); + const listCostUsd = sumCategories(listCost); + if (reported !== null && listCostUsd <= 0) return unsplit(reported, "providerReported"); + const premiumUsd = + record.speed === "standard" ? 0 : listCostUsd - sumCategories(costByCategory(totals, rate)); + const scale = reported === null ? 1 : reported / listCostUsd; return { - costUsd: standardCostUsd * (record.fast ? rate.fastMultiplier : 1), - costSource: "modelPriced", + costUsd: reported ?? listCostUsd, + costSource: reported === null ? "modelPriced" : "providerReported", + categoryCostUsd: { + input: listCost.input * scale, + cacheRead: listCost.cacheRead * scale, + cacheWrite: listCost.cacheWrite * scale, + output: listCost.output * scale, + }, + speedPremiumUsd: premiumUsd * scale, }; } @@ -238,9 +352,6 @@ export function cacheSavingsUsd( const rate = overrides?.get(record.model.trim()) ?? lookupRate(table, record.rateModel ?? record.model); if (rate === null) return 0; - return ( - record.totals.cachedInputTokens * - (rate.inputCostPerToken - rate.cacheReadCostPerToken) * - (record.fast ? rate.fastMultiplier : 1) - ); + const rates = ratesAt(rate, record.speed); + return record.totals.cachedInputTokens * (rates.inputCostPerToken - rates.cacheReadCostPerToken); } diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts index 6455b1eb7770..b59768cf892b 100644 --- a/apps/server/src/usage/usageScanCache.test.ts +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -4,6 +4,7 @@ import { decodeScanCache, dedupeWithinFile, encodeScanCache, + makeScanCacheWriter, pruneScanCache, type CachedFile, type ScanCache, @@ -24,7 +25,7 @@ function record(overrides: Partial = {}): UsageRecord { reasoningTokens: 0, }, reportedCostUsd: null, - fast: false, + speed: "standard", dedupeKey: "msg_1:", ...overrides, }; @@ -61,7 +62,7 @@ describe("scan cache round trip", () => { [ "/a.jsonl", 100, - [record(), record({ dedupeKey: "msg_2:", model: "claude-opus-5-5", fast: true })], + [record(), record({ dedupeKey: "msg_2:", model: "claude-opus-5-5", speed: "fast" })], ], ["/b.jsonl", 200, [record({ sessionId: "session-b", reportedCostUsd: 1.5 })]], ]); @@ -79,11 +80,14 @@ describe("scan cache round trip", () => { size: 80, mtimeMs: 400, provider: "codex", - records: [record({ provider: "codex", model: "gpt-5.2-codex", dedupeKey: null })], + records: [ + record({ provider: "codex", model: "gpt-6-astra", dedupeKey: null, speed: "ultrafast" }), + ], tailRecords: [], position: position({ codexState: { - model: "gpt-5.2-codex", + model: "gpt-6-astra", + speed: "ultrafast", sessionId: "session-c", lastUsageSignature: '{"input_tokens":1}', sawSessionMeta: true, @@ -128,8 +132,8 @@ describe("scan cache round trip", () => { expect(decodeScanCache(JSON.parse(JSON.stringify(poisoned))).has("/a.jsonl")).toBe(false); }); - it("drops an entry whose fast flag is not 0 or 1", () => { - const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record({ fast: true })]]])); + it("drops an entry whose speed is not a known index", () => { + const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record({ speed: "fast" })]]])); const row = encoded.files["/a.jsonl"]!.r[0]!; const poisoned = { ...encoded, @@ -139,13 +143,33 @@ describe("scan cache round trip", () => { expect(decodeScanCache(JSON.parse(JSON.stringify(poisoned))).has("/a.jsonl")).toBe(false); }); - it("rejects a document from the previous cache version", () => { + it("rejects a document from before records carried a speed", () => { const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]])); const previous = { ...encoded, version: 3 }; expect(decodeScanCache(JSON.parse(JSON.stringify(previous))).size).toBe(0); }); + it("rewrites only changed entries and still restores the whole cache", () => { + const write = makeScanCacheWriter(); + const cache = cacheWith([ + ["/a.jsonl", 100, [record()]], + ["/b.jsonl", 200, [record({ sessionId: "session-b" })]], + ]); + const sources = { "claude\u0000/projects": { dir: "/projects", volumeId: "1:2" } }; + expect(decodeScanCache(JSON.parse(write(cache, { sources })))).toEqual(cache); + + // The replacement adds intern entries; /a's memoised indexes must hold. + cache.set("/b.jsonl", { + ...cache.get("/b.jsonl")!, + size: 30, + records: [record({ sessionId: "session-c", model: "claude-opus-5-5", dedupeKey: "msg_3:" })], + }); + const document = JSON.parse(write(cache, { sources })); + expect(decodeScanCache(document)).toEqual(cache); + expect(document.sources).toEqual(sources); + }); + it("interns repeated model and session strings", () => { const encoded = encodeScanCache( cacheWith([["/a.jsonl", 100, [record(), record({ dedupeKey: "msg_2:" }), record()]]]), diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index 79cca5cff8e2..04e7ab4ce982 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -17,14 +17,33 @@ import type { UsageProviderKind } from "@t3tools/contracts"; import { GUARD_LENGTH, type TranscriptParsePosition } from "./usageTranscriptReader.ts"; -import type { CodexScanState, UsageRecord } from "./usageTranscripts.ts"; +import type { CodexScanState, UsageRecord, UsageSpeed } from "./usageTranscripts.ts"; // v2: Codex fork-copy suppression changed what a file parses to, so v1 // entries would keep serving double-counted records forever. // v3: entries carry the parse position and reducer state so a grown file // re-parses only its appended bytes instead of starting over. // v4: records carry Claude fast mode, which v3 rows never captured. -const USAGE_SCAN_CACHE_VERSION = 4 as const; +// v5: Codex records carry their service tier. v4 rows store speed the same +// way, so v4 entries still load; see `decodeScanCache` for v4 Codex entries. +const USAGE_SCAN_CACHE_VERSION = 5 as const; +const SPEED_COMPATIBLE_SINCE_VERSION = 4; + +/** + * Each cache version writes its own file in the state directory. An older + * server sharing that directory cannot read a newer cache and would replace + * it, dropping saved usage for deleted transcripts. Separate files keep both. + * A v5 server reads the legacy (v4) file once, when its own file is missing. + */ +export const SCAN_CACHE_FILE_NAME = "usage-scan-cache-v5.json"; +export const LEGACY_SCAN_CACHE_FILE_NAME = "usage-scan-cache.json"; + +/** Serialised as the index into this list. */ +const SPEEDS: readonly UsageSpeed[] = ["standard", "fast", "ultrafast"]; + +function isSpeed(value: unknown): value is UsageSpeed { + return SPEEDS.some((speed) => speed === value); +} export interface CachedFile { readonly size: number; @@ -59,7 +78,7 @@ type SerializedRecord = readonly [ reasoningTokens: number, dedupeKey: string | null, reportedCostUsd: number | null, - fast: 0 | 1, + speed: number, ]; interface SerializedFile { @@ -84,26 +103,32 @@ interface SerializedCache { readonly files: Readonly>; } -/** Serialises the cache, interning the repeated model and session strings. */ -export function encodeScanCache(cache: ScanCache): SerializedCache { - const models: string[] = []; - const sessions: string[] = []; - const modelIndex = new Map(); - const sessionIndex = new Map(); +/** Model and session strings, each stored once and referenced by index. */ +interface InternTables { + readonly models: string[]; + readonly sessions: string[]; + readonly modelIndex: Map; + readonly sessionIndex: Map; +} - const intern = (table: string[], index: Map, value: string): number => { - const existing = index.get(value); - if (existing !== undefined) return existing; - const next = table.length; - table.push(value); - index.set(value, next); - return next; - }; +function makeInternTables(): InternTables { + return { models: [], sessions: [], modelIndex: new Map(), sessionIndex: new Map() }; +} + +function intern(table: string[], index: Map, value: string): number { + const existing = index.get(value); + if (existing !== undefined) return existing; + const next = table.length; + table.push(value); + index.set(value, next); + return next; +} +function serializeFile(entry: CachedFile, tables: InternTables): SerializedFile { const serializeRecord = (record: UsageRecord): SerializedRecord => [ record.timestampMs, - intern(models, modelIndex, record.model), - intern(sessions, sessionIndex, record.sessionId), + intern(tables.models, tables.modelIndex, record.model), + intern(tables.sessions, tables.sessionIndex, record.sessionId), record.totals.uncachedInputTokens, record.totals.cachedInputTokens, record.totals.cacheCreationTokens, @@ -111,25 +136,70 @@ export function encodeScanCache(cache: ScanCache): SerializedCache { record.totals.reasoningTokens, record.dedupeKey, record.reportedCostUsd, - record.fast ? 1 : 0, + SPEEDS.indexOf(record.speed), ]; + return { + s: entry.size, + m: entry.mtimeMs, + p: entry.provider, + r: entry.records.map(serializeRecord), + t: entry.tailRecords.map(serializeRecord), + o: entry.position.resumeOffset, + gl: entry.position.guardLength, + gh: entry.position.guardHash, + cs: entry.position.codexState, + }; +} +/** Serialises the cache, interning the repeated model and session strings. */ +export function encodeScanCache(cache: ScanCache): SerializedCache { + const tables = makeInternTables(); const files: Record = {}; - for (const [path, entry] of cache) { - files[path] = { - s: entry.size, - m: entry.mtimeMs, - p: entry.provider, - r: entry.records.map(serializeRecord), - t: entry.tailRecords.map(serializeRecord), - o: entry.position.resumeOffset, - gl: entry.position.guardLength, - gh: entry.position.guardHash, - cs: entry.position.codexState, - }; - } + for (const [path, entry] of cache) files[path] = serializeFile(entry, tables); + return { + version: USAGE_SCAN_CACHE_VERSION, + models: tables.models, + sessions: tables.sessions, + files, + }; +} - return { version: USAGE_SCAN_CACHE_VERSION, models, sessions, files }; +/** + * Returns a function that serialises the cache to JSON text, re-encoding only + * the entries that changed since its last call. Call it once per persist. + * + * Writes the same document as `encodeScanCache`. Most entries never change + * between scans, and encoding all of them made each persist cost close to a + * second on a large cache. Entries are replaced, never mutated, when their file + * changes, so an entry's JSON is memoised by identity. The intern tables only + * grow, so a memoised entry's indexes stay valid; a pruned entry can leave an + * unused string behind until the next process start. + */ +export function makeScanCacheWriter(): ( + cache: ScanCache, + extra: Readonly>, +) => string { + const tables = makeInternTables(); + const fragments = new WeakMap(); + return (cache, extra) => { + const files: string[] = []; + for (const [path, entry] of cache) { + let fragment = fragments.get(entry); + if (fragment === undefined) { + fragment = JSON.stringify(serializeFile(entry, tables)); + fragments.set(entry, fragment); + } + files.push(`${JSON.stringify(path)}:${fragment}`); + } + // Encoded after the files, which may have added to the intern tables. + const head = JSON.stringify({ + ...extra, + version: USAGE_SCAN_CACHE_VERSION, + models: tables.models, + sessions: tables.sessions, + }); + return `${head.slice(0, -1)},"files":{${files.join(",")}}}`; + }; } function isRecordArray(value: unknown): value is readonly unknown[] { @@ -147,7 +217,14 @@ export function decodeScanCache(document: unknown): ScanCache { if (typeof document !== "object" || document === null) return cache; const root = document as Partial; - if (root.version !== USAGE_SCAN_CACHE_VERSION) return cache; + const version = root.version; + if ( + typeof version !== "number" || + version < SPEED_COMPATIBLE_SINCE_VERSION || + version > USAGE_SCAN_CACHE_VERSION + ) { + return cache; + } if (!isRecordArray(root.models) || !isRecordArray(root.sessions)) return cache; if (typeof root.files !== "object" || root.files === null) return cache; @@ -180,8 +257,9 @@ export function decodeScanCache(document: unknown): ScanCache { reasoning, dedupeKey, reportedCostUsd, - fast, + speedIndex, ] = row as SerializedRecord; + const speed = typeof speedIndex === "number" ? SPEEDS[speedIndex] : undefined; const model = typeof modelIndex === "number" ? models[modelIndex] : undefined; if ( @@ -193,7 +271,7 @@ export function decodeScanCache(document: unknown): ScanCache { !Number.isFinite(cacheCreation) || !Number.isFinite(output) || !Number.isFinite(reasoning) || - (fast !== 0 && fast !== 1) + speed === undefined ) { return null; } @@ -211,7 +289,7 @@ export function decodeScanCache(document: unknown): ScanCache { reasoningTokens: reasoning, }, reportedCostUsd: typeof reportedCostUsd === "number" ? reportedCostUsd : null, - fast: fast === 1, + speed, dedupeKey: typeof dedupeKey === "string" ? dedupeKey : null, }); } @@ -242,7 +320,11 @@ export function decodeScanCache(document: unknown): ScanCache { ) { continue; } - const codexState = decodeCodexState(entry.cs); + // v4 Codex records predate service tiers, so they all priced as standard. + // Keep them, because the rollout may be gone, but make a live rollout + // re-parse whole: no file has size -1, and a zero position cannot resume. + const legacyCodex = entry.p === "codex" && version < USAGE_SCAN_CACHE_VERSION; + const codexState = legacyCodex ? null : decodeCodexState(entry.cs); if (codexState === undefined) continue; const provider: UsageProviderKind = entry.p; @@ -251,17 +333,14 @@ export function decodeScanCache(document: unknown): ScanCache { if (records === null || tailRecords === null) continue; cache.set(path, { - size: entry.s, + size: legacyCodex ? -1 : entry.s, mtimeMs: entry.m, provider, records, tailRecords, - position: { - resumeOffset: entry.o, - guardLength: entry.gl, - guardHash: entry.gh, - codexState, - }, + position: legacyCodex + ? { resumeOffset: 0, guardLength: 0, guardHash: 0, codexState: null } + : { resumeOffset: entry.o, guardLength: entry.gl, guardHash: entry.gh, codexState }, }); } @@ -279,6 +358,7 @@ function decodeCodexState(value: unknown): CodexScanState | null | undefined { const state = value as Partial; if ( typeof state.model !== "string" || + !isSpeed(state.speed) || typeof state.sessionId !== "string" || (state.lastUsageSignature !== null && typeof state.lastUsageSignature !== "string") || typeof state.sawSessionMeta !== "boolean" || @@ -290,6 +370,7 @@ function decodeCodexState(value: unknown): CodexScanState | null | undefined { } return { model: state.model, + speed: state.speed, sessionId: state.sessionId, lastUsageSignature: state.lastUsageSignature ?? null, sawSessionMeta: state.sawSessionMeta, diff --git a/apps/server/src/usage/usageTranscriptReader.test.ts b/apps/server/src/usage/usageTranscriptReader.test.ts index 6c95a36df19b..e55c01ad4919 100644 --- a/apps/server/src/usage/usageTranscriptReader.test.ts +++ b/apps/server/src/usage/usageTranscriptReader.test.ts @@ -76,6 +76,14 @@ function codexModelLine(model: string): string { })}\n`; } +function codexTierLine(serviceTier: string): string { + return `${JSON.stringify({ + type: "event_msg", + timestamp: "2026-08-01T10:00:01Z", + payload: { type: "thread_settings_applied", thread_settings: { service_tier: serviceTier } }, + })}\n`; +} + function codexUsageLine(outputTokens: number, secondsOffset: number): string { return `${JSON.stringify({ type: "event_msg", @@ -111,19 +119,24 @@ describe("readTranscriptRecords resume", () => { it("carries the Codex reducer state across the resume boundary", async () => { const path = NodePath.join(dir, "rollout.jsonl"); - await NodeFSP.writeFile(path, codexMetaLine() + codexModelLine("gpt-5.2-codex")); + await NodeFSP.writeFile( + path, + codexMetaLine() + codexModelLine("gpt-5.2-codex") + codexTierLine("ultrafast"), + ); const first = await readTranscriptRecords(path, "codex"); assert.isNotNull(first); assert.strictEqual(first.records.length, 0); - // The appended usage event has no turn_context or session_meta of its own; - // model and session must come from the state captured before the boundary. + // The appended usage event has no turn_context, thread settings, or + // session_meta of its own; model, tier, and session must come from the + // state captured before the boundary. await NodeFSP.appendFile(path, codexUsageLine(9, 5)); const second = await readTranscriptRecords(path, "codex", first.position); assert.isNotNull(second); assert.isTrue(second.resumed); assert.strictEqual(second.records.length, 1); assert.strictEqual(second.records[0]?.model, "gpt-5.2-codex"); + assert.strictEqual(second.records[0]?.speed, "ultrafast"); assert.strictEqual(second.records[0]?.sessionId, "codex-session-1"); }); diff --git a/apps/server/src/usage/usageTranscriptReader.ts b/apps/server/src/usage/usageTranscriptReader.ts index faa686990777..bca22468a95e 100644 --- a/apps/server/src/usage/usageTranscriptReader.ts +++ b/apps/server/src/usage/usageTranscriptReader.ts @@ -85,6 +85,8 @@ export const GUARD_LENGTH = 64; // readers; it never discards a record because of its size. const STREAMING_THRESHOLD_BYTES = 8 * 1024 * 1024; const NEWLINE = 0x0a; +/** `stat` calls one transcript walk keeps in flight. The libuv pool has 4 threads. */ +const STAT_CONCURRENCY = 32; const CARRIAGE_RETURN = 0x0d; type SelectedFields = { readonly [key: string]: true | SelectedFields }; @@ -108,6 +110,7 @@ const USAGE_FIELDS: Record<"claude" | "codex" | "grok", SelectedFields> = { id: true, session_id: true, model: true, + thread_settings: { service_tier: true }, forked_from_id: true, source: { subagent: { thread_spawn: { parent_thread_id: true } } }, info: { last_token_usage: true }, @@ -155,15 +158,19 @@ function fnv1a(buffer: Buffer): number { * `fileName` restricts the walk to a single basename (Grok's `updates.jsonl`). * Grok sessions also ship multi-megabyte `chat_history` and `events` logs that * never carry usage, so the basename filter keeps a cold scan off those files. + * + * Directories are listed depth-first, one at a time, then the candidates are + * stat'd by a fixed pool of workers: a warm scan stats thousands of files, and + * one at a time each waits its own trip through the thread pool. Results keep + * `readdir` order, which the aggregator's first-seen dedupe relies on. */ export async function listTranscriptFiles( root: string, sinceMs: number, options?: { readonly fileName?: string }, ): Promise { - const found: TranscriptFile[] = []; const fileName = options?.fileName; - + const candidates: string[] = []; const walk = async (dir: string): Promise => { let entries; try { @@ -173,28 +180,33 @@ export async function listTranscriptFiles( } for (const entry of entries) { const child = NodePath.join(dir, entry.name); - if (entry.isDirectory()) { - await walk(child); - continue; - } - if (fileName !== undefined) { - if (entry.name !== fileName) continue; - } else if (!entry.name.endsWith(".jsonl")) { - continue; + if (entry.isDirectory()) await walk(child); + else if (fileName !== undefined ? entry.name === fileName : entry.name.endsWith(".jsonl")) { + candidates.push(child); } + } + }; + await walk(root); + + const found: Array = Array.from({ length: candidates.length }); + // Each worker pulls the next candidate from one shared iterator. + const queue = candidates.entries(); + const statQueued = async (): Promise => { + for (const [index, path] of queue) { try { - const stats = await NodeFSP.stat(child); + const stats = await NodeFSP.stat(path); if (stats.mtimeMs >= sinceMs) { - found.push({ path: child, size: stats.size, mtimeMs: stats.mtimeMs }); + found[index] = { path, size: stats.size, mtimeMs: stats.mtimeMs }; } } catch { // Vanished between readdir and stat. } } }; - - await walk(root); - return found; + await Promise.all( + Array.from({ length: Math.min(STAT_CONCURRENCY, candidates.length) }, statQueued), + ); + return found.filter((file) => file !== undefined); } /** @@ -245,9 +257,10 @@ async function guardMatches( * still match, so only appended lines are read; otherwise the whole file is * re-parsed from the start and `resumed` reports `false`. * - * Codex carries the active model on `turn_context` lines that hold no usage of - * their own, so those still have to pass through the reducer to keep model - * attribution correct. + * Codex carries the active model on `turn_context` lines and the service tier + * on `thread_settings_applied` lines. Neither holds usage of its own, but both + * still have to pass through the reducer to keep attribution and pricing + * correct. */ export async function readTranscriptRecords( filePath: string, @@ -283,6 +296,7 @@ export async function readTranscriptRecords( if ( !mightCarryUsage(line, provider) && !line.includes('"turn_context"') && + !line.includes('"thread_settings_applied"') && !line.includes('"session_meta"') ) { return; diff --git a/apps/server/src/usage/usageTranscriptStreaming.test.ts b/apps/server/src/usage/usageTranscriptStreaming.test.ts index 2187fd4aa431..fd23593e5523 100644 --- a/apps/server/src/usage/usageTranscriptStreaming.test.ts +++ b/apps/server/src/usage/usageTranscriptStreaming.test.ts @@ -122,7 +122,7 @@ describe("large usage records", () => { reasoningTokens: 0, }, reportedCostUsd: 0.25, - fast: true, + speed: "fast", dedupeKey: "m1:r-m1", }, ]); diff --git a/apps/server/src/usage/usageTranscripts.test.ts b/apps/server/src/usage/usageTranscripts.test.ts index ace3b7d18cf7..8d668a139822 100644 --- a/apps/server/src/usage/usageTranscripts.test.ts +++ b/apps/server/src/usage/usageTranscripts.test.ts @@ -53,15 +53,15 @@ describe("parseClaudeLine", () => { reasoningTokens: 0, }); expect(record?.dedupeKey).toBe("msg_1:"); - expect(record?.fast).toBe(false); + expect(record?.speed).toBe("standard"); }); it("marks fast-mode requests", () => { const line = (speed: string) => parseClaudeLine(claudeLine({ messageId: "msg_1", contentType: "text", speed })); - expect(line("fast")?.fast).toBe(true); - expect(line("standard")?.fast).toBe(false); + expect(line("fast")?.speed).toBe("fast"); + expect(line("standard")?.speed).toBe("standard"); }); it("gives every content block of one message the same dedupe key", () => { @@ -148,6 +148,27 @@ describe("parseCodexLine", () => { expect(parseCodexLine(tokenCount(100, 0, 10, 0), state)).not.toBeNull(); }); + it("carries the service tier from the latest thread settings", () => { + const settings = (thread_settings: Record) => + JSON.stringify({ + type: "event_msg", + timestamp: "2026-08-01T05:17:42.000Z", + payload: { type: "thread_settings_applied", thread_settings }, + }); + const state = initialCodexScanState(); + parseCodexLine(turnContext, state); + const speedAfter = (line: string, output: number) => { + parseCodexLine(line, state); + return parseCodexLine(tokenCount(100, 0, output, 0), state)?.speed; + }; + + expect(parseCodexLine(tokenCount(100, 0, 1, 0), state)?.speed).toBe("standard"); + expect(speedAfter(settings({ service_tier: "ultrafast" }), 2)).toBe("ultrafast"); + expect(speedAfter(settings({ service_tier: "priority" }), 3)).toBe("fast"); + // Codex omits the field when no tier was requested. + expect(speedAfter(settings({ model: "gpt-6-astra" }), 4)).toBe("standard"); + }); + // A forked/subagent rollout opens with the parent's history copied in and // every line re-stamped to the fork instant, then the ancestors' session // metas. Counting those again multiplied usage ~1.85x on real data (#5758). diff --git a/apps/server/src/usage/usageTranscripts.ts b/apps/server/src/usage/usageTranscripts.ts index c3903ef47194..451f8a726b3e 100644 --- a/apps/server/src/usage/usageTranscripts.ts +++ b/apps/server/src/usage/usageTranscripts.ts @@ -8,6 +8,13 @@ */ import type { UsageProviderKind, UsageTokenTotals } from "@t3tools/contracts"; +/** + * Billing speed of a request. Faster speeds bill at a model-specific premium. + * Claude fast mode and Codex `priority` are `fast`; Codex `ultrafast` is its + * own, more expensive tier. + */ +export type UsageSpeed = "standard" | "fast" | "ultrafast"; + export interface UsageRecord { readonly provider: UsageProviderKind; readonly timestampMs: number; @@ -20,11 +27,8 @@ export interface UsageRecord { readonly sessionId: string; readonly totals: UsageTokenTotals; readonly reportedCostUsd: number | null; - /** - * Whether the request ran in fast mode, which bills at a model-specific - * multiple of the standard rate. Only Claude Code records this. - */ - readonly fast: boolean; + /** Only Claude Code and Codex record a speed; other providers are `standard`. */ + readonly speed: UsageSpeed; /** * Key for cross-file de-duplication, or `null` when the record is inherently * unique and needs no dedup. @@ -50,16 +54,6 @@ function parseTimestampMs(value: unknown): number | null { return Number.isNaN(parsed) ? null : parsed; } -export function addTotals(a: UsageTokenTotals, b: UsageTokenTotals): UsageTokenTotals { - return { - uncachedInputTokens: a.uncachedInputTokens + b.uncachedInputTokens, - cachedInputTokens: a.cachedInputTokens + b.cachedInputTokens, - cacheCreationTokens: a.cacheCreationTokens + b.cacheCreationTokens, - outputTokens: a.outputTokens + b.outputTokens, - reasoningTokens: a.reasoningTokens + b.reasoningTokens, - }; -} - export function totalTokens(totals: UsageTokenTotals): number { // reasoningTokens is a subset of outputTokens and must not be added again. return ( @@ -159,7 +153,7 @@ export function parseClaudeRecord(parsed: unknown): UsageRecord | null { reasoningTokens: 0, }, reportedCostUsd: typeof cost === "number" && Number.isFinite(cost) ? cost : null, - fast: usageRecord["speed"] === "fast", + speed: usageRecord["speed"] === "fast" ? "fast" : "standard", dedupeKey, }; } @@ -171,12 +165,14 @@ export function parseClaudeRecord(parsed: unknown): UsageRecord | null { /** * Rolling state for a single Codex rollout file. * - * Codex `token_count` events carry no model, so the model is carried forward - * from the most recent `turn_context`. Sessions that switch models mid-run - * attribute correctly from the switch onward. + * Codex `token_count` events carry no model or service tier, so both are + * carried forward: the model from the most recent `turn_context`, the tier from + * the most recent `thread_settings_applied`. Sessions that switch either + * mid-run attribute correctly from the switch onward. */ export interface CodexScanState { model: string; + speed: UsageSpeed; sessionId: string; lastUsageSignature: string | null; sawSessionMeta: boolean; @@ -188,6 +184,7 @@ export interface CodexScanState { export function initialCodexScanState(): CodexScanState { return { model: "", + speed: "standard", sessionId: "", lastUsageSignature: null, sawSessionMeta: false, @@ -265,6 +262,14 @@ export function parseCodexRecord(parsed: unknown, state: CodexScanState): UsageR return null; } + if (payloadType === "thread_settings_applied") { + const settings = payloadRecord["thread_settings"]; + if (typeof settings === "object" && settings !== null) { + state.speed = codexSpeed((settings as Record)["service_tier"]); + } + return null; + } + if (payloadType !== "token_count") return null; const info = payloadRecord["info"]; @@ -323,13 +328,24 @@ export function parseCodexRecord(parsed: unknown, state: CodexScanState): UsageR totals, // Codex does not report cost in the rollout. reportedCostUsd: null, - fast: false, + speed: state.speed, // Events surviving the fork-copy suppression above are unique to this // rollout, so they need no global dedup. dedupeKey: null, }; } +/** + * Maps a Codex `service_tier` to its billing speed. Codex omits the field when + * no tier was requested, which bills as standard, as do `default` and + * `standard`. `fast` is accepted as an alias of `priority`. + */ +function codexSpeed(serviceTier: unknown): UsageSpeed { + if (serviceTier === "priority" || serviceTier === "fast") return "fast"; + if (serviceTier === "ultrafast") return "ultrafast"; + return "standard"; +} + /* -------------------------------------------------------------------------- */ /* Grok Build */ /* -------------------------------------------------------------------------- */ @@ -457,7 +473,7 @@ export function parseGrokRecord(parsed: unknown): readonly UsageRecord[] { sessionId, totals: grokTotalsToUsage(topLevel), reportedCostUsd: grokCostTicksToUsd(topLevel.costUsdTicks), - fast: false, + speed: "standard", // No prompt id means we cannot tell two same-second updates apart. dedupeKey: promptId === null ? null : `${sessionId}:${promptId}:grok`, }, @@ -504,7 +520,7 @@ export function parseGrokRecord(parsed: unknown): readonly UsageRecord[] { sessionId, totals, reportedCostUsd, - fast: false, + speed: "standard", dedupeKey: promptId === null ? null : `${sessionId}:${promptId}:${entry.model}`, }); } diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index 1bba13cd4bd9..6b8920501281 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -392,8 +392,9 @@ it.effect.each([ }).pipe(Effect.scoped, Effect.provide(GitCaptureContractLayer)), ); -for (const blockedPhase of ["discovery", "probe", "retry"] as const) { - it.effect(`checkpoint recovery has one deadline including ${blockedPhase}`, () => +it.effect.each(["discovery", "probe", "retry"] as const)( + "checkpoint recovery has one deadline including %s", + (blockedPhase) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -478,8 +479,7 @@ for (const blockedPhase of ["discovery", "probe", "retry"] as const) { assert.isFalse(yield* driver.checkpoints.hasCheckpointRef({ cwd, checkpointRef })); assert.deepEqual(yield* fs.readFile(path.join(cwd, ".git", "index")), originalIndex); }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), - ); -} +); it.effect("checkpoint recovery preserves interruption and removes the private index", () => Effect.gen(function* () { @@ -548,116 +548,103 @@ it.effect("checkpoint capture does not rerun clean filters for unchanged indexed }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), ); -for (const nested of [false, true]) { - for (const indexState of [ - "sparse", - "flags", - "manual-skip", - "missing", - "non-cone-missing", - ] as const) { - it.effect( - `sparse checkpoint preserves two captures (nested=${nested}, index=${indexState})`, - () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const driver = yield* GitVcsDriver.makeVcsDriverShape(); - const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-sparse-" }); - const { git } = yield* makeCheckpointFixture(driver, cwd); - const write = Effect.fn(function* (name: string, contents: string) { - yield* fs.makeDirectory(path.dirname(path.join(cwd, name)), { recursive: true }); - yield* fs.writeFileString(path.join(cwd, name), contents); - }); - for (const name of [ - "scope/in/edit", - "scope/in/delete", - "scope/out/deep/absent", - "scope/out/present", - "elsewhere/file", - ]) { - yield* write(name, "original\n"); - } - yield* git(["add", "."]); - yield* git(["commit", "-m", "sparse fixture"]); - yield* git([ - "sparse-checkout", - "set", - "--cone", - "--sparse-index", - "scope/in", - "elsewhere", - ]); - if (indexState === "non-cone-missing") - yield* git(["sparse-checkout", "set", "--no-cone", "/scope/in/", "/elsewhere/"]); - yield* write("scope/in/edit", "staged\n"); - yield* write("elsewhere/file", "staged outside\n"); - yield* git(["add", "."]); - if (indexState === "flags") - yield* git(["update-index", "--assume-unchanged", "scope/in/delete"]); - if (indexState === "manual-skip") - yield* git(["update-index", "--skip-worktree", "scope/in/delete"]); - yield* git(["config", "sparse.expectFilesOutsideOfPatterns", "true"]); - yield* write("scope/in/edit", "working\n"); - yield* write("scope/out/present", "modified skipped\n"); - yield* write("scope/out/new file", "new outside cone\n"); - yield* write("elsewhere/file", "working outside\n"); - yield* fs.remove(path.join(cwd, "scope/in/delete")); - const indexPath = path.join(cwd, ".git/index"); - if (indexState.endsWith("missing")) yield* fs.remove(indexPath); - const originalIndex = yield* fs - .readFile(indexPath) - .pipe(Effect.orElseSucceed(() => null)); - const captureCwd = nested ? path.join(cwd, "scope") : cwd; - for (const turn of [1, 2]) { - const ref = CheckpointRef.make(`refs/t3/checkpoints/sparse/${turn}`); - if (turn === 2) { - yield* write("scope/in/edit", "second\n"); - yield* fs.remove(path.join(cwd, "scope/out/new file")); - yield* write("scope/out/second", "second addition\n"); - } - const capture = driver.checkpoints.captureCheckpoint({ - cwd: captureCwd, - checkpointRef: ref, - }); - if (indexState === "non-cone-missing") { - assert.strictEqual((yield* capture.pipe(Effect.flip))._tag, "VcsProcessExitError"); - assert.isFalse( - yield* driver.checkpoints.hasCheckpointRef({ cwd: captureCwd, checkpointRef: ref }), - ); - assert.isFalse(yield* fs.exists(indexPath)); - break; - } - yield* capture; - for (const [name, content] of [ - ["scope/out/deep/absent", "original\n"], - ["scope/out/present", "modified skipped\n"], - ["scope/in/edit", turn === 1 ? "working\n" : "second\n"], - ["elsewhere/file", nested ? "original\n" : "working outside\n"], - [ - turn === 1 ? "scope/out/new file" : "scope/out/second", - turn === 1 ? "new outside cone\n" : "second addition\n", - ], - ]) { - assert.strictEqual((yield* git(["show", `${ref}:${name}`])).stdout, content); - } - const files = (yield* git(["ls-tree", "-rz", "--name-only", ref])).stdout.split("\0"); - assert.notInclude(files, "scope/in/delete"); - if (turn === 2) assert.notInclude(files, "scope/out/new file"); - assert.deepEqual( - yield* fs.readFile(indexPath).pipe(Effect.orElseSucceed(() => null)), - originalIndex, - ); - assert.isFalse(yield* fs.exists(path.join(cwd, "scope/out/deep/absent"))); - assert.strictEqual( - yield* fs.readFileString(path.join(cwd, "elsewhere/file")), - "working outside\n", - ); - } - }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), - ); - } -} +it.effect.each( + [false, true].flatMap((nested) => + (["sparse", "flags", "manual-skip", "missing", "non-cone-missing"] as const).map( + (indexState) => ({ nested, indexState }), + ), + ), +)( + "sparse checkpoint preserves two captures (nested=$nested, index=$indexState)", + ({ nested, indexState }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-sparse-" }); + const { git } = yield* makeCheckpointFixture(driver, cwd); + const write = Effect.fn(function* (name: string, contents: string) { + yield* fs.makeDirectory(path.dirname(path.join(cwd, name)), { recursive: true }); + yield* fs.writeFileString(path.join(cwd, name), contents); + }); + for (const name of [ + "scope/in/edit", + "scope/in/delete", + "scope/out/deep/absent", + "scope/out/present", + "elsewhere/file", + ]) { + yield* write(name, "original\n"); + } + yield* git(["add", "."]); + yield* git(["commit", "-m", "sparse fixture"]); + yield* git(["sparse-checkout", "set", "--cone", "--sparse-index", "scope/in", "elsewhere"]); + if (indexState === "non-cone-missing") + yield* git(["sparse-checkout", "set", "--no-cone", "/scope/in/", "/elsewhere/"]); + yield* write("scope/in/edit", "staged\n"); + yield* write("elsewhere/file", "staged outside\n"); + yield* git(["add", "."]); + if (indexState === "flags") + yield* git(["update-index", "--assume-unchanged", "scope/in/delete"]); + if (indexState === "manual-skip") + yield* git(["update-index", "--skip-worktree", "scope/in/delete"]); + yield* git(["config", "sparse.expectFilesOutsideOfPatterns", "true"]); + yield* write("scope/in/edit", "working\n"); + yield* write("scope/out/present", "modified skipped\n"); + yield* write("scope/out/new file", "new outside cone\n"); + yield* write("elsewhere/file", "working outside\n"); + yield* fs.remove(path.join(cwd, "scope/in/delete")); + const indexPath = path.join(cwd, ".git/index"); + if (indexState.endsWith("missing")) yield* fs.remove(indexPath); + const originalIndex = yield* fs.readFile(indexPath).pipe(Effect.orElseSucceed(() => null)); + const captureCwd = nested ? path.join(cwd, "scope") : cwd; + for (const turn of [1, 2]) { + const ref = CheckpointRef.make(`refs/t3/checkpoints/sparse/${turn}`); + if (turn === 2) { + yield* write("scope/in/edit", "second\n"); + yield* fs.remove(path.join(cwd, "scope/out/new file")); + yield* write("scope/out/second", "second addition\n"); + } + const capture = driver.checkpoints.captureCheckpoint({ + cwd: captureCwd, + checkpointRef: ref, + }); + if (indexState === "non-cone-missing") { + assert.strictEqual((yield* capture.pipe(Effect.flip))._tag, "VcsProcessExitError"); + assert.isFalse( + yield* driver.checkpoints.hasCheckpointRef({ cwd: captureCwd, checkpointRef: ref }), + ); + assert.isFalse(yield* fs.exists(indexPath)); + break; + } + yield* capture; + for (const [name, content] of [ + ["scope/out/deep/absent", "original\n"], + ["scope/out/present", "modified skipped\n"], + ["scope/in/edit", turn === 1 ? "working\n" : "second\n"], + ["elsewhere/file", nested ? "original\n" : "working outside\n"], + [ + turn === 1 ? "scope/out/new file" : "scope/out/second", + turn === 1 ? "new outside cone\n" : "second addition\n", + ], + ]) { + assert.strictEqual((yield* git(["show", `${ref}:${name}`])).stdout, content); + } + const files = (yield* git(["ls-tree", "-rz", "--name-only", ref])).stdout.split("\0"); + assert.notInclude(files, "scope/in/delete"); + if (turn === 2) assert.notInclude(files, "scope/out/new file"); + assert.deepEqual( + yield* fs.readFile(indexPath).pipe(Effect.orElseSucceed(() => null)), + originalIndex, + ); + assert.isFalse(yield* fs.exists(path.join(cwd, "scope/out/deep/absent"))); + assert.strictEqual( + yield* fs.readFileString(path.join(cwd, "elsewhere/file")), + "working outside\n", + ); + } + }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), +); it.effect("checkpoint capture keeps the legacy path when Git lacks add --sparse", () => Effect.gen(function* () { @@ -699,206 +686,186 @@ it.effect("checkpoint capture keeps the legacy path when Git lacks add --sparse" }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), ); -for (const indexMode of ["normal", "flags", "sparse"] as const) { - it.effect( - `checkpoint index inspection handles entries beyond the output cap (index=${indexMode})`, - () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const liveProcess = yield* VcsProcess.VcsProcess; - const driver = yield* GitVcsDriver.makeVcsDriverShape(); - const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-inspection-" }); - const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); - yield* fs.writeFileString(path.join(cwd, ".gitattributes"), "stable filter=probe\n"); - yield* fs.writeFileString(path.join(cwd, "stable"), "unchanged\n"); - yield* fs.writeFileString(path.join(cwd, "z-skipped"), "original\n"); - yield* fs.makeDirectory(path.join(cwd, "excluded")); - yield* fs.writeFileString(path.join(cwd, "excluded/file"), "absent\n"); - yield* fs.writeFileString( - path.join(cwd, ".git/filter.cjs"), - 'require("node:fs").appendFileSync(".git/reads", "read\\n"); process.stdin.pipe(process.stdout);', - ); - yield* git(["config", "filter.probe.clean", "node .git/filter.cjs"]); - yield* fs.utimes(path.join(cwd, "stable"), 1_700_000_000, 1_700_000_000); - yield* git(["add", "."]); - yield* git(["commit", "-m", "inspection fixture"]); - if (indexMode === "flags") yield* git(["update-index", "--skip-worktree", "z-skipped"]); - if (indexMode === "sparse") - yield* git(["sparse-checkout", "set", "--cone", "--sparse-index", "included"]); - yield* fs.writeFileString(path.join(cwd, "z-skipped"), "modified\n"); - yield* fs.writeFileString(path.join(cwd, ".git/reads"), ""); - const originalIndex = yield* fs.readFile(path.join(cwd, ".git/index")); - const captureDriver = yield* GitVcsDriver.makeVcsDriverShape().pipe( - Effect.provideService(VcsProcess.VcsProcess, { - run: (input) => - liveProcess.run( - input.args.includes("ls-files") - ? { - ...input, - maxOutputBytes: 8, - onStdoutChunk: (chunk) => { - for (let i = 0; i < chunk.length; i++) - input.onStdoutChunk?.(chunk.subarray(i, i + 1)); - }, - } - : input, - ), - }), - ); - yield* captureDriver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); - assert.strictEqual( - (yield* git(["show", `${checkpointRef}:z-skipped`])).stdout, - "modified\n", - ); - if (indexMode !== "flags") - assert.strictEqual(yield* fs.readFileString(path.join(cwd, ".git/reads")), ""); - assert.deepEqual(yield* fs.readFile(path.join(cwd, ".git/index")), originalIndex); - }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), - ); -} +it.effect.each(["normal", "flags", "sparse"] as const)( + "checkpoint index inspection handles entries beyond the output cap (index=%s)", + (indexMode) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const liveProcess = yield* VcsProcess.VcsProcess; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-inspection-" }); + const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); + yield* fs.writeFileString(path.join(cwd, ".gitattributes"), "stable filter=probe\n"); + yield* fs.writeFileString(path.join(cwd, "stable"), "unchanged\n"); + yield* fs.writeFileString(path.join(cwd, "z-skipped"), "original\n"); + yield* fs.makeDirectory(path.join(cwd, "excluded")); + yield* fs.writeFileString(path.join(cwd, "excluded/file"), "absent\n"); + yield* fs.writeFileString( + path.join(cwd, ".git/filter.cjs"), + 'require("node:fs").appendFileSync(".git/reads", "read\\n"); process.stdin.pipe(process.stdout);', + ); + yield* git(["config", "filter.probe.clean", "node .git/filter.cjs"]); + yield* fs.utimes(path.join(cwd, "stable"), 1_700_000_000, 1_700_000_000); + yield* git(["add", "."]); + yield* git(["commit", "-m", "inspection fixture"]); + if (indexMode === "flags") yield* git(["update-index", "--skip-worktree", "z-skipped"]); + if (indexMode === "sparse") + yield* git(["sparse-checkout", "set", "--cone", "--sparse-index", "included"]); + yield* fs.writeFileString(path.join(cwd, "z-skipped"), "modified\n"); + yield* fs.writeFileString(path.join(cwd, ".git/reads"), ""); + const originalIndex = yield* fs.readFile(path.join(cwd, ".git/index")); + const captureDriver = yield* GitVcsDriver.makeVcsDriverShape().pipe( + Effect.provideService(VcsProcess.VcsProcess, { + run: (input) => + liveProcess.run( + input.args.includes("ls-files") + ? { + ...input, + maxOutputBytes: 8, + onStdoutChunk: (chunk) => { + for (let i = 0; i < chunk.length; i++) + input.onStdoutChunk?.(chunk.subarray(i, i + 1)); + }, + } + : input, + ), + }), + ); + yield* captureDriver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); + assert.strictEqual((yield* git(["show", `${checkpointRef}:z-skipped`])).stdout, "modified\n"); + if (indexMode !== "flags") + assert.strictEqual(yield* fs.readFileString(path.join(cwd, ".git/reads")), ""); + assert.deepEqual(yield* fs.readFile(path.join(cwd, ".git/index")), originalIndex); + }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), +); -for (const timestamp of [1_700_000_000, 1_700_000_000.9999]) { - it.effect( - `checkpoint capture preserves same-size edits with racy index timestamps (${timestamp})`, - () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const driver = yield* GitVcsDriver.makeVcsDriverShape(); - const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-racy-" }); - const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); - const filePath = path.join(cwd, "file.txt"); - const indexPath = path.join(cwd, ".git", "index"); - yield* git(["config", "core.trustctime", "false"]); - yield* fileSystem.writeFileString(filePath, "before\n"); - yield* fileSystem.utimes(filePath, timestamp, timestamp); - yield* git(["add", "file.txt"]); - yield* git(["commit", "-m", "record racy file"]); - yield* fileSystem.utimes(indexPath, timestamp, timestamp); - const originalIndex = yield* fileSystem.readFile(indexPath); - const originalIndexMtime = (yield* fileSystem.stat(indexPath)).mtime; - yield* fileSystem.writeFileString(filePath, "after!\n"); - yield* fileSystem.utimes(filePath, timestamp, timestamp); +it.effect.each([1_700_000_000, 1_700_000_000.9999])( + "checkpoint capture preserves same-size edits with racy index timestamps (%s)", + (timestamp) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-racy-" }); + const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); + const filePath = path.join(cwd, "file.txt"); + const indexPath = path.join(cwd, ".git", "index"); + yield* git(["config", "core.trustctime", "false"]); + yield* fileSystem.writeFileString(filePath, "before\n"); + yield* fileSystem.utimes(filePath, timestamp, timestamp); + yield* git(["add", "file.txt"]); + yield* git(["commit", "-m", "record racy file"]); + yield* fileSystem.utimes(indexPath, timestamp, timestamp); + const originalIndex = yield* fileSystem.readFile(indexPath); + const originalIndexMtime = (yield* fileSystem.stat(indexPath)).mtime; + yield* fileSystem.writeFileString(filePath, "after!\n"); + yield* fileSystem.utimes(filePath, timestamp, timestamp); - yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); + yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); - assert.strictEqual((yield* git(["show", `${checkpointRef}:file.txt`])).stdout, "after!\n"); - assert.deepEqual(yield* fileSystem.readFile(indexPath), originalIndex); - assert.deepEqual((yield* fileSystem.stat(indexPath)).mtime, originalIndexMtime); - }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), - ); -} -for (const nested of [false, true]) { - for (const indexState of [ - "sparse", - "flags", - "manual-skip", - "missing", - "non-cone-missing", - ] as const) { - it.effect( - `sparse checkpoint preserves two captures (nested=${nested}, index=${indexState})`, - () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const driver = yield* GitVcsDriver.makeVcsDriverShape(); - const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-sparse-" }); - const { git } = yield* makeCheckpointFixture(driver, cwd); - const write = Effect.fn(function* (name: string, contents: string) { - yield* fs.makeDirectory(path.dirname(path.join(cwd, name)), { recursive: true }); - yield* fs.writeFileString(path.join(cwd, name), contents); - }); - for (const name of [ - "scope/in/edit", - "scope/in/delete", - "scope/out/deep/absent", - "scope/out/present", - "elsewhere/file", - ]) { - yield* write(name, "original\n"); - } - yield* git(["add", "."]); - yield* git(["commit", "-m", "sparse fixture"]); - yield* git([ - "sparse-checkout", - "set", - "--cone", - "--sparse-index", - "scope/in", - "elsewhere", - ]); - if (indexState === "non-cone-missing") - yield* git(["sparse-checkout", "set", "--no-cone", "/scope/in/", "/elsewhere/"]); - yield* write("scope/in/edit", "staged\n"); - yield* write("elsewhere/file", "staged outside\n"); - yield* git(["add", "."]); - if (indexState === "flags") - yield* git(["update-index", "--assume-unchanged", "scope/in/delete"]); - if (indexState === "manual-skip") - yield* git(["update-index", "--skip-worktree", "scope/in/delete"]); - yield* git(["config", "sparse.expectFilesOutsideOfPatterns", "true"]); - yield* write("scope/in/edit", "working\n"); - yield* write("scope/out/present", "modified skipped\n"); - yield* write("scope/out/new file", "new outside cone\n"); - yield* write("elsewhere/file", "working outside\n"); - yield* fs.remove(path.join(cwd, "scope/in/delete")); - const indexPath = path.join(cwd, ".git/index"); - if (indexState.endsWith("missing")) yield* fs.remove(indexPath); - const originalIndex = yield* fs - .readFile(indexPath) - .pipe(Effect.orElseSucceed(() => null)); - const captureCwd = nested ? path.join(cwd, "scope") : cwd; - for (const turn of [1, 2]) { - const ref = CheckpointRef.make(`refs/t3/checkpoints/sparse/${turn}`); - if (turn === 2) { - yield* write("scope/in/edit", "second\n"); - yield* fs.remove(path.join(cwd, "scope/out/new file")); - yield* write("scope/out/second", "second addition\n"); - } - const capture = driver.checkpoints.captureCheckpoint({ - cwd: captureCwd, - checkpointRef: ref, - }); - if (indexState === "non-cone-missing") { - assert.strictEqual((yield* capture.pipe(Effect.flip))._tag, "VcsProcessExitError"); - assert.isFalse( - yield* driver.checkpoints.hasCheckpointRef({ cwd: captureCwd, checkpointRef: ref }), - ); - assert.isFalse(yield* fs.exists(indexPath)); - break; - } - yield* capture; - for (const [name, content] of [ - ["scope/out/deep/absent", "original\n"], - ["scope/out/present", "modified skipped\n"], - ["scope/in/edit", turn === 1 ? "working\n" : "second\n"], - ["elsewhere/file", nested ? "original\n" : "working outside\n"], - [ - turn === 1 ? "scope/out/new file" : "scope/out/second", - turn === 1 ? "new outside cone\n" : "second addition\n", - ], - ]) { - assert.strictEqual((yield* git(["show", `${ref}:${name}`])).stdout, content); - } - const files = (yield* git(["ls-tree", "-rz", "--name-only", ref])).stdout.split("\0"); - assert.notInclude(files, "scope/in/delete"); - if (turn === 2) assert.notInclude(files, "scope/out/new file"); - assert.deepEqual( - yield* fs.readFile(indexPath).pipe(Effect.orElseSucceed(() => null)), - originalIndex, - ); - assert.isFalse(yield* fs.exists(path.join(cwd, "scope/out/deep/absent"))); - assert.strictEqual( - yield* fs.readFileString(path.join(cwd, "elsewhere/file")), - "working outside\n", - ); - } - }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), - ); - } -} + assert.strictEqual((yield* git(["show", `${checkpointRef}:file.txt`])).stdout, "after!\n"); + assert.deepEqual(yield* fileSystem.readFile(indexPath), originalIndex); + assert.deepEqual((yield* fileSystem.stat(indexPath)).mtime, originalIndexMtime); + }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), +); +it.effect.each( + [false, true].flatMap((nested) => + (["sparse", "flags", "manual-skip", "missing", "non-cone-missing"] as const).map( + (indexState) => ({ nested, indexState }), + ), + ), +)( + "sparse checkpoint preserves two captures (nested=$nested, index=$indexState)", + ({ nested, indexState }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-sparse-" }); + const { git } = yield* makeCheckpointFixture(driver, cwd); + const write = Effect.fn(function* (name: string, contents: string) { + yield* fs.makeDirectory(path.dirname(path.join(cwd, name)), { recursive: true }); + yield* fs.writeFileString(path.join(cwd, name), contents); + }); + for (const name of [ + "scope/in/edit", + "scope/in/delete", + "scope/out/deep/absent", + "scope/out/present", + "elsewhere/file", + ]) { + yield* write(name, "original\n"); + } + yield* git(["add", "."]); + yield* git(["commit", "-m", "sparse fixture"]); + yield* git(["sparse-checkout", "set", "--cone", "--sparse-index", "scope/in", "elsewhere"]); + if (indexState === "non-cone-missing") + yield* git(["sparse-checkout", "set", "--no-cone", "/scope/in/", "/elsewhere/"]); + yield* write("scope/in/edit", "staged\n"); + yield* write("elsewhere/file", "staged outside\n"); + yield* git(["add", "."]); + if (indexState === "flags") + yield* git(["update-index", "--assume-unchanged", "scope/in/delete"]); + if (indexState === "manual-skip") + yield* git(["update-index", "--skip-worktree", "scope/in/delete"]); + yield* git(["config", "sparse.expectFilesOutsideOfPatterns", "true"]); + yield* write("scope/in/edit", "working\n"); + yield* write("scope/out/present", "modified skipped\n"); + yield* write("scope/out/new file", "new outside cone\n"); + yield* write("elsewhere/file", "working outside\n"); + yield* fs.remove(path.join(cwd, "scope/in/delete")); + const indexPath = path.join(cwd, ".git/index"); + if (indexState.endsWith("missing")) yield* fs.remove(indexPath); + const originalIndex = yield* fs.readFile(indexPath).pipe(Effect.orElseSucceed(() => null)); + const captureCwd = nested ? path.join(cwd, "scope") : cwd; + for (const turn of [1, 2]) { + const ref = CheckpointRef.make(`refs/t3/checkpoints/sparse/${turn}`); + if (turn === 2) { + yield* write("scope/in/edit", "second\n"); + yield* fs.remove(path.join(cwd, "scope/out/new file")); + yield* write("scope/out/second", "second addition\n"); + } + const capture = driver.checkpoints.captureCheckpoint({ + cwd: captureCwd, + checkpointRef: ref, + }); + if (indexState === "non-cone-missing") { + assert.strictEqual((yield* capture.pipe(Effect.flip))._tag, "VcsProcessExitError"); + assert.isFalse( + yield* driver.checkpoints.hasCheckpointRef({ cwd: captureCwd, checkpointRef: ref }), + ); + assert.isFalse(yield* fs.exists(indexPath)); + break; + } + yield* capture; + for (const [name, content] of [ + ["scope/out/deep/absent", "original\n"], + ["scope/out/present", "modified skipped\n"], + ["scope/in/edit", turn === 1 ? "working\n" : "second\n"], + ["elsewhere/file", nested ? "original\n" : "working outside\n"], + [ + turn === 1 ? "scope/out/new file" : "scope/out/second", + turn === 1 ? "new outside cone\n" : "second addition\n", + ], + ]) { + assert.strictEqual((yield* git(["show", `${ref}:${name}`])).stdout, content); + } + const files = (yield* git(["ls-tree", "-rz", "--name-only", ref])).stdout.split("\0"); + assert.notInclude(files, "scope/in/delete"); + if (turn === 2) assert.notInclude(files, "scope/out/new file"); + assert.deepEqual( + yield* fs.readFile(indexPath).pipe(Effect.orElseSucceed(() => null)), + originalIndex, + ); + assert.isFalse(yield* fs.exists(path.join(cwd, "scope/out/deep/absent"))); + assert.strictEqual( + yield* fs.readFileString(path.join(cwd, "elsewhere/file")), + "working outside\n", + ); + } + }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), +); it.effect("checkpoint capture preserves racy edits made after resetting the index", () => Effect.gen(function* () { @@ -943,89 +910,87 @@ it.effect("checkpoint capture preserves racy edits made after resetting the inde }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), ); -for (const nested of [false, true]) { - for (const indexMode of ["normal", "flags", "split"] as const) { - it.effect( - `checkpoint index reuse preserves two turns (nested=${nested}, index=${indexMode})`, - () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const driver = yield* GitVcsDriver.makeVcsDriverShape(); - const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-turns-" }); - const { git } = yield* makeCheckpointFixture(driver, cwd); - const write = (name: string, contents: string) => - fileSystem.writeFileString(path.join(cwd, name), contents); - yield* fileSystem.makeDirectory(path.join(cwd, "scope")); - for (const name of [ - "scope/staged", - "scope/deleted", - "scope/assumed", - "scope/skipped", - "outside", - ]) { - yield* write(name, "original\n"); - } - yield* git(["add", "."]); - yield* git(["commit", "-m", "initial scoped files"]); - yield* write("scope/staged", "staged\n"); - yield* write("scope/new-deleted", "staged then deleted\n"); - yield* write("outside", "staged outside\n"); - yield* git(["add", "."]); - if (indexMode === "flags") { - yield* git(["update-index", "--assume-unchanged", "scope/assumed"]); - yield* git(["update-index", "--skip-worktree", "scope/skipped"]); - } - if (indexMode === "split") { - yield* git(["update-index", "--split-index"]); - } - const originalIndex = yield* fileSystem.readFile(path.join(cwd, ".git", "index")); - for (const name of ["scope/staged", "scope/assumed", "scope/skipped", "outside"]) { - yield* write(name, "working\n"); - } - yield* write("scope/new", "first\n"); - yield* fileSystem.remove(path.join(cwd, "scope/deleted")); - yield* fileSystem.remove(path.join(cwd, "scope/new-deleted")); - const captureCwd = nested ? path.join(cwd, "scope") : cwd; - const first = CheckpointRef.make("refs/t3/checkpoints/turns/1"); - const second = CheckpointRef.make("refs/t3/checkpoints/turns/2"); - yield* driver.checkpoints.captureCheckpoint({ cwd: captureCwd, checkpointRef: first }); - for (const name of ["scope/staged", "scope/assumed", "scope/skipped"]) { - assert.strictEqual((yield* git(["show", `${first}:${name}`])).stdout, "working\n"); - } - assert.strictEqual( - (yield* git(["show", `${first}:outside`])).stdout, - nested ? "original\n" : "working\n", - ); - const files = (yield* git(["ls-tree", "-r", "--name-only", first])).stdout.split("\n"); - assert.notInclude(files, "scope/deleted"); - assert.notInclude(files, "scope/new-deleted"); - assert.include(files, "scope/new"); +it.effect.each( + [false, true].flatMap((nested) => + (["normal", "flags", "split"] as const).map((indexMode) => ({ nested, indexMode })), + ), +)( + "checkpoint index reuse preserves two turns (nested=$nested, index=$indexMode)", + ({ nested, indexMode }) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-turns-" }); + const { git } = yield* makeCheckpointFixture(driver, cwd); + const write = (name: string, contents: string) => + fileSystem.writeFileString(path.join(cwd, name), contents); + yield* fileSystem.makeDirectory(path.join(cwd, "scope")); + for (const name of [ + "scope/staged", + "scope/deleted", + "scope/assumed", + "scope/skipped", + "outside", + ]) { + yield* write(name, "original\n"); + } + yield* git(["add", "."]); + yield* git(["commit", "-m", "initial scoped files"]); + yield* write("scope/staged", "staged\n"); + yield* write("scope/new-deleted", "staged then deleted\n"); + yield* write("outside", "staged outside\n"); + yield* git(["add", "."]); + if (indexMode === "flags") { + yield* git(["update-index", "--assume-unchanged", "scope/assumed"]); + yield* git(["update-index", "--skip-worktree", "scope/skipped"]); + } + if (indexMode === "split") { + yield* git(["update-index", "--split-index"]); + } + const originalIndex = yield* fileSystem.readFile(path.join(cwd, ".git", "index")); + for (const name of ["scope/staged", "scope/assumed", "scope/skipped", "outside"]) { + yield* write(name, "working\n"); + } + yield* write("scope/new", "first\n"); + yield* fileSystem.remove(path.join(cwd, "scope/deleted")); + yield* fileSystem.remove(path.join(cwd, "scope/new-deleted")); + const captureCwd = nested ? path.join(cwd, "scope") : cwd; + const first = CheckpointRef.make("refs/t3/checkpoints/turns/1"); + const second = CheckpointRef.make("refs/t3/checkpoints/turns/2"); + yield* driver.checkpoints.captureCheckpoint({ cwd: captureCwd, checkpointRef: first }); + for (const name of ["scope/staged", "scope/assumed", "scope/skipped"]) { + assert.strictEqual((yield* git(["show", `${first}:${name}`])).stdout, "working\n"); + } + assert.strictEqual( + (yield* git(["show", `${first}:outside`])).stdout, + nested ? "original\n" : "working\n", + ); + const files = (yield* git(["ls-tree", "-r", "--name-only", first])).stdout.split("\n"); + assert.notInclude(files, "scope/deleted"); + assert.notInclude(files, "scope/new-deleted"); + assert.include(files, "scope/new"); - yield* write("scope/staged", "second\n"); - yield* fileSystem.remove(path.join(cwd, "scope/new")); - yield* write("scope/second", "added in second turn\n"); - yield* driver.checkpoints.captureCheckpoint({ cwd: captureCwd, checkpointRef: second }); - assert.strictEqual( - (yield* git(["diff", "--name-only", first, second])).stdout, - "scope/new\nscope/second\nscope/staged\n", - ); - assert.strictEqual((yield* git(["show", `${second}:scope/staged`])).stdout, "second\n"); - assert.strictEqual( - (yield* git(["show", `${second}:scope/second`])).stdout, - "added in second turn\n", - ); - assert.deepEqual( - yield* fileSystem.readFile(path.join(cwd, ".git", "index")), - originalIndex, - ); - }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), - ); - } -} + yield* write("scope/staged", "second\n"); + yield* fileSystem.remove(path.join(cwd, "scope/new")); + yield* write("scope/second", "added in second turn\n"); + yield* driver.checkpoints.captureCheckpoint({ cwd: captureCwd, checkpointRef: second }); + assert.strictEqual( + (yield* git(["diff", "--name-only", first, second])).stdout, + "scope/new\nscope/second\nscope/staged\n", + ); + assert.strictEqual((yield* git(["show", `${second}:scope/staged`])).stdout, "second\n"); + assert.strictEqual( + (yield* git(["show", `${second}:scope/second`])).stdout, + "added in second turn\n", + ); + assert.deepEqual(yield* fileSystem.readFile(path.join(cwd, ".git", "index")), originalIndex); + }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), +); -for (const indexState of ["missing", "invalid"] as const) { - it.effect(`checkpoint capture falls back when the user index is ${indexState}`, () => +it.effect.each(["missing", "invalid"] as const)( + "checkpoint capture falls back when the user index is %s", + (indexState) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -1048,8 +1013,7 @@ for (const indexState of ["missing", "invalid"] as const) { assert.strictEqual(yield* fileSystem.readFileString(indexPath), "invalid index"); } }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), - ); -} +); it.effect("restores empty checkpoints without changing paths outside the workspace", () => Effect.gen(function* () { diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index d7e511afa8c9..99220a8bf78f 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -76,6 +76,7 @@ export interface GitStatusDetails { upstreamRef: string | null; hasWorkingTreeChanges: boolean; workingTree: VcsStatusResult["workingTree"]; + branchChanges?: VcsStatusResult["branchChanges"]; hasUpstream: boolean; aheadCount: number; behindCount: number; @@ -85,6 +86,8 @@ export interface GitStatusDetails { export interface GitLocalStatusOptions { /** Skip revision walks and return zero divergence counts for local-only consumers. */ readonly includeDivergence?: boolean; + /** Also read the diff panel's Changes totals. Failures leave them out. */ + readonly includeBranchChanges?: boolean; } export interface GitRemoteStatusDetails { diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 53a18551aaf0..fec77d110dbb 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -26,6 +26,7 @@ import { GitCommandError, ReviewDiffPreviewInput, type ReviewDiffFileContentsInput, + type ReviewDiffPreviewResult, type WorktreeSubmodules, } from "@t3tools/contracts"; import * as ServerConfig from "../config.ts"; @@ -254,50 +255,48 @@ it.effect.each([{ timeoutMs: null }, { timeoutMs: 30_001 }])( }).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), ); -for (const location of ["root", "nested", "worktree"] as const) { - it.effect( - `skips clean filters while the ${location} index is locked and resumes after unlock`, - () => - Effect.gen(function* () { - const driver = yield* GitVcsDriver.GitVcsDriver; - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const repository = yield* makeTmpDir(); - yield* initRepoWithCommit(repository); - const cwd = location === "worktree" ? yield* makeTmpDir() : repository; - if (location === "worktree") { - yield* git(repository, ["worktree", "add", "--detach", cwd]); - } - yield* git(cwd, ["config", "filter.probe.clean", "echo clean >> .filter-runs; cat"]); - yield* writeTextFile(cwd, ".gitattributes", "asset.bin filter=probe\n"); - yield* writeTextFile(cwd, ".gitignore", ".filter-runs\n"); - yield* writeTextFile(cwd, "asset.bin", "original\n"); - yield* git(cwd, ["add", "."]); - yield* git(cwd, ["commit", "-m", "filtered asset"]); - NodeFS.utimesSync(path.join(cwd, "asset.bin"), 1, 1); - const runsPath = path.join(cwd, ".filter-runs"); - yield* fs.remove(runsPath, { force: true }); - const indexPath = yield* git(cwd, ["rev-parse", "--git-path", "index"]); - const lockPath = `${path.resolve(cwd, indexPath)}.lock`; - yield* fs.writeFileString(lockPath, ""); - const statusCwd = location === "nested" ? path.join(cwd, "nested") : cwd; - yield* fs.makeDirectory(statusCwd, { recursive: true }); - - for (let poll = 0; poll < 3; poll++) { - const result = yield* driver.statusDetailsLocal(statusCwd).pipe(Effect.result); - assert.isTrue(Result.isFailure(result)); - if (Result.isFailure(result)) assert.include(result.failure.detail, "index is locked"); - } - assert.isFalse(yield* fs.exists(runsPath)); - assert.isTrue(yield* fs.exists(lockPath)); - - yield* fs.remove(lockPath); - const status = yield* driver.statusDetailsLocal(statusCwd); - assert.isFalse(status.hasWorkingTreeChanges); - assert.include(yield* fs.readFileString(runsPath), "clean"); - }).pipe(Effect.provide(TestLayer)), - ); -} +it.effect.each(["root", "nested", "worktree"] as const)( + "skips clean filters while the %s index is locked and resumes after unlock", + (location) => + Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const repository = yield* makeTmpDir(); + yield* initRepoWithCommit(repository); + const cwd = location === "worktree" ? yield* makeTmpDir() : repository; + if (location === "worktree") { + yield* git(repository, ["worktree", "add", "--detach", cwd]); + } + yield* git(cwd, ["config", "filter.probe.clean", "echo clean >> .filter-runs; cat"]); + yield* writeTextFile(cwd, ".gitattributes", "asset.bin filter=probe\n"); + yield* writeTextFile(cwd, ".gitignore", ".filter-runs\n"); + yield* writeTextFile(cwd, "asset.bin", "original\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "filtered asset"]); + NodeFS.utimesSync(path.join(cwd, "asset.bin"), 1, 1); + const runsPath = path.join(cwd, ".filter-runs"); + yield* fs.remove(runsPath, { force: true }); + const indexPath = yield* git(cwd, ["rev-parse", "--git-path", "index"]); + const lockPath = `${path.resolve(cwd, indexPath)}.lock`; + yield* fs.writeFileString(lockPath, ""); + const statusCwd = location === "nested" ? path.join(cwd, "nested") : cwd; + yield* fs.makeDirectory(statusCwd, { recursive: true }); + + for (let poll = 0; poll < 3; poll++) { + const result = yield* driver.statusDetailsLocal(statusCwd).pipe(Effect.result); + assert.isTrue(Result.isFailure(result)); + if (Result.isFailure(result)) assert.include(result.failure.detail, "index is locked"); + } + assert.isFalse(yield* fs.exists(runsPath)); + assert.isTrue(yield* fs.exists(lockPath)); + + yield* fs.remove(lockPath); + const status = yield* driver.statusDetailsLocal(statusCwd); + assert.isFalse(status.hasWorkingTreeChanges); + assert.include(yield* fs.readFileString(runsPath), "clean"); + }).pipe(Effect.provide(TestLayer)), +); it.effect("uses stable diagnostics for every parsed non-repository command", () => { const commands: Array<{ readonly args: ReadonlyArray; readonly lcAll?: string }> = []; @@ -822,7 +821,7 @@ it.effect("backs off and logs failed fetch attempts across linked worktrees", () ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), ); -for (const scenario of [ +it.effect.each([ { name: "HTTPS credentials", stderr: "fatal: Authentication failed for", @@ -885,53 +884,51 @@ for (const scenario of [ stderr: "fatal: unexpected remote failure", expected: "git fetch origin failed", }, -] as const) { - it.effect(`reports ${scenario.name} during fetch without retaining remote output`, () => - Effect.gen(function* () { - const secret = "secret-fetch-token"; - const stderr = `${scenario.stderr}\nhttps://user:${secret}@example.com/private?token=${secret}`; - const attempts = yield* Ref.make(0); - const spawner = ChildProcessSpawner.make((command) => - Effect.gen(function* () { - if (!ChildProcess.isStandardCommand(command)) - return yield* Effect.die("expected Git command"); - if (command.args[0] !== "fetch") return makeNonRepositoryHandle(); - assert.deepEqual(command.args, ["fetch", "--quiet", "origin"]); - assert.equal(command.options.env?.LC_ALL, "C"); - assert.equal(command.options.env?.GIT_TERMINAL_PROMPT, "0"); - yield* Ref.update(attempts, (count) => count + 1); - return ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(1), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(128)), - isRunning: Effect.succeed(false), - kill: () => Effect.void, - unref: Effect.succeed(Effect.void), - stdin: Sink.drain, - stdout: Stream.encodeText(Stream.make(secret)), - stderr: Stream.encodeText(Stream.make(stderr)), - all: Stream.empty, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }); - }), - ); - const driver = yield* makeGitVcsDriverCore().pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ); - const cwd = yield* makeTmpDir(); - const error = yield* driver.fetchRemote({ cwd, remoteName: "origin" }).pipe(Effect.flip); - assert.include(error.detail, scenario.expected); - assert.equal(error.exitCode, 128); - assert.equal(error.stderrLength, stderr.length); - assert.equal(error.stdoutLength, secret.length); - assert.notInclude(error.message, secret); - assert.notInclude(yield* encodeGitCommandError(error), secret); - assert.notProperty(error, "stderr"); - assert.notProperty(error, "args"); - assert.equal(yield* Ref.get(attempts), 1); - }).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), - ); -} +] as const)("reports $name during fetch without retaining remote output", (scenario) => + Effect.gen(function* () { + const secret = "secret-fetch-token"; + const stderr = `${scenario.stderr}\nhttps://user:${secret}@example.com/private?token=${secret}`; + const attempts = yield* Ref.make(0); + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (!ChildProcess.isStandardCommand(command)) + return yield* Effect.die("expected Git command"); + if (command.args[0] !== "fetch") return makeNonRepositoryHandle(); + assert.deepEqual(command.args, ["fetch", "--quiet", "origin"]); + assert.equal(command.options.env?.LC_ALL, "C"); + assert.equal(command.options.env?.GIT_TERMINAL_PROMPT, "0"); + yield* Ref.update(attempts, (count) => count + 1); + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(128)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.encodeText(Stream.make(secret)), + stderr: Stream.encodeText(Stream.make(stderr)), + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const cwd = yield* makeTmpDir(); + const error = yield* driver.fetchRemote({ cwd, remoteName: "origin" }).pipe(Effect.flip); + assert.include(error.detail, scenario.expected); + assert.equal(error.exitCode, 128); + assert.equal(error.stderrLength, stderr.length); + assert.equal(error.stdoutLength, secret.length); + assert.notInclude(error.message, secret); + assert.notInclude(yield* encodeGitCommandError(error), secret); + assert.notProperty(error, "stderr"); + assert.notProperty(error, "args"); + assert.equal(yield* Ref.get(attempts), 1); + }).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), +); it.layer(TestLayer)("GitVcsDriver core integration", (it) => { describe("process environment", () => { @@ -1132,6 +1129,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { Effect.gen(function* () { const cwd = yield* makeTmpDir(); const { initialBranch } = yield* initRepoWithCommit(cwd); + const mergeBase = yield* git(cwd, ["rev-parse", "HEAD"]); yield* writeTextFile(cwd, "untracked.txt", "untracked content\n"); const paths = Array.from({ length: 5000 }, (_, index) => `${"a".repeat(220)}-${index}.txt`); const stats = paths.map((path) => `1\t0\t${path}\0`).join(""); @@ -1142,10 +1140,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; const spawner = ChildProcessSpawner.make((command) => { if (ChildProcess.isStandardCommand(command)) { - if ( - command.args.includes("--numstat") && - command.args.includes(`${initialBranch}...HEAD`) - ) { + if (command.args.includes("--numstat") && command.args.includes(mergeBase)) { return Effect.succeed(makeSuccessfulHandle(stats)); } if (command.args.includes("ls-files") && command.args.includes("--others")) { @@ -1449,8 +1444,9 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); - for (const splitIndex of [false, true]) { - it.effect(`keeps the preceding second cached in review previews (split: ${splitIndex})`, () => + it.effect.each([false, true])( + "keeps the preceding second cached in review previews (split: %s)", + (splitIndex) => Effect.gen(function* () { const cwd = yield* makeTmpDir(); yield* initRepoWithCommit(cwd); @@ -1484,53 +1480,50 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { assert.deepStrictEqual(yield* fs.readFile(indexPath), originalIndex); assert.deepStrictEqual((yield* fs.stat(indexPath)).mtime, originalMtime); }), - ); - } + ); - for (const [timestamp, splitIndex] of [ + it.effect.each([ [1_700_000_000, false], [1_700_000_000.9999, false], [1_700_000_000, true], [1_700_000_000.9999, true], - ] as const) { - it.effect( - `preserves same-size edits with a racy review index (${timestamp}, split: ${splitIndex})`, - () => - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - yield* initRepoWithCommit(cwd); - const driver = yield* GitVcsDriver.GitVcsDriver; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const filePath = path.join(cwd, "tracked.txt"); - const indexPath = path.join(cwd, ".git", "index"); - // Reproduce a same-timestamp edit without relying on filesystem clock resolution. - yield* git(cwd, ["config", "core.trustctime", "false"]); - yield* writeTextFile(cwd, "tracked.txt", "before\n"); - yield* fileSystem.utimes(filePath, timestamp, timestamp); - yield* git(cwd, ["add", "tracked.txt"]); - yield* git(cwd, ["commit", "-m", "record racy file"]); - if (splitIndex) yield* git(cwd, ["update-index", "--split-index"]); - yield* fileSystem.utimes(indexPath, timestamp, timestamp); - const originalIndex = yield* fileSystem.readFile(indexPath); - const originalIndexMtime = (yield* fileSystem.stat(indexPath)).mtime; - yield* writeTextFile(cwd, "tracked.txt", "after!\n"); - yield* fileSystem.utimes(filePath, timestamp, timestamp); - yield* writeTextFile(cwd, "untracked.txt", "new\n"); - - const preview = yield* driver.getReviewDiffPreview({ cwd }); - const dirty = preview.sources.find((source) => source.kind === "working-tree")!; - assert.deepStrictEqual(dirty.files, [ - { path: "tracked.txt", previousPath: null, additions: 1, deletions: 1 }, - { path: "untracked.txt", previousPath: null, additions: 1, deletions: 0 }, - ]); - assert.include(dirty.diff, "-before"); - assert.include(dirty.diff, "+after!"); - assert.deepStrictEqual(yield* fileSystem.readFile(indexPath), originalIndex); - assert.deepStrictEqual((yield* fileSystem.stat(indexPath)).mtime, originalIndexMtime); - }), - ); - } + ] as const)( + "preserves same-size edits with a racy review index (%s, split: %s)", + ([timestamp, splitIndex]) => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const filePath = path.join(cwd, "tracked.txt"); + const indexPath = path.join(cwd, ".git", "index"); + // Reproduce a same-timestamp edit without relying on filesystem clock resolution. + yield* git(cwd, ["config", "core.trustctime", "false"]); + yield* writeTextFile(cwd, "tracked.txt", "before\n"); + yield* fileSystem.utimes(filePath, timestamp, timestamp); + yield* git(cwd, ["add", "tracked.txt"]); + yield* git(cwd, ["commit", "-m", "record racy file"]); + if (splitIndex) yield* git(cwd, ["update-index", "--split-index"]); + yield* fileSystem.utimes(indexPath, timestamp, timestamp); + const originalIndex = yield* fileSystem.readFile(indexPath); + const originalIndexMtime = (yield* fileSystem.stat(indexPath)).mtime; + yield* writeTextFile(cwd, "tracked.txt", "after!\n"); + yield* fileSystem.utimes(filePath, timestamp, timestamp); + yield* writeTextFile(cwd, "untracked.txt", "new\n"); + + const preview = yield* driver.getReviewDiffPreview({ cwd }); + const dirty = preview.sources.find((source) => source.kind === "working-tree")!; + assert.deepStrictEqual(dirty.files, [ + { path: "tracked.txt", previousPath: null, additions: 1, deletions: 1 }, + { path: "untracked.txt", previousPath: null, additions: 1, deletions: 0 }, + ]); + assert.include(dirty.diff, "-before"); + assert.include(dirty.diff, "+after!"); + assert.deepStrictEqual(yield* fileSystem.readFile(indexPath), originalIndex); + assert.deepStrictEqual((yield* fileSystem.stat(indexPath)).mtime, originalIndexMtime); + }), + ); it.effect("keeps complete stats for files beyond the combined patch limit", () => Effect.gen(function* () { @@ -1572,6 +1565,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { assert.deepStrictEqual(branch.files, [ { path: "a-large.txt", previousPath: null, additions: 4000, deletions: 0 }, + { path: "untracked.txt", previousPath: null, additions: 4000, deletions: 0 }, { path: "z-last.txt", previousPath: null, additions: 1, deletions: 0 }, ]); assert.deepStrictEqual(dirty.files, [ @@ -1746,7 +1740,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); - it.effect("loads merge-base and head contents for branch diff expansion", () => + it.effect("loads merge-base and disk contents for Changes expansion", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); const { initialBranch } = yield* initRepoWithCommit(cwd); @@ -1755,6 +1749,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { yield* writeTextFile(cwd, "README.md", "# branch change\nunchanged context\n"); yield* git(cwd, ["add", "README.md"]); yield* git(cwd, ["commit", "-m", "change readme"]); + yield* writeTextFile(cwd, "README.md", "# dirty change\nunchanged context\n"); const contents = yield* driver.getReviewDiffFileContents( makeReviewDiffFileContentsInput(cwd, { @@ -1765,12 +1760,184 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { ); assert.strictEqual(contents.oldContents, "# test\n"); - assert.strictEqual(contents.newContents, "# branch change\nunchanged context\n"); + assert.strictEqual(contents.newContents, "# dirty change\nunchanged context\n"); + }), + ); + + it.effect("Changes combines commits, uncommitted edits, and untracked files", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + const sourceFiles = ( + preview: ReviewDiffPreviewResult, + kind: "working-tree" | "branch-range", + ) => preview.sources.find((source) => source.kind === kind)!.files; + yield* git(cwd, ["checkout", "-b", "feature/combined"]); + yield* writeTextFile(cwd, "README.md", "# test\ncommitted\n"); + yield* git(cwd, ["commit", "-am", "commit edit"]); + + // A fully committed branch has no uncommitted work, but Changes still shows it. + const clean = yield* driver.getReviewDiffPreview({ cwd, baseRef: initialBranch }); + assert.deepStrictEqual(sourceFiles(clean, "working-tree"), []); + assert.deepStrictEqual(sourceFiles(clean, "branch-range"), [ + { path: "README.md", previousPath: null, additions: 1, deletions: 0 }, + ]); + + yield* writeTextFile(cwd, "README.md", "# test\ncommitted\ndirty\n"); + yield* writeTextFile(cwd, "new.txt", "new\n"); + const dirty = yield* driver.getReviewDiffPreview({ cwd, baseRef: initialBranch }); + const changes = dirty.sources.find((source) => source.kind === "branch-range")!; + assert.deepStrictEqual(changes.files, [ + { path: "README.md", previousPath: null, additions: 2, deletions: 0 }, + { path: "new.txt", previousPath: null, additions: 1, deletions: 0 }, + ]); + assert.strictEqual(changes.diff.match(/^diff --git a\/README\.md /gm)?.length, 1); + assert.include(changes.diff, "+dirty"); + assert.deepStrictEqual(sourceFiles(dirty, "working-tree"), [ + { path: "README.md", previousPath: null, additions: 1, deletions: 0 }, + { path: "new.txt", previousPath: null, additions: 1, deletions: 0 }, + ]); + }), + ); + + it.effect("Changes on the default branch compares with its remote copy", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["branch", "-M", "main"]); + yield* writeTextFile(cwd, "unpushed.txt", "unpushed\n"); + yield* git(cwd, ["add", "unpushed.txt"]); + yield* git(cwd, ["commit", "-m", "unpushed"]); + + // Without a remote there is no base, so Changes equals Uncommitted. + const local = yield* driver.getReviewDiffPreview({ cwd }); + const localChanges = local.sources.find((source) => source.kind === "branch-range")!; + assert.isNull(localChanges.baseRef); + assert.deepStrictEqual(localChanges.files, []); + + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "origin", "HEAD~1:refs/heads/main"]); + yield* git(cwd, ["fetch", "origin"]); + const preview = yield* driver.getReviewDiffPreview({ cwd }); + const changes = preview.sources.find((source) => source.kind === "branch-range")!; + assert.strictEqual(changes.baseRef, "origin/main"); + assert.deepStrictEqual(changes.files, [ + { path: "unpushed.txt", previousPath: null, additions: 1, deletions: 0 }, + ]); + }), + ); + + it.effect("Changes accepts an explicit base on a detached HEAD and rejects a bad one", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["checkout", "--detach"]); + yield* writeTextFile(cwd, "detached.txt", "detached\n"); + yield* git(cwd, ["add", "detached.txt"]); + yield* git(cwd, ["commit", "-m", "detached work"]); + + const explicit = yield* driver.getReviewDiffPreview({ cwd, baseRef: initialBranch }); + assert.deepStrictEqual( + explicit.sources.find((source) => source.kind === "branch-range")!.files, + [{ path: "detached.txt", previousPath: null, additions: 1, deletions: 0 }], + ); + const implicit = yield* driver.getReviewDiffPreview({ cwd }); + assert.deepStrictEqual( + implicit.sources.find((source) => source.kind === "branch-range")!.files, + [], + ); + const error = yield* driver + .getReviewDiffPreview({ cwd, baseRef: "missing-base" }) + .pipe(Effect.flip); + assert.strictEqual(error.operation, "GitVcsDriver.resolveReviewMergeBase"); + }), + ); + + it.effect("Changes compares with the empty tree before the first commit", () => + Effect.gen(function* () { + const upstream = yield* makeTmpDir(); + yield* initRepoWithCommit(upstream); + yield* git(upstream, ["branch", "-M", "main"]); + const cwd = yield* makeTmpDir(); + const driver = yield* GitVcsDriver.GitVcsDriver; + // An unborn main whose remote copy exists must not fail on merge-base. + yield* git(cwd, ["init", "-b", "main"]); + yield* git(cwd, ["remote", "add", "origin", upstream]); + yield* git(cwd, ["fetch", "origin"]); + yield* writeTextFile(cwd, "new.txt", "one\ntwo\n"); + + const preview = yield* driver.getReviewDiffPreview({ cwd }); + const changes = preview.sources.find((source) => source.kind === "branch-range")!; + assert.isNull(changes.baseRef); + assert.deepStrictEqual(changes.files, [ + { path: "new.txt", previousPath: null, additions: 2, deletions: 0 }, + ]); + const status = yield* driver.statusDetailsLocal(cwd, { includeBranchChanges: true }); + assert.deepStrictEqual(status.branchChanges, { + baseRef: null, + insertions: 2, + deletions: 0, + }); + }), + ); + + it.effect("Changes does not show newer base commits as deletions", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["checkout", "-b", "feature/rebased"]); + yield* writeTextFile(cwd, "feature.txt", "feature\n"); + yield* git(cwd, ["add", "feature.txt"]); + yield* git(cwd, ["commit", "-m", "feature work"]); + yield* git(cwd, ["checkout", initialBranch]); + yield* writeTextFile(cwd, "upstream.txt", "upstream\n"); + yield* git(cwd, ["add", "upstream.txt"]); + yield* git(cwd, ["commit", "-m", "base moves"]); + yield* git(cwd, ["checkout", "feature/rebased"]); + + for (const rebase of [false, true]) { + if (rebase) yield* git(cwd, ["rebase", initialBranch]); + const preview = yield* driver.getReviewDiffPreview({ cwd, baseRef: initialBranch }); + assert.deepStrictEqual( + preview.sources.find((source) => source.kind === "branch-range")!.files, + [{ path: "feature.txt", previousPath: null, additions: 1, deletions: 0 }], + ); + } }), ); }); describe("repository status", () => { + it.effect("reads Changes totals with untracked files when requested", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + const pathService = yield* Path.Path; + yield* git(cwd, ["branch", "-M", "main"]); + yield* git(cwd, ["checkout", "-b", "feature/totals"]); + yield* writeTextFile(cwd, "README.md", "# test\ncommitted\n"); + yield* git(cwd, ["commit", "-am", "commit edit"]); + yield* writeTextFile(cwd, "nested/untracked.txt", "one\ntwo\n"); + + const status = yield* driver.statusDetailsLocal(pathService.join(cwd, "nested"), { + includeBranchChanges: true, + }); + assert.deepStrictEqual(status.branchChanges, { + baseRef: "main", + insertions: 3, + deletions: 0, + }); + assert.isUndefined((yield* driver.statusDetailsLocal(cwd)).branchChanges); + }), + ); + it.effect("reports non-repository directories without failing", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -2940,8 +3107,9 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }); describe("remote operations", () => { - for (const failure of ["offline", "auth", "timeout"] as const) { - it.effect(`does not retry a scoped fetch after ${failure}`, () => + it.effect.each(["offline", "auth", "timeout"] as const)( + "does not retry a scoped fetch after %s", + (failure) => Effect.gen(function* () { const cwd = yield* makeTmpDir(); const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -2996,8 +3164,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { ); } }), - ); - } + ); it.effect("creates a worktree from the latest fetched remote commit", () => Effect.gen(function* () { diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 61c84424219b..971d0e7a9a95 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -62,6 +62,16 @@ const REVIEW_DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024; // prefixes. A repository or global diff.noprefix or diff.mnemonicPrefix would // otherwise leak into the patch and leave every parsed file unnamed. export const PATCH_RENDER_PREFIX_ARGS = ["--src-prefix=a/", "--dst-prefix=b/"] as const; +// Shared by review previews and status totals, so the Changes row matches the Changes view. +const REVIEW_DIFF_ARGS = [ + "diff", + "--find-renames", + "--no-color", + "--no-ext-diff", + "--no-textconv", + "--minimal", + ...PATCH_RENDER_PREFIX_ARGS, +]; const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); @@ -1518,9 +1528,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return remoteName; }); + // `allowRemoteOfCurrent` lets the review diff compare the default branch with its remote copy. const resolveBaseBranchForNoUpstream = Effect.fn("resolveBaseBranchForNoUpstream")(function* ( cwd: string, refName: string, + options?: { readonly allowRemoteOfCurrent?: boolean }, ) { const configuredBaseBranch = yield* runGitStdout( "GitVcsDriver.resolveBaseBranchForNoUpstream.config", @@ -1552,7 +1564,21 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* : remotePrefix && candidate.startsWith(remotePrefix) ? candidate.slice(remotePrefix.length) : candidate; - if (normalizedCandidate.length === 0 || normalizedCandidate === refName) { + if (normalizedCandidate.length === 0) { + continue; + } + if (normalizedCandidate === refName) { + if ( + options?.allowRemoteOfCurrent && + primaryRemoteName && + (yield* remoteBranchExists({ + cwd, + remoteName: primaryRemoteName, + refName: normalizedCandidate, + })) + ) { + return `${primaryRemoteName}/${normalizedCandidate}`; + } continue; } @@ -1923,6 +1949,12 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } files.sort((a, b) => a.path.localeCompare(b.path)); + const branchChanges = options?.includeBranchChanges + ? yield* readBranchChangeTotals(repositoryPaths?.worktreeRoot ?? cwd, refName).pipe( + Effect.orElseSucceed(() => undefined), + ) + : undefined; + return { isRepo: true, hasOriginRemote: hasPrimaryRemote, @@ -1935,6 +1967,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* insertions, deletions, }, + ...(branchChanges ? { branchChanges } : {}), hasUpstream: upstreamRef !== null, aheadCount, behindCount, @@ -2413,6 +2446,145 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return env; }); + // Before the first commit, review diffs compare with the empty tree instead of HEAD. + const readEmptyTreeHash = Effect.fn("readEmptyTreeHash")(function* (cwd: string) { + const stdout = yield* runGitStdout("GitVcsDriver.review.emptyTree", cwd, [ + "hash-object", + "-t", + "tree", + (yield* HostProcessPlatform) === "win32" ? "NUL" : "/dev/null", + ]); + return stdout.trim(); + }); + + // Lists untracked files and adds them to a temporary index, so a diff against any commit + // shows them as new. Returns null when the list is too big to read. Needs a Scope. + const prepareUntrackedReviewIndex = Effect.fn("prepareUntrackedReviewIndex")(function* ( + cwd: string, + pathArgs: ReadonlyArray, + onlyPath?: string, + ) { + const untracked = yield* executeGit( + "GitVcsDriver.review.listUntracked", + cwd, + ["ls-files", "--others", "--exclude-standard", "-z", "--", ...pathArgs], + { maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES }, + ).pipe( + Effect.catchIf( + (error) => error.outputLength === undefined, + () => Effect.succeed(null), + ), + ); + if (untracked === null) return null; + const paths = splitNullSeparatedGitStdoutPaths(untracked).filter( + (candidate) => onlyPath === undefined || candidate === onlyPath, + ); + if (paths.length === 0) return { env: undefined }; + const env = yield* prepareReviewIndex(cwd, paths).pipe( + Effect.catchTags({ + PlatformError: (cause) => + Effect.fail( + new GitCommandError({ + operation: "GitVcsDriver.prepareReviewIndex", + cwd, + command: "git diff", + detail: "Could not prepare the review index.", + cause, + }), + ), + }), + ); + return { env }; + }); + + // The diff panel's Changes view compares the working tree with merge-base(base, HEAD). + // With no usable base it compares with HEAD, so Changes equals Uncommitted. + const resolveReviewMergeBase = Effect.fn("resolveReviewMergeBase")(function* ( + cwd: string, + branch: string | null, + explicitBaseRef?: string, + ) { + const baseRef = + explicitBaseRef ?? + (branch + ? yield* resolveBaseBranchForNoUpstream(cwd, branch, { allowRemoteOfCurrent: true }).pipe( + Effect.orElseSucceed(() => null), + ) + : null); + if (baseRef === null) return { baseRef, mergeBase: "HEAD" }; + const args = ["merge-base", baseRef, "HEAD"]; + const result = yield* executeGit("GitVcsDriver.resolveReviewMergeBase", cwd, args, { + allowNonZeroExit: true, + }); + const mergeBase = result.stdout.trim(); + if (result.exitCode !== 0 || mergeBase.length === 0) { + // Before the first commit there is nothing to compare with, so Changes equals Uncommitted. + const head = yield* executeGit( + "GitVcsDriver.resolveReviewMergeBase.head", + cwd, + ["rev-parse", "--verify", "--quiet", "HEAD"], + { allowNonZeroExit: true }, + ); + if (explicitBaseRef === undefined && head.exitCode !== 0) { + return { baseRef: null, mergeBase: "HEAD" }; + } + return yield* new GitCommandError({ + ...gitCommandContext({ operation: "GitVcsDriver.resolveReviewMergeBase", cwd, args }), + detail: `Could not find a common commit between '${baseRef}' and HEAD.`, + exitCode: result.exitCode, + }); + } + return { baseRef, mergeBase }; + }); + + // Totals for the thread panel's Changes row. Same base and untracked files as the Changes view. + const readBranchChangeTotals = Effect.fn("readBranchChangeTotals")(function* ( + cwd: string, + branch: string | null, + ) { + const { baseRef, mergeBase } = yield* resolveReviewMergeBase(cwd, branch); + const untracked = yield* prepareUntrackedReviewIndex(cwd, []); + if (untracked === null) { + return yield* new GitCommandError({ + operation: "GitVcsDriver.readBranchChangeTotals", + command: "git ls-files", + cwd, + detail: "Too many untracked files to count.", + }); + } + const readNumstat = (ref: string) => + executeGit( + "GitVcsDriver.readBranchChangeTotals", + cwd, + [...REVIEW_DIFF_ARGS, "--numstat", "-z", ref, "--"], + { + allowNonZeroExit: true, + maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES, + env: untracked.env, + }, + ); + let result = yield* readNumstat(mergeBase); + if (result.exitCode !== 0 && mergeBase === "HEAD" && isUnbornHeadStderr(result.stderr)) { + result = yield* readNumstat(yield* readEmptyTreeHash(cwd)); + } + if (result.exitCode !== 0) { + return yield* new GitCommandError({ + operation: "GitVcsDriver.readBranchChangeTotals", + command: "git diff --numstat", + cwd, + detail: "Could not read Changes totals.", + exitCode: result.exitCode, + }); + } + let insertions = 0; + let deletions = 0; + for (const file of parseReviewNumstat(result.stdout)) { + insertions += file.additions; + deletions += file.deletions; + } + return { baseRef, insertions, deletions }; + }, Effect.scoped); + const getReviewDiffPreview = Effect.fn("getReviewDiffPreview")(function* ( input: ReviewDiffPreviewInput, ) { @@ -2439,21 +2611,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } const cwd = repository.worktreeRoot; - const branch = repository.currentBranch; - const baseRef = - input.baseRef ?? - (branch - ? yield* resolveBaseBranchForNoUpstream(cwd, branch).pipe(Effect.orElseSucceed(() => null)) - : null); + // A per-file request only reads its own source. + const dirtyRef = input.file?.sourceKind === "branch-range" ? null : "HEAD"; + const review = + input.file?.sourceKind === "working-tree" + ? { baseRef: input.baseRef ?? null, mergeBase: null } + : yield* resolveReviewMergeBase(cwd, repository.currentBranch, input.baseRef); const diffArgs = [ - "diff", - "--find-renames", - "--no-color", - "--no-ext-diff", - "--no-textconv", - "--minimal", - ...PATCH_RENDER_PREFIX_ARGS, + ...REVIEW_DIFF_ARGS, ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), ]; const readStats = Effect.fn("GitVcsDriver.getReviewDiffPreview.stat")(function* ( @@ -2469,12 +2635,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); if (result.exitCode === 0) return { ref, files: parseReviewNumstat(result.stdout) }; if (ref === "HEAD" && isUnbornHeadStderr(result.stderr)) { - const emptyTree = (yield* runGitStdout("GitVcsDriver.getReviewDiffPreview.emptyTree", cwd, [ - "hash-object", - "-t", - "tree", - (yield* HostProcessPlatform) === "win32" ? "NUL" : "/dev/null", - ])).trim(); + const emptyTree = yield* readEmptyTreeHash(cwd); const stdout = yield* runGitStdoutWithOptions( "GitVcsDriver.getReviewDiffPreview.unbornStat", cwd, @@ -2491,6 +2652,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* exitCode: result.exitCode, }); }); + // One commit argument diffs that commit against the working tree. const readTrackedDiff = Effect.fn("GitVcsDriver.getReviewDiffPreview.tracked")(function* ( ref: string | null, env?: NodeJS.ProcessEnv, @@ -2506,54 +2668,27 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); return { ...patch, files: stat.files }; }); - const readDirty = Effect.gen(function* () { - if (input.file?.sourceKind === "branch-range") return yield* readTrackedDiff(null); - const untracked = yield* executeGit( - "GitVcsDriver.review.listUntracked", - cwd, - ["ls-files", "--others", "--exclude-standard", "-z", "--", ...pathArgs], - { maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES }, - ).pipe( - Effect.catchIf( - (error) => error.outputLength === undefined, - () => Effect.succeed(null), - ), - ); - if (untracked === null) { - const tracked = yield* readTrackedDiff("HEAD"); - return { ...tracked, files: undefined, stdoutTruncated: true }; - } - const paths = splitNullSeparatedGitStdoutPaths(untracked).filter( - (candidate) => !input.file || candidate === input.file.path, - ); - if (paths.length === 0) return yield* readTrackedDiff("HEAD"); - const env = yield* prepareReviewIndex(cwd, paths).pipe( - Effect.catchTags({ - PlatformError: (cause) => - Effect.fail( - new GitCommandError({ - operation: "GitVcsDriver.prepareReviewIndex", - cwd, - command: "git diff", - detail: "Could not prepare the review index.", - cause, - }), - ), - }), - ); - return yield* readTrackedDiff("HEAD", env); + const [dirtyTrackedResult, baseResult] = yield* Effect.gen(function* () { + const untracked = yield* prepareUntrackedReviewIndex(cwd, pathArgs, input.file?.path); + // With no base both sources diff HEAD, so read it once. + const [dirty, base] = + review.mergeBase === dirtyRef + ? yield* readTrackedDiff(dirtyRef, untracked?.env).pipe( + Effect.map((result) => [result, result] as const), + ) + : yield* Effect.all( + [ + readTrackedDiff(dirtyRef, untracked?.env), + readTrackedDiff(review.mergeBase, untracked?.env), + ], + { concurrency: 2 }, + ); + if (untracked !== null) return [dirty, base] as const; + // Too many untracked files to list: show tracked changes and mark totals incomplete. + const incomplete = (ref: string | null, result: typeof dirty) => + ref === null ? result : { ...result, files: undefined, stdoutTruncated: true }; + return [incomplete(dirtyRef, dirty), incomplete(review.mergeBase, base)] as const; }).pipe(Effect.scoped); - const [dirtyTrackedResult, baseResult] = yield* Effect.all( - [ - readDirty, - readTrackedDiff( - baseRef && branch && input.file?.sourceKind !== "working-tree" - ? `${baseRef}...HEAD` - : null, - ), - ], - { concurrency: 2 }, - ); const dirtyFiles = dirtyTrackedResult.files; const baseFiles = baseResult.files; const dirtyDiff = dirtyTrackedResult.stdout; @@ -2574,14 +2709,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); const [dirtyDiffHash, baseDiffHash] = yield* Effect.all([ hashDiff(dirtyDiff, dirtyFiles ?? []), - hashDiff(baseDiff, baseFiles), + hashDiff(baseDiff, baseFiles ?? []), ]); const sources: ReviewDiffPreviewSource[] = [ { id: "working-tree", kind: "working-tree", - title: "Dirty worktree", + title: "Uncommitted", baseRef: "HEAD", headRef: null, diff: dirtyDiff, @@ -2592,11 +2727,12 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* { id: "branch-range", kind: "branch-range", - title: baseRef ? `Against ${baseRef}` : "Against base branch", - baseRef, - headRef: branch ?? "HEAD", + title: review.baseRef ? `Changes vs ${review.baseRef}` : "Changes", + baseRef: review.baseRef, + // For display only. The new side is the working tree. + headRef: repository.currentBranch ?? "HEAD", diff: baseDiff, - files: baseFiles, + ...(baseFiles === undefined ? {} : { files: baseFiles }), diffHash: baseDiffHash, truncated: baseResult.stdoutTruncated, }, @@ -2712,54 +2848,31 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return new TextDecoder("utf-8").decode(bytes); }); + // Both views compare a commit with the working tree: HEAD for Uncommitted, + // merge-base(base, HEAD) for Changes. The new side always comes from disk. const getReviewDiffFileContents = Effect.fn("getReviewDiffFileContents")(function* ( input: ReviewDiffFileContentsInput, ) { - if (input.sourceKind === "working-tree") { - const repositoryRoot = yield* runGitStdout( - "GitVcsDriver.getReviewDiffFileContents.repositoryRoot", - input.cwd, - ["rev-parse", "--show-toplevel"], - ).pipe(Effect.map((value) => value.trim())); - if (repositoryRoot.length === 0) { - return yield* reviewDiffFileError(input, "Could not resolve the Git repository root."); - } - const [oldContents, newContents] = yield* Effect.all( - [ - input.changeType === "new" - ? Effect.succeed("") - : readReviewFileAtRevision(input, input.baseRef ?? "HEAD", input.oldPath), - input.changeType === "deleted" - ? Effect.succeed("") - : readWorkingTreeReviewFile(input, repositoryRoot), - ], - { concurrency: 2 }, - ); - return { oldContents, newContents }; - } - - if (!input.baseRef || !input.headRef) { - return yield* reviewDiffFileError( - input, - "Branch diff file expansion requires both base and head refs.", - ); - } - const mergeBase = yield* runGitStdout( - "GitVcsDriver.getReviewDiffFileContents.mergeBase", + const repositoryRoot = yield* runGitStdout( + "GitVcsDriver.getReviewDiffFileContents.repositoryRoot", input.cwd, - ["merge-base", input.baseRef, input.headRef], + ["rev-parse", "--show-toplevel"], ).pipe(Effect.map((value) => value.trim())); - if (mergeBase.length === 0) { - return yield* reviewDiffFileError(input, "Could not resolve the branch comparison base."); + if (repositoryRoot.length === 0) { + return yield* reviewDiffFileError(input, "Could not resolve the Git repository root."); } + const oldRevision = + input.sourceKind === "working-tree" + ? (input.baseRef ?? "HEAD") + : (yield* resolveReviewMergeBase(input.cwd, null, input.baseRef ?? undefined)).mergeBase; const [oldContents, newContents] = yield* Effect.all( [ input.changeType === "new" ? Effect.succeed("") - : readReviewFileAtRevision(input, mergeBase, input.oldPath), + : readReviewFileAtRevision(input, oldRevision, input.oldPath), input.changeType === "deleted" ? Effect.succeed("") - : readReviewFileAtRevision(input, input.headRef, input.newPath), + : readWorkingTreeReviewFile(input, repositoryRoot), ], { concurrency: 2 }, ); diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index ac21a61ddf78..24a7657d0e27 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -78,6 +78,8 @@ function makeTestLayer(state: { remoteInvalidationCalls: number; remoteStatusRefreshUpstreamValues?: Array; backgroundWorkEnabled?: boolean; + /** Runs before each remote status read, e.g. to hold a fetch open. */ + beforeRemoteStatus?: Effect.Effect; }) { return VcsStatusBroadcaster.layer.pipe( Layer.provideMerge(NodeServices.layer), @@ -90,11 +92,15 @@ function makeTestLayer(state: { return state.currentLocalStatus; }), remoteStatus: (_input, options) => - Effect.sync(() => { - state.remoteStatusCalls += 1; - state.remoteStatusRefreshUpstreamValues?.push(options?.refreshUpstream); - return state.currentRemoteStatus; - }), + Effect.suspend(() => state.beforeRemoteStatus ?? Effect.void).pipe( + Effect.andThen( + Effect.sync(() => { + state.remoteStatusCalls += 1; + state.remoteStatusRefreshUpstreamValues?.push(options?.refreshUpstream); + return state.currentRemoteStatus; + }), + ), + ), invalidateLocalStatus: () => Effect.sync(() => { state.localInvalidationCalls += 1; @@ -847,6 +853,90 @@ describe("VcsStatusBroadcaster", () => { }).pipe(Effect.provide(Layer.merge(makeTestLayer(state), TestClock.layer()))); }); + // A push from a terminal moves ahead; a PR merged on the host moves ahead-of-default. + it.effect.each([ + ["a push", { ...baseRemoteStatus, aheadCount: 1, aheadOfDefaultCount: 0 }], + ["a merged pull request", { ...baseRemoteStatus, aheadOfDefaultCount: 2 }], + ] as const)("re-reads local status when a fetch reflects %s", ([, initialRemote]) => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: initialRemote as VcsStatusRemoteResult | null, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + yield* broadcaster.getStatus({ cwd: "/repo" }); + const scope = yield* Scope.make(); + const snapshotDeferred = yield* Deferred.make(); + const localUpdatedDeferred = yield* Deferred.make(); + yield* Stream.runForEach( + broadcaster.streamStatus( + { cwd: "/repo" }, + { automaticRemoteRefreshInterval: Effect.succeed(Duration.minutes(1)) }, + ), + (event) => + event._tag === "snapshot" + ? Deferred.succeed(snapshotDeferred, event).pipe(Effect.ignore) + : event._tag === "localUpdated" + ? Deferred.succeed(localUpdatedDeferred, event).pipe(Effect.ignore) + : Effect.void, + ).pipe(Effect.forkIn(scope)); + yield* Deferred.await(snapshotDeferred); + + // The next fetch moves the base, so the Changes totals shrink. + const pushedLocal: VcsStatusLocalResult = { + ...baseLocalStatus, + branchChanges: { baseRef: "origin/main", insertions: 0, deletions: 0 }, + }; + state.currentLocalStatus = pushedLocal; + state.currentRemoteStatus = { ...baseRemoteStatus, aheadOfDefaultCount: 0 }; + yield* TestClock.adjust(Duration.minutes(1)); + + assert.deepStrictEqual(yield* Deferred.await(localUpdatedDeferred), { + _tag: "localUpdated", + local: pushedLocal, + } satisfies VcsStatusStreamEvent); + yield* Scope.close(scope, Exit.void); + }).pipe(Effect.provide(Layer.merge(makeTestLayer(state), TestClock.layer()))); + }); + + it.effect("an explicit refresh reads local totals after the fetch", () => { + const state: Parameters[0] = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const fetchStarted = yield* Deferred.make(); + const finishFetch = yield* Deferred.make(); + state.beforeRemoteStatus = Deferred.succeed(fetchStarted, undefined).pipe( + Effect.andThen(Deferred.await(finishFetch)), + ); + const refresh = yield* broadcaster.refreshStatus("/repo").pipe(Effect.forkScoped); + yield* Deferred.await(fetchStarted); + + // The fetch moves the base while it runs, so totals read before it ends would be stale. + const fetchedLocal: VcsStatusLocalResult = { + ...baseLocalStatus, + branchChanges: { baseRef: "origin/main", insertions: 0, deletions: 0 }, + }; + state.currentLocalStatus = fetchedLocal; + yield* Deferred.succeed(finishFetch, undefined); + + const status = yield* Fiber.join(refresh); + assert.deepStrictEqual(status.branchChanges, fetchedLocal.branchChanges); + }).pipe(Effect.provide(makeTestLayer(state))); + }); + it("backs off remote refresh failures exponentially and honors larger configured intervals", () => { assert.equal( Duration.toMillis(VcsStatusBroadcaster.remoteRefreshFailureDelay(1, Duration.seconds(1))), diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index 40f0e1bc8685..118e2bd29807 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -462,9 +462,22 @@ export const make = Effect.gen(function* () { if (options?.refreshUpstream !== false) { yield* workflow.invalidateRemoteStatus(cwd); } + const previousRemote = (yield* getCachedStatus(cwd))?.remote?.value; const remote = yield* workflow.remoteStatus({ cwd }, options); const pulled = yield* maybeAutoPull(cwd, remote, options?.policyCwds ?? [cwd]); if (pulled !== null) return pulled.remote; + // Local status holds the Changes totals, which compare against remote refs. A fetch can + // move them with no local trigger (a push from a terminal, a PR merged on the host), so + // re-read local status on the first fetch and whenever divergence moves. + if ( + remote && + (!previousRemote || + previousRemote.aheadCount !== remote.aheadCount || + previousRemote.behindCount !== remote.behindCount || + previousRemote.aheadOfDefaultCount !== remote.aheadOfDefaultCount) + ) { + yield* refreshLocalStatusCore(cwd); + } return yield* updateCachedRemoteStatus(cwd, remote, { publish: true }); }), ); @@ -480,10 +493,9 @@ export const make = Effect.gen(function* () { cwd, Effect.gen(function* () { yield* workflow.invalidateStatus(cwd); - const [local, remote] = yield* Effect.all( - [workflow.localStatus({ cwd }), workflow.remoteStatus({ cwd })], - { concurrency: "unbounded" }, - ); + // Local after remote: the fetch can move the base that the Changes totals compare with. + const remote = yield* workflow.remoteStatus({ cwd }); + const local = yield* workflow.localStatus({ cwd }); const pulled = yield* maybeAutoPull(cwd, remote, [rawCwd]); if (pulled !== null) return mergeGitStatusParts(pulled.local, pulled.remote); return yield* updateCachedStatus(cwd, local, remote, { publish: true }); diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index e280f0750cef..630d64c49b6c 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -1,15 +1,29 @@ import { assert, it } from "@effect/vitest"; -import { ORCHESTRATION_PROTOCOL_VERSION } from "@t3tools/contracts"; +import { + ORCHESTRATION_PROTOCOL_VERSION, + type ServerConfig, + type ServerConfigStreamEvent, +} from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as ConfigProvider from "effect/ConfigProvider"; 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 Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import * as ExternalLauncher from "./process/externalLauncher.ts"; import { hasCompatibleOrchestrationProtocol, resolveAvailableEditorsForConfig, shouldUseBoundedThreadSnapshot, + withLateEditorConfig, } from "./ws.ts"; it("accepts only the current orchestration protocol before websocket RPC setup", () => { @@ -48,3 +62,175 @@ it.effect("does not block server config when editor discovery never resolves", ( assert.deepEqual(availableEditors, []); }), ); + +// Only the fields the late-editor fold reads or rewrites. +const snapshotConfig = (fields: Partial) => + ({ availableEditors: [], settings: {}, ...fields }) as unknown as ServerConfig; + +const settingsUpdated = (settings: object): ServerConfigStreamEvent => ({ + version: 1, + type: "settingsUpdated", + payload: { settings: settings as ServerConfig["settings"] }, +}); + +it.effect("resends late editors without rolling back updates already sent", () => + Effect.gen(function* () { + const settingsSent = yield* Deferred.make(); + const events = yield* withLateEditorConfig( + snapshotConfig({ settings: { enableProviderUpdateChecks: true } as never }), + Stream.make(settingsUpdated({ enableProviderUpdateChecks: false })), + { + resolveAvailableEditors: () => Effect.succeed(["file-manager"]), + // Holds the late snapshot until the settings change has gone out. + resolveFileManagerRevealKind: () => + Deferred.await(settingsSent).pipe(Effect.as("file-explorer" as const)), + }, + ).pipe( + Stream.tap((event) => + event.type === "settingsUpdated" ? Deferred.succeed(settingsSent, undefined) : Effect.void, + ), + Stream.runCollect, + ); + + const [first, second] = Array.from(events); + assert.equal(events.length, 2); + assert.equal(first?.type, "settingsUpdated"); + assert.equal(second?.type, "snapshot"); + if (second?.type === "snapshot") { + assert.deepEqual(second.config.availableEditors, ["file-manager"]); + assert.equal(second.config.shellRevealInFileManagerKind, "file-explorer"); + assert.deepEqual(second.config.settings, { enableProviderUpdateChecks: false } as never); + } + }), +); + +it.effect("sends no late snapshot when the scan matches the snapshot", () => + Effect.gen(function* () { + const events = yield* withLateEditorConfig( + snapshotConfig({ availableEditors: ["vscode"] }), + Stream.empty, + { + resolveAvailableEditors: () => Effect.succeed(["vscode"]), + resolveFileManagerRevealKind: () => Effect.succeed(undefined), + }, + ).pipe(Stream.runCollect); + + assert.equal(events.length, 0); + }), +); + +it.effect("resends a file manager reveal kind that missed the snapshot", () => + Effect.gen(function* () { + const events = yield* withLateEditorConfig( + snapshotConfig({ availableEditors: ["file-manager"] }), + Stream.empty, + { + resolveAvailableEditors: () => Effect.succeed(["file-manager"]), + resolveFileManagerRevealKind: () => Effect.succeed("file-explorer"), + }, + ).pipe(Stream.runCollect); + + const [late] = Array.from(events); + assert.equal(events.length, 1); + assert.equal(late?.type, "snapshot"); + if (late?.type === "snapshot") { + assert.equal(late.config.shellRevealInFileManagerKind, "file-explorer"); + } + }), +); + +// The real launcher on Windows over a filesystem whose probes park until +// released, like a host too busy to finish discovery inside the snapshot timeout. +const makeParkedWindowsLauncher = Effect.gen(function* () { + const parkedProbes = yield* Queue.unbounded(); + const release = yield* Deferred.make(); + const launcher = yield* ExternalLauncher.make.pipe( + Effect.provide( + Layer.mergeAll( + FileSystem.layerNoop({ + stat: () => + Queue.offer(parkedProbes, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.as({ type: "File" } as FileSystem.File.Info), + ), + }), + Path.layer, + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.die("unexpected spawn")), + ), + ), + ), + ); + const onWindows = (effect: Effect.Effect) => + effect.pipe( + Effect.provideService(HostProcessPlatform, "win32"), + Effect.provide( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { PATH: "C:\\t3-late-editors-test", PATHEXT: ".EXE" }, + }), + ), + ), + ); + return { + editors: { + resolveAvailableEditors: () => onWindows(launcher.resolveAvailableEditors()), + resolveFileManagerRevealKind: () => onWindows(launcher.resolveFileManagerRevealKind()), + }, + probeParked: Queue.take(parkedProbes), + releaseProbes: Deferred.succeed(release, undefined), + }; +}); + +it.effect("recovers editors after a real scan outlasts the config timeout", () => + Effect.gen(function* () { + const { editors, probeParked, releaseProbes } = yield* makeParkedWindowsLauncher; + + const snapshotFiber = yield* resolveAvailableEditorsForConfig( + editors.resolveAvailableEditors(), + ).pipe(Effect.forkChild); + yield* probeParked; + yield* TestClock.adjust(Duration.seconds(5)); + const snapshotEditors = yield* Fiber.join(snapshotFiber); + assert.deepEqual(snapshotEditors, []); + + const lateFiber = yield* withLateEditorConfig( + snapshotConfig({ availableEditors: snapshotEditors }), + Stream.empty, + editors, + ).pipe(Stream.runCollect, Effect.forkChild); + yield* releaseProbes; + + const [late] = Array.from(yield* Fiber.join(lateFiber)); + assert.equal(late?.type, "snapshot"); + if (late?.type === "snapshot") { + assert.equal(late.config.availableEditors.includes("vscode"), true); + } + }).pipe(Effect.scoped), +); + +it.effect("recovers a reveal kind whose real probe outlasts the config timeout", () => + Effect.gen(function* () { + const { editors, probeParked, releaseProbes } = yield* makeParkedWindowsLauncher; + + // The snapshot's bounded probe timed out: file manager, but no reveal kind. + const lateFiber = yield* withLateEditorConfig( + snapshotConfig({ availableEditors: ["file-manager"] }), + Stream.empty, + { + resolveAvailableEditors: () => Effect.succeed(["file-manager"]), + resolveFileManagerRevealKind: editors.resolveFileManagerRevealKind, + }, + ).pipe(Stream.runCollect, Effect.forkChild); + yield* probeParked; + yield* TestClock.adjust(Duration.seconds(6)); + yield* releaseProbes; + + const [late] = Array.from(yield* Fiber.join(lateFiber)); + assert.equal(late?.type, "snapshot"); + if (late?.type === "snapshot") { + assert.equal(late.config.shellRevealInFileManagerKind, "file-explorer"); + } + }).pipe(Effect.scoped), +); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index b8c78c5e28d9..2c6c3859fb01 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -76,6 +76,8 @@ import { type RelayClientInstallProgressEvent, type ServerSelfUpdateError, type ServerSelfUpdateProgressEvent, + type ServerConfig as ClientServerConfig, + type ServerConfigStreamEvent, type ServerLifecycleStreamEvent, type FilesystemBrowseFailure, FilesystemBrowseError, @@ -155,9 +157,9 @@ import * as ThreadSearch from "./orchestration-v2/ThreadSearch.ts"; import * as OrchestrationEventStore from "./persistence/Services/OrchestrationEventStore.ts"; import { userFacingDispatchErrorMessage } from "./orchestration-v2/UserFacingErrors.ts"; import { - observeRpcEffect as instrumentRpcEffect, - observeRpcStream as instrumentRpcStream, - observeRpcStreamEffect as instrumentRpcStreamEffect, + observeRpcEffect, + observeRpcStream, + observeRpcStreamEffect, } from "./observability/RpcInstrumentation.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; import * as ProviderInstanceRegistry from "./provider/Services/ProviderInstanceRegistry.ts"; @@ -206,7 +208,11 @@ import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; -import { requiredScopeForRpcMethod, requiredScopeForDeviceList } from "./auth/RpcAuthorization.ts"; +import { + requiredScopeForDeviceList, + rpcAuthorizationError, + rpcScopeAuthorizationLayer, +} from "./auth/RpcAuthorization.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; @@ -262,6 +268,94 @@ const resolveFileManagerRevealKindForConfig = ( discovery: Effect.Effect, ) => resolveDiscoveryForConfig(discovery, () => undefined); +type EditorDiscovery = Pick< + ExternalLauncher.ExternalLauncher["Service"], + "resolveAvailableEditors" | "resolveFileManagerRevealKind" +>; + +// The config fields that follow from which editors are installed. +const resolveEditorConfig = ( + 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, + }), + }; + }); + +/** + * Live config updates that follow a snapshot of `config`. A busy host can + * outlast the snapshot's discovery timeouts, which send no editors, or no + * reveal kind for the file manager. The scan keeps running, so once it lands + * this resends the config: clients replace theirs on any snapshot. The resent + * config is folded from the live updates already sent, so it cannot roll back + * a change that landed while the scan ran. + */ +export const withLateEditorConfig = ( + config: ClientServerConfig, + liveUpdates: Stream.Stream, + launcher: EditorDiscovery, +) => { + const lateEditorConfig = Stream.fromEffect(launcher.resolveAvailableEditors()).pipe( + Stream.filter( + (editors) => + editors.join() !== config.availableEditors.join() || + (editors.includes("file-manager") && config.shellRevealInFileManagerKind === undefined), + ), + // Unbounded, unlike the snapshot: the reveal-kind probe is not shared, so + // a timeout here would cancel a probe that outlasts it every time. + Stream.mapEffect((editors) => + resolveEditorConfig(editors, launcher.resolveFileManagerRevealKind()), + ), + Stream.filter( + (editorConfig) => + editorConfig.availableEditors.join() !== config.availableEditors.join() || + editorConfig.shellRevealInFileManagerKind !== config.shellRevealInFileManagerKind, + ), + Stream.map((editorConfig) => ({ type: "editorsResolved" as const, editorConfig })), + ); + + return Stream.merge(liveUpdates, lateEditorConfig).pipe( + Stream.mapAccum( + (): ClientServerConfig => config, + (current, event): readonly [ClientServerConfig, ReadonlyArray] => { + switch (event.type) { + case "editorsResolved": { + const { + availableEditors: _editors, + shellRevealInFileManager: _reveal, + shellRevealInFileManagerKind: _revealKind, + ...rest + } = current; + const next = { ...rest, ...event.editorConfig }; + return [next, [{ version: 1, type: "snapshot", config: next }]]; + } + case "keybindingsUpdated": + return [{ ...current, ...event.payload }, [event]]; + case "providerStatuses": + return [{ ...current, providers: event.payload.providers }, [event]]; + case "settingsUpdated": + return [{ ...current, settings: event.payload.settings }, [event]]; + // Themes and usage-limit sources never ride in a snapshot; clients + // carry their projected values across one. + default: + return [current, [event]]; + } + }, + ), + ); +}; + function unexpectedCompatibilityError(error: never): never { throw new Error(`Unhandled compatibility error: ${String(error)}`); } @@ -1221,25 +1315,15 @@ const makeWsRpcLayer = ( const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor; const resourceTelemetry = yield* ResourceTelemetry.ResourceTelemetry; const relayClient = yield* RelayClient.RelayClient; - const authorizationError = (requiredScope: AuthEnvironmentScope) => - new EnvironmentAuthorizationError({ - message: `The authenticated token is missing required scope: ${requiredScope}.`, - requiredScope, - }); + // RpcScopeAuthorization checks each RPC's declared scope before its handler + // runs. This covers the one RPC whose scope depends on its input. const authorizeEffect = ( requiredScope: AuthEnvironmentScope, effect: Effect.Effect, ): Effect.Effect => currentSession.scopes.includes(requiredScope) ? effect - : Effect.fail(authorizationError(requiredScope)); - const authorizeStream = ( - requiredScope: AuthEnvironmentScope, - stream: Stream.Stream, - ): Stream.Stream => - currentSession.scopes.includes(requiredScope) - ? stream - : Stream.fail(authorizationError(requiredScope)); + : Effect.fail(rpcAuthorizationError(requiredScope)); const acpRegistryProject = Effect.fn("ws.acpRegistry.project")(function* ( projectId: ProjectId, @@ -1562,40 +1646,6 @@ const makeWsRpcLayer = ( yield* providerRegistry.refreshInstance(input.instanceId); return { disabled: true } as const; }); - const observeRpcEffect = ( - method: string, - effect: Effect.Effect, - traceAttributes?: Readonly>, - ) => - instrumentRpcEffect( - method, - authorizeEffect(requiredScopeForRpcMethod(method), effect), - traceAttributes, - ); - const observeRpcStream = ( - method: string, - stream: Stream.Stream, - traceAttributes?: Readonly>, - ) => - instrumentRpcStream( - method, - authorizeStream(requiredScopeForRpcMethod(method), stream), - traceAttributes, - ); - const observeRpcStreamEffect = ( - method: string, - effect: Effect.Effect< - Stream.Stream, - EffectError, - EffectContext - >, - traceAttributes?: Readonly>, - ) => - instrumentRpcStreamEffect( - method, - authorizeEffect(requiredScopeForRpcMethod(method), effect), - traceAttributes, - ); const loadAuthAccessSnapshot = () => Effect.all({ pairingLinks: serverAuth.listPairingLinks(), @@ -1622,14 +1672,10 @@ const makeWsRpcLayer = ( const environment = yield* serverEnvironment.getDescriptor; const auth = yield* serverAuth.getDescriptor(); const scratchWorkspaceRoot = yield* managedFolders.scratchRoot; - const availableEditors: ReadonlyArray = yield* resolveAvailableEditorsForConfig( - externalLauncher.resolveAvailableEditors(), + const editorConfig = yield* resolveEditorConfig( + yield* resolveAvailableEditorsForConfig(externalLauncher.resolveAvailableEditors()), + resolveFileManagerRevealKindForConfig(externalLauncher.resolveFileManagerRevealKind()), ); - const fileManagerRevealKind = availableEditors.includes("file-manager") - ? yield* resolveFileManagerRevealKindForConfig( - externalLauncher.resolveFileManagerRevealKind(), - ) - : undefined; return { environment, @@ -1639,7 +1685,7 @@ const makeWsRpcLayer = ( keybindings: keybindingsConfig.keybindings, issues: keybindingsConfig.issues, providers, - availableEditors, + ...editorConfig, // Same discovery-with-timeout treatment as editors: a slow probe // must not stall server.getConfig, so it degrades to no targets. remoteOpenTargets: yield* resolveAvailableEditorsForConfig( @@ -1661,12 +1707,6 @@ const makeWsRpcLayer = ( }, settings, shellResumeCompletionMarker: true, - ...(fileManagerRevealKind === undefined - ? {} - : { - shellRevealInFileManager: true, - shellRevealInFileManagerKind: fileManagerRevealKind, - }), threadResumeCompletionMarker: true, threadSnapshotPagination: true, ...Option.match(scratchWorkspaceRoot, { @@ -3642,7 +3682,7 @@ const makeWsRpcLayer = ( return Stream.concat( rpcInitialItems([{ version: 1 as const, type: "snapshot" as const, config }]), - liveUpdates, + withLateEditorConfig(config, liveUpdates, externalLauncher), ); }), { "rpc.aggregate": "server" }, @@ -3772,6 +3812,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( const { protocol, httpEffect } = yield* RpcServer.makeProtocolWithHttpEffectWebsocket; yield* RpcServer.make(ServerWsRpcGroup, { disableTracing: true }).pipe( Effect.provideService(RpcServer.Protocol, withTerminalOutputWindow(protocol)), + Effect.provide(rpcScopeAuthorizationLayer(session.scopes)), Effect.forkScoped, ); // @effect-diagnostics-next-line returnEffectInGen:off diff --git a/apps/web/package.json b/apps/web/package.json index 5209d306ca04..ffccf206f910 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -40,6 +40,7 @@ "@tiptap/starter-kit": "^3.31.3", "class-variance-authority": "^0.7.1", "culori": "^4.0.2", + "dompurify": "^3.4.16", "effect": "catalog:", "hast-util-to-html": "^9.0.5", "hast-util-to-jsx-runtime": "^2.3.6", @@ -47,7 +48,10 @@ "jose": "catalog:", "jsonc-parser": "3.3.1", "jszip": "3.10.1", + "lucide": "^0.564.0", "lucide-react": "^0.564.0", + "mermaid": "^11.17.2", + "morphicons": "^1.7.1", "react": "19.2.6", "react-dom": "19.2.6", "react-markdown": "^10.1.0", diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index c405b7a9efa9..918d28845068 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -35,15 +35,16 @@ import LegacyThreadSidebar from "./LegacySidebar"; import { useThreadVisitedMigration } from "../hooks/useThreadVisitedMigration"; import ThreadSidebar from "./Sidebar"; import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; -import { SidebarChromeHeader } from "./sidebar/SidebarChrome"; +import { SidebarBrandWidthProbe, SidebarChromeHeader } from "./sidebar/SidebarChrome"; import { MainAppLocationTracker } from "./sidebar/mainAppLocation"; import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; import { useProjects } from "../state/entities"; import { + clampThreadSidebarWidth, resolveInitialThreadSidebarWidth, resolveThreadSidebarMaximumWidth, + resolveThreadSidebarMinimumWidth, THREAD_MAIN_CONTENT_MIN_WIDTH, - THREAD_SIDEBAR_MIN_WIDTH, THREAD_SIDEBAR_WIDTH_STORAGE_KEY, } from "./threadSidebarWidth"; import { @@ -234,7 +235,9 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { // and a clamped drag ends with an unchanged width, which skips the re-render // that would otherwise refresh a render-time snapshot. const viewportWidth = useSyncExternalStore(subscribeToViewportWidth, readViewportWidth); - const sidebarMaximumWidth = resolveThreadSidebarMaximumWidth(viewportWidth); + const [brandWidth, setBrandWidth] = useState(0); + const sidebarMinimumWidth = resolveThreadSidebarMinimumWidth(brandWidth); + const sidebarMaximumWidth = resolveThreadSidebarMaximumWidth(viewportWidth, sidebarMinimumWidth); const resetSidebarWidth = () => { try { removeLocalStorageItem(THREAD_SIDEBAR_WIDTH_STORAGE_KEY); @@ -250,7 +253,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { : false; }); const sidebarProviderStyle = { - "--sidebar-width": `${sidebarWidth}px`, + "--sidebar-width": `${clampThreadSidebarWidth(sidebarWidth, sidebarMinimumWidth, sidebarMaximumWidth)}px`, "--panel-animation-duration": `${panelAnimationDurationMs}ms`, ...(isMacosDesktop && !isWindowFullscreen ? { "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET } @@ -302,6 +305,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { defaultOpen style={sidebarProviderStyle} > + nextWidth <= currentWidth || wrapper.clientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH, diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index cfa62ad7fe92..d018802a590a 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -16,7 +16,8 @@ export { export interface EnvironmentOption { environmentId: EnvironmentId; - projectId: ProjectId; + /** Null when the machine's "No project" folder is not created yet. */ + projectId: ProjectId | null; label: string; isPrimary: boolean; machine: EnvironmentMachineKind; diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index 465be828364b..3afa31e2acee 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -59,7 +59,11 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe }: BranchToolbarEnvModeSelectorProps) { const workspacePath = displayMode === "panel" ? (activeWorktreePath ?? workspaceRoot) : null; const workspaceDisplayName = resolveWorkspaceDisplayName(workspacePath); - const workspaceKind = activeWorktreePath ? "Worktree" : "Project folder"; + // The panel names the workspace kind only when it is not the project folder. + const workspaceKind = activeWorktreePath ? "Worktree" : null; + const lockedWorkspaceKind = forceNewWorktree ? "Worktree" : workspaceKind; + const selectWorkspaceKind = + effectiveEnvMode === "worktree" && !activeWorktreePath ? "Create" : workspaceKind; const composerFloatingLayerProps = useComposerMenuProps(); const showPreviousWorktree = Boolean(previousWorktreeLabel && onUsePreviousWorktree); const envModeItems = useMemo( @@ -147,9 +151,9 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe : (workspaceDisplayName ?? resolveLockedWorkspaceLabel(activeWorktreePath, effectiveEnvMode))} - {displayMode === "panel" ? ( + {displayMode === "panel" && lockedWorkspaceKind ? ( - {forceNewWorktree ? "Worktree" : workspaceKind} + {lockedWorkspaceKind} ) : null} @@ -210,9 +214,9 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe - {displayMode === "panel" ? ( + {displayMode === "panel" && selectWorkspaceKind ? ( - {effectiveEnvMode === "worktree" && !activeWorktreePath ? "Create" : workspaceKind} + {selectWorkspaceKind} ) : null} diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index ea02607cc115..5358e253af4f 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -5,9 +5,8 @@ import { encodeComposerContextClipboardHtml, } from "@t3tools/shared/composerContextClipboard"; import { - CheckIcon, ChevronRightIcon, - CopyIcon, + CodeIcon, FileSpreadsheetIcon, FileTextIcon, GlobeIcon, @@ -15,18 +14,18 @@ import { InfoIcon, LightbulbIcon, MailIcon, - Maximize2Icon, MessageSquareIcon, MessageSquareWarningIcon, - Minimize2Icon, OctagonAlertIcon, PlayIcon, PresentationIcon, SparklesIcon, TriangleAlertIcon, + WorkflowIcon, WrapTextIcon, type LucideIcon, } from "lucide-react"; +import { Check, Copy, Maximize2, Minimize2 } from "lucide"; import type { AssetResource, EnvironmentId, @@ -121,6 +120,7 @@ import { import { hasSpecificPierreIconForFileName, syntheticFileNameForLanguageId } from "../pierre-icons"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { Button } from "./ui/button"; +import { MorphIcon } from "~/components/MorphIcon"; import { ContextChip } from "./ContextChip"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "./ui/collapsible"; import { ScrollArea } from "./ui/scroll-area"; @@ -141,6 +141,7 @@ import { GitHubIcon } from "./Icons"; import { createIncrementalHighlightedDocument } from "../lib/incrementalHighlighting"; import { HighlightedCodeLines } from "./chat/HighlightedCodeLines"; import { RenderErrorBoundary } from "./RenderErrorBoundary"; +import { MermaidDiagram } from "./chat/MermaidDiagram"; import { useTheme } from "../hooks/useTheme"; import { getClientSettings, useClientSettings } from "../hooks/useSettings"; import { @@ -846,7 +847,7 @@ function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) { /> } > - {expanded ? : } + {expandLabel} @@ -866,7 +867,7 @@ function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) { /> } > - {copied ? : } + {copyLabel} @@ -975,6 +976,9 @@ function MarkdownCodeBlock({ theme, onRunShellCommand, isStreaming, + leadingActions, + canWrap = true, + diagram = false, children, }: { code: string; @@ -983,6 +987,10 @@ function MarkdownCodeBlock({ theme: "light" | "dark"; onRunShellCommand?: ((command: string) => void) | undefined; isStreaming: boolean; + leadingActions?: ReactNode; + canWrap?: boolean; + /** Renders content instead of code, with actions below it like tables. */ + diagram?: boolean; children: ReactNode; }) { const [copied, setCopied] = useState(false); @@ -1040,6 +1048,37 @@ function MarkdownCodeBlock({ [], ); + const copyButton = ( + + + } + > + + + {copyLabel} + + ); + + if (diagram) { + return ( +
+ {children} +
+ {leadingActions} + {copyButton} +
+
+ ); + } + return (
- - setWrapped((value) => !value)} - aria-label={wrapLabel} - /> - } - > - - - {wrapLabel} - + {leadingActions} + {canWrap ? ( + + setWrapped((value) => !value)} + aria-label={wrapLabel} + /> + } + > + + + {wrapLabel} + + ) : null} {canRun ? ( Run in terminal ) : null} + {copyButton} + +
+ {children} + + ); +} + +/** + * Mermaid fences render as a diagram once the response settles; streaming and + * the code toggle keep the highlighted source. + */ +function MarkdownMermaidCodeBlock({ + code, + fenceTitle, + theme, + isStreaming, + onExpand, + children, +}: { + code: string; + fenceTitle: string | null; + theme: "light" | "dark"; + isStreaming: boolean; + onExpand: (imageUrl: string) => void; + children: ReactNode; +}) { + const [showCode, setShowCode] = useState(false); + const showDiagram = !showCode && !isStreaming && code.trim().length > 0; + const toggleLabel = showCode ? "Show diagram" : "Show code"; + return ( + setShowCode((value) => !value)} + aria-label={toggleLabel} /> } > - {copied ? : } + {showCode ? : } - {copyLabel} + {toggleLabel} - - - {children} - + ) + } + > + {showDiagram ? ( + + + Rendering diagram + + } + > + + + + ) : ( + children + )} + ); } @@ -3300,7 +3398,7 @@ const CHAT_MARKDOWN_COMPONENTS = { return {children}; }, pre: function MarkdownPre({ node, children, ...props }) { - const { resolvedTheme, diffThemeName, isStreaming, onRunShellCommand, text } = use( + const { resolvedTheme, diffThemeName, expandMedia, isStreaming, onRunShellCommand, text } = use( ChatMarkdownRendererContext, ); const codeBlock = extractCodeBlock(children); @@ -3310,6 +3408,42 @@ const CHAT_MARKDOWN_COMPONENTS = { const language = extractFenceLanguage(codeBlock.className); const fenceTitle = extractFenceTitle(extractPreCodeMeta(node)); + const highlightedCode = ( + {children}} + > + {/* Reserve the block's height but stay hidden until Shiki has colored + it, so plain text never flashes before the highlighted version. */} + + {children} + + } + > + + + + ); + if (language === "mermaid") { + return ( + expandMedia({ images: [{ src, name: "Mermaid diagram" }], index: 0 })} + > + {highlightedCode} + + ); + } return ( - {children}} - > - {/* Reserve the block's height but stay hidden until Shiki has colored - it, so plain text never flashes before the highlighted version. */} - - {children} - - } - > - - - + {highlightedCode} ); }, diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 0f48fa1b9f06..9e6b96b21018 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -628,6 +628,30 @@ describe("hasServerAcknowledgedLocalDispatch", () => { ).toBe(true); }); + it("holds a first send while the thread shell still reports a preparing run", () => { + // The draft had no run. The server thread's shell shows the new run before + // the detail projection behind `phase` loads. + const localDispatch = createLocalDispatchSnapshot(makeThread()); + const preparingRun = { + ...completedTurn, + status: "preparing" as const, + startedAt: null, + completedAt: null, + }; + + expect( + hasServerAcknowledgedLocalDispatch({ + localDispatch, + phase: "disconnected", + latestRun: preparingRun, + runtime: { ...readySession, status: "preparing", activeRunId: preparingRun.runId }, + hasPendingApproval: false, + hasPendingUserInput: false, + threadError: null, + }), + ).toBe(false); + }); + it("waits for the matching running turn before acknowledging", () => { const localDispatch = createLocalDispatchSnapshot( makeThread({ latestRun: completedTurn, runtime: readySession }), diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 255a4f7e8cd3..3323a8f3e0b9 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -57,7 +57,7 @@ import { stripInlineContextReferences } from "~/lib/composerContextReferences"; import type { DraftThreadEnvMode } from "../composerDraftStore"; import { collapseExpandedComposerCursor, type ComposerSubmissionIntent } from "../composer-logic"; import type { ReviewCommentContext } from "../reviewCommentContext"; -import type { TimelineEntry } from "../session-logic"; +import { derivePhase, type TimelineEntry } from "../session-logic"; import type { PreviewMiniPlayerSource } from "../previewMiniPlayerStore"; import type { DesktopPreviewOverlay } from "../previewStateStore"; import type { RightPanelSurface } from "../rightPanelStore"; @@ -1248,7 +1248,10 @@ export function hasServerAcknowledgedLocalDispatch(input: { if (input.hasPendingApproval || input.hasPendingUserInput || Boolean(input.threadError)) { return true; } - if (input.phase === "connecting") { + // The thread shell can report a preparing or starting run before the detail + // projection behind `phase` loads, so either source still connecting holds + // the send. + if (input.phase === "connecting" || derivePhase(input.runtime ?? null) === "connecting") { return false; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 8fbe528b34b4..a8eb8fd64df0 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -22,6 +22,8 @@ import { rememberCheckoutIsRepo, } from "./ChatView.logic"; import { useLoadBalancedEnvironment } from "../hooks/useLoadBalancedEnvironment"; +import { useScratchProject } from "../hooks/useScratchProject"; +import { isScratchProject } from "@t3tools/client-runtime/state/projects"; import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; import { latestExecutedRun, @@ -85,9 +87,10 @@ import { import { readPastedComposerContext } from "./composerInlineTokenPaste"; import { isPasteAsTextShortcut } from "@t3tools/client-runtime/text-paste"; import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; -import { useThreadActions } from "../hooks/useThreadActions"; +import { useAcknowledgeThreadWoke, useThreadActions } from "../hooks/useThreadActions"; import { deriveProviderSubagentStatus, + deriveReportedModelSelection, formatModelSelectionEffort, deriveRunlessWorkStartedAt, deriveThreadActivityRun, @@ -116,6 +119,7 @@ import { createModelSelection, formatModelSlugName, resolvePromptInjectedEffort, + resolveSelectableModel, } from "@t3tools/shared/model"; import { projectScriptCwd, @@ -249,6 +253,7 @@ import { useThreadPreviewState, } from "../previewStateStore"; import { BrowserSettingsReadError } from "../browser/openFileInPreview"; +import { previewRuntimeTabId } from "../browser/previewRuntimeTabId"; import { addBrowserSurface } from "./preview/addBrowserSurface"; import { closePreviewSession } from "./preview/closePreviewSession"; import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer"; @@ -261,7 +266,10 @@ import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore, } from "../previewMiniPlayerStore"; -import { pullRequestPanelContext } from "./pullRequest/pullRequestDetail.logic"; +import { + pullRequestPanelContext, + threadPullRequestPanelTarget, +} from "./pullRequest/pullRequestDetail.logic"; import { PullRequestDetailPanel } from "./pullRequest/PullRequestDetailPanel"; import { PullRequestDetailGhost } from "./pullRequest/PullRequestGhosts"; import { PullRequestsUnavailableState } from "./pullRequest/PullRequestsUnavailableState"; @@ -289,7 +297,10 @@ import { import { cn, randomUUID } from "~/lib/utils"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; import { stackedThreadToast, toastManager } from "./ui/toast"; -import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; +import { + decodeProjectScriptKeybindingRule, + keybindingValueForCommand, +} from "~/lib/projectScriptKeybindings"; import { type NewProjectScriptInput } from "./ProjectScriptsControl"; import { buildProjectScript, @@ -1529,6 +1540,9 @@ export default function ChatView(props: ChatViewProps) { const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, { reportFailure: false, }); + const removeKeybinding = useAtomCommand(serverEnvironment.removeKeybinding, { + reportFailure: false, + }); const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close"); @@ -1625,6 +1639,9 @@ export default function ChatView(props: ChatViewProps) { }); const serverThreadProjection = useThreadProjection(routeThreadDetailRef); const serverProjection = serverThreadProjection?.projection ?? null; + const reportedModelSelection = serverProjection + ? deriveReportedModelSelection(serverProjection) + : null; const threadStatus = useThreadStatus(routeThreadDetailRef); const threadSyncPhase = resolveThreadSyncPhase({ detailExists: serverProjection !== null, @@ -2216,10 +2233,10 @@ export default function ChatView(props: ChatViewProps) { useLayoutEffect(() => { const explicitThreadRef = explicitDiffOpenRef.current; explicitDiffOpenRef.current = null; - // Generic openings always show the checkout, including tab fallbacks and thread changes. + // Generic openings always show Changes, including tab fallbacks and thread changes. // A timeline click instead opens the specific turn/file the user requested. if (diffOpen && activeThreadRef && explicitThreadRef !== activeThreadRef) { - useDiffPanelStore.getState().selectGitScope(activeThreadRef, "unstaged"); + useDiffPanelStore.getState().selectGitScope(activeThreadRef, "branch"); } }, [activeThreadRef, diffOpen]); const rightPanelState = useRightPanelStore((state) => @@ -2229,6 +2246,14 @@ export default function ChatView(props: ChatViewProps) { selectActiveRightPanelSurface(state.byThreadKey, activeThreadRef), ); const activePreviewState = useThreadPreviewState(activeThreadRef); + const activePreviewServerEpoch = activePreviewState.serverEpoch; + const resolvePreviewRuntimeTabId = useMemo( + () => + activeThreadRef + ? (tabId: string) => previewRuntimeTabId(activeThreadRef, activePreviewServerEpoch, tabId) + : undefined, + [activeThreadRef, activePreviewServerEpoch], + ); const activePreviewMiniPlayer = usePreviewMiniPlayerStore((state) => selectThreadPreviewMiniPlayer(state.byThreadKey, activeThreadRef), ); @@ -2628,26 +2653,56 @@ export default function ChatView(props: ChatViewProps) { }, [navigate, setEnvironmentEnabled], ); + const { scratchWorkspaceRootFor, openScratchProject } = useScratchProject(); + const activeProjectIsScratch = + activeProject !== null && + isScratchProject( + activeProject, + environmentById.get(activeProject.environmentId)?.serverConfig?.scratchWorkspaceRoot ?? null, + ); const logicalProjectEnvironments = useMemo(() => { if (!activeProject) return []; - const logicalKey = deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings); - const memberProjects = allProjects.filter( - (p) => deriveLogicalProjectKeyFromSettings(p, projectGroupingSettings) === logicalKey, - ); - const seen = new Set(); const envs: EnvironmentOption[] = []; - for (const p of memberProjects) { - if (seen.has(p.environmentId)) continue; - seen.add(p.environmentId); - const isPrimary = p.environmentId === primaryEnvironmentId; - const environment = environmentById.get(p.environmentId) ?? null; + const pushEnvironment = (environmentId: EnvironmentId, projectId: ProjectId | null) => { + const environment = environmentById.get(environmentId) ?? null; envs.push({ - environmentId: p.environmentId, - projectId: p.id, - label: environment?.label ?? p.environmentId, - isPrimary, + environmentId, + projectId, + label: environment?.label ?? environmentId, + isPrimary: environmentId === primaryEnvironmentId, machine: resolveEnvironmentMachineKind(environment?.serverConfig ?? null), }); + }; + if (activeProjectIsScratch && draftId) { + // Each machine keeps its own "No project" folder at its own path, so they + // never group as one logical project. Offer every machine that has one. + for (const environment of environments) { + const scratchRoot = scratchWorkspaceRootFor(environment.environmentId); + // Keep the current machine visible so an offline source can still switch away. + if (scratchRoot === null && environment.environmentId !== activeProject.environmentId) + continue; + const scratchProject = + environment.environmentId === activeProject.environmentId + ? activeProject + : allProjects.find( + (p) => + p.environmentId === environment.environmentId && isScratchProject(p, scratchRoot), + ); + pushEnvironment(environment.environmentId, scratchProject?.id ?? null); + } + } else { + const logicalKey = deriveLogicalProjectKeyFromSettings( + activeProject, + projectGroupingSettings, + ); + const seen = new Set(); + for (const p of allProjects) { + if (seen.has(p.environmentId)) continue; + if (deriveLogicalProjectKeyFromSettings(p, projectGroupingSettings) !== logicalKey) + continue; + seen.add(p.environmentId); + pushEnvironment(p.environmentId, p.id); + } } // Sort: primary first, then alphabetical envs.sort((a, b) => { @@ -2655,8 +2710,21 @@ export default function ChatView(props: ChatViewProps) { return a.label.localeCompare(b.label); }); return envs; - }, [activeProject, allProjects, projectGroupingSettings, primaryEnvironmentId, environmentById]); + }, [ + activeProject, + activeProjectIsScratch, + allProjects, + draftId, + environments, + projectGroupingSettings, + primaryEnvironmentId, + environmentById, + scratchWorkspaceRootFor, + ]); const hasMultipleEnvironments = logicalProjectEnvironments.length > 1; + // Auto balance retargets to an existing project; a machine's "No project" + // folder may not exist until it is picked. + const canAutoBalanceEnvironments = hasMultipleEnvironments && !activeProjectIsScratch; const activeEnvironmentOption = logicalProjectEnvironments.find( (environment) => environment.environmentId === activeThread?.environmentId, @@ -2875,7 +2943,7 @@ export default function ChatView(props: ChatViewProps) { clientSettingsHydrated && draftId && !envLocked && - hasMultipleEnvironments && + canAutoBalanceEnvironments && loadBalancingSettings.loadBalancingEnabled && draftThread?.environmentSelection !== "manual" && (!composerHasAttachments || Boolean(draftThread?.loadBalancedEnvironmentId)) && @@ -4055,8 +4123,16 @@ export default function ChatView(props: ChatViewProps) { const showProviderSubagentBar = isProviderSubagent; const composerMounted = !showProviderSubagentBar; const providerSubagentModels = selectedProviderEntry?.models ?? EMPTY_PROVIDER_MODELS; + // Providers can report a dated id or alias (claude-haiku-4-5-20251001). + const providerSubagentModelSlug = selectedProviderEntry + ? resolveSelectableModel( + selectedProviderEntry.driverKind, + activeThread?.modelSelection.model, + providerSubagentModels, + ) + : null; const providerSubagentCatalogModel = providerSubagentModels.find( - (model) => model.slug === activeThread?.modelSelection.model, + (model) => model.slug === providerSubagentModelSlug, ); const providerSubagentModelLabel = providerSubagentCatalogModel ? getTriggerDisplayModelName(providerSubagentCatalogModel) @@ -4064,7 +4140,11 @@ export default function ChatView(props: ChatViewProps) { const providerSubagentEffortLabel = activeThread === undefined ? null - : formatModelSelectionEffort(activeThread.modelSelection, providerSubagentModels); + : formatModelSelectionEffort( + activeThread.modelSelection, + providerSubagentModels, + reportedModelSelection, + ); const mountComposerContextStrip = shouldShowComposerContextStrip({ isDraftHeroState, persistInActiveThreads: settings.persistComposerContextStrip, @@ -4165,7 +4245,7 @@ export default function ChatView(props: ChatViewProps) { const target = logicalProjectEnvironments.find( (environment) => environment.environmentId === loadBalancing.environmentId, ); - if (!target) return; + if (!target?.projectId) return; setDraftThreadContext(draftId, { projectRef: scopeProjectRef(target.environmentId, target.projectId), environmentSelection: "auto", @@ -4218,23 +4298,91 @@ export default function ChatView(props: ChatViewProps) { : "Auto balance" : undefined; - // Handle environment change for draft threads. When the user picks a - // different environment we update the draft context to point at the physical - // project in that environment while keeping the same logical project. + const environmentChangeRef = useRef(null); + const [isEnvironmentChanging, setIsEnvironmentChanging] = useState(false); + useLayoutEffect(() => { + return () => { + environmentChangeRef.current = null; + setIsEnvironmentChanging(false); + }; + }, [draftId, activeProjectKey]); + const onEnvironmentChange = useCallback( (nextEnvironmentId: EnvironmentId) => { - if (envLocked || !draftId) return; + if (envLocked || !draftId || sendInFlightRef.current) return; + const originalDraft = getDraftSession(draftId); + if (!originalDraft || originalDraft.promotedTo) return; const target = logicalProjectEnvironments.find( (env) => env.environmentId === nextEnvironmentId, ); if (!target) return; - setDraftThreadContext(draftId, { - projectRef: scopeProjectRef(target.environmentId, target.projectId), - environmentSelection: "manual", - loadBalancedEnvironmentId: null, - }); + const request = Symbol(); + environmentChangeRef.current = request; + setIsEnvironmentChanging(false); + const retarget = (project: (typeof allProjects)[number]) => { + const currentDraft = getDraftSession(draftId); + if ( + environmentChangeRef.current !== request || + sendInFlightRef.current || + !currentDraft || + currentDraft.promotedTo || + currentDraft.environmentId !== originalDraft.environmentId || + currentDraft.projectId !== originalDraft.projectId + ) + return; + const projectRef = scopeProjectRef(target.environmentId, project.id); + if (activeProjectIsScratch) { + // Scratch projects are machine-local, so move their logical mapping too. + setLogicalProjectDraftThreadId( + deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings), + projectRef, + draftId, + { environmentSelection: "manual", loadBalancedEnvironmentId: null }, + ); + } else { + setDraftThreadContext(draftId, { + projectRef, + environmentSelection: "manual", + loadBalancedEnvironmentId: null, + }); + } + }; + const finish = () => { + if (environmentChangeRef.current === request) { + environmentChangeRef.current = null; + setIsEnvironmentChanging(false); + } + }; + if (target.projectId !== null) { + const project = allProjects.find( + (project) => + project.environmentId === target.environmentId && project.id === target.projectId, + ); + if (project) retarget(project); + finish(); + return; + } + // Keep send disabled until the destination Scratch project is ready. + setIsEnvironmentChanging(true); + void openScratchProject(target.environmentId) + .then((project) => { + if (project) retarget(project); + }) + .finally(finish); }, - [draftId, envLocked, logicalProjectEnvironments, setDraftThreadContext], + [ + activeProjectIsScratch, + allProjects, + draftId, + envLocked, + getDraftSession, + logicalProjectEnvironments, + openScratchProject, + projectGroupingSettings, + sendInFlightRef, + setDraftThreadContext, + setLogicalProjectDraftThreadId, + ], ); const activeTerminalGroup = @@ -4801,20 +4949,64 @@ export default function ChatView(props: ChatViewProps) { command: input.keybindingCommand, }); - if (isElectron && keybindingRule) { - return mapAtomCommandResult( - await upsertKeybinding({ - environmentId, - input: keybindingRule, - }), - () => undefined, - ); + if (!isElectron) return updateResult; + + const scriptId = input.keybindingCommand + ? projectScriptIdFromCommand(input.keybindingCommand) + : null; + if (!keybindingRule && !input.previousScripts.some((script) => script.id === scriptId)) { + return updateResult; } - return updateResult; + const retainedElsewhere = + !input.nextScripts.some((script) => script.id === scriptId) && + (settings.defaultProjectScripts.some((script) => script.id === scriptId) || + Object.entries(settings.projectSettingsOverrides).some( + ([projectId, entry]) => + projectId !== input.projectId && + entry.defaultProjectScripts?.some((script) => script.id === scriptId), + ) || + allProjects.some( + (other) => + other.environmentId === environmentId && + other.id !== input.projectId && + resolveProjectScripts(settings, other).some((script) => script.id === scriptId), + )); + if (!keybindingRule && retainedElsewhere) return updateResult; + + const previousRules = ( + environmentById.get(environmentId)?.serverConfig?.keybindings ?? [] + ).flatMap((binding) => { + if (binding.command !== input.keybindingCommand || binding.whenAst) return []; + const previous = decodeProjectScriptKeybindingRule({ + keybinding: keybindingValueForCommand([binding], input.keybindingCommand), + command: input.keybindingCommand, + }); + return previous ? [previous] : []; + }); + const previous = previousRules.at(-1); + for (const rule of keybindingRule ? previousRules.slice(0, -1) : previousRules) { + const result = await removeKeybinding({ environmentId, input: rule }); + if (result._tag === "Failure") return mapAtomCommandResult(result, () => undefined); + } + return keybindingRule + ? mapAtomCommandResult( + await upsertKeybinding({ + environmentId, + input: + previous && previous.key !== keybindingRule.key + ? { ...keybindingRule, replace: previous } + : keybindingRule, + }), + () => undefined, + ) + : updateResult; }, [ + allProjects, + environmentById, environmentId, - settings.projectSettingsOverrides, + removeKeybinding, + settings, supportsProjectSettingsOverrides, updateProjectScriptSettings, upsertKeybinding, @@ -4997,7 +5189,7 @@ export default function ChatView(props: ChatViewProps) { ); const addDiffSurface = useCallback(() => { if (!activeThreadRef || !isServerThread || !isGitRepo) return; - useDiffPanelStore.getState().selectGitScope(activeThreadRef, "unstaged"); + useDiffPanelStore.getState().selectGitScope(activeThreadRef, "branch"); useRightPanelStore.getState().open(activeThreadRef, "diff"); onDiffPanelOpen?.(); }, [activeThreadRef, isGitRepo, isServerThread, onDiffPanelOpen]); @@ -5352,7 +5544,7 @@ export default function ChatView(props: ChatViewProps) { if (!panels.openProactive(activeThreadRef, { id: "diff", kind: "diff" }, userActionRevision)) { return; } - useDiffPanelStore.getState().selectGitScope(activeThreadRef, "unstaged"); + useDiffPanelStore.getState().selectGitScope(activeThreadRef, "branch"); onDiffPanelOpen?.(); }, [ turnDiffSummaries, @@ -6387,6 +6579,9 @@ export default function ChatView(props: ChatViewProps) { optimisticUserMessages, ]); + // Keyed on the thread, not the draft: a draft's promotion to its server + // route keeps this instance and drops `draftId`, and the send it is still + // dispatching must survive that swap. useEffect(() => { setOptimisticUserMessages((existing) => { for (const message of existing) { @@ -6396,7 +6591,7 @@ export default function ChatView(props: ChatViewProps) { }); resetLocalDispatch(); setExpandedImage(null); - }, [draftId, resetLocalDispatch, threadId]); + }, [resetLocalDispatch, threadId]); const closeExpandedImage = useCallback(() => { setExpandedImage(null); @@ -6584,12 +6779,20 @@ export default function ChatView(props: ChatViewProps) { }, ); }, [activeThreadReferenceCopyTarget]); + const pullRequestPanelTarget = activeThread + ? threadPullRequestPanelTarget({ + projectId: activeThread.projectId, + pullRequests: visiblePullRequests, + linkedPullRequest: linkedThreadPullRequest, + branchPullRequest: activeThreadShell?.branchPullRequest ?? activeThread.branchPullRequest, + }) + : null; const addPullRequestSurface = useCallback(() => { - if (!supportsPullRequests || activeThreadRef === null || linkedThreadPullRequest === null) + if (!supportsPullRequests || activeThreadRef === null || pullRequestPanelTarget === null) return; - useRightPanelStore.getState().openPullRequest(activeThreadRef, linkedThreadPullRequest); - }, [activeThreadRef, linkedThreadPullRequest, supportsPullRequests]); - const pullRequestSurfaceAvailable = supportsPullRequests && linkedThreadPullRequest !== null; + useRightPanelStore.getState().openPullRequest(activeThreadRef, pullRequestPanelTarget); + }, [activeThreadRef, pullRequestPanelTarget, supportsPullRequests]); + const pullRequestSurfaceAvailable = supportsPullRequests && pullRequestPanelTarget !== null; const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true; const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true; const supportsPinning = serverConfig?.environment.capabilities.threadPinning === true; @@ -6614,14 +6817,20 @@ export default function ChatView(props: ChatViewProps) { activeThreadShell !== null && supportsSnooze ? threadWokeAt(activeThreadShell, { now: new Date().toISOString() }) : null; + const acknowledgeThreadWoke = useAcknowledgeThreadWoke(); const acknowledgeActiveThreadWoke = useCallback(() => { if (activeThreadRef === null || activeThreadWokeAt === null) return; - markThreadVisited(scopedThreadKey(activeThreadRef), activeThreadWokeAt); - }, [activeThreadRef, activeThreadWokeAt, markThreadVisited]); - // Mirror of the sidebar's Woke pill for the open thread. - const activeThreadLastVisitedAt = useUiStateStore((store) => + acknowledgeThreadWoke(activeThreadRef, activeThreadWokeAt); + }, [acknowledgeThreadWoke, activeThreadRef, activeThreadWokeAt]); + // Mirror of the sidebar's Woke pill for the open thread. Same watermark as + // the sidebar, so an acknowledgement from any device hides it. + const activeThreadLocalVisitedAt = useUiStateStore((store) => activeThreadKey === null ? undefined : store.threadLastVisitedAtById[activeThreadKey], ); + const activeThreadLastVisitedAt = resolveThreadLastVisitedAt( + activeThreadShell?.lastVisitedAt, + activeThreadLocalVisitedAt, + ); const activeThreadWokeVisible = useMemo(() => { if (activeThreadWokeAt === null) return false; if (activeThreadShell?.settledOverride === "settled") return false; @@ -6631,7 +6840,7 @@ export default function ChatView(props: ChatViewProps) { // above stamps it); folding that floor in here keeps a completion- // triggered wake from flashing a banner for one frame before the stamp // lands. An unparseable stored visit counts as never-visited: corrupt - // local data must not eat the wake signal. + // data must not eat the wake signal. const storedVisitMs = activeThreadLastVisitedAt ? Date.parse(activeThreadLastVisitedAt) : NaN; const completedAtMs = activeLatestRun?.completedAt ? Date.parse(activeLatestRun.completedAt) @@ -6824,7 +7033,7 @@ export default function ChatView(props: ChatViewProps) { // The stack renders items[0] front-most and tucks the rest behind hover, so // ordering is priority: system banners, then the branch-mismatch notice, // and the informational parked-thread banner last — it must never cover another. - // Background work (subagent fleets, workflow runs, watch loops) can outlive + // Background work (subagent fleets, workflow runs, watch loops, dev servers) can outlive // the turn; once it settles, the composer stop button is gone, so this // banner is the only visible stop affordance. The interrupt path also // accepts a completed run while its provider still has background work. @@ -6872,9 +7081,14 @@ export default function ChatView(props: ChatViewProps) { id: `background-work:${activeThread.id}`, variant: "default", priority: "activity", + // A dev server can run for hours after the agent is done, so only work + // that will wake the agent pulses. icon: (
- - - - - - +
- - - - - + + + + + + {breakdownModels.length === 0 ? ( - ) : ( - breakdownModels.map((model) => ( - - - - - - - )) + breakdownModels.map((model, index) => { + const key = `${model.provider}:${model.model}`; + const value = metric === "tokens" ? model.totalTokens : model.costUsd; + const share = modelShare( + model, + metric === "tokens" ? "tokens" : "cost", + ); + return ( + + + + + + + + ); + }) )}
ModelCostShareTokens
#ModelCostShareTokens
+ No activity in this window.
- - - {model.model} - - - {isModelCostUnknown(model) ? ( - Unpriced - ) : ( - formatUsd(model.costUsd) - )} - - {isModelCostUnknown(model) ? "—" : formatPercent(model.costShare)} - - {formatTokens(model.totalTokens)} -
{index + 1} + {/* The button's overlay makes the whole row open the model. + Focus shows as the row's hover fill, not a ring. */} + +
+
0 && breakdownPeak > 0 + ? `max(0.5rem, ${(value / breakdownPeak) * 100}%)` + : 0, + backgroundColor: + PROVIDER_PRESENTATION[model.provider].color, + }} + /> +
+
+ {isModelCostUnknown(model) ? ( + Unpriced + ) : ( + formatUsd(model.costUsd) + )} + + {share === null ? "" : formatPercent(share)} + {formatTokens(model.totalTokens)}
@@ -772,6 +841,35 @@ export function UsagePage() { + {selectedModel !== undefined && !showingLimits ? ( + { + setSelectedModelKey(null); + setPriceDialog({ model: selectedModel.model }); + }} + onClose={() => setSelectedModelKey(null)} + /> + ) : null} + {priceDialog ? ( + { + if (!open) setPriceDialog(null); + }} + /> + ) : null} ); } @@ -989,6 +1087,7 @@ function UsageEnvironmentFilter({ isPartial, duplicateSources, contractMismatches, + onOpenModelPrices, }: { readonly environments: readonly EnvironmentUsageStatus[]; readonly selectedEnvironments: readonly EnvironmentUsageStatus[]; @@ -998,8 +1097,8 @@ function UsageEnvironmentFilter({ readonly isPartial: boolean; readonly duplicateSources: readonly string[]; readonly contractMismatches: MergedUsage["contractMismatches"]; + readonly onOpenModelPrices: () => void; }) { - const [modelPricesOpen, setModelPricesOpen] = useState(false); const allSelected = selectedEnvironmentIds === null; const label = allSelected ? "All environments" @@ -1015,121 +1114,108 @@ function UsageEnvironmentFilter({ contractMismatches.length > 0; return ( - <> - - } - className="group/usage-environment min-w-0 max-w-full" - > - {label} - - {showUsageStatus && pendingCount > 0 ? ( - <> - - - {pendingCount} {pendingCount === 1 ? "environment" : "environments"} still - scanning - {isPartial ? "; totals are partial" : ""} - - - ) : showUsageStatus && hasIssue ? ( - - ) : ( - - )} - - - - onSelectionChange(checked ? null : new Set())} - > - All environments - - - {environments.map((environment) => { - const checked = - selectedEnvironmentIds === null || - selectedEnvironmentIds.has(environment.environmentId); - const status = - environment.error !== null - ? "Unavailable" - : environment.summary !== null && - !isCompatibleUsageContractVersion( - environment.summary.contractVersion, - USAGE_CONTRACT_VERSION, - ) - ? "Update required" - : environment.summary === null - ? "Scanning…" - : environment.isPending - ? "Refreshing…" - : "Ready"; - return ( - { - const next = new Set(selectedEnvironments.map((entry) => entry.environmentId)); - if (nextChecked) next.add(environment.environmentId); - else next.delete(environment.environmentId); - onSelectionChange(next.size === environments.length ? null : next); - }} - > - - {environment.label} - {showUsageStatus ? ( - - {status} - - ) : null} - - - ); - })} - {environments.length === 0 ? ( -

No environments connected.

- ) : null} - {showUsageStatus && isPartial ? ( -

- Totals are partial while selected environments scan. -

- ) : null} - {showUsageStatus ? ( - + } className="group/usage-environment min-w-0 max-w-full"> + {label} + + {showUsageStatus && pendingCount > 0 ? ( + <> + + + {pendingCount} {pendingCount === 1 ? "environment" : "environments"} still scanning + {isPartial ? "; totals are partial" : ""} + + + ) : showUsageStatus && hasIssue ? ( + - ) : null} - - setModelPricesOpen(true)}> - - Model prices - -
-
- {modelPricesOpen ? ( - - ) : null} - + ) : ( + + )} + + + + onSelectionChange(checked ? null : new Set())} + > + All environments + + + {environments.map((environment) => { + const checked = + selectedEnvironmentIds === null || + selectedEnvironmentIds.has(environment.environmentId); + const status = + environment.error !== null + ? "Unavailable" + : environment.summary !== null && + !isCompatibleUsageContractVersion( + environment.summary.contractVersion, + USAGE_CONTRACT_VERSION, + ) + ? "Update required" + : environment.summary === null + ? "Scanning…" + : environment.isPending + ? "Refreshing…" + : "Ready"; + return ( + { + const next = new Set(selectedEnvironments.map((entry) => entry.environmentId)); + if (nextChecked) next.add(environment.environmentId); + else next.delete(environment.environmentId); + onSelectionChange(next.size === environments.length ? null : next); + }} + > + + {environment.label} + {showUsageStatus ? ( + + {status} + + ) : null} + + + ); + })} + {environments.length === 0 ? ( +

No environments connected.

+ ) : null} + {showUsageStatus && isPartial ? ( +

+ Totals are partial while selected environments scan. +

+ ) : null} + {showUsageStatus ? ( + + ) : null} + + + + Model prices + +
+ ); } @@ -1173,15 +1259,16 @@ function UsageSkeleton() {

Totals

-
- {["Processed tokens", "Cached input", "Uncached input", "Output", "Cache savings"].map( - (label) => ( -
- {label} - -
- ), - )} + +
+ +
+
+ + +
@@ -1195,3 +1282,16 @@ function UsageSkeleton() { ); } + +function MetricSkeletons({ labels }: { readonly labels: readonly string[] }) { + return ( +
+ {labels.map((label) => ( +
+ {label} + +
+ ))} +
+ ); +} diff --git a/apps/web/src/components/usage/UsagePriceOverrides.tsx b/apps/web/src/components/usage/UsagePriceOverrides.tsx index 79ad4d473409..0b52687c81b0 100644 --- a/apps/web/src/components/usage/UsagePriceOverrides.tsx +++ b/apps/web/src/components/usage/UsagePriceOverrides.tsx @@ -34,6 +34,7 @@ import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; import { USAGE_PRICE_FIELDS } from "./usagePriceForm"; import { isEmptyUsagePriceDraft, + usageAliasCell, usagePriceCell, usagePriceTableChanges, usagePriceTableErrors, @@ -63,6 +64,10 @@ const priceTargetsAtom = Atom.make((get): readonly UsagePriceTarget[] => environmentId, label: environment.entry.target.label, prices: settings?.usagePriceOverrides ?? null, + aliases: + environment.serverConfig?.environment.capabilities.usageModelAliases === true + ? (settings?.usageModelAliases ?? null) + : null, unavailable: environment.connection.phase !== "connected" ? "Offline" @@ -91,13 +96,19 @@ type SaveAttempt = { >; }; +/** + * Edits custom model prices and mappings. A mapped model's usage counts as its + * target model. `initialModel` opens with a new row for that model. + */ export function UsagePriceOverrides({ usage, initialSelectedEnvironmentIds, + initialModel, onOpenChange, }: { readonly usage: readonly EnvironmentUsageStatus[]; readonly initialSelectedEnvironmentIds: ReadonlySet | null; + readonly initialModel?: string | undefined; readonly onOpenChange: (open: boolean) => void; }) { const environments = useAtomValue(priceTargetsAtom); @@ -105,14 +116,29 @@ export function UsagePriceOverrides({ const selected = environments.filter( (environment) => selectedIds === null || selectedIds.has(environment.environmentId), ); - const [drafts, setDrafts] = useState([]); + // A model that already has a custom price or mapping is edited in its existing row. + const [drafts, setDrafts] = useState(() => + initialModel === undefined || + selected.some( + (environment) => + Object.hasOwn(environment.prices ?? {}, initialModel) || + Object.hasOwn(environment.aliases ?? {}, initialModel), + ) + ? [] + : [{ id: "new:initial", model: initialModel, isNew: true, values: {} }], + ); const [pending, setPending] = useState(false); const [attempt, setAttempt] = useState(null); const focusRowRef = useRef(null); const nextRowId = useRef(0); const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: false }); const customModels = [ - ...new Set(selected.flatMap((environment) => Object.keys(environment.prices ?? {}))), + ...new Set( + selected.flatMap((environment) => [ + ...Object.keys(environment.prices ?? {}), + ...Object.keys(environment.aliases ?? {}), + ]), + ), ].sort(); const models = [ ...new Set([ @@ -179,19 +205,31 @@ export function UsagePriceOverrides({ ); setAttempt(null); }; + // An existing row edited back to its saved values has nothing left to save. + const matchesSaved = ( + row: UsagePriceDraft, + cell: { value: string; placeholder: string }, + value: string, + ) => + !row.isNew && + cell.placeholder !== "Mixed" && + cell.placeholder !== "Unavailable" && + value === cell.value; + const updateOrDropDraft = (draft: UsagePriceDraft) => { + if (!draft.isNew && Object.keys(draft.values).length === 0 && draft.alias === undefined) + setDrafts((previous) => previous.filter((entry) => entry.id !== draft.id)); + else updateDraft(draft); + }; const editCell = (row: UsagePriceDraft, field: UsagePriceField, value: string) => { const values = { ...row.values, [field]: value }; const original = usagePriceCell(selected, row.model, field); - if ( - !row.isNew && - original.placeholder !== "Mixed" && - original.placeholder !== "Unavailable" && - value === original.value - ) - delete values[field]; - if (!row.isNew && Object.keys(values).length === 0) - setDrafts((previous) => previous.filter((entry) => entry.id !== row.id)); - else updateDraft({ ...row, values }); + if (matchesSaved(row, original, value)) delete values[field]; + updateOrDropDraft({ ...row, values }); + }; + const editAlias = (row: UsagePriceDraft, value: string) => { + const { alias: _alias, ...rest } = row; + const original = usageAliasCell(selected, row.model); + updateOrDropDraft(matchesSaved(row, original, value) ? rest : { ...rest, alias: value }); }; const save = async (retry = false) => { if (pending) return; @@ -202,7 +240,7 @@ export function UsagePriceOverrides({ (destination) => environments.find( (environment) => environment.environmentId === destination.environmentId, - ) ?? { ...destination, prices: null, unavailable: "Environment removed" }, + ) ?? { ...destination, prices: null, aliases: null, unavailable: "Environment removed" }, ); const changes = new Map( targets.map((target) => [target.environmentId, usagePriceTableChanges(target, edits)]), @@ -245,11 +283,11 @@ export function UsagePriceOverrides({ if (!pending) onOpenChange(open); }} > - + Custom model prices - Prices apply to all past and future usage on the environments you select. + Prices and mappings apply to all past and future usage on the environments you select. @@ -314,9 +352,10 @@ export function UsagePriceOverrides({ ) : ( <>
- +
- + + {USAGE_PRICE_FIELDS.map((field) => ( ))} @@ -325,6 +364,7 @@ export function UsagePriceOverrides({ Model ID + Map to {USAGE_PRICE_FIELDS.map((field) => ( {field.label} ))} @@ -348,132 +388,157 @@ export function UsagePriceOverrides({ {rows.length === 0 ? ( - +

{selected.some((environment) => environment.prices === null) ? "Some environment prices are unavailable." - : "No custom prices. Add a row to override automatic pricing."} + : "No custom prices or mappings. Add a row to set one."}

) : ( - rows.map((row) => ( - - - {row.isNew ? ( - { - if (node && focusRowRef.current === row.id) { - node.focus(); - focusRowRef.current = null; + rows.map((row) => { + const aliasCell = usageAliasCell(selected, row.model); + const alias = (row.alias ?? aliasCell.value).trim(); + return ( + + + {row.isNew ? ( + { + if (node && focusRowRef.current === row.id) { + node.focus(); + focusRowRef.current = null; + } + }} + aria-label="New model ID" + aria-invalid={ + (row.model.trim() !== "" && errors.has(row.id)) || undefined + } + list="usage-price-models" + placeholder="Model ID" + autoComplete="off" + spellCheck={false} + disabled={locked} + onChange={(event) => + updateDraft({ ...row, model: event.target.value }) } - }} - aria-label="New model ID" - aria-invalid={ - (row.model.trim() !== "" && errors.has(row.id)) || undefined - } - list="usage-price-models" - placeholder="Model ID" - autoComplete="off" - spellCheck={false} - disabled={locked} - onChange={(event) => - updateDraft({ ...row, model: event.target.value }) - } - /> + /> + ) : ( + + {row.model} + + )} + {errors.has(row.id) && + (row.model.trim() !== "" || + Object.values(row.values).some((value) => value !== "")) ? ( +

+ {errors.get(row.id)} +

+ ) : null} +
+ {row.removed ? ( + + + Automatic pricing after saving + + ) : ( - - {row.model} - + + editAlias(row, event.target.value)} + /> + )} - {errors.has(row.id) && - (row.model.trim() !== "" || - Object.values(row.values).some((value) => value !== "")) ? ( -

- {errors.get(row.id)} -

- ) : null} -
- {row.removed ? ( - - - Automatic pricing after saving - - - ) : ( - USAGE_PRICE_FIELDS.map((field) => { - const cell = usagePriceCell(selected, row.model, field.key); - return ( - - - editCell(row, field.key, event.target.value) - } - /> - - ); - }) - )} - - - } - disabled={locked} - aria-label={ - row.removed - ? `Undo reset for ${row.model}` - : row.isNew - ? "Remove new model" - : `Reset price for ${row.model} to automatic` - } - onClick={() => { - if (row.isNew) - setDrafts((previous) => - previous.filter((entry) => entry.id !== row.id), - ); - else if (row.removed) { - if (Object.keys(row.values).length === 0) + {row.removed ? null : alias !== "" ? ( + + + Counted as {alias} + + + ) : ( + USAGE_PRICE_FIELDS.map((field) => { + const cell = usagePriceCell(selected, row.model, field.key); + return ( + + + editCell(row, field.key, event.target.value) + } + /> + + ); + }) + )} + + + } + disabled={locked} + aria-label={ + row.removed + ? `Undo reset for ${row.model}` + : row.isNew + ? "Remove new model" + : `Reset price for ${row.model} to automatic` + } + onClick={() => { + if (row.isNew) setDrafts((previous) => previous.filter((entry) => entry.id !== row.id), ); - else updateDraft({ ...row, removed: false }); - } else updateDraft({ ...row, removed: true }); - }} - > - {row.isNew ? : } - - - {row.removed - ? "Undo reset" - : row.isNew - ? "Remove row" - : "Reset to automatic"} - - - -
- )) + else if (row.removed) + updateOrDropDraft({ ...row, removed: false }); + else updateDraft({ ...row, removed: true }); + }} + > + {row.isNew ? ( + + ) : ( + + )} + + + {row.removed + ? "Undo reset" + : row.isNew + ? "Remove row" + : "Reset to automatic"} + + + +
+ ); + }) )}
@@ -485,8 +550,20 @@ export function UsagePriceOverrides({
{ +export interface EnvironmentQueryView { readonly data: A | null; readonly dataUpdatedAt: number; readonly error: string | null; + readonly failure: E | null; readonly isPending: boolean; readonly isSuccess: boolean; readonly refresh: () => void; @@ -25,7 +26,7 @@ export function formatEnvironmentQueryError(cause: Cause.Cause): string export function useEnvironmentQuery( atom: Atom.Atom> | null, -): EnvironmentQueryView { +): EnvironmentQueryView { const selectedAtom = atom ?? EMPTY_ASYNC_RESULT_ATOM; const result = useAtomValue(selectedAtom); const refresh = useAtomRefresh(selectedAtom); @@ -38,6 +39,8 @@ export function useEnvironmentQuery( ? (Option.getOrNull(result.previousSuccess)?.timestamp ?? 0) : 0, error: result._tag === "Failure" ? formatEnvironmentQueryError(result.cause) : null, + failure: + result._tag === "Failure" ? Option.getOrNull(Cause.findErrorOption(result.cause)) : null, isPending: atom !== null && result.waiting, isSuccess: result._tag === "Success", refresh, diff --git a/apps/web/src/state/server.ts b/apps/web/src/state/server.ts index 065cf1d66434..df9ca1b56e60 100644 --- a/apps/web/src/state/server.ts +++ b/apps/web/src/state/server.ts @@ -10,6 +10,7 @@ import { type ServerSettings, } from "@t3tools/contracts"; import { createServerEnvironmentAtoms } from "@t3tools/client-runtime/state/server"; +import { createOutdatedServerUpdateCommand } from "@t3tools/client-runtime/state/outdatedServerUpdate"; import { createEnvironmentServerConfigsAtom } from "@t3tools/client-runtime/state/shell"; import { mergeWithDefaultKeybindings } from "@t3tools/shared/keybindings"; import * as Option from "effect/Option"; @@ -32,6 +33,8 @@ export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRunt usageLimitSources: true, usageLimitsCommand: true, }); +/** Updates a host whose protocol is too old for this client to connect to. */ +export const updateOutdatedServer = createOutdatedServerUpdateCommand(connectionAtomRuntime); export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({ catalogValueAtom: environmentCatalog.catalogValueAtom, serverConfigValueAtom: serverEnvironment.configValueAtom, diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts index f61bb9060384..7bd4657e7d56 100644 --- a/apps/web/src/state/usage.ts +++ b/apps/web/src/state/usage.ts @@ -10,6 +10,7 @@ import { useAtomValue } from "@effect/atom-react"; import { USAGE_CONTRACT_VERSION, type EnvironmentId, + type UsageBucket, type UsageSummary, type UsageSummaryInput, } from "@t3tools/contracts"; @@ -79,6 +80,33 @@ export interface UsageView { readonly refresh: (input?: UsageSummaryInput) => Promise; } +/** + * Merges every environment that has answered. `keepBucket` narrows the merge, + * for example to one model; source ownership still applies, so the result + * matches that slice of the full merge. Session counts are per directory and + * are not narrowed. + */ +export function mergeAnsweredUsage( + environments: readonly EnvironmentUsageStatus[], + keepBucket?: (bucket: UsageBucket) => boolean, +): MergedUsage { + const answered: EnvironmentUsage[] = environments.flatMap(({ environmentId, label, summary }) => + summary === null + ? [] + : [ + { + environmentId, + label, + summary: + keepBucket === undefined + ? summary + : { ...summary, buckets: summary.buckets.filter(keepBucket) }, + }, + ], + ); + return mergeUsage(answered, USAGE_CONTRACT_VERSION); +} + export function useUsage( input: UsageSummaryInput, selectedEnvironmentIds: ReadonlySet | null = null, @@ -126,20 +154,7 @@ export function useUsage( [selectedEnvironments, windowKey], ); - const merged = useMemo(() => { - const answered: EnvironmentUsage[] = selectedEnvironments.flatMap((environment) => - environment.summary === null - ? [] - : [ - { - environmentId: environment.environmentId, - label: environment.label, - summary: environment.summary, - }, - ], - ); - return mergeUsage(answered, USAGE_CONTRACT_VERSION); - }, [selectedEnvironments]); + const merged = useMemo(() => mergeAnsweredUsage(selectedEnvironments), [selectedEnvironments]); const answeredCount = selectedEnvironments.filter( (environment) => environment.summary !== null, diff --git a/apps/web/src/vscodeThemeImport.test.ts b/apps/web/src/vscodeThemeImport.test.ts index 797346a6557b..03860b631480 100644 --- a/apps/web/src/vscodeThemeImport.test.ts +++ b/apps/web/src/vscodeThemeImport.test.ts @@ -142,6 +142,157 @@ describe("VS Code theme import", () => { ]); }); + it("prefers a visible input background over a transparent border", () => { + const catppuccin = parseVsCodeThemeFile({ + name: "Catppuccin Mocha", + type: "dark", + colors: { + "editor.background": "#1e1e2e", + "input.border": "#00000000", + "input.background": "#313244", + }, + }); + expect(asHex(catppuccin.colors.input)).toBe("#313244"); + }); + + it("keeps unchecked inputs distinct from the checked action color", () => { + const gruvbox = parseVsCodeThemeFile({ + name: "Gruvbox Light Soft", + type: "light", + colors: { + "editor.background": "#f2e5bc", + "button.background": "#45858880", + "input.background": "#45858880", + "input.border": "#928374", + }, + }); + expect( + contrastRatio(gruvbox.colors.input, gruvbox.colors.messageAction), + ).toBeGreaterThanOrEqual(1.1); + }); + + it("keeps the derived input distinct when a button reuses it", () => { + const theme = parseVsCodeThemeFile({ + name: "Derived input collision", + type: "dark", + colors: { + "editor.background": "#1e1e2e", + focusBorder: "#89b4fa", + "button.background": "#525661", + }, + }); + + expect(asHex(theme.colors.messageAction)).toBe("#525661"); + expect(asHex(theme.colors.input)).not.toBe("#525661"); + expect(contrastRatio(theme.colors.input, theme.colors.canvas)).toBeGreaterThanOrEqual(1.1); + expect(contrastRatio(theme.colors.input, theme.colors.messageAction)).toBeGreaterThanOrEqual( + 1.1, + ); + }); + + it("skips a transparent focus border for a visible accent key", () => { + const vitesse = parseVsCodeThemeFile({ + name: "Vitesse Dark", + type: "dark", + colors: { + "editor.background": "#121212", + focusBorder: "#00000000", + "button.background": "#4d9375", + }, + }); + expect(asHex(vitesse.colors.accent)).toBe("#4d9375"); + expect(asHex(vitesse.colors.focus)).toBe("#4d9375"); + expect(contrastRatio(vitesse.colors.focus, vitesse.colors.canvas)).toBeGreaterThanOrEqual(1.1); + }); + + it("keeps focus visible against an explicit raised surface", () => { + const theme = parseVsCodeThemeFile({ + name: "Raised focus", + type: "dark", + colors: { + "editor.background": "#000000", + focusBorder: "#111111", + "editorWidget.background": "#111111", + "button.background": "#4d9375", + }, + }); + expect(contrastRatio(theme.colors.focus, theme.colors.surfaceRaised)).toBeGreaterThanOrEqual( + 1.1, + ); + expect(asHex(theme.colors.focus)).toBe("#4d9375"); + }); + + it("keeps the fallback focus visible against an explicit raised surface", () => { + const theme = parseVsCodeThemeFile({ + name: "Raised fallback focus", + type: "dark", + colors: { + "editor.background": "#121212", + "editorWidget.background": "#346bf1", + }, + }); + expect(asHex(theme.colors.focus)).toBe("#ffffff"); + expect(contrastRatio(theme.colors.focus, theme.colors.canvas)).toBeGreaterThanOrEqual(1.1); + expect(contrastRatio(theme.colors.focus, theme.colors.surfaceRaised)).toBeGreaterThanOrEqual( + 1.1, + ); + }); + + it("skips a transparent button background for the action color", () => { + const theme = parseVsCodeThemeFile({ + name: "Transparent button", + type: "dark", + colors: { + "editor.background": "#121212", + focusBorder: "#4d9375", + "button.background": "#00000000", + }, + }); + expect(asHex(theme.colors.messageAction)).toBe("#4d9375"); + }); + + it("skips a button background that matches the raised surface", () => { + const theme = parseVsCodeThemeFile({ + name: "Raised button", + type: "dark", + colors: { + "editor.background": "#121212", + focusBorder: "#4d9375", + "editorWidget.background": "#2a2d3a", + "button.background": "#2a2d3a", + }, + }); + expect(asHex(theme.colors.messageAction)).toBe("#4d9375"); + }); + + it("uses a visible default accent when the file has no usable accent key", () => { + const theme = parseVsCodeThemeFile({ + name: "No accent", + type: "dark", + colors: { "editor.background": "#121212", focusBorder: "#121212" }, + }); + expect(contrastRatio(theme.colors.focus, theme.colors.canvas)).toBeGreaterThanOrEqual(1.1); + }); + + it("validates placeholders against the resolved raised surface", () => { + const lightPlus = parseVsCodeThemeFile({ + name: "Light Plus Shape", + type: "light", + colors: { + "editor.background": "#eaeff3", + "editor.foreground": "#1f1f1f", + "editorWidget.background": "#ffffff", + "input.placeholderForeground": "#767676", + }, + }); + expect( + contrastRatio(lightPlus.colors.placeholder, lightPlus.colors.surfaceRaised), + ).toBeGreaterThanOrEqual(4.5); + expect( + contrastRatio(lightPlus.colors.placeholder, lightPlus.colors.surfaceRaised), + ).toBeLessThan(contrastRatio(lightPlus.colors.text, lightPlus.colors.surfaceRaised)); + }); + it("fills every role the file omits with a readable derived value", () => { const theme = parseVsCodeThemeFile(VSCODE_DARK); const colors = getThemeColorsForMode(theme, "dark")!; diff --git a/apps/web/src/vscodeThemeImport.ts b/apps/web/src/vscodeThemeImport.ts index fc0fc2bf8cbc..2d6ff7f6aa80 100644 --- a/apps/web/src/vscodeThemeImport.ts +++ b/apps/web/src/vscodeThemeImport.ts @@ -1,5 +1,6 @@ import { createVividThemeColors, + getStandardThemeColors, getThemeModes, isReservedThemeId, parseThemeFile, @@ -16,10 +17,10 @@ import { * * VS Code themes describe editor chrome, not an app palette: they carry a few * hundred workbench keys, leave most of them unset, and freely use 8-digit - * hex with alpha for overlays. So the conversion derives a complete, contrast - * -solved palette from the theme's editor background and accent, then layers - * the workbench colors it did specify on top. Anything the file omits keeps - * the derived value instead of falling back to an unrelated palette. + * hex with alpha for overlays. The conversion derives a complete palette from + * a visible accent and the editor background, then layers usable workbench + * colors on top. Foregrounds must remain readable, while authored focus and + * control candidates must remain distinct from adjacent surfaces and states. */ type VsCodeRgba = { r: number; g: number; b: number; a: number }; @@ -212,26 +213,61 @@ export function parseVsCodeThemeFile(value: unknown): ThemeDefinition { const canvas = { r: canvasColor.r, g: canvasColor.g, b: canvasColor.b }; const appearance = resolveAppearance(value, canvas); - const accentColor = pick( + const canvasHex = toHex(canvas); + const raisedSurfaceCandidateHex = solidOver( + canvas, + "editorWidget.background", + "dropdown.background", + ); + + /** Accent and control candidates must clear this small separation floor + * from every adjacent surface or state checked by the importer. */ + const standsApart = (first: string, second: string) => + contrastRatio(hexToRgb(first), hexToRgb(second)) >= 1.1; + + let accentColor: VsCodeRgba | null = null; + let accentHex: string | null = null; + for (const key of [ "focusBorder", "button.background", "textLink.foreground", "activityBarBadge.background", "progressBar.background", "badge.background", - ); - const canvasHex = toHex(canvas); - const accentHex = accentColor ? flattenOver(accentColor, canvas) : null; + ]) { + const candidate = parseVsCodeColor(colors[key]); + if (!candidate) continue; + const candidateHex = flattenOver(candidate, canvas); + if ( + !standsApart(candidateHex, canvasHex) || + (raisedSurfaceCandidateHex !== null && !standsApart(candidateHex, raisedSurfaceCandidateHex)) + ) + continue; + accentColor = candidate; + accentHex = candidateHex; + break; + } + if (!accentColor || !accentHex) { + const standardAccentHex = themeColorToHex(getStandardThemeColors(appearance).accent)!; + accentHex = + [standardAccentHex, "#ffffff", "#000000"].find( + (candidate) => + standsApart(candidate, canvasHex) && + (raisedSurfaceCandidateHex === null || standsApart(candidate, raisedSurfaceCandidateHex)), + ) ?? standardAccentHex; + accentColor = parseVsCodeColor(accentHex)!; + } // The derived palette is the floor: every role starts contrast-solved, then // the theme's own workbench colors replace what it actually specified. The // floor derives from a muted accent -- the vivid engine carries the accent // hue into every surface, which washes an imported neutral palette (a gray // theme with a blue focusBorder would get blue code and text surfaces). - const mutedAccentHex = accentColor - ? flattenOver({ r: accentColor.r, g: accentColor.g, b: accentColor.b, a: 0.2 }, canvas) - : null; - const derived = createVividThemeColors(appearance, canvasHex, mutedAccentHex ?? canvasHex); + const mutedAccentHex = flattenOver( + { r: accentColor.r, g: accentColor.g, b: accentColor.b, a: 0.2 }, + canvas, + ); + const derived = createVividThemeColors(appearance, canvasHex, mutedAccentHex); const sidebarHex = solidOver(canvas, "sideBar.background", "activityBar.background") ?? derived.sidebar; const sidebar = hexToRgb(sidebarHex); @@ -258,6 +294,34 @@ export function parseVsCodeThemeFile(value: unknown): ThemeDefinition { return relativeLuminance(surfaceRgb) < 0.179 ? "#ffffff" : "#000000"; }; + const surfaceRaisedHex = raisedSurfaceCandidateHex ?? derived.surfaceRaised; + // The checked switch track maps to messageAction, so resolve it before + // choosing the input role used by the unchecked track. + const buttonHex = solidOver(canvas, "button.background"); + const actionHex = + buttonHex && standsApart(buttonHex, canvasHex) && standsApart(buttonHex, surfaceRaisedHex) + ? buttonHex + : accentHex; + const inputCandidates = [ + derived.input, + derived.surfaceRaised, + getStandardThemeColors(appearance).input, + "#000000", + "#ffffff", + "#808080", + ]; + let inputHex = + inputCandidates.find( + (candidate) => standsApart(candidate, canvasHex) && standsApart(candidate, actionHex), + ) ?? "#808080"; + for (const key of ["input.background", "input.border"]) { + const candidate = solidOver(canvas, key); + if (candidate && standsApart(candidate, canvasHex) && standsApart(candidate, actionHex)) { + inputHex = candidate; + break; + } + } + const overrides: Partial> = { canvas: canvasHex, text: readableOn(canvasHex, derived.text, "editor.foreground", "foreground"), @@ -268,15 +332,14 @@ export function parseVsCodeThemeFile(value: unknown): ThemeDefinition { "disabledForeground", ), surface: solidOver(canvas, "editorWidget.background") ?? derived.surface, - surfaceRaised: - solidOver(canvas, "editorWidget.background", "dropdown.background") ?? derived.surfaceRaised, + surfaceRaised: surfaceRaisedHex, surfaceOverlay: solidOver(canvas, "menu.background", "quickInput.background", "dropdown.background") ?? derived.surfaceOverlay, border: solidOver(canvas, "panel.border", "editorGroup.border", "contrastBorder") ?? derived.border, - input: solidOver(canvas, "input.border", "dropdown.border") ?? derived.input, - placeholder: readableOn(canvasHex, derived.placeholder, "input.placeholderForeground"), + input: inputHex, + placeholder: readableOn(surfaceRaisedHex, derived.placeholder, "input.placeholderForeground"), error: readableOn(canvasHex, derived.error, "editorError.foreground", "errorForeground"), warning: readableOn(canvasHex, derived.warning, "editorWarning.foreground"), accentSurface: @@ -303,23 +366,15 @@ export function parseVsCodeThemeFile(value: unknown): ThemeDefinition { terminalScrollbar: solidOver(terminal, "scrollbarSlider.background") ?? derived.terminalScrollbar, }; - if (accentHex) { - overrides.accent = accentHex; - overrides.focus = accentHex; - // The button pair is the closest thing VS Code has to our action color. - const actionHex = solidOver(canvas, "button.background") ?? accentHex; - overrides.messageAction = actionHex; - overrides.messageActionForeground = readableOn( - actionHex, - derived.messageActionForeground, - "button.foreground", - ); - overrides.accentForeground = readableOn( - accentHex, - derived.accentForeground, - "button.foreground", - ); - } + overrides.accent = accentHex; + overrides.focus = accentHex; + overrides.messageAction = actionHex; + overrides.messageActionForeground = readableOn( + actionHex, + derived.messageActionForeground, + "button.foreground", + ); + overrides.accentForeground = readableOn(accentHex, derived.accentForeground, "button.foreground"); // Reuse the theme-file parser so ids, names, and color values go through the // same validation as a hand-written file. diff --git a/docs/fork/0027-symlinked-settings-stay-linked.md b/docs/fork/0027-symlinked-settings-stay-linked.md deleted file mode 100644 index 5147bda9e946..000000000000 --- a/docs/fork/0027-symlinked-settings-stay-linked.md +++ /dev/null @@ -1,31 +0,0 @@ -# 0027: Symlinked settings stay linked - -- PRs: [TrogonStack/t3code#73](https://github.com/TrogonStack/t3code/pull/73), [TrogonStack/t3code#74](https://github.com/TrogonStack/t3code/pull/74) -- Status: active - -## What you can do now - -- Keep T3 Code's settings in a dotfiles repository and link them into the T3 - home. Changing a setting in the app writes through the link, so the - repository copy is always the one in use. -- Edit settings from the app as often as you like without the link quietly - turning into a standalone copy that the repository no longer sees. - -## Why - -Saving settings used to replace a linked file with a regular one. Nothing -failed and the app kept working, so the first sign was a later discovery that -the repository and the machine had drifted apart, with no record of which -changes happened where. Configuration that was supposed to be versioned had -stopped being versioned on the first save. - -That silence is the reason to fix it rather than document it. A setup that -breaks loudly gets fixed on the spot; one that detaches without a trace costs -a reconciliation session weeks later, after the two copies have each picked up -changes the other lacks. - -## Upstream considerations - -A clean upstream submission. Writes still land atomically, nothing changes for -anyone who does not use links, and the behavior is what a user who links a -config file expects. Once an equivalent lands upstream, this entry goes. diff --git a/docs/fork/README.md b/docs/fork/README.md index d23ab558a332..403911e0167c 100644 --- a/docs/fork/README.md +++ b/docs/fork/README.md @@ -45,5 +45,3 @@ Each entry uses these sections: active, [#57](https://github.com/TrogonStack/t3code/pull/57) - **0026** [Telemetry says which app sent it](./0026-telemetry-says-which-app-sent-it.md) active, [#68](https://github.com/TrogonStack/t3code/pull/68) -- **0027** [Symlinked settings stay linked](./0027-symlinked-settings-stay-linked.md) - active, [#73](https://github.com/TrogonStack/t3code/pull/73), [#74](https://github.com/TrogonStack/t3code/pull/74) diff --git a/docs/internals/composer-context-references.md b/docs/internals/composer-context-references.md index 855f327d6165..3f71261aa762 100644 --- a/docs/internals/composer-context-references.md +++ b/docs/internals/composer-context-references.md @@ -123,7 +123,7 @@ trailing `` form is parsed only when reading messages sent by The composer sends `message.text` as canonical prose with reference links and `message.context.records` built from the draft (`buildMessageContext` in `apps/web/src/lib/composerContextRecords.ts`). Expired terminal excerpts are dropped from both. -The server projects provider text at turn start (`ProviderCommandReactor`), so the persisted +The server projects provider text at turn start (`projectComposerContextForProvider`), so the persisted message stays readable and the provider receives markers plus one envelope. Review comments and preview annotations enter the draft through store mutators. A mounted composer diff --git a/docs/internals/connection-runtime.md b/docs/internals/connection-runtime.md index 301e1e0ffecb..35c8b0a73658 100644 --- a/docs/internals/connection-runtime.md +++ b/docs/internals/connection-runtime.md @@ -10,16 +10,20 @@ several views need the same environment. The [supervisor](../../packages/client-runtime/src/connection/supervisor.ts) owns transport retry policy; resolving an endpoint and opening an RPC session are single -attempts. Transient failures retry with capped backoff. Offline states and -authentication failures wait for a wakeup instead of spending attempts on -unchanged conditions. +attempts. Transient failures retry with jittered exponential backoff, capped at +five minutes, that resets only after a connection stays up. Without jitter, every +client of a restarted server reconnects in the same second; with a short cap, a +client that can never connect retries all day. Offline states and authentication +failures wait for a wakeup instead of spending attempts on unchanged conditions. -Foregrounding needs different treatment depending on the connection's state. -It wakes a retry immediately, leaves an ordinary in-flight attempt alone, and -probes an established session before replacing it. A long mobile background -suspension forces replacement because the OS can kill a socket without reporting -closure. Treating every foreground event as a reconnect delays healthy attempts; -treating every resume as harmless leaves suspended sockets stuck. +Foregrounding, an explicit retry, and an offline report probe the established +session, and only a failed probe reconnects. Offline reports are often wrong, for +example for a loopback server. A long mobile background suspension is the one +exception: it replaces the session at once, because the OS can kill a socket +without reporting closure, and a probe would hold a dead socket in "Resuming" +until it times out. That fresh attempt runs even while the network reports +offline. Foregrounding also wakes a pending retry immediately and +leaves an ordinary in-flight attempt alone. The [registry](../../packages/client-runtime/src/connection/registry.ts) scopes connections by environment. An involuntary disconnect retains the registration diff --git a/docs/internals/devices.md b/docs/internals/devices.md index 0d37f4c379f9..7a082c17e150 100644 --- a/docs/internals/devices.md +++ b/docs/internals/devices.md @@ -60,7 +60,7 @@ a shim directory to the provider's PATH. The CLI installs on the environment server even when that server cannot run simulators. Hosts start on demand. That environment is fixed when the provider subprocess spawns, so -[`prepareMcpSession`](../../apps/server/src/provider/Layers/ProviderService.ts) +[`prepareMcpSession`](../../apps/server/src/orchestration-v2/ProviderSessionManager.ts) starts agent-device only when device support and agent access have both been enabled, the session has the `device` capability, and the machine can run at least one platform. Starting it later from `device_open` would leave the diff --git a/docs/internals/environment-auth.md b/docs/internals/environment-auth.md index 2428783ccc56..08802cbb202b 100644 --- a/docs/internals/environment-auth.md +++ b/docs/internals/environment-auth.md @@ -28,7 +28,8 @@ Bearer and DPoP clients obtain short-lived WebSocket tickets through authenticat HTTP so long-lived tokens stay out of socket URLs. Browser sessions can authenticate the upgrade with their cookie. A successful handshake grants no extra authority: [every RPC declares a required -scope](../../apps/server/src/auth/RpcAuthorization.ts). +scope](../../apps/server/src/auth/RpcAuthorization.ts), and the WebSocket RPC +group's `RpcScopeAuthorization` middleware checks it before any handler runs. Desktop restarts forget the previous local bearer token, so its reusable bootstrap grant replaces earlier sessions for the same subject and method. diff --git a/docs/internals/glossary.md b/docs/internals/glossary.md index e2d275a7180c..3506f189bab5 100644 --- a/docs/internals/glossary.md +++ b/docs/internals/glossary.md @@ -13,23 +13,21 @@ Terms whose meaning matters across T3 Code. Architecture and lifecycle constrain | Workspace root | The project's base filesystem directory on the environment. | | Worktree | A separate Git checkout a thread can use instead of the project's main checkout. | | Thread | The durable conversation and work history for a project. It survives provider process exits. | -| Turn | One user-to-agent work cycle. Provider work can finish before checkpoint and diff work settles. | +| Turn | One user-to-agent cycle, a V2 run. Provider work can end before checkpoint and diff work settles. | | Activity | A non-message timeline item, such as a tool action, approval, or failure. | | T3 home | The base data directory. Runtime state normally lives under its `userdata` directory. | ## Orchestration -| Term | Meaning | -| ----------------------- | -------------------------------------------------------------------------------------------- | -| Command | A request to change domain state. Accepting it does not mean its side effects have finished. | -| Event | A persisted fact produced by a command. | -| Decider | The pure logic that turns a command and current state into events. | -| Projection / read model | A view of current state derived from persisted events. | -| Projector | The logic that applies events to a read model. | -| Reactor | A worker that performs follow-up work in response to recorded intent or runtime signals. | -| Command receipt | A durable record of a command's result, used to make retries idempotent. | -| Runtime receipt | A test-only signal that an asynchronous milestone completed. | -| Quiesced | The relevant follow-up workers have finished, beyond the provider turn merely ending. | +| Term | Meaning | +| ----------------------- | --------------------------------------------------------------------------------------------------------- | +| Command | A request to change domain state. Accepting it does not mean its side effects have finished. | +| Event | A persisted fact produced by a command. | +| Orchestrator | The service that serializes commands and decides their events from current state, without I/O. | +| Projection / read model | A persisted view of current state, committed in the same transaction as the events that change it. | +| Command receipt | A durable record of a command's result, used to make retries idempotent. | +| Outbox effect | Side-effect intent committed with the events, such as starting a provider turn or capturing a checkpoint. | +| Effect worker | The worker that runs outbox effects after commit and feeds their results back as commands. | ## Providers and checkpoints @@ -52,7 +50,7 @@ Terms whose meaning matters across T3 Code. Architecture and lifecycle constrain | Term | Meaning | | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Pull request link | A persisted thread association identified by host, repository, and number. Links can cross projects within an environment and carry a server-maintained snapshot. | -| Pull request sync | The reactor that refreshes each distinct linked review once per cadence and discovers native stack layers. Explicit refreshes and failed stack reads trigger another read. | +| Pull request sync | The worker that refreshes each distinct linked review once per cadence and discovers native stack layers. Explicit refreshes and failed stack reads trigger another read. | | Current pull request | The link used by single-review controls and older clients. Open work takes precedence; a completed single chain points at its top layer. Unrelated terminal links use the latest update. | ## Composer context diff --git a/docs/internals/overview.md b/docs/internals/overview.md index 3d3218a7ca3d..149bcf57dff0 100644 --- a/docs/internals/overview.md +++ b/docs/internals/overview.md @@ -87,8 +87,11 @@ must reject that operation before changing the filesystem. Thread settlement is server-owned. The [settlement service](../../apps/server/src/orchestration-v2/ThreadSettlementService.ts) evaluates PR and inactivity settings without a connected client. Merge notifications invalidate cached PR state -and trigger a check. The guarded `thread.auto-settle` command rejects newer activity, explicit -settlement overrides, and live or blocked work. It records the activity timestamp for stable +and trigger a check. A merge outside T3, such as an agent running `gh pr merge`, sends no +notification, so the [PR sync reactor](../../apps/server/src/orchestration-v2/PullRequestSyncReactor.ts) +re-reads a thread's open links when a run that ran a merge or close command ends. The guarded +`thread.auto-settle` command rejects newer activity, explicit settlement overrides, and live or +blocked work. It records the activity timestamp for stable sorting and detaches idle provider sessions. Clients render the persisted result; they do not derive settlement from their own clocks or PR caches. diff --git a/docs/internals/product-analytics.md b/docs/internals/product-analytics.md index 0e88610ada99..e9123640a541 100644 --- a/docs/internals/product-analytics.md +++ b/docs/internals/product-analytics.md @@ -41,6 +41,13 @@ Unknown counts stay absent. Partial usage contains valid observed counts but cannot establish a whole-turn total. Keep these distinctions when changing token normalization or building reports. +## Delivery + +A send can fail after PostHog has stored the batch, so every retry is a copy. +[Delivery](../../apps/server/src/telemetry/AnalyticsService.ts) gives each event a +uuid when it is recorded, backs off after a failed send, and drops a batch after a +few tries. Without these limits, one stuck batch was sent every second for days. + ## Collection boundary Keep analytics payloads to product metadata and normalized measurements. Do not diff --git a/docs/internals/providers.md b/docs/internals/providers.md index f3fa3543c83d..fb3c280a2c79 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -3,7 +3,7 @@ Orchestration records intent and state without knowing which provider runs a thread. Provider protocols, account ownership, permissions, and capabilities belong at the [adapter boundary](../../apps/server/src/orchestration-v2/ProviderAdapter.ts). Normalize there -instead of spreading provider checks through reactors and clients. +instead of spreading provider checks through orchestration and clients. A driver kind identifies an integration; an instance identifies one configuration and account lifecycle. Route work by instance, so two accounts using the same driver do not share mutable @@ -11,16 +11,23 @@ session or catalog state. ## Process and account isolation -T3-managed OpenCode chat uses one server per thread. Its MCP registrations are directory-scoped, while -T3's MCP connection is thread-scoped. Sharing a chat server between threads in one directory would -let them replace each other's connection. Catalog and text-generation work can share the -[instance-owned helper](../../apps/server/src/provider/OpenCodeServerOwner.ts), which closes -after an idle period. External OpenCode servers remain externally owned and can require an -external restart to pick up configuration changes. - -OpenCode also stores persistent approval grants per directory. Automatic full-access replies use -`once` so they cannot widen a supervised thread's permissions on a shared external server. -See the [adapter](../../apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts). +The `opencode` driver probes the installed version and runs the 1.x or 2.x runtime. OpenCode's MCP +registrations are directory-scoped, while T3's MCP connection is thread-scoped, so threads in one +directory must not share one T3 MCP entry. + +- **1.x** uses one T3-managed chat server per thread, so threads cannot replace each other's + connection. Catalog and text-generation work can share the + [instance-owned helper](../../apps/server/src/provider/OpenCodeServerOwner.ts), which closes + after an idle period. See the [1.x adapter](../../apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts). +- **2.x** serves every directory from one + [server per instance](../../apps/server/src/provider/opencode2/OpenCode2Server.ts). Each thread + registers its own `t3-code-` MCP entry, and session permission rules deny every other + thread's entry. See the [2.x adapter](../../apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts). + +External OpenCode servers remain externally owned and can require an external restart to pick up +configuration changes. OpenCode stores "always" approval grants for the whole project. Automatic +full-access replies use `once` so they cannot widen a supervised thread's permissions on a shared +server. On 2.x, a session-wide approval also replies `once` and becomes T3's own rule on that session. Pi runs the user's own `pi` install in RPC mode and owns native extension, package, and project trust discovery. T3 injects only its namespaced MCP bridge, so a Pi session behaves as it does in diff --git a/docs/internals/server-updates.md b/docs/internals/server-updates.md index e40675a14162..34fa5aa47b09 100644 --- a/docs/internals/server-updates.md +++ b/docs/internals/server-updates.md @@ -61,7 +61,9 @@ stopped backends and replays the failure for the same token. Restart continuation is an environment-owned preference, off by default. The [v2 recovery service](../../apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts) requires matching durable run, provider thread, session, and native resume identity. -Ordinary queued work, finished runs, and background-only work do not qualify. +Queued runs never started, so recovery holds them and continues the run they wait +behind. A finished run qualifies only when the restart cancelled its background work; +its continuation tells the provider what will not report back. Recovery retires effects tied to the lost process and records continuation intent in the durable outbox. That intent survives another restart before provider startup. @@ -71,7 +73,16 @@ closing providers, then reconciles after ingestion has stopped so a late complet cannot be overwritten by a stale cancellation. The [continuation handler](../../apps/server/src/orchestration-v2/RestartContinuation.ts) -rechecks the preference, archive state, provider selection, and newer user work before -dispatching. Stable command and message IDs prevent duplicate submissions after an -outbox retry. Codex resumes without adding provider prompt text; other adapters receive -the continuation message through their normal turn path. +rechecks the preference, archive state, provider selection, newer user work, a stop +the user requested, and maintenance turns such as `/compact` before dispatching. Stable +command and message IDs prevent duplicate submissions after an outbox retry. Codex +resumes without adding provider prompt text unless the turn lost background work; +other adapters receive the continuation message through their normal turn path. + +Delegated tasks (`delegate_task` child threads) are reconciled as their own threads, +never as the parent's background work. The orchestrator settles child results and +completion deliveries in a startup pass after reconciliation, because the terminal-run +listener ignores reconciliation's cancellations. A cancelled child whose restart +continuation is still pending in the outbox is not a result yet; the continuation's run +settles it, or the handler settles it when it declines to continue. Schedulers wait for +activation so they cannot start runs that reconciliation would then cancel. diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index ae68b1fc8539..eab33d76f5b0 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -228,6 +228,14 @@ that driver; an explicit `providerInstanceId` is honored exactly and fails when unavailable. Selecting a different provider without a model uses that provider's first advertised model. +Each delegated review round uses a new `delegate_task` call with the original brief, +prior findings, responses, and unresolved objections. Track each round by its own `taskId` and use +a distinct `clientRequestId` per round, stable across retries of that round. +`childThreadId` is backing storage, not a target for another review round through +`t3_thread_send`. Ordinary thread messaging remains available for user-requested +conversations; it does not reopen a completed task. There is no task-level follow-up +API for preserving the same reviewer session. + Delegation requires an active parent run owned by the MCP credential's provider session. The request becomes the V2 command `delegated_task.request`. @@ -272,8 +280,12 @@ the published task result. ### `task_cancel` Interrupts the currently active task run through the normal V2 `run.interrupt` -command. Native background work between turns currently has no interruptible run. It is idempotent for terminal tasks and accepts an optional cancellation -reason. Use `t3_thread_interrupt` to interrupt a later follow-up run. +command and disposes automatic parent delivery. Native background work between +turns currently has no interruptible run. For a terminal task, it returns the +existing status and disposes delivery without interrupting later child-thread runs, +even when `task_status` reports `hasPendingChildRuns: true`. Published task results +remain available. It accepts an optional cancellation reason. Use +`t3_thread_interrupt` to stop a later active run. ### `create_threads` diff --git a/docs/user/composer.md b/docs/user/composer.md index 59efc3c30f01..8cb8d7805ac9 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -274,5 +274,8 @@ On web and desktop, HTML and PDF files open as rendered pages. Switch an HTML file to source view to read its markup; a link to a specific line opens source automatically. HTML previews cannot access your T3 Code session. +The file viewer recognizes images, HTML, and PDF files by their filename extension, +including filenames or folders containing `#` or `?`. + On mobile, select a PDF attachment or link to open it. iOS uses the native viewer; Android opens a compatible installed file viewer. diff --git a/docs/user/install.md b/docs/user/install.md index 3a9fb3296257..77b09863d746 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -98,6 +98,14 @@ Install T3 Code from the The phone connects to a server on another machine. Follow [remote access](./remote-access.md) to link it through T3 Connect or a pairing URL. +Nightly builds need the beta app. The store apps cannot connect to them. A Nightly build also +shows these links as QR codes in **Settings → General → Mobile app**. + +- **iPhone and iPad:** join the [TestFlight beta](https://testflight.apple.com/join/XgaxaRtd). +- **Android:** join the [beta group](https://groups.google.com/g/t3-code-v2-beta). With the same + Google account, open the [Google Play testing page](https://play.google.com/apps/testing/com.t3tools.t3code) + and become a tester. + If the app crashes during launch, open Settings → Diagnostics on the next launch that succeeds. It lists startup crashes from the last 7 days with the error and component stack that store crash reports leave out. Copy the report and paste it diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 43bd23333f66..9f1ef0626fdd 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -11,11 +11,12 @@ inserts a new line. This applies to the web and desktop composer at desktop widt **Follow-up behavior** chooses Queue or Steer while the agent runs. Use `mod+Enter` to do the opposite for one message, even when the send shortcut -requires a modifier. In a new thread, `mod+Alt+Enter` starts the thread in the -background and opens a fresh composer. Change either shortcut in -**Settings → Keybindings** under **Composer: Opposite Queue or Steer Action** or -**Composer: Start in Background**. These bindings take priority over the send -shortcut. Click the send button to use the configured follow-up behavior. +requires a modifier. `mod+Alt+Enter` sends, keeps that thread running in the +background, and opens a fresh new-thread composer. In a new thread, `mod+Enter` +does the same. Change these shortcuts in **Settings → Keybindings** under +**Composer: Opposite Queue or Steer Action**, **Composer: Start in Background**, +or **Composer: Send and Start New Thread**. These bindings take priority over the +send shortcut. Click the send button to use the configured follow-up behavior. When an active turn has queued messages, `mod+Shift+Enter` sends the first as a steer. Change it under **Queue: Send First Queued Message as Steer** in Keybindings. @@ -119,7 +120,8 @@ a shortcut. shortcut; assign one in **Settings → Keybindings**. `thread.undo` (`mod+z` by default) reverses the actions shown in the notice at the -bottom of the sidebar, such as unpin, settle, snooze, or archive. Consecutive +bottom of the sidebar, such as unpin, settle, snooze, archive, or discarding a +draft. Consecutive actions of the same kind undo together. The notice remains available for five seconds after the latest action. The default shortcut skips text fields and terminals so native undo keeps working there. diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 3d068a232be7..64beb946c408 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -181,6 +181,13 @@ closed reviews refresh periodically so reopening one on the host is detected. Me when requested. With **Auto-settle merged threads** enabled, a thread can settle after every linked review is terminal. An open or unsynced link keeps it active. +Ask the agent to watch, monitor, or babysit a pull request and it calls `watch_pull_request`. While +the thread is active, the server checks the pull request every minute and wakes the agent when a check +fails, the required checks pass, someone else comments or reviews, or the branch starts to conflict. +Comments from your own account do not wake it. Watching ends when the pull request merges or closes, +after 10 wakes in a row that bring only comments, or when the server cannot read the pull request for +15 minutes. To start or stop it yourself, use the row menu in the **Linked pull requests** panel. + Cross-repository links use a project on the same host. Azure DevOps reviews require a project checked out from the matching organization and repository. diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index e18c9bac7c8e..6ce2901f4545 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -60,7 +60,8 @@ Pin a thread from its menu to keep it above your active work. On web and desktop, unpinning, settling, snoozing, and archiving a thread each show a notification with **Undo** for five seconds. Undo restores the thread's previous state, including its pinned position, and reopens an archived thread you were -viewing. `mod+z` triggers the most recent Undo when no text field is focused; see +viewing. Discarding an unsent draft from the sidebar works the same way: Undo brings +back its text and attachments. `mod+z` triggers the most recent Undo when no text field is focused; see [Keybindings](./keybindings.md#commands-with-special-behavior). On web and desktop, you can also drag files from your computer onto any thread row: @@ -123,13 +124,14 @@ open. ### Fold working threads (beta) -On web and desktop, turn on **Settings → General → Working section (beta)** to move threads that -are working or monitoring into a collapsed **Working** section at the bottom of the sidebar. A -thread returns to the top of the active list when it finishes, fails, or needs an approval or -answer. Pinned threads stay in the pinned section. +Turn on **Settings → General → Working section (beta)** on web and desktop, or **Settings → +Thread behavior → Working section** on iOS and Android, to move threads that are working or +monitoring into a collapsed **Working** section below the active list. A thread returns to the top +of the active list when it finishes, fails, or needs an approval or answer. Pinned threads stay in +the pinned section. Each device keeps its own choice. While this is on, the active list is ordered by when each thread last came back to you, so you -cannot drag to reorder it. Your saved order returns when you turn it off. +cannot drag or move threads within it. Your saved order returns when you turn it off. ## Settle finished work @@ -146,7 +148,8 @@ threads whose pull request merged. A closed pull request can also settle an idle thread. Work in progress, pending questions or approvals, and live background work prevent automatic settlement. An open pull request does not prevent inactivity settlement, but an old closed or merged pull request does not settle work you -resumed after it closed. +resumed after it closed. Only your own messages count as resuming. A turn that +finished background work or a pull request watch starts on its own does not. To keep one thread out of the settled shelf no matter how long it sits idle, open its menu, choose **Auto-settle behavior**, and pick **Disabled**. The current option is checked. Pick diff --git a/docs/user/usage.md b/docs/user/usage.md index 5c52122bf8a2..1ac918d5c52c 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -8,7 +8,10 @@ desktop when the terminal is not focused. Customize `usage.open` in **Usage** combines Codex, Claude Code, Grok Build, OpenCode, Antigravity, and Cursor history from your connected environments. It shows token use, cache savings, model breakdowns, and estimated API-equivalent -cost. These estimates are not your subscription bill. +cost, split by token type and by speed. These estimates are not your subscription bill. +**Premium** is what Fast and Ultrafast requests cost above standard rates. Cost that cannot be +split, such as a provider-reported cost for a model without public rates, shows as **Other**. +Select a model under **Breakdown** to see its trend, cache hit rate, and cost per million tokens. Totals depend on the history available on each server. Grok turns without a saved completed-turn record are missing from the totals. @@ -47,7 +50,8 @@ On web or desktop, open the environment dropdown on **Usage**, then choose **Mod edit, or reset a model's estimated price. **Apply to** starts with your current Usage filter; choose all environments or select individual destinations. Enter the exact model ID and USD rates per million input and output tokens. You can enter any model ID, including models -without public pricing. +without public pricing. When a model on **Usage** has no known price, select it under +**Breakdown** and choose **Set price** to open this table with that model added. Cache read and cache write rates are optional and use the input rate when blank. Enter `0` for tokens that are free. Saved prices replace automatic pricing for all of that environment's @@ -57,6 +61,11 @@ edited rows. Untouched cells keep each environment's rate. Select one environmen prices. **Reset to automatic** marks a model's override for removal when you save; you can undo it before saving. +To count one model as another, such as a preview model under its released name, enter the target +model ID under **Map to**. The mapped model no longer appears on **Usage**: its tokens and cost +move to the target model and use the target's price. Clear **Map to** or reset the row to show +the model on its own again. + Each destination reports whether the change saved. Offline or unavailable environments are marked **Not saved**. Reconnect them and choose **Retry failed saves** to finish the same change without writing again to environments that already saved. Changes are not queued after you close diff --git a/infra/relay/src/agentActivity/ApnsClient.test.ts b/infra/relay/src/agentActivity/ApnsClient.test.ts index 6c54600e0543..9031c7df18a2 100644 --- a/infra/relay/src/agentActivity/ApnsClient.test.ts +++ b/infra/relay/src/agentActivity/ApnsClient.test.ts @@ -366,79 +366,79 @@ describe("ApnsClient", () => { }).pipe(Effect.provide(layer)); }); - for (const requestKind of ["live-activity", "push-notification"] as const) { - for (const stage of ["send", "read-response"] as const) { - it.effect(`aborts a stalled ${requestKind} ${stage} after ten seconds`, () => - Effect.gen(function* () { - const started = yield* Deferred.make(); - const signals: AbortSignal[] = []; - const stalledHttpClient = HttpClient.make((request, _url, signal) => { - signals.push(signal); - const stall = Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)); - if (stage === "send") return stall; - const response = HttpClientResponse.fromWeb(request, new Response("", { status: 200 })); - Object.defineProperty(response, "text", { value: stall }); - return Effect.succeed(response); - }); - const layer = ApnsClient.layer.pipe( - Layer.provide(Layer.succeed(HttpClient.HttpClient, stalledHttpClient)), - Layer.provide( - Layer.succeed(ApnsProviderTokens.ApnsProviderTokens, { - getJwt: () => Effect.succeed("test-jwt"), - }), - ), - ); - const apns = yield* ApnsClient.ApnsClient.pipe(Effect.provide(layer)); - const credentials = { - teamId: "team-timeout", - keyId: "key-timeout", - privateKey: Redacted.make("unused-test-key"), - bundleId: "com.t3tools.test", - environment: "sandbox", - } satisfies ApnsCredentials; - const send = - requestKind === "live-activity" - ? apns.sendLiveActivityRequest({ - credentials, - issuedAtUnixSeconds: 123, - request: apns.makeLiveActivityRequest({ - event: "update", - token: "long-push-token", - state, - nowEpochSeconds: 123, - nowIso: DateTime.formatIso(now), - }), - }) - : apns.sendPushNotificationRequest({ - credentials, - issuedAtUnixSeconds: 123, - request: apns.makePushNotificationRequest({ - token: "long-push-token", - notification: { - title: "Thread", - body: "Done", - environmentId: "env", - threadId: "thread", - deepLink: "/", - }, - }), - }); - const fiber = yield* send.pipe(Effect.flip, Effect.forkChild); - yield* Deferred.await(started); - yield* TestClock.adjust("10 seconds"); - expect(signals[0]?.aborted).toBe(true); - const error = yield* Fiber.join(fiber); - expect(error).toMatchObject({ - _tag: "ApnsHttpRequestError", - requestKind, - event: requestKind === "live-activity" ? "update" : null, - stage, - status: stage === "read-response" ? 200 : null, - tokenSuffix: "sh-token", - cause: { _tag: "TimeoutError" }, - }); - }), + it.effect.each( + (["live-activity", "push-notification"] as const).flatMap((requestKind) => + (["send", "read-response"] as const).map((stage) => ({ requestKind, stage })), + ), + )("aborts a stalled $requestKind $stage after ten seconds", ({ requestKind, stage }) => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const signals: AbortSignal[] = []; + const stalledHttpClient = HttpClient.make((request, _url, signal) => { + signals.push(signal); + const stall = Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)); + if (stage === "send") return stall; + const response = HttpClientResponse.fromWeb(request, new Response("", { status: 200 })); + Object.defineProperty(response, "text", { value: stall }); + return Effect.succeed(response); + }); + const layer = ApnsClient.layer.pipe( + Layer.provide(Layer.succeed(HttpClient.HttpClient, stalledHttpClient)), + Layer.provide( + Layer.succeed(ApnsProviderTokens.ApnsProviderTokens, { + getJwt: () => Effect.succeed("test-jwt"), + }), + ), ); - } - } + const apns = yield* ApnsClient.ApnsClient.pipe(Effect.provide(layer)); + const credentials = { + teamId: "team-timeout", + keyId: "key-timeout", + privateKey: Redacted.make("unused-test-key"), + bundleId: "com.t3tools.test", + environment: "sandbox", + } satisfies ApnsCredentials; + const send = + requestKind === "live-activity" + ? apns.sendLiveActivityRequest({ + credentials, + issuedAtUnixSeconds: 123, + request: apns.makeLiveActivityRequest({ + event: "update", + token: "long-push-token", + state, + nowEpochSeconds: 123, + nowIso: DateTime.formatIso(now), + }), + }) + : apns.sendPushNotificationRequest({ + credentials, + issuedAtUnixSeconds: 123, + request: apns.makePushNotificationRequest({ + token: "long-push-token", + notification: { + title: "Thread", + body: "Done", + environmentId: "env", + threadId: "thread", + deepLink: "/", + }, + }), + }); + const fiber = yield* send.pipe(Effect.flip, Effect.forkChild); + yield* Deferred.await(started); + yield* TestClock.adjust("10 seconds"); + expect(signals[0]?.aborted).toBe(true); + const error = yield* Fiber.join(fiber); + expect(error).toMatchObject({ + _tag: "ApnsHttpRequestError", + requestKind, + event: requestKind === "live-activity" ? "update" : null, + stage, + status: stage === "read-response" ? 200 : null, + tokenSuffix: "sh-token", + cause: { _tag: "TimeoutError" }, + }); + }), + ); }); diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts index e98c2b639598..934ebfdb4423 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts @@ -1909,8 +1909,9 @@ describe("live activity alert decisions", () => { }); describe("queued iOS alert policy", () => { - for (const scenario of ["enabled", "muted", "late"] as const) { - it.effect(`checks the current policy for a ${scenario} completion`, () => { + it.effect.each(["enabled", "muted", "late"] as const)( + "checks the current policy for a %s completion", + (scenario) => { let sent = 0; const completed = { ...state, phase: "completed" as const }; const prefs = JSON.parse(enabledPreferences); @@ -1957,8 +1958,8 @@ describe("queued iOS alert policy", () => { }), ), ); - }); - } + }, + ); }); describe("fast completion delivery", () => { @@ -2019,74 +2020,74 @@ describe("fast completion delivery", () => { }); describe("signed APNs registration metadata", () => { - for (const kind of ["live_activity_update", "push_notification"] as const) { - for (const changed of ["bundle", "environment", "legacy"] as const) { - it.effect(`routes ${kind} using current registration with ${changed} job metadata`, () => { - const attempts: DeliveryAttempts.DeliveryAttemptInput[] = []; - const requests: HttpClientRequest.HttpClientRequest[] = []; - const payload = makeApnsDeliveryJobPayload({ - kind, - userId: target.user_id, - deviceId: target.device_id, - token: "unchanged-token", - ...(changed === "legacy" - ? {} - : { bundleId: "com.t3tools.t3code.dev", apsEnvironment: "sandbox" as const }), - aggregate: kind === "live_activity_update" ? aggregate : null, - ...(kind === "push_notification" - ? { - notification: { - title: "Thread", - body: "Input: Project", - environmentId: "env", - threadId: "thread", - deepLink: "/", - }, - } - : {}), - createdAt: "1970-01-01T00:00:00.000Z", - expiresAt: "1970-01-01T00:10:00.000Z", - jobId: `metadata-${kind}-${changed}`, - }); - const signed = signApnsDeliveryJob({ - secret: config.apnsDeliveryJobSigningSecret, - payload, - }); - return Effect.gen(function* () { - const deliveries = yield* ApnsDeliveries.ApnsDeliveries; - const result = yield* deliveries.processSignedJob(signed); - expect(result.ok).toBe(true); - expect(requests).toHaveLength(1); - expect(requests[0]?.url).toBe( - `${changed === "environment" ? "https://api.push.apple.com" : "https://api.sandbox.push.apple.com"}/3/device/unchanged-token`, - ); - expect(requests[0]?.headers["apns-topic"]).toBe( - `${changed === "bundle" ? "com.t3tools.t3code.preview" : "com.t3tools.t3code.dev"}${kind === "live_activity_update" ? ".push-type.liveactivity" : ""}`, - ); - }).pipe( - Effect.provide( - makeLayer({ - attempts, - config: signingConfig, - currentTargets: [ - { - ...target, - push_token: "unchanged-token", - activity_push_token: "unchanged-token", - bundle_id: - changed === "bundle" ? "com.t3tools.t3code.preview" : "com.t3tools.t3code.dev", - aps_environment: changed === "environment" ? "production" : "sandbox", - }, - ], - execute: (request) => - Effect.sync(() => { - requests.push(request); - return HttpClientResponse.fromWeb(request, new Response("", { status: 200 })); - }), + it.effect.each( + (["live_activity_update", "push_notification"] as const).flatMap((kind) => + (["bundle", "environment", "legacy"] as const).map((changed) => ({ kind, changed })), + ), + )("routes $kind using current registration with $changed job metadata", ({ kind, changed }) => { + const attempts: DeliveryAttempts.DeliveryAttemptInput[] = []; + const requests: HttpClientRequest.HttpClientRequest[] = []; + const payload = makeApnsDeliveryJobPayload({ + kind, + userId: target.user_id, + deviceId: target.device_id, + token: "unchanged-token", + ...(changed === "legacy" + ? {} + : { bundleId: "com.t3tools.t3code.dev", apsEnvironment: "sandbox" as const }), + aggregate: kind === "live_activity_update" ? aggregate : null, + ...(kind === "push_notification" + ? { + notification: { + title: "Thread", + body: "Input: Project", + environmentId: "env", + threadId: "thread", + deepLink: "/", + }, + } + : {}), + createdAt: "1970-01-01T00:00:00.000Z", + expiresAt: "1970-01-01T00:10:00.000Z", + jobId: `metadata-${kind}-${changed}`, + }); + const signed = signApnsDeliveryJob({ + secret: config.apnsDeliveryJobSigningSecret, + payload, + }); + return Effect.gen(function* () { + const deliveries = yield* ApnsDeliveries.ApnsDeliveries; + const result = yield* deliveries.processSignedJob(signed); + expect(result.ok).toBe(true); + expect(requests).toHaveLength(1); + expect(requests[0]?.url).toBe( + `${changed === "environment" ? "https://api.push.apple.com" : "https://api.sandbox.push.apple.com"}/3/device/unchanged-token`, + ); + expect(requests[0]?.headers["apns-topic"]).toBe( + `${changed === "bundle" ? "com.t3tools.t3code.preview" : "com.t3tools.t3code.dev"}${kind === "live_activity_update" ? ".push-type.liveactivity" : ""}`, + ); + }).pipe( + Effect.provide( + makeLayer({ + attempts, + config: signingConfig, + currentTargets: [ + { + ...target, + push_token: "unchanged-token", + activity_push_token: "unchanged-token", + bundle_id: + changed === "bundle" ? "com.t3tools.t3code.preview" : "com.t3tools.t3code.dev", + aps_environment: changed === "environment" ? "production" : "sandbox", + }, + ], + execute: (request) => + Effect.sync(() => { + requests.push(request); + return HttpClientResponse.fromWeb(request, new Response("", { status: 200 })); }), - ), - ); - }); - } - } + }), + ), + ); + }); }); diff --git a/infra/relay/src/agentActivity/FcmDeliveries.test.ts b/infra/relay/src/agentActivity/FcmDeliveries.test.ts index a32819f39b5e..b42a4348a2c0 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.test.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.test.ts @@ -215,36 +215,39 @@ describe("Android delivery routing", () => { deepLink: "/threads/env/second-thread", }; - for (const [firstPhase, secondPhase, title, active] of [ - ["waiting_for_approval", "waiting_for_input", "2 agents need attention", "true"], - ["completed", "failed", "2 agents finished", "false"], - ] as const) { - it.effect( - `routes grouped ${firstPhase} and ${secondPhase} to the overview once across their queued jobs`, - () => { - const h = harness(); - h.current.otherStates = [secondState]; - return Effect.gen(function* () { - const delivery = yield* FcmDeliveries.FcmDeliveries; - yield* delivery.process(h.job); - h.current.state = { ...state, phase: firstPhase }; - h.current.otherStates = [{ ...secondState, phase: secondPhase }]; - yield* delivery.process({ ...h.job, state: h.current.state }); - yield* delivery.process({ ...h.job, state: h.current.otherStates[0] }); - yield* delivery.process({ ...h.job, state: h.current.state }); - const alerts = h.sent.filter((message) => message.alert); - expect(alerts).toHaveLength(1); - expect(alerts[0]?.data).toMatchObject({ - alert_title: title, - alert_body: "Fix notifications, Second thread", - alert_path: "/", - active, - }); - expect(h.marked.at(-1)?.aggregate?.activities).toHaveLength(2); - }).pipe(Effect.provide(h.layer)); - }, - ); - } + it.effect.each([ + { + firstPhase: "waiting_for_approval", + secondPhase: "waiting_for_input", + title: "2 agents need attention", + active: "true", + }, + { firstPhase: "completed", secondPhase: "failed", title: "2 agents finished", active: "false" }, + ] as const)( + "routes grouped $firstPhase and $secondPhase to the overview once across their queued jobs", + ({ firstPhase, secondPhase, title, active }) => { + const h = harness(); + h.current.otherStates = [secondState]; + return Effect.gen(function* () { + const delivery = yield* FcmDeliveries.FcmDeliveries; + yield* delivery.process(h.job); + h.current.state = { ...state, phase: firstPhase }; + h.current.otherStates = [{ ...secondState, phase: secondPhase }]; + yield* delivery.process({ ...h.job, state: h.current.state }); + yield* delivery.process({ ...h.job, state: h.current.otherStates[0] }); + yield* delivery.process({ ...h.job, state: h.current.state }); + const alerts = h.sent.filter((message) => message.alert); + expect(alerts).toHaveLength(1); + expect(alerts[0]?.data).toMatchObject({ + alert_title: title, + alert_body: "Fix notifications, Second thread", + alert_path: "/", + active, + }); + expect(h.marked.at(-1)?.aggregate?.activities).toHaveLength(2); + }).pipe(Effect.provide(h.layer)); + }, + ); it.effect("filters disabled event types before counting a group", () => { const h = harness(); @@ -294,13 +297,9 @@ describe("Android delivery routing", () => { }).pipe(Effect.provide(h.layer)); }); - for (const phase of [ - "completed", - "waiting_for_approval", - "waiting_for_input", - "failed", - ] as const) { - it.effect(`deleting one thread preserves another thread's ${phase} alert`, () => { + it.effect.each(["completed", "waiting_for_approval", "waiting_for_input", "failed"] as const)( + "deleting one thread preserves another thread's %s alert", + (phase) => { const h = harness(); h.current.otherStates = [secondState]; return Effect.gen(function* () { @@ -316,8 +315,8 @@ describe("Android delivery routing", () => { yield* delivery.process({ ...h.job, state: h.current.state }); expect(h.sent.filter((message) => message.alert)).toHaveLength(1); }).pipe(Effect.provide(h.layer)); - }); - } + }, + ); it.effect("registration replay establishes a baseline without alerting", () => { const h = harness(); @@ -333,8 +332,9 @@ describe("Android delivery routing", () => { }).pipe(Effect.provide(h.layer)); }); - for (const restriction of ["mutedEnvironments", "revokedEnvironments"] as const) { - it.effect(`excludes ${restriction} when forming cross-environment groups`, () => { + it.effect.each(["mutedEnvironments", "revokedEnvironments"] as const)( + "excludes %s when forming cross-environment groups", + (restriction) => { const h = harness(); const other = { ...secondState, environmentId: EnvironmentId.make("other-env") }; h.current.target.last_aggregate_json = encodeJson(aggregateFor([state, other])); @@ -349,8 +349,8 @@ describe("Android delivery routing", () => { alert_body: "Approval: Project", }); }).pipe(Effect.provide(h.layer)); - }); - } + }, + ); it("gives a group a stable retry identity independent of row order", () => { const other = { @@ -409,13 +409,14 @@ describe("Android delivery routing", () => { ).toMatchObject({ alert_title: "Second thread", alert_body: "Input: Project" }); }); - for (const [phase, body, preference] of [ - ["waiting_for_approval", "Approval: Project", "notifyOnApproval"], - ["waiting_for_input", "Input: Project", "notifyOnInput"], - ["completed", "Done: Project", "notifyOnCompletion"], - ["failed", "Failed: Project", "notifyOnFailure"], - ] as const) { - it.effect(`uses iOS alert wording for ${phase} and honors its preference`, () => { + it.effect.each([ + { phase: "waiting_for_approval", body: "Approval: Project", preference: "notifyOnApproval" }, + { phase: "waiting_for_input", body: "Input: Project", preference: "notifyOnInput" }, + { phase: "completed", body: "Done: Project", preference: "notifyOnCompletion" }, + { phase: "failed", body: "Failed: Project", preference: "notifyOnFailure" }, + ] as const)( + "uses iOS alert wording for $phase and honors its preference", + ({ phase, body, preference }) => { const h = harness(); h.current.state = { ...state, phase }; return Effect.gen(function* () { @@ -433,8 +434,8 @@ describe("Android delivery routing", () => { yield* delivery.process({ ...h.job, state: h.current.state }); expect(h.sent.slice(1).every((sent) => !sent.alert && !sent.data.alert_id)).toBe(true); }).pipe(Effect.provide(h.layer)); - }); - } + }, + ); it("trims and truncates alert text like iOS", () => { expect( @@ -572,23 +573,23 @@ describe("Android delivery routing", () => { expect(h.sent[1]?.data.alert_id).toBeUndefined(); }).pipe(Effect.provide(h.layer)); }); - for (const ongoing of [true, false]) { - for (const phase of ["completed", "failed"] as const) { - it.effect(`does not alert a stale ${phase} without a baseline (ongoing=${ongoing})`, () => { - const h = harness(); - h.current.state = { ...state, phase, updatedAt: "1969-12-31T23:57:00.000Z" }; - h.current.target.preferences_json = encodeJson({ - ...preferences, - liveActivitiesEnabled: ongoing, - }); - return Effect.gen(function* () { - const delivery = yield* FcmDeliveries.FcmDeliveries; - yield* delivery.process({ ...h.job, state: h.current.state }); - expect(h.sent.every((message) => !message.alert)).toBe(true); - }).pipe(Effect.provide(h.layer)); - }); - } - } + it.effect.each( + [true, false].flatMap((ongoing) => + (["completed", "failed"] as const).map((phase) => ({ ongoing, phase })), + ), + )("does not alert a stale $phase without a baseline (ongoing=$ongoing)", ({ ongoing, phase }) => { + const h = harness(); + h.current.state = { ...state, phase, updatedAt: "1969-12-31T23:57:00.000Z" }; + h.current.target.preferences_json = encodeJson({ + ...preferences, + liveActivitiesEnabled: ongoing, + }); + return Effect.gen(function* () { + const delivery = yield* FcmDeliveries.FcmDeliveries; + yield* delivery.process({ ...h.job, state: h.current.state }); + expect(h.sent.every((message) => !message.alert)).toBe(true); + }).pipe(Effect.provide(h.layer)); + }); it.effect( "retains finished results without extending expiry on replay and clears expired cards", @@ -821,8 +822,9 @@ it("stops reducing five-character row fields and fits the remaining alert", () = }); describe("FCM queue message isolation", () => { - for (const failure of ["invalid-job", "fcm-rejection"] as const) { - it.effect(`retries only the ${failure} message and delivers the rest of its batch`, () => { + it.effect.each(["invalid-job", "fcm-rejection"] as const)( + "retries only the %s message and delivers the rest of its batch", + (failure) => { const h = harness(); const outcomes = new Map(); const message = (id: string, body: unknown): Cloudflare.Queues.Message => ({ @@ -863,6 +865,6 @@ describe("FCM queue message isolation", () => { expect(h.sent).toHaveLength(1); expect(h.marked).toHaveLength(1); }).pipe(Effect.provide(h.layer)); - }); - } + }, + ); }); diff --git a/knip.jsonc b/knip.jsonc index 306c161b954a..8865bf58869c 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -66,9 +66,10 @@ "ignoreIssues": { "src/components/ui/*.tsx": ["exports", "nsExports", "duplicates"] }, }, "apps/mobile": { - // Expo loads plugins by string; Metro embeds the browser entry in the native WebView. + // Expo loads plugins and the fingerprint config by string; Metro embeds the browser entry in the native WebView. "entry": [ "index.ts!", + "fingerprint.config.js", "plugins/*.cjs", "scripts/generate-device-stream.mts", "src/features/devices/device-stream.browser.ts!", diff --git a/oxlint-plugin-t3code/index.ts b/oxlint-plugin-t3code/index.ts index 075d16d5b390..855ba1e7d098 100644 --- a/oxlint-plugin-t3code/index.ts +++ b/oxlint-plugin-t3code/index.ts @@ -7,6 +7,8 @@ import noInlineSchemaCompile from "./rules/no-inline-schema-compile.ts"; import noManualEffectRuntimeInTests from "./rules/no-manual-effect-runtime-in-tests.ts"; import noMobileUniwindThemeEscapeHatches from "./rules/no-mobile-uniwind-theme-escape-hatches.ts"; import noNativeTitleTooltip from "./rules/no-native-title-tooltip.ts"; +import noTestInLoop from "./rules/no-test-in-loop.ts"; +import noUnscopedHas from "./rules/no-unscoped-has.ts"; export default definePlugin({ meta: { @@ -20,5 +22,7 @@ export default definePlugin({ "no-manual-effect-runtime-in-tests": noManualEffectRuntimeInTests, "no-mobile-uniwind-theme-escape-hatches": noMobileUniwindThemeEscapeHatches, "no-native-title-tooltip": noNativeTitleTooltip, + "no-test-in-loop": noTestInLoop, + "no-unscoped-has": noUnscopedHas, }, }); diff --git a/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.ts b/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.ts index ae90cb2f29bd..5a5d13b696f7 100644 --- a/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.ts +++ b/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.ts @@ -19,9 +19,9 @@ const EFFECT_RUNTIME_METHODS = new Set([ "runSyncWith", ]); -// Existing manual runners are tracked as debt through the `maxOccurrences` -// option, set per file in the lint config. The rule permits no net-new -// occurrences in those files, while every other test file must have zero. +// The lint config can set `maxOccurrences` per file to track existing manual +// runners as debt. The rule permits no net-new occurrences in those files, +// while every other test file must have zero. const readMaxOccurrences = (options: ReadonlyArray): number => { const [first] = options; return typeof first === "object" && diff --git a/oxlint-plugin-t3code/rules/no-test-in-loop.test.ts b/oxlint-plugin-t3code/rules/no-test-in-loop.test.ts new file mode 100644 index 000000000000..39efb80f8951 --- /dev/null +++ b/oxlint-plugin-t3code/rules/no-test-in-loop.test.ts @@ -0,0 +1,114 @@ +import { assert, describe } from "@effect/vitest"; + +import { createOxlintRuleHarness } from "../test/utils.ts"; + +const rule = createOxlintRuleHarness("t3code/no-test-in-loop", { + filename: "fixture.test.ts", +}); + +describe("t3code/no-test-in-loop", () => { + rule.valid( + "allows it.each and it.effect.each", + ` + import { it } from "@effect/vitest"; + import * as Effect from "effect/Effect"; + + it.each([1, 2])("handles %s", (value) => {}); + it.effect.each([1, 2])("handles %s", (value) => Effect.succeed(value)); + `, + ); + + rule.valid( + "allows loops inside a test body", + ` + import { it } from "@effect/vitest"; + + it("checks every value", () => { + for (const value of [1, 2]) { + if (value < 0) throw new Error("negative"); + } + }); + `, + ); + + rule.valid( + "ignores node:test files, which have no .each", + ` + import { test } from "node:test"; + + for (const value of [1, 2]) { + test(\`handles \${value}\`, () => {}); + } + `, + ); + + rule.invalid( + "reports it inside a for...of loop", + ` + import { it } from "@effect/vitest"; + + for (const value of [1, 2]) { + it(\`handles \${value}\`, () => {}); + } + `, + (output) => { + assert.match(output, /Use it\.each\(cases\)/); + }, + ); + + rule.invalid( + "reports it.effect inside a for loop nested in describe", + ` + import { describe, it } from "@effect/vitest"; + import * as Effect from "effect/Effect"; + + describe("cases", () => { + for (let index = 0; index < 2; index++) { + it.effect(\`handles \${index}\`, () => Effect.void); + } + }); + `, + (output) => { + assert.match(output, /Use it\.effect\.each\(cases\)/); + }, + ); + + rule.invalid( + "reports test inside a for...in loop", + ` + import { test } from "vitest"; + + for (const key in { a: 1 }) { + test(key, () => {}); + } + `, + ); + + rule.invalid( + "reports a describe inside a loop once, not the tests inside it", + ` + import { describe, it } from "vitest"; + + for (const bundle of ["a.js", "b.js"]) { + describe(bundle, () => { + it("first", () => {}); + it("second", () => {}); + }); + } + `, + (output) => { + assert.match(output, /Use describe\.each\(cases\)/); + assert.equal(output.match(/no-test-in-loop/g)?.length, 1); + }, + ); +}); + +const productionRule = createOxlintRuleHarness("t3code/no-test-in-loop"); + +productionRule.valid( + "ignores non-test files", + ` + const it = (_name: string) => {}; + for (const value of ["a"]) it(value); + `, +); diff --git a/oxlint-plugin-t3code/rules/no-test-in-loop.ts b/oxlint-plugin-t3code/rules/no-test-in-loop.ts new file mode 100644 index 000000000000..b963fdee62df --- /dev/null +++ b/oxlint-plugin-t3code/rules/no-test-in-loop.ts @@ -0,0 +1,79 @@ +import { defineRule, type ESTree } from "@oxlint/plugins"; +import * as Option from "effect/Option"; + +import { getPropertyName, unwrapExpression } from "../utils.ts"; + +const TEST_FILE_PATTERN = /\.(?:test|spec)\.[cm]?[jt]sx?$/u; +// node:test has no `.each`, so files written against it keep their loops. +const NODE_TEST_IMPORT_PATTERN = /["']node:test["']/u; +// Calls that declare a test or a group of tests; all of them expose `.each`. +const TEST_FUNCTIONS = new Set(["describe", "it", "suite", "test"]); +// Modifiers whose tester also exposes `.each` (@effect/vitest's `it.effect` and `it.live`). +const EACH_CAPABLE_MODIFIERS = new Set(["effect", "live"]); +const LOOPS = new Set(["ForStatement", "ForInStatement", "ForOfStatement"]); +const FUNCTIONS = new Set(["ArrowFunctionExpression", "FunctionExpression", "FunctionDeclaration"]); + +/** Returns the reported name for `it(…)`, `describe(…)`, `it.effect(…)`, and friends. */ +const testCallName = (callee: unknown): Option.Option => { + const expression = unwrapExpression(callee); + if (Option.isNone(expression)) return Option.none(); + + if (expression.value.type === "Identifier") { + return TEST_FUNCTIONS.has(expression.value.name) + ? Option.some(expression.value.name) + : Option.none(); + } + + if (expression.value.type !== "MemberExpression") return Option.none(); + const object = unwrapExpression(expression.value.object); + const property = getPropertyName(expression.value.property); + if (Option.isNone(object) || Option.isNone(property)) return Option.none(); + if (!EACH_CAPABLE_MODIFIERS.has(property.value)) return Option.none(); + if (object.value.type !== "Identifier" || !TEST_FUNCTIONS.has(object.value.name)) { + return Option.none(); + } + return Option.some(`${object.value.name}.${property.value}`); +}; + +/** + * Reports a test declaration that runs once per loop iteration. A loop around a + * `describe` is reported on the `describe`, so the tests inside it stay quiet. + */ +const isInsideLoop = (node: ESTree.Node): boolean => { + let current = node.parent; + while (current) { + if (LOOPS.has(current.type)) return true; + // Anything inside a function (a describe body, a helper, a test body) runs + // once per call of that function, not once per iteration. + if (FUNCTIONS.has(current.type)) return false; + current = current.parent; + } + return false; +}; + +export default defineRule({ + meta: { + type: "suggestion", + docs: { + description: + "Disallow declaring tests inside a for loop; use it.each / it.effect.each / describe.each instead.", + }, + }, + create(context) { + if (!TEST_FILE_PATTERN.test(context.filename)) return {}; + if (NODE_TEST_IMPORT_PATTERN.test(context.sourceCode.text)) return {}; + + return { + CallExpression(node) { + const name = testCallName(node.callee); + if (Option.isNone(name)) return; + if (!isInsideLoop(node)) return; + + context.report({ + node: node.callee, + message: `Do not call ${name.value}(…) inside a for loop. Use ${name.value}.each(cases)(name, …) instead.`, + }); + }, + }; + }, +}); diff --git a/oxlint-plugin-t3code/rules/no-unscoped-has.test.ts b/oxlint-plugin-t3code/rules/no-unscoped-has.test.ts new file mode 100644 index 000000000000..76f0e7c7159d --- /dev/null +++ b/oxlint-plugin-t3code/rules/no-unscoped-has.test.ts @@ -0,0 +1,124 @@ +/* oxlint-disable t3code/no-unscoped-has -- the fixtures are invalid on purpose */ +import { assert, describe } from "@effect/vitest"; + +import { createOxlintRuleHarness } from "../test/utils.ts"; + +const rule = createOxlintRuleHarness("t3code/no-unscoped-has", { + filename: "fixture.tsx", +}); + +describe("t3code/no-unscoped-has", () => { + rule.valid( + "allows :has() on the element itself", + `const className = "[&:has([data-slot=icon])]:ps-2";`, + ); + + rule.valid( + "allows :has() anchored to an attribute", + `const className = "[&+[data-chat-composer-form]:has(>[data-slot=banner])]:mt-0";`, + ); + + rule.valid( + "allows :has() inside :not() on the element itself", + `const className = "[&:not(:has(+[data-slot=footer]))]:rounded-b-2xl";`, + ); + + rule.valid( + "allows built-in has-* variants", + `const className = "has-[>[data-slot=icon]]:ps-2 group-has-[:checked]:opacity-100";`, + ); + + rule.valid( + "allows a sibling selector without :has()", + `const className = "[&+*_[data-chat-composer-form]>[data-slot=attachment]]:before:rounded-none";`, + ); + + rule.valid("ignores prose mentioning :has()", `const note = "uses :has( for styling";`); + + rule.valid( + "ignores :has() inside an arbitrary value", + `const className = "before:content-[':has(foo)']";`, + ); + + rule.valid( + "allows a negated :has() on the element itself", + `const className = "[&:not(.collapsed):not(:has(>[data-slot=icon]))]:ps-2";`, + ); + + rule.valid( + "allows :has() on a group or peer element", + `const className = "group-[:has(input)]:p-2 peer-[:has(input)]:p-2 group-[&:has(input)]/row:p-2";`, + ); + + rule.valid( + "allows a selector list whose own branch is anchored", + `const className = "[:is(.a,.b):has(x)_&]:p-2 [&:not(.a,:has(x))]:p-2";`, + ); + + rule.valid( + "ignores :has() text in quoted attribute values", + `const className = "data-[foo='_:has(x)']:p-2 [&_[data-query='_:has(foo)']]:p-2";`, + ); + + rule.valid( + "allows a selector without & on the element itself", + `const className = "[:has(>input)]:p-2 not-[:has(>[data-slot=icon])]:ps-2";`, + ); + + rule.invalid( + "reports a sibling :has() with nothing anchoring it", + `const className = "[&+:has([data-chat-composer-form])_[data-chat-composer-form]]:before:rounded-none";`, + (output) => { + assert.match(output, /Anchor the :has\(\)/); + }, + ); + + rule.invalid( + "reports a descendant :has() with nothing anchoring it", + `const className = cn("p-2", "[&_:has(>input)]:gap-1");`, + ); + + rule.invalid( + "reports a universal :has() ancestor", + "const className = `flex [*:has([data-open])_&]:hidden`;", + ); + + rule.invalid( + "reports :has() whose only anchor is negated", + `const className = "[*:not(.safe):has([data-open])_&]:hidden";`, + ); + + rule.invalid("reports uppercase :HAS()", `const className = "[*:HAS([data-open])_&]:hidden";`); + + rule.invalid( + "reports a selector-list branch borrowing another branch's anchor", + `const className = "[.safe,:has(input)_&]:p-2";`, + ); + + rule.invalid( + "reports an unanchored branch inside :is()", + `const className = "[&_:is(.safe,:has(input))]:p-2";`, + ); + + rule.invalid( + "reports an unanchored :has() inside a named group variant", + `const className = "group-[&_:has(x)]/row:p-2";`, + ); + + rule.invalid( + "reports a :has() on an ancestor of a selector without &", + `const className = "[:has(x)_.foo]:p-2";`, + ); + + rule.invalid( + "reports a :has() on an ancestor of a group", + `const className = "group-[:has(x)_.y]:p-2";`, + ); + + rule.invalid("reports an in-* ancestor :has()", `const className = "in-[:has(x)]:p-2";`); + + rule.invalid( + "reports :has() anchored to the document root", + `const className = "[body:has([data-dialog-open])_&]:overflow-hidden";`, + ); +}); diff --git a/oxlint-plugin-t3code/rules/no-unscoped-has.ts b/oxlint-plugin-t3code/rules/no-unscoped-has.ts new file mode 100644 index 000000000000..088505b3e0cf --- /dev/null +++ b/oxlint-plugin-t3code/rules/no-unscoped-has.ts @@ -0,0 +1,179 @@ +import { defineRule } from "@oxlint/plugins"; + +const COMBINATOR_PATTERN = /[\s>+~]/u; +// A class, id, attribute, or leading tag narrows a compound. Negated ones don't, +// so `:not(...)` is removed before this check. +const NARROWING_PATTERN = /[.#[]|^[a-z]/iu; +const ROOT_COMPOUND_PATTERN = /^(?:html|body|:root)(?![\w-])/iu; + +// group-[...] and peer-[...] match the .group/.peer element, in-[...] matches an +// ancestor, and data-[...] and aria-[...] hold attribute values, not selectors. +const GROUP_PREFIX_PATTERN = /(?:^|:)(?:group|peer)-$/u; +const ANCESTOR_PREFIX_PATTERN = /(?:^|:)in-$/u; +const ATTRIBUTE_PREFIX_PATTERN = /(?:^|:)(?:data|aria)-$/u; +const QUOTED_PATTERN = /(["'])(?:\\.|(?!\1).)*\1/gu; + +// A variant's closing bracket is followed by ":" or a group/peer name like "/row:". +const VARIANT_END_PATTERN = /^(?:\/[\w-]+)?:/u; + +/** Tailwind arbitrary variants in a class token: top-level `[...]` groups used as variants. */ +function variantGroups(token: string): { prefix: string; group: string }[] { + const groups: { prefix: string; group: string }[] = []; + let depth = 0; + let start = -1; + for (let index = 0; index < token.length; index++) { + const char = token[index]; + if (char === "[") { + if (depth === 0) start = index + 1; + depth++; + } else if (char === "]" && depth > 0) { + depth--; + if (depth === 0 && VARIANT_END_PATTERN.test(token.slice(index + 1))) { + groups.push({ prefix: token.slice(0, start - 1), group: token.slice(start, index) }); + } + } + } + return groups; +} + +/** Index of the ")" closing the "(" at `open`, or the selector's length. */ +function closingParen(selector: string, open: number): number { + let depth = 0; + for (let index = open; index < selector.length; index++) { + if (selector[index] === "(") depth++; + else if (selector[index] === ")" && --depth === 0) return index; + } + return selector.length; +} + +/** + * Whether nothing after `from` in the enclosing branch is joined by a combinator, + * i.e. the element ending at `from` is the subject that its wrapper matches. + */ +function isWrapperSubject(selector: string, from: number): boolean { + let depth = 0; + for (let index = from; index < selector.length; index++) { + const char = selector[index] ?? ""; + if (char === "(") depth++; + else if (char === ")") { + if (depth === 0) return true; + depth--; + } else if (depth > 0) continue; + else if (char === ",") return true; + else if (COMBINATOR_PATTERN.test(char)) { + const next = selector.slice(index).trimStart()[0]; + if (/\s/u.test(char) && (next === undefined || next === ")" || next === ",")) continue; + return false; + } + } + return true; +} + +/** The compound selector each `:has(` in `selector` is attached to. */ +function hasCompounds(selector: string): string[] { + const compounds: string[] = []; + let index = selector.indexOf(":has("); + while (index !== -1) { + let compound = ""; + let depth = 0; + let subjectEnd = closingParen(selector, index + ":has".length) + 1; + // Other branches of a selector list are skipped until the wrapper that holds + // them opens. An unbalanced "(" means the :has() sits inside :not()/:is()/ + // :where(); the compound outside that wrapper applies only when the :has() + // is in the wrapper's subject position. + let skippingBranch = false; + for (let position = index - 1; position >= 0; position--) { + const char = selector[position] ?? ""; + if (char === ")") depth++; + else if (char === "(") { + if (depth > 0) depth--; + else { + if (!isWrapperSubject(selector, subjectEnd)) break; + skippingBranch = false; + subjectEnd = closingParen(selector, position) + 1; + } + } else if (depth === 0 && char === ",") { + skippingBranch = true; + continue; + } else if (depth === 0 && !skippingBranch && COMBINATOR_PATTERN.test(char)) break; + if (!skippingBranch) compound = char + compound; + } + compounds.push(compound); + index = selector.indexOf(":has(", index + 1); + } + return compounds; +} + +/** `compound` without any `:not(...)`, including one left open around the `:has()`. */ +function withoutNegations(compound: string): string { + let result = ""; + let index = 0; + while (index < compound.length) { + if (!compound.startsWith(":not(", index)) { + result += compound[index]; + index++; + continue; + } + let depth = 0; + for (index += ":not".length; index < compound.length; index++) { + if (compound[index] === "(") depth++; + else if (compound[index] === ")" && --depth === 0) break; + } + index++; + } + return result; +} + +/** Arbitrary variants in `text` whose `:has()` is unanchored or anchored to the document root. */ +function findUnscopedHasVariants(text: string): string[] { + // Selectors are ASCII case-insensitive. + if (!text.toLowerCase().includes(":has(")) return []; + const offenders: string[] = []; + for (const token of text.split(/\s+/u)) { + for (const { prefix, group } of variantGroups(token)) { + if (ATTRIBUTE_PREFIX_PATTERN.test(prefix)) continue; + // Tailwind writes spaces as "_", and "&" is the element carrying the class. + // A selector without "&" applies to that element, as `&:is(...)`. + const relative = group.toLowerCase().replace(QUOTED_PATTERN, '""').replaceAll("_", " "); + const owner = GROUP_PREFIX_PATTERN.test(prefix) ? ".group" : ".self"; + const selector = ANCESTOR_PREFIX_PATTERN.test(prefix) + ? `:is(${relative.replaceAll("&", "*")}) .self` + : relative.includes("&") + ? relative.replaceAll("&", owner) + : `${owner}:is(${relative})`; + const unscoped = hasCompounds(selector).some((compound) => { + const anchor = withoutNegations(compound); + return !NARROWING_PATTERN.test(anchor) || ROOT_COMPOUND_PATTERN.test(anchor); + }); + if (unscoped) offenders.push(`[${group}]`); + } + } + return offenders; +} + +export default defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow Tailwind arbitrary variants with a :has() that is not anchored to a class, attribute, id, or tag.", + }, + }, + create(context) { + const message = (variant: string) => + `Anchor the :has() in ${variant} to a class, attribute, or tag below the document root, e.g. [&+[data-x]_…] or a has-* variant. Chrome evaluates an unanchored :has() on every ancestor, so any DOM change then restyles the whole page.`; + return { + Literal(node) { + if (typeof node.value !== "string") return; + for (const variant of findUnscopedHasVariants(node.value)) { + context.report({ node, message: message(variant) }); + } + }, + TemplateElement(node) { + for (const variant of findUnscopedHasVariants(node.value.cooked ?? node.value.raw)) { + context.report({ node, message: message(variant) }); + } + }, + }; + }, +}); diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 7fd112ec655b..067f44ffea72 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -219,6 +219,10 @@ "types": "./src/state/server.ts", "default": "./src/state/server.ts" }, + "./state/outdatedServerUpdate": { + "types": "./src/state/outdatedServerUpdate.ts", + "default": "./src/state/outdatedServerUpdate.ts" + }, "./state/session": { "types": "./src/state/session.ts", "default": "./src/state/session.ts" @@ -255,6 +259,10 @@ "types": "./src/state/threadSort.ts", "default": "./src/state/threadSort.ts" }, + "./state/thread-inbox": { + "types": "./src/state/threadInbox.ts", + "default": "./src/state/threadInbox.ts" + }, "./state/thread-relationships": { "types": "./src/state/threadRelationships.ts", "default": "./src/state/threadRelationships.ts" diff --git a/packages/client-runtime/src/connection/catalog.ts b/packages/client-runtime/src/connection/catalog.ts index 8295f05f29ec..d1cbbdad9995 100644 --- a/packages/client-runtime/src/connection/catalog.ts +++ b/packages/client-runtime/src/connection/catalog.ts @@ -43,6 +43,8 @@ export interface ConnectionCatalogEntry { readonly enabled: boolean; /** Discovery rejection stays visible while the saved connection is switched off. */ readonly unsupportedReason?: string; + /** The rejection came from an outdated host, which can still be updated remotely. */ + readonly serverUpdateRequired?: boolean; } export class BearerConnectionCredential extends Schema.TaggedClass()( diff --git a/packages/client-runtime/src/connection/compatibility.test.ts b/packages/client-runtime/src/connection/compatibility.test.ts index e6a2cf3ca18f..3f9dc8f99245 100644 --- a/packages/client-runtime/src/connection/compatibility.test.ts +++ b/packages/client-runtime/src/connection/compatibility.test.ts @@ -50,5 +50,29 @@ describe("orchestration protocol compatibility", () => { ); expect(error).toMatchObject({ reason: "unsupported" }); expect(error?.message).toContain("This client is not supported"); + expect(error).not.toHaveProperty("serverUpdateRequired"); + }); + + it("offers a remote update only for an older host that can update itself", () => { + const older = descriptor(ORCHESTRATION_PROTOCOL_VERSION - 1); + const withCapabilities = (capabilities: ExecutionEnvironmentDescriptor["capabilities"]) => + orchestrationProtocolCompatibilityError({ ...older, capabilities }); + + expect( + withCapabilities({ repositoryIdentity: true, serverSelfUpdate: "boot-service" }), + ).toMatchObject({ serverUpdateRequired: true }); + expect(withCapabilities({ repositoryIdentity: true })).not.toHaveProperty( + "serverUpdateRequired", + ); + expect( + withCapabilities({ repositoryIdentity: true, serverSelfUpdate: "desktop-managed" }), + ).not.toHaveProperty("serverUpdateRequired"); + expect( + withCapabilities({ + repositoryIdentity: true, + serverSelfUpdate: "desktop-managed", + desktopAppUpdate: true, + }), + ).toMatchObject({ serverUpdateRequired: true }); }); }); diff --git a/packages/client-runtime/src/connection/compatibility.ts b/packages/client-runtime/src/connection/compatibility.ts index 0a08ddb2acfa..c0ec739e55e0 100644 --- a/packages/client-runtime/src/connection/compatibility.ts +++ b/packages/client-runtime/src/connection/compatibility.ts @@ -14,13 +14,25 @@ export function orchestrationProtocolCompatibilityError( if (serverProtocolVersion === ORCHESTRATION_PROTOCOL_VERSION) { return null; } - return new ConnectionBlockedError({ - reason: "unsupported", - detail: - serverProtocolVersion > ORCHESTRATION_PROTOCOL_VERSION - ? `This client is not supported by this server. Update your app or use a compatible release to connect to ${descriptor.label}.` - : `This client requires a newer server. Update T3 Code on ${descriptor.label} to connect.`, - }); + return serverProtocolVersion > ORCHESTRATION_PROTOCOL_VERSION + ? new ConnectionBlockedError({ + reason: "unsupported", + detail: `This client is not supported by this server. Update your app or use a compatible release to connect to ${descriptor.label}.`, + }) + : new ConnectionBlockedError({ + reason: "unsupported", + detail: `This client requires a newer server. Update T3 Code on ${descriptor.label} to connect.`, + ...(canSelfUpdate(descriptor) ? { serverUpdateRequired: true } : {}), + }); +} + +/** Whether this client can drive the host's update remotely. */ +function canSelfUpdate(descriptor: ExecutionEnvironmentDescriptor): boolean { + const { serverSelfUpdate, desktopAppUpdate } = descriptor.capabilities; + return ( + serverSelfUpdate !== undefined && + (serverSelfUpdate !== "desktop-managed" || desktopAppUpdate === true) + ); } export function appendOrchestrationProtocol(socketUrl: string): string { diff --git a/packages/client-runtime/src/connection/index.ts b/packages/client-runtime/src/connection/index.ts index cd6312163681..04833c3feb37 100644 --- a/packages/client-runtime/src/connection/index.ts +++ b/packages/client-runtime/src/connection/index.ts @@ -16,3 +16,5 @@ export * as EnvironmentSupervisor from "./supervisor.ts"; export * as Wakeups from "./wakeups.ts"; export { orchestrationProtocolCompatibilityError } from "./compatibility.ts"; +// Flat so consumers' inferred command types can name it. +export { OutdatedHostUpdateError } from "./outdatedHostUpdate.ts"; diff --git a/packages/client-runtime/src/connection/layer.ts b/packages/client-runtime/src/connection/layer.ts index 8b1a4eaa5dde..8ec233fcc097 100644 --- a/packages/client-runtime/src/connection/layer.ts +++ b/packages/client-runtime/src/connection/layer.ts @@ -75,6 +75,8 @@ export function layerWithOptions(options: RpcSession.RpcSessionOptions) { registryLayer, RelayEnvironmentDiscovery.layer, onboardingLayer, + // Exposed for updating hosts too old to connect through the driver. + ConnectionResolver.layer, ); const connectionStartupLayer = Layer.effectDiscard( Effect.gen(function* () { diff --git a/packages/client-runtime/src/connection/model.ts b/packages/client-runtime/src/connection/model.ts index 45d1866ec713..78d11b84b9e4 100644 --- a/packages/client-runtime/src/connection/model.ts +++ b/packages/client-runtime/src/connection/model.ts @@ -94,6 +94,8 @@ export class ConnectionBlockedError extends Schema.TaggedError, - options?: { readonly failDescriptor?: boolean; readonly protocolVersion?: number }, + options?: { + readonly failDescriptor?: boolean; + readonly protocolVersion?: number; + readonly selfUpdate?: boolean; + }, ) { const fetchFn = ((input, init = {}) => { const url = String(input); @@ -56,6 +60,7 @@ function pairingHttpLayer( orchestrationProtocolVersion: options?.protocolVersion ?? ORCHESTRATION_PROTOCOL_VERSION, capabilities: { repositoryIdentity: true, + ...(options?.selfUpdate === true ? { serverSelfUpdate: "boot-service" } : {}), }, }), ); @@ -145,6 +150,51 @@ describe("connection onboarding", () => { }), ); + it.effect("pairs an outdated server so it can be updated from this client", () => + Effect.gen(function* () { + const calls: Array<{ readonly url: string; readonly init: RequestInit }> = []; + const registration = yield* preparePairingRegistration({ + host: "remote.example.test", + pairingCode: "pairing-token", + }).pipe( + Effect.provide( + Layer.mergeAll( + CLIENT_PRESENTATION_LAYER, + pairingHttpLayer(calls, { + protocolVersion: ORCHESTRATION_PROTOCOL_VERSION - 1, + selfUpdate: true, + }), + ), + ), + ); + expect(registration.target.environmentId).toBe("environment-paired"); + expect(calls.map((call) => call.url)).toContain("https://remote.example.test/oauth/token"); + }), + ); + + it.effect("refuses an outdated server that cannot update itself", () => + Effect.gen(function* () { + const calls: Array<{ readonly url: string; readonly init: RequestInit }> = []; + const error = yield* preparePairingRegistration({ + host: "remote.example.test", + pairingCode: "pairing-token", + }).pipe( + Effect.provide( + Layer.mergeAll( + CLIENT_PRESENTATION_LAYER, + pairingHttpLayer(calls, { protocolVersion: ORCHESTRATION_PROTOCOL_VERSION - 1 }), + ), + ), + Effect.flip, + ); + expect(error).toMatchObject({ reason: "unsupported" }); + expect(error).not.toHaveProperty("serverUpdateRequired"); + expect(calls.map((call) => call.url)).toEqual([ + "https://remote.example.test/.well-known/t3/environment", + ]); + }), + ); + it.effect("does not consume a pairing credential when descriptor discovery fails", () => Effect.gen(function* () { const calls: Array<{ readonly url: string; readonly init: RequestInit }> = []; diff --git a/packages/client-runtime/src/connection/onboarding.ts b/packages/client-runtime/src/connection/onboarding.ts index 24c03addfa66..fec7847b541f 100644 --- a/packages/client-runtime/src/connection/onboarding.ts +++ b/packages/client-runtime/src/connection/onboarding.ts @@ -93,7 +93,10 @@ export const preparePairingRegistration = Effect.fn( httpBaseUrl: target.httpBaseUrl, }).pipe(Effect.mapError(mapRemoteEnvironmentError)); const compatibilityError = orchestrationProtocolCompatibilityError(descriptor); - if (compatibilityError !== null) return yield* compatibilityError; + // An outdated server is still saved so it can be updated from this client. + if (compatibilityError !== null && compatibilityError.serverUpdateRequired !== true) { + return yield* compatibilityError; + } const access = yield* bootstrapRemoteBearerSession({ httpBaseUrl: target.httpBaseUrl, credential: target.credential, diff --git a/packages/client-runtime/src/connection/outdatedHostUpdate.test.ts b/packages/client-runtime/src/connection/outdatedHostUpdate.test.ts new file mode 100644 index 000000000000..e63d095facb5 --- /dev/null +++ b/packages/client-runtime/src/connection/outdatedHostUpdate.test.ts @@ -0,0 +1,278 @@ +import { + EnvironmentId, + ORCHESTRATION_PROTOCOL_VERSION, + ExecutionEnvironmentDescriptor, + WS_METHODS, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import * as Socket from "effect/unstable/socket/Socket"; + +import type { ConnectionCatalogEntry } from "./catalog.ts"; +import { orchestrationProtocolCompatibilityError } from "./compatibility.ts"; +import { PrimaryConnectionTarget } from "./model.ts"; +import { updateOutdatedHost } from "./outdatedHostUpdate.ts"; +import * as EnvironmentRegistry from "./registry.ts"; +import * as ConnectionResolver from "./resolver.ts"; +import * as RelayEnvironmentDiscovery from "../relay/discovery.ts"; + +const TARGET = new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make("environment-old"), + label: "Build Mac", + httpBaseUrl: "https://build.example.test", + wsBaseUrl: "wss://build.example.test", +}); + +const descriptor = (protocol: number | undefined, serverVersion: string) => + ({ + environmentId: TARGET.environmentId, + label: TARGET.label, + platform: { os: "darwin", arch: "arm64" }, + serverVersion, + ...(protocol === undefined ? {} : { orchestrationProtocolVersion: protocol }), + capabilities: { repositoryIdentity: true, serverSelfUpdate: "boot-service" }, + }) satisfies ExecutionEnvironmentDescriptor; + +const RpcRequest = Schema.TaggedStruct("Request", { + id: Schema.Union([Schema.String, Schema.Number]), + payload: Schema.Unknown, + tag: Schema.String, +}); +const isRpcRequest = Schema.is(RpcRequest); +const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); +const encodeJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); +const encodeDescriptor = Schema.encodeSync(Schema.fromJsonString(ExecutionEnvironmentDescriptor)); + +type Listener = (event: { readonly type: string; readonly data?: unknown }) => void; + +/** Answers update RPCs the way a protocol-1 server does. */ +class OutdatedHostSocket { + static readonly OPEN = 1; + readyState = 0; + readonly requests: Array = []; + private readonly listeners = new Map>(); + + readonly url: string; + private readonly onUpdate: () => void; + + constructor(url: string, onUpdate: () => void) { + this.url = url; + this.onUpdate = onUpdate; + queueMicrotask(() => { + this.readyState = OutdatedHostSocket.OPEN; + this.emit({ type: "open" }); + }); + } + + addEventListener(type: string, listener: Listener) { + const listeners = this.listeners.get(type) ?? new Set(); + listeners.add(listener); + this.listeners.set(type, listeners); + } + + removeEventListener(type: string, listener: Listener) { + this.listeners.get(type)?.delete(listener); + } + + send(data: string) { + const message = decodeJson(data); + if (!isRpcRequest(message)) return; + this.requests.push(message); + if (message.tag !== WS_METHODS.serverUpdateServer) return; + this.onUpdate(); + queueMicrotask(() => + this.emit({ + type: "message", + data: encodeJson({ + _tag: "Exit", + requestId: message.id, + exit: { + _tag: "Success", + value: { targetVersion: "0.0.46", method: "boot-service", updateId: "update-1" }, + }, + }), + }), + ); + } + + close() { + this.readyState = 3; + this.emit({ type: "close" }); + } + + private emit(event: { readonly type: string; readonly data?: unknown }) { + for (const listener of this.listeners.get(event.type) ?? []) listener(event); + } +} + +describe("updateOutdatedHost", () => { + it.effect("updates a protocol-1 host over a bare socket, then switches it back on", () => + Effect.gen(function* () { + const blocked = orchestrationProtocolCompatibilityError(descriptor(undefined, "0.0.45")); + expect(blocked).toMatchObject({ serverUpdateRequired: true }); + + const sockets: Array = []; + let served: ExecutionEnvironmentDescriptor = descriptor(undefined, "0.0.45"); + const entries = yield* SubscriptionRef.make< + ReadonlyMap + >( + new Map([ + [ + TARGET.environmentId, + { + target: TARGET, + profile: Option.none(), + enabled: false, + unsupportedReason: blocked?.message ?? "", + serverUpdateRequired: true, + }, + ], + ]), + ); + const calls: Array = []; + const registry = EnvironmentRegistry.EnvironmentRegistry.of({ + entries, + setCompatibility: (_environmentId: EnvironmentId, error: unknown) => + Effect.sync(() => calls.push(`compatibility:${error === null ? "clear" : "block"}`)), + setEnabled: (_environmentId: EnvironmentId, enabled: boolean) => + Effect.sync(() => calls.push(`enabled:${enabled}`)), + } as unknown as EnvironmentRegistry.EnvironmentRegistry["Service"]); + const resolver = ConnectionResolver.ConnectionResolver.of({ + prepare: () => Effect.die(new Error("The update must bypass the protocol gate.")), + prepareForUpdate: () => + Effect.sync(() => served).pipe( + Effect.map((current) => ({ + descriptor: current, + prepared: { + environmentId: TARGET.environmentId, + label: TARGET.label, + httpBaseUrl: TARGET.httpBaseUrl, + socketUrl: "wss://build.example.test/ws?wsTicket=ticket", + httpAuthorization: null, + target: TARGET, + }, + })), + ), + }); + const httpClient = HttpClient.make((request) => + Effect.sync(() => + HttpClientResponse.fromWeb(request, new Response(encodeDescriptor(served))), + ), + ); + + const result = yield* updateOutdatedHost( + TARGET.environmentId, + { targetVersion: "0.0.46" }, + () => Effect.void, + ).pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, registry), + Layer.succeed(ConnectionResolver.ConnectionResolver, resolver), + Layer.succeed( + RelayEnvironmentDiscovery.RelayEnvironmentDiscovery, + RelayEnvironmentDiscovery.RelayEnvironmentDiscovery.of({ + state: yield* SubscriptionRef.make( + RelayEnvironmentDiscovery.EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE, + ), + refresh: Effect.void, + }), + ), + Layer.succeed(HttpClient.HttpClient, httpClient), + Layer.succeed(Socket.WebSocketConstructor, (url) => { + // The host relaunches on a compatible protocol once the update lands. + const socket = new OutdatedHostSocket(url, () => { + served = descriptor(ORCHESTRATION_PROTOCOL_VERSION, "0.0.46"); + }); + sockets.push(socket); + return socket as unknown as globalThis.WebSocket; + }), + ), + ), + ); + + expect(sockets[0]?.url).not.toContain("orchestrationProtocol"); + expect(sockets[0]?.requests.map((request) => request.tag)).toEqual([ + WS_METHODS.serverUpdateServer, + ]); + expect(result.targetVersion).toBe("0.0.46"); + expect(calls).toEqual(["compatibility:clear", "enabled:true"]); + }), + ); + + it.effect("refuses a host that cannot update itself without opening a socket", () => + Effect.gen(function* () { + const manual = { + ...descriptor(undefined, "0.0.45"), + capabilities: { repositoryIdentity: true }, + }; + let opened = false; + const error = yield* Effect.flip( + updateOutdatedHost(TARGET.environmentId, { targetVersion: "0.0.46" }, () => Effect.void), + ).pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed( + EnvironmentRegistry.EnvironmentRegistry, + EnvironmentRegistry.EnvironmentRegistry.of({ + entries: yield* SubscriptionRef.make< + ReadonlyMap + >( + new Map([ + [ + TARGET.environmentId, + { target: TARGET, profile: Option.none(), enabled: false }, + ], + ]), + ), + } as unknown as EnvironmentRegistry.EnvironmentRegistry["Service"]), + ), + Layer.succeed( + ConnectionResolver.ConnectionResolver, + ConnectionResolver.ConnectionResolver.of({ + prepare: () => Effect.die(new Error("unused")), + prepareForUpdate: () => + Effect.succeed({ + descriptor: manual, + prepared: { + environmentId: TARGET.environmentId, + label: TARGET.label, + httpBaseUrl: TARGET.httpBaseUrl, + socketUrl: "wss://build.example.test/ws", + httpAuthorization: null, + target: TARGET, + }, + }), + }), + ), + Layer.succeed( + RelayEnvironmentDiscovery.RelayEnvironmentDiscovery, + RelayEnvironmentDiscovery.RelayEnvironmentDiscovery.of({ + state: yield* SubscriptionRef.make( + RelayEnvironmentDiscovery.EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE, + ), + refresh: Effect.void, + }), + ), + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die(new Error("unused"))), + ), + Layer.succeed(Socket.WebSocketConstructor, () => { + opened = true; + throw new Error("unused"); + }), + ), + ), + ); + expect(error).toMatchObject({ _tag: "OutdatedHostUpdateError" }); + expect(opened).toBe(false); + }), + ); +}); diff --git a/packages/client-runtime/src/connection/outdatedHostUpdate.ts b/packages/client-runtime/src/connection/outdatedHostUpdate.ts new file mode 100644 index 000000000000..0ca445b89386 --- /dev/null +++ b/packages/client-runtime/src/connection/outdatedHostUpdate.ts @@ -0,0 +1,190 @@ +import { + ORCHESTRATION_PROTOCOL_VERSION, + type EnvironmentId, + type ExecutionEnvironmentDescriptor, + type ServerSelfUpdateInput, + type ServerSelfUpdateResult, + WS_METHODS, +} from "@t3tools/contracts"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Schedule from "effect/Schedule"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as RpcClient from "effect/unstable/rpc/RpcClient"; +import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization"; +import * as Socket from "effect/unstable/socket/Socket"; + +import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts"; +import { makeWsRpcProtocolClient } from "../rpc/protocol.ts"; +import { isLegacyUpdateHandoffLoss, resolveServerUpdateProgressResult } from "../state/server.ts"; +import * as RelayEnvironmentDiscovery from "../relay/discovery.ts"; +import * as ConnectionResolver from "./resolver.ts"; +import * as EnvironmentRegistry from "./registry.ts"; + +// A v1 host restarting into v2 runs migrations before its descriptor answers again. +const OUTDATED_HOST_RESTART_TIMEOUT = Duration.minutes(4); +const SOCKET_OPEN_TIMEOUT = "15 seconds"; + +export class OutdatedHostUpdateError extends Schema.TaggedError()( + "OutdatedHostUpdateError", + { + environmentId: Schema.String, + message: Schema.String, + }, +) {} + +export type OutdatedHostUpdateStage = "downloading" | "installing" | "resuming"; + +/** + * Updates a host whose orchestration protocol is too old for this client. + * + * The normal session refuses to open against such a host, so this opens a + * bare socket and calls only the self-update RPCs, whose wire shape has not + * changed across protocol versions. Once the host relaunches on a compatible + * protocol the environment is switched back on and connects normally. + */ +export const updateOutdatedHost = Effect.fn("clientRuntime.connection.updateOutdatedHost")( + function* ( + environmentId: EnvironmentId, + input: ServerSelfUpdateInput, + onStage: (stage: OutdatedHostUpdateStage) => Effect.Effect, + ) { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + const resolver = yield* ConnectionResolver.ConnectionResolver; + const webSocketConstructor = yield* Socket.WebSocketConstructor; + const httpClient = yield* HttpClient.HttpClient; + const entry = (yield* SubscriptionRef.get(registry.entries)).get(environmentId); + if (entry === undefined) { + return yield* new EnvironmentRegistry.EnvironmentNotRegisteredError({ environmentId }); + } + const { prepared, descriptor } = yield* resolver.prepareForUpdate(entry); + const capabilities = descriptor.capabilities; + if ( + capabilities.serverSelfUpdate === undefined || + (capabilities.serverSelfUpdate === "desktop-managed" && + capabilities.desktopAppUpdate !== true) + ) { + return yield* new OutdatedHostUpdateError({ + environmentId, + message: `Update T3 Code on ${descriptor.label} manually; it cannot update itself.`, + }); + } + + const result = yield* Effect.scoped( + Effect.gen(function* () { + const protocolContext = yield* Layer.build( + Layer.effect( + RpcClient.Protocol, + RpcClient.makeProtocolSocket({ + retryTransientErrors: false, + retryPolicy: Schedule.recurs(0), + }), + ).pipe( + Layer.provide( + Layer.mergeAll( + Socket.layerWebSocket(prepared.socketUrl, { + openTimeout: SOCKET_OPEN_TIMEOUT, + }).pipe( + Layer.provide(Layer.succeed(Socket.WebSocketConstructor, webSocketConstructor)), + ), + RpcSerialization.layerJson, + ), + ), + ), + ); + const client = yield* makeWsRpcProtocolClient.pipe(Effect.provide(protocolContext)); + + const updateResult: ServerSelfUpdateResult = + capabilities.serverSelfUpdateProgress === true + ? yield* Effect.gen(function* () { + const terminal = yield* Ref.make(Option.none()); + const streamExit = yield* client[WS_METHODS.serverUpdateServerWithProgress]( + input, + ).pipe( + Stream.runForEach((event) => + event.type === "complete" + ? Ref.set(terminal, Option.some(event.result)) + : onStage(event.stage), + ), + Effect.exit, + ); + return yield* resolveServerUpdateProgressResult( + input.targetVersion, + yield* Ref.get(terminal), + streamExit, + ); + }) + : yield* client[WS_METHODS.serverUpdateServer](input).pipe( + // Older servers can drop the socket before acknowledging the restart. + Effect.catchCauseIf( + (cause) => + (capabilities.serverSelfUpdate === "boot-service" || + capabilities.serverSelfUpdate === "respawn") && + isLegacyUpdateHandoffLoss(cause), + () => + Effect.succeed({ + targetVersion: input.targetVersion, + method: capabilities.serverSelfUpdate as "boot-service" | "respawn", + } satisfies ServerSelfUpdateResult), + ), + ); + + if ( + updateResult.method === "desktop-app" && + updateResult.desktopUpdateToken !== undefined + ) { + // The commit relaunches the desktop app, so a dropped socket is success. + yield* client[WS_METHODS.serverCommitDesktopUpdate]({ + requestId: updateResult.desktopUpdateToken, + }).pipe( + Effect.catchCauseIf( + (cause) => isLegacyUpdateHandoffLoss(cause), + () => Effect.void, + ), + ); + } + return updateResult; + }), + ); + + yield* onStage("resuming"); + const resumed = yield* fetchRemoteEnvironmentDescriptor({ + httpBaseUrl: prepared.httpBaseUrl, + }).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.option, + Effect.repeat({ + schedule: Schedule.spaced(Duration.seconds(1)), + until: (current) => Option.exists(current, isCompatibleDescriptor), + }), + Effect.timeoutOption(OUTDATED_HOST_RESTART_TIMEOUT), + Effect.map(Option.flatten), + ); + if (Option.isNone(resumed)) { + return yield* new OutdatedHostUpdateError({ + environmentId, + message: `${descriptor.label} did not come back on a compatible T3 Code version.`, + }); + } + + // Discovery still holds the old relay descriptor and would re-block the + // environment from it, so replace that before clearing the block. + if (entry.target._tag === "RelayConnectionTarget") { + const discovery = yield* RelayEnvironmentDiscovery.RelayEnvironmentDiscovery; + yield* discovery.refresh; + } + yield* registry.setCompatibility(environmentId, null); + yield* registry.setEnabled(environmentId, true); + return { ...result, targetVersion: resumed.value.serverVersion }; + }, +); + +function isCompatibleDescriptor(descriptor: ExecutionEnvironmentDescriptor): boolean { + return (descriptor.orchestrationProtocolVersion ?? 1) === ORCHESTRATION_PROTOCOL_VERSION; +} diff --git a/packages/client-runtime/src/connection/registry.test.ts b/packages/client-runtime/src/connection/registry.test.ts index 998cbe479f2a..7a4d581d3c8e 100644 --- a/packages/client-runtime/src/connection/registry.test.ts +++ b/packages/client-runtime/src/connection/registry.test.ts @@ -1443,6 +1443,46 @@ describe("EnvironmentRegistry", () => { }), ); + it.effect("keeps one session per environment across concurrent registrations and retries", () => + Effect.gen(function* () { + const harness = yield* makeHarness([]); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + const registration = new PrimaryConnectionRegistration({ target: TARGET }); + yield* Effect.all( + Array.from({ length: 5 }, () => registry.registerPlatform(registration)), + { concurrency: "unbounded", discard: true }, + ); + yield* awaitConnectionState( + registry, + TARGET.environmentId, + (state) => state.phase === "connected", + ); + + // Platform polls and explicit retries reach a healthy connection at once. + yield* Effect.all( + [ + ...Array.from({ length: 5 }, () => registry.registerPlatform(registration)), + ...Array.from({ length: 5 }, () => registry.retryNow(TARGET.environmentId)), + registry.reconcilePlatform([registration]), + ], + { concurrency: "unbounded", discard: true }, + ); + for (let attempt = 0; attempt < 100; attempt += 1) { + yield* Effect.yieldNow; + } + + expect(yield* Ref.get(harness.sessions)).toHaveLength(1); + expect(yield* Ref.get(harness.releasedSessions)).toBe(0); + expect(yield* registry.state(TARGET.environmentId)).toMatchObject({ + phase: "connected", + generation: 1, + }); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + it.effect("retains a healthy runtime when the platform repeats an identical registration", () => Effect.gen(function* () { const harness = yield* makeHarness([]); diff --git a/packages/client-runtime/src/connection/registry.ts b/packages/client-runtime/src/connection/registry.ts index af1cc46faa9b..ab8eeb2a3b22 100644 --- a/packages/client-runtime/src/connection/registry.ts +++ b/packages/client-runtime/src/connection/registry.ts @@ -42,6 +42,17 @@ import { const isSshConnectionProfile = Schema.is(SshConnectionProfile); +function unsupportedState( + entry: ConnectionCatalogEntry, +): Pick { + return { + ...(entry.unsupportedReason === undefined + ? {} + : { unsupportedReason: entry.unsupportedReason }), + ...(entry.serverUpdateRequired === true ? { serverUpdateRequired: true } : {}), + }; +} + export class EnvironmentNotRegisteredError extends Schema.TaggedError()( "EnvironmentNotRegisteredError", { @@ -455,7 +466,7 @@ export const make = Effect.gen(function* () { enabled: previous.enabled, ...(previous.unsupportedReason !== undefined && gitHubRoutingConnectionKey(previous) === gitHubRoutingConnectionKey(registered) - ? { unsupportedReason: previous.unsupportedReason } + ? unsupportedState(previous) : {}), }; if ( @@ -494,7 +505,7 @@ export const make = Effect.gen(function* () { const entry: ConnectionCatalogEntry = previous?.unsupportedReason !== undefined && gitHubRoutingConnectionKey(previous) === gitHubRoutingConnectionKey(registered) - ? { ...registered, enabled: false, unsupportedReason: previous.unsupportedReason } + ? { ...registered, enabled: false, ...unsupportedState(previous) } : registered; const persistedTarget = (yield* Ref.get(persistedTargetsByEnvironment)).get( target.environmentId, @@ -844,11 +855,26 @@ export const make = Effect.gen(function* () { environmentId, Effect.gen(function* () { const entry = (yield* SubscriptionRef.get(entries)).get(environmentId); - if (entry === undefined || entry.unsupportedReason === (error?.message ?? undefined)) + if ( + entry === undefined || + (entry.unsupportedReason === (error?.message ?? undefined) && + entry.serverUpdateRequired === (error?.serverUpdateRequired ?? undefined)) + ) return; - const { unsupportedReason: _previousReason, ...rest } = entry; + const { + unsupportedReason: _previousReason, + serverUpdateRequired: _previousUpdateRequired, + ...rest + } = entry; const next: ConnectionCatalogEntry = - error === null ? rest : { ...rest, enabled: false, unsupportedReason: error.message }; + error === null + ? rest + : { + ...rest, + enabled: false, + unsupportedReason: error.message, + ...(error.serverUpdateRequired === true ? { serverUpdateRequired: true } : {}), + }; if ( error !== null && entry.enabled && diff --git a/packages/client-runtime/src/connection/resolver.test.ts b/packages/client-runtime/src/connection/resolver.test.ts index 0a5b72b30231..7f42a631cab4 100644 --- a/packages/client-runtime/src/connection/resolver.test.ts +++ b/packages/client-runtime/src/connection/resolver.test.ts @@ -444,8 +444,9 @@ describe("ConnectionResolver", () => { }), ); - for (const scenario of ["unchanged", "changed", "revocation-failed"] as const) { - it.effect(`handles ${scenario} SSH routing consent before saving or authorizing`, () => + it.effect.each(["unchanged", "changed", "revocation-failed"] as const)( + "handles %s SSH routing consent before saving or authorizing", + (scenario) => Effect.gen(function* () { const calls: string[] = []; const target = new SshConnectionTarget({ @@ -537,8 +538,7 @@ describe("ConnectionResolver", () => { } expect(yield* permissions.get(entry)).toBe(scenario === "changed" ? "off" : "read-write"); }), - ); - } + ); it.effect("preserves relay authorization failure classification and trace details", () => Effect.gen(function* () { diff --git a/packages/client-runtime/src/connection/resolver.ts b/packages/client-runtime/src/connection/resolver.ts index f54ebd3949fb..77b778a50aa8 100644 --- a/packages/client-runtime/src/connection/resolver.ts +++ b/packages/client-runtime/src/connection/resolver.ts @@ -1,4 +1,7 @@ -import type { AuthClientPresentationMetadata } from "@t3tools/contracts"; +import type { + AuthClientPresentationMetadata, + ExecutionEnvironmentDescriptor, +} from "@t3tools/contracts"; import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -49,6 +52,17 @@ export class ConnectionResolver extends Context.Service< readonly prepare: ( entry: ConnectionCatalogEntry, ) => Effect.Effect; + /** + * Authorizes a socket without the orchestration protocol gate, for hosts + * too old to connect normally. Only update RPCs may run over it. + */ + readonly prepareForUpdate: (entry: ConnectionCatalogEntry) => Effect.Effect< + { + readonly prepared: PreparedConnection; + readonly descriptor: ExecutionEnvironmentDescriptor; + }, + ConnectionAttemptError + >; } >()("@t3tools/client-runtime/connection/resolver/ConnectionResolver") {} @@ -245,7 +259,7 @@ export const make = Effect.gen(function* () { const ssh = yield* makeSshBroker(); const httpClient = yield* HttpClient.HttpClient; - const prepare = Effect.fn("clientRuntime.connection.broker.prepare")(function* ( + const authorize = Effect.fn("clientRuntime.connection.broker.authorize")(function* ( entry: ConnectionCatalogEntry, ) { const target: ConnectionTarget = entry.target; @@ -277,6 +291,13 @@ export const make = Effect.gen(function* () { actual: descriptor.environmentId, }); } + return { prepared, descriptor }; + }); + + const prepare = Effect.fn("clientRuntime.connection.broker.prepare")(function* ( + entry: ConnectionCatalogEntry, + ) { + const { prepared, descriptor } = yield* authorize(entry); const compatibilityError = orchestrationProtocolCompatibilityError(descriptor); if (compatibilityError !== null) { return yield* compatibilityError; @@ -287,7 +308,7 @@ export const make = Effect.gen(function* () { }; }); - return ConnectionResolver.of({ prepare }); + return ConnectionResolver.of({ prepare, prepareForUpdate: authorize }); }); export const layer = Layer.effect(ConnectionResolver, make); diff --git a/packages/client-runtime/src/connection/supervisor.test.ts b/packages/client-runtime/src/connection/supervisor.test.ts index d64863b9ffcf..1d518f017d6d 100644 --- a/packages/client-runtime/src/connection/supervisor.test.ts +++ b/packages/client-runtime/src/connection/supervisor.test.ts @@ -5,6 +5,7 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Random from "effect/Random"; import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; @@ -186,6 +187,11 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?: }); const dependencies = Layer.mergeAll( + // Jitter at its maximum, so each retry waits exactly its ceiling: 2s, 4s, 8s... + Layer.succeed(Random.Random, { + nextDoubleUnsafe: () => 1 - Number.EPSILON, + nextIntUnsafe: () => 0, + }), Layer.succeed(Connectivity.Connectivity, connectivity), Layer.succeed( ConnectionWakeups.ConnectionWakeups, @@ -225,6 +231,17 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?: }; }); +describe("retryDelayMs", () => { + it("doubles from 2 seconds to a 5 minute cap, jittered within the upper half of each step", () => { + const ceilings = [2_000, 4_000, 8_000, 16_000, 32_000, 64_000, 128_000, 256_000, 300_000]; + for (const [failureCount, ceiling] of [...ceilings, 300_000].entries()) { + expect(EnvironmentSupervisor.retryDelayMs(failureCount, 0)).toBe(ceiling / 2); + expect(EnvironmentSupervisor.retryDelayMs(failureCount, 0.5)).toBe((ceiling * 3) / 4); + expect(EnvironmentSupervisor.retryDelayMs(failureCount, 1 - Number.EPSILON)).toBe(ceiling); + } + }); +}); + describe("EnvironmentSupervisor", () => { it.effect("exports each relay setup as a standalone linked trace that ends at readiness", () => Effect.gen(function* () { @@ -353,7 +370,7 @@ describe("EnvironmentSupervisor", () => { }), ); - it.effect("retries forever with exponential backoff capped at sixteen seconds", () => + it.effect("retries forever with exponential backoff capped at five minutes", () => Effect.gen(function* () { const harness = yield* makeHarness({ prepare: () => Effect.fail(transient()), @@ -368,15 +385,20 @@ describe("EnvironmentSupervisor", () => { ); expect(yield* Ref.get(harness.prepareCount)).toBe(1); - for (const [index, delay] of [3_000, 4_000, 8_000, 16_000, 16_000, 16_000].entries()) { - yield* TestClock.adjust(delay); + const delays = [ + 2_000, 4_000, 8_000, 16_000, 32_000, 64_000, 128_000, 256_000, 300_000, 300_000, + ]; + for (const [index, delay] of delays.entries()) { + yield* TestClock.adjust(delay - 1); + expect(yield* Ref.get(harness.prepareCount)).toBe(index + 1); + yield* TestClock.adjust(1); yield* eventuallyState( supervisor.state, (state) => state.phase === "backoff" && state.attempt === index + 2, ); } - expect(yield* Ref.get(harness.prepareCount)).toBe(7); + expect(yield* Ref.get(harness.prepareCount)).toBe(delays.length + 1); }).pipe(Effect.provide(TestClock.layer())), ); @@ -578,7 +600,7 @@ describe("EnvironmentSupervisor", () => { ); expect(yield* Ref.get(harness.prepareCount)).toBe(3); - yield* TestClock.adjust("2999 millis"); + yield* TestClock.adjust("1999 millis"); expect(yield* Ref.get(harness.prepareCount)).toBe(3); yield* TestClock.adjust("1 milli"); yield* eventuallyState( @@ -641,9 +663,12 @@ describe("EnvironmentSupervisor", () => { }).pipe(Effect.provide(TestClock.layer())), ); - it.effect("releases a live session while offline and starts a new generation when online", () => + it.effect("keeps a session that still answers when the network reports offline", () => Effect.gen(function* () { - const harness = yield* makeHarness(); + const probeCount = yield* Ref.make(0); + const harness = yield* makeHarness({ + probe: () => Ref.update(probeCount, (count) => count + 1), + }); const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { initiallyDesired: true, }).pipe(Effect.provide(harness.dependencies)); @@ -652,21 +677,160 @@ describe("EnvironmentSupervisor", () => { supervisor.state, (state) => state.phase === "connected" && state.generation === 1, ); + // A loopback server, or a flap shorter than the probe, keeps working. yield* harness.setNetworkStatus("offline"); - yield* awaitState(supervisor.state, (state) => state.phase === "offline"); + for (let attempt = 0; attempt < 100; attempt += 1) { + if ((yield* Ref.get(probeCount)) > 0) break; + yield* Effect.yieldNow; + } + yield* harness.setNetworkStatus("online"); + yield* Effect.yieldNow; - expect(yield* Ref.get(harness.releaseCount)).toBe(1); - expect(Option.isNone(yield* SubscriptionRef.get(supervisor.session))).toBe(true); + expect(yield* Ref.get(probeCount)).toBe(1); + expect(yield* Ref.get(harness.sessionCount)).toBe(1); + expect(yield* Ref.get(harness.releaseCount)).toBe(0); + expect(yield* SubscriptionRef.get(supervisor.state)).toMatchObject({ + phase: "connected", + generation: 1, + }); + }), + ); + + it.effect("replaces the session on a long resume while the network reports offline", () => + Effect.gen(function* () { + const probeCount = yield* Ref.make(0); + const harness = yield* makeHarness({ + probe: () => Ref.update(probeCount, (count) => count + 1), + }); + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); - yield* harness.setNetworkStatus("online"); yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 1, + ); + // A wrong offline report: the probe answers, so the session stays. + yield* harness.setNetworkStatus("offline"); + for (let attempt = 0; attempt < 100; attempt += 1) { + if ((yield* Ref.get(probeCount)) > 0) break; + yield* Effect.yieldNow; + } + expect(yield* Ref.get(harness.sessionCount)).toBe(1); + + // The replacement connects although the network still reports offline. + yield* harness.wake("application-active-reconnect"); + const replaced = yield* awaitState( supervisor.state, (state) => state.phase === "connected" && state.generation === 2, ); + + expect(replaced.attempt).toBe(1); + expect(yield* Ref.get(probeCount)).toBe(1); expect(yield* Ref.get(harness.sessionCount)).toBe(2); + expect(yield* Ref.get(harness.releaseCount)).toBe(1); + }), + ); + + it.effect( + "releases a session that stops answering while offline and reconnects when online", + () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + probe: (attempt) => (attempt === 1 ? Effect.never : Effect.void), + }); + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 1, + ); + yield* harness.setNetworkStatus("offline"); + yield* TestClock.adjust("3 seconds"); + yield* awaitState(supervisor.state, (state) => state.phase === "offline"); + + expect(yield* Ref.get(harness.releaseCount)).toBe(1); + expect(Option.isNone(yield* SubscriptionRef.get(supervisor.session))).toBe(true); + + yield* harness.setNetworkStatus("online"); + yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 2, + ); + expect(yield* Ref.get(harness.sessionCount)).toBe(2); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("probes instead of replacing a healthy session on an explicit retry", () => + Effect.gen(function* () { + const probeCount = yield* Ref.make(0); + const harness = yield* makeHarness({ + probe: () => Ref.update(probeCount, (count) => count + 1), + }); + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + yield* supervisor.retryNow; + for (let attempt = 0; attempt < 100; attempt += 1) { + if ((yield* Ref.get(probeCount)) > 0) break; + yield* Effect.yieldNow; + } + + expect(yield* Ref.get(probeCount)).toBe(1); + expect(yield* Ref.get(harness.sessionCount)).toBe(1); + expect(yield* Ref.get(harness.releaseCount)).toBe(0); }), ); + it.effect("keeps the backoff ladder after an explicit retry finds a healthy session", () => + Effect.gen(function* () { + const probeCount = yield* Ref.make(0); + const harness = yield* makeHarness({ + probe: () => Ref.update(probeCount, (count) => count + 1), + }); + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + yield* harness.closeLatestSession(); + yield* awaitState( + supervisor.state, + (state) => state.phase === "backoff" && state.attempt === 1, + ); + yield* TestClock.adjust("2 seconds"); + yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 2, + ); + + yield* supervisor.retryNow; + for (let attempt = 0; attempt < 100; attempt += 1) { + if ((yield* Ref.get(probeCount)) > 0) break; + yield* Effect.yieldNow; + } + expect(yield* Ref.get(probeCount)).toBe(1); + + // The flapping session keeps climbing the ladder: the answered retry does + // not reset it after this unrelated close. + yield* harness.closeLatestSession(); + yield* awaitState( + supervisor.state, + (state) => state.phase === "backoff" && state.attempt === 2, + ); + yield* TestClock.adjust("4 seconds"); + const reconnected = yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 3, + ); + expect(reconnected.attempt).toBe(3); + }).pipe(Effect.provide(TestClock.layer())), + ); + it.effect("retries a blocked connection when platform credentials change", () => Effect.gen(function* () { const harness = yield* makeHarness({ @@ -844,7 +1008,7 @@ describe("EnvironmentSupervisor", () => { supervisor.state, (state) => state.phase === "backoff" && state.attempt === 1, ); - yield* TestClock.adjust("3 seconds"); + yield* TestClock.adjust("2 seconds"); yield* awaitState( supervisor.state, (state) => state.phase === "connected" && state.generation === 2 && state.attempt === 2, @@ -964,6 +1128,35 @@ describe("EnvironmentSupervisor", () => { }), ); + it.effect("reconnects immediately when the session closes during a resume probe", () => + Effect.gen(function* () { + const probeStarted = yield* Deferred.make(); + const harness = yield* makeHarness({ + probe: (attempt) => + attempt === 1 + ? Deferred.succeed(probeStarted, undefined).pipe(Effect.andThen(Effect.never)) + : Effect.void, + }); + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + yield* harness.wake("application-active-probe"); + yield* Deferred.await(probeStarted); + // The OS reports the suspended socket's close before the probe answers. + yield* harness.closeLatestSession(); + + // No TestClock advance: the unanswered probe skips the first backoff rung. + const reconnected = yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 2, + ); + expect(reconnected.attempt).toBe(1); + expect(yield* Ref.get(harness.sessionCount)).toBe(2); + }).pipe(Effect.provide(TestClock.layer())), + ); + it.effect("reconnects immediately when the foreground liveness probe fails", () => Effect.gen(function* () { const allowReconnect = yield* Deferred.make(); @@ -1020,7 +1213,7 @@ describe("EnvironmentSupervisor", () => { supervisor.state, (state) => state.phase === "backoff" && state.attempt === 1, ); - yield* TestClock.adjust("2999 millis"); + yield* TestClock.adjust("1999 millis"); expect(yield* Ref.get(harness.prepareCount)).toBe(2); yield* TestClock.adjust("1 milli"); yield* eventuallyState( @@ -1055,6 +1248,32 @@ describe("EnvironmentSupervisor", () => { }).pipe(Effect.provide(TestClock.layer())), ); + it.effect("an explicit retry shortens a stalled desktop foreground probe", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + probe: (attempt) => (attempt === 1 ? Effect.never : Effect.void), + }); + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + yield* harness.wake("application-active"); + yield* TestClock.adjust("5 seconds"); + yield* supervisor.retryNow; + // The retry's 3 second limit applies, not the 10 seconds left of the 15. + yield* TestClock.adjust("2999 millis"); + expect(yield* Ref.get(harness.sessionCount)).toBe(1); + yield* TestClock.adjust("1 milli"); + yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 2 && state.attempt === 1, + ); + + expect(yield* Ref.get(harness.sessionCount)).toBe(2); + }).pipe(Effect.provide(TestClock.layer())), + ); + it.effect("quickly times out a stalled mobile foreground liveness probe", () => Effect.gen(function* () { const harness = yield* makeHarness({ diff --git a/packages/client-runtime/src/connection/supervisor.ts b/packages/client-runtime/src/connection/supervisor.ts index b75413bbb7de..a8e1a0831f90 100644 --- a/packages/client-runtime/src/connection/supervisor.ts +++ b/packages/client-runtime/src/connection/supervisor.ts @@ -2,11 +2,13 @@ import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; +import * as Random from "effect/Random"; import * as Ref from "effect/Ref"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; @@ -29,10 +31,13 @@ import { safeErrorLogAttributes } from "../errors/safeLog.ts"; import { NETWORK_BLOCKING_HINT } from "../errors/network.ts"; import * as ConnectionWakeups from "./wakeups.ts"; -const RETRY_DELAYS_MS = [3_000, 4_000, 8_000, 16_000] as const; +const RETRY_BASE_DELAY_MS = 1_000; +const RETRY_MAX_DELAY_MS = 300_000; const CONNECTION_ESTABLISHMENT_TIMEOUT = "15 seconds"; const CONNECTION_PROBE_TIMEOUT = "15 seconds"; -const MOBILE_CONNECTION_PROBE_TIMEOUT = "3 seconds"; +// Mobile resumes, explicit retries, and offline events want a fast answer: +// the user is waiting, or the network may be gone. +const QUICK_CONNECTION_PROBE_TIMEOUT = "3 seconds"; const BACKOFF_RESET_AFTER_MS = 30_000; interface SupervisorIntent { @@ -101,8 +106,19 @@ export interface EnvironmentSupervisorOptions { readonly initiallyDesired?: boolean; } -function retryDelayMs(failureCount: number): number { - return RETRY_DELAYS_MS[Math.min(failureCount, RETRY_DELAYS_MS.length - 1)] ?? 16_000; +/** + * Delay before the next attempt after `failureCount` consecutive failures + * (0 for the first retry). The ceiling doubles from 2s up to 5 minutes, and + * the delay is a random point in its upper half: never quicker than half the + * ceiling, and spread out so clients that lost the same server do not all + * reconnect in the same second. `random` is in [0, 1). + * + * The long cap only applies to a connection that keeps failing. Returning to + * the app, the network coming back, and an explicit retry all skip the wait. + */ +export function retryDelayMs(failureCount: number, random: number): number { + const ceiling = Math.min(RETRY_MAX_DELAY_MS, RETRY_BASE_DELAY_MS * 2 ** (failureCount + 1)); + return Math.round(ceiling / 2 + (ceiling / 2) * random); } function annotateTarget(target: ConnectionTarget) { @@ -235,10 +251,11 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( const intent = yield* Ref.make(initialIntent); const signals = yield* Queue.unbounded(); const resetRetryState = yield* Ref.make(false); - // Set when a foreground wake probe fails or times out: the user is actively - // returning to the app on a dead transport, so the follow-up reconnect skips - // the first backoff rung instead of sleeping. - const wakeProbeFailed = yield* Ref.make(false); + // Set while a probe of the live session is running, and kept when it fails + // or times out: something asked whether the connection still works and it + // closed or failed before answering, so the follow-up reconnect skips the + // first backoff rung instead of sleeping. + const probeUnanswered = yield* Ref.make(false); const state = yield* SubscriptionRef.make( !initialIntent.desired ? availableState(initialIntent, 0) @@ -392,96 +409,118 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( } }); + // Signals that end a connected lease whatever its health: "reset" ends it + // and restarts the retry ladder, "end" ends it, undefined keeps it. + const connectedLeaseEnd = Effect.fnUntraced(function* (next: SupervisorSignal) { + if (next._tag === "DisconnectRequested") { + return "end" as const; + } + if (next._tag !== "Wakeup") { + return undefined; + } + if (next.reason === "application-active-reconnect") { + // Mobile operating systems often kill a suspended socket without a close + // event. A probe would show a dead socket as "Resuming" until it times + // out, so a long background resume replaces the session at once. + return "reset" as const; + } + if (next.reason === "credentials-changed" && target._tag === "RelayConnectionTarget") { + yield* logManagedRelayAccountChange; + return "end" as const; + } + return undefined; + }); + + // How long a signal waits for the live session to answer a probe, or + // undefined when the signal does not question the connection. + const probeTimeoutFor = (next: SupervisorSignal): Duration.Input | undefined => { + switch (next._tag) { + case "RetryRequested": + return QUICK_CONNECTION_PROBE_TIMEOUT; + case "NetworkChanged": + return next.network === "offline" ? QUICK_CONNECTION_PROBE_TIMEOUT : undefined; + case "Wakeup": + if (next.reason === "application-active") { + return CONNECTION_PROBE_TIMEOUT; + } + return next.reason === "application-active-probe" + ? QUICK_CONNECTION_PROBE_TIMEOUT + : undefined; + case "ConnectRequested": + case "DisconnectRequested": + return undefined; + } + }; + + // Holds a connected lease until it must end, and returns whether to restart + // the retry ladder. Returning to the app, an explicit retry, and the network + // reporting offline all probe the live session instead of replacing it, so a + // healthy socket is not torn down (the offline report is often wrong, for + // example for a loopback server). Only a long mobile resume replaces the + // session without a probe. A failed probe fails this effect, and the + // supervisor reconnects. const monitorConnectedLease = Effect.fnUntraced(function* ( lease: ConnectionDriver.EnvironmentConnectionLease, ) { + // A probe answers an explicit retry here, so the retry must not also reset + // the backoff of a later, unrelated failure. + const takeSignal = Queue.take(signals).pipe( + Effect.tap((next) => + next._tag === "RetryRequested" ? Ref.set(resetRetryState, false) : Effect.void, + ), + ); for (;;) { - const next = yield* Queue.take(signals); - switch (next._tag) { - case "DisconnectRequested": - case "RetryRequested": - return false; - case "NetworkChanged": - if (next.network === "offline") { - return false; - } - break; - case "Wakeup": - if (next.reason === "credentials-changed" && target._tag === "RelayConnectionTarget") { - yield* logManagedRelayAccountChange; - return false; - } - if (next.reason === "application-active-reconnect") { - // Mobile operating systems commonly suspend sockets without - // delivering a close event. A long background resume deliberately - // replaces that lease and starts a fresh attempt without backoff. - return true; - } - if (next.reason === "application-active" || next.reason === "application-active-probe") { - const probe = yield* lease.session.probe.pipe( - Effect.timeoutOrElse({ - duration: - next.reason === "application-active-probe" - ? MOBILE_CONNECTION_PROBE_TIMEOUT - : CONNECTION_PROBE_TIMEOUT, - orElse: () => - Effect.fail( - new ConnectionTransientError({ - reason: "timeout", - detail: `${target.label} did not respond to a connection health check.`, - }), - ), - }), - Effect.forkChild, - ); - for (;;) { - const probeEvent = yield* Effect.raceFirst( - Fiber.await(probe).pipe( - Effect.map((exit) => ({ _tag: "ProbeCompleted" as const, exit })), - ), - Queue.take(signals).pipe( - Effect.map((signal) => ({ _tag: "Signal" as const, signal })), - ), - ); - if (probeEvent._tag === "ProbeCompleted") { - if (Exit.isFailure(probeEvent.exit)) { - yield* Ref.set(wakeProbeFailed, true); - } - yield* probeEvent.exit; - break; - } - switch (probeEvent.signal._tag) { - case "DisconnectRequested": - case "RetryRequested": - yield* Fiber.interrupt(probe); - return false; - case "NetworkChanged": - if (probeEvent.signal.network === "offline") { - yield* Fiber.interrupt(probe); - return false; - } - break; - case "Wakeup": - if (probeEvent.signal.reason === "application-active-reconnect") { - yield* Fiber.interrupt(probe); - return true; - } - if ( - probeEvent.signal.reason === "credentials-changed" && - target._tag === "RelayConnectionTarget" - ) { - yield* Fiber.interrupt(probe); - return false; - } - break; - case "ConnectRequested": - break; - } - } + const next = yield* takeSignal; + const end = yield* connectedLeaseEnd(next); + if (end !== undefined) { + return end === "reset"; + } + const probeTimeout = probeTimeoutFor(next); + if (probeTimeout === undefined) { + continue; + } + yield* Ref.set(probeUnanswered, true); + const probe = yield* Effect.forkChild(lease.session.probe); + // Monotonic nanoseconds, so a wall-clock correction cannot move the deadline. + let deadline = (yield* Clock.monotonicTimeNanos) + Duration.toNanosUnsafe(probeTimeout); + for (;;) { + const remaining = deadline - (yield* Clock.monotonicTimeNanos); + const probeEvent = yield* Effect.raceAllFirst([ + Fiber.await(probe).pipe( + Effect.map((exit) => ({ _tag: "ProbeCompleted" as const, exit })), + ), + takeSignal.pipe(Effect.map((signal) => ({ _tag: "Signal" as const, signal }))), + Effect.sleep(Duration.nanos(remaining > 0n ? remaining : 0n)).pipe( + Effect.as({ _tag: "TimedOut" as const }), + ), + ]); + if (probeEvent._tag === "TimedOut") { + yield* Fiber.interrupt(probe); + return yield* new ConnectionTransientError({ + reason: "timeout", + detail: `${target.label} did not respond to a connection health check.`, + }); + } + if (probeEvent._tag === "ProbeCompleted") { + if (Exit.isSuccess(probeEvent.exit)) { + yield* Ref.set(probeUnanswered, false); } + yield* probeEvent.exit; break; - case "ConnectRequested": - break; + } + const endDuringProbe = yield* connectedLeaseEnd(probeEvent.signal); + if (endDuringProbe !== undefined) { + yield* Fiber.interrupt(probe); + return endDuringProbe === "reset"; + } + // A retry or an offline report during a desktop foreground probe wants + // its quicker answer, so it shortens the running probe. + const signalTimeout = probeTimeoutFor(probeEvent.signal); + if (signalTimeout !== undefined) { + const signalDeadline = + (yield* Clock.monotonicTimeNanos) + Duration.toNanosUnsafe(signalTimeout); + if (signalDeadline < deadline) deadline = signalDeadline; + } } } }); @@ -491,6 +530,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( generation: number, lastFailure: ConnectionAttemptError | null, pendingRetry: Option.Option, + ignoreOffline: boolean, ) { yield* SubscriptionRef.set(prepared, Option.none()); const establishment = yield* Effect.raceAllFirst([ @@ -556,7 +596,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( const active = establishment.exit.value; const currentIntent = yield* Ref.get(intent); - if (!currentIntent.desired || currentIntent.network === "offline") { + if (!currentIntent.desired || (currentIntent.network === "offline" && !ignoreOffline)) { return { _tag: "Interrupted", established: false, @@ -641,6 +681,10 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( failureCount = 0; pendingRetry = Option.none(); }; + // Set after a long resume ends an attempt or a session. The fresh attempt + // runs even while the network reports offline: the report is often wrong, + // and the replaced session must not leave the client offline. + let replacing = false; for (;;) { if (yield* Ref.getAndSet(resetRetryState, false)) { @@ -657,7 +701,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( yield* waitForSignal; continue; } - if (currentIntent.network === "offline") { + if (currentIntent.network === "offline" && !replacing) { yield* clearLease; yield* setState(offlineState(currentIntent, generation, failureCount + 1, latestFailure)); const applicationActivated = yield* waitForSignal; @@ -670,11 +714,12 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( const attempt = failureCount + 1; const nextGeneration = generation + 1; const outcome: AttemptOutcome = yield* Effect.scoped( - runAttempt(attempt, nextGeneration, latestFailure, pendingRetry), + runAttempt(attempt, nextGeneration, latestFailure, pendingRetry, replacing), ); + replacing = false; // Consumed on every iteration so a stale marker can never leak into a // later, unrelated failure. - const failedWakeProbe = yield* Ref.getAndSet(wakeProbeFailed, false); + const failedProbe = yield* Ref.getAndSet(probeUnanswered, false); if (outcome.established) { generation = nextGeneration; if (outcome.stable) { @@ -685,6 +730,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( if (outcome._tag === "Interrupted") { if (outcome.resetRetry) { resetRetryLadder(); + replacing = true; } continue; } @@ -711,18 +757,19 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( continue; } - if (failedWakeProbe) { - // The wake probe found a dead transport while the user is returning to - // the app, so reconnect immediately instead of sleeping the first - // backoff rung. Only this first attempt skips the ladder; if it fails - // too, normal backoff resumes. + if (failedProbe) { + // A probe found a dead transport, or the transport closed while a probe + // waited for an answer (the user returned to the app, asked to retry, + // or the network changed), so reconnect immediately instead + // of sleeping the first backoff rung. Only this first attempt skips the + // ladder; if it fails too, normal backoff resumes. resetRetryLadder(); yield* setState(connectingState(yield* Ref.get(intent), generation, 1, error)); continue; } failureCount += 1; - const delayMs = retryDelayMs(failureCount - 1); + const delayMs = retryDelayMs(failureCount - 1, yield* Random.next); pendingRetry = Option.map(attemptSpan, (previousAttempt) => ({ previousAttempt, failureCount, diff --git a/packages/client-runtime/src/connection/wakeups.ts b/packages/client-runtime/src/connection/wakeups.ts index 721b941645c0..5f19924c47a1 100644 --- a/packages/client-runtime/src/connection/wakeups.ts +++ b/packages/client-runtime/src/connection/wakeups.ts @@ -16,6 +16,7 @@ export function isApplicationActiveWakeup(reason: ConnectionWakeup): boolean { ); } +// A long resume replaces the session, and the new session subscribes on its own. export function shouldResubscribeAfterWakeup(reason: ConnectionWakeup): boolean { return reason === "application-active" || reason === "application-active-probe"; } diff --git a/packages/client-runtime/src/operations/commands.test.ts b/packages/client-runtime/src/operations/commands.test.ts index a7d7d960efea..a9833f7499d7 100644 --- a/packages/client-runtime/src/operations/commands.test.ts +++ b/packages/client-runtime/src/operations/commands.test.ts @@ -502,83 +502,81 @@ describe("V2 environment commands", () => { }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), ); - for (const status of [ + it.effect.each([ "waiting", "completed", "failed", "interrupted", "cancelled", "rolled_back", - ] as const) { - it.effect(`dispatches Stop for ${status} runs with background commands except rollback`, () => - Effect.gen(function* () { - const waitingRunId = RunId.make("run-waiting"); - const projection: OrchestrationV2ThreadProjection = { - ...v2Projection, - runs: [ - { - id: waitingRunId, - threadId: v2ThreadId, - ordinal: 1, - providerInstanceId: v2Projection.thread.providerInstanceId, - modelSelection: v2Projection.thread.modelSelection, - providerThreadId: null, - userMessageId: MessageId.make("message-waiting"), - rootNodeId: null, - activeAttemptId: null, - status, - requestedAt: v2Now, - startedAt: v2Now, - completedAt: null, - checkpointId: null, - contextHandoffId: null, - }, - ], - turnItems: [ - { - id: TurnItemId.make("background-command"), - threadId: v2ThreadId, - runId: waitingRunId, - nodeId: null, - providerThreadId: null, - providerTurnId: null, - nativeItemRef: null, - parentItemId: null, - ordinal: 1, - status: "running", - title: null, - startedAt: v2Now, - completedAt: null, - updatedAt: v2Now, - type: "command_execution", - input: "vp run dev", - }, - ], - }; - const commands: OrchestrationV2Command[] = []; - const supervisor = yield* makeSupervisor({ commands, projects: [], projection }); + ] as const)("dispatches Stop for %s runs with background commands except rollback", (status) => + Effect.gen(function* () { + const waitingRunId = RunId.make("run-waiting"); + const projection: OrchestrationV2ThreadProjection = { + ...v2Projection, + runs: [ + { + id: waitingRunId, + threadId: v2ThreadId, + ordinal: 1, + providerInstanceId: v2Projection.thread.providerInstanceId, + modelSelection: v2Projection.thread.modelSelection, + providerThreadId: null, + userMessageId: MessageId.make("message-waiting"), + rootNodeId: null, + activeAttemptId: null, + status, + requestedAt: v2Now, + startedAt: v2Now, + completedAt: null, + checkpointId: null, + contextHandoffId: null, + }, + ], + turnItems: [ + { + id: TurnItemId.make("background-command"), + threadId: v2ThreadId, + runId: waitingRunId, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + status: "running", + title: null, + startedAt: v2Now, + completedAt: null, + updatedAt: v2Now, + type: "command_execution", + input: "vp run dev", + }, + ], + }; + const commands: OrchestrationV2Command[] = []; + const supervisor = yield* makeSupervisor({ commands, projects: [], projection }); - const result = yield* interruptThreadTurn({ threadId: v2ThreadId }).pipe( - Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), - ); + const result = yield* interruptThreadTurn({ threadId: v2ThreadId }).pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + ); - expect(result).toEqual({ sequence: status === "rolled_back" ? 0 : 1 }); - expect(commands).toEqual( - status === "rolled_back" - ? [] - : [ - { - type: "run.interrupt", - commandId: expect.any(String), - threadId: v2ThreadId, - runId: waitingRunId, - holdQueue: true, - }, - ], - ); - }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), - ); - } + expect(result).toEqual({ sequence: status === "rolled_back" ? 0 : 1 }); + expect(commands).toEqual( + status === "rolled_back" + ? [] + : [ + { + type: "run.interrupt", + commandId: expect.any(String), + threadId: v2ThreadId, + runId: waitingRunId, + holdQueue: true, + }, + ], + ); + }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), + ); it.effect( "dispatches V2-native relationship and queue commands without compatibility shaping", diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index ded1ff733e22..c0cb8e67504c 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -1027,6 +1027,20 @@ export const linkThreadPullRequest = Effect.fn("EnvironmentCommands.linkThreadPu }); }, ); +export type WatchThreadPullRequestInput = Omit< + Extract, + "type" | "commandId" +> & + CommandMetadata; +export const watchThreadPullRequest = Effect.fn("EnvironmentCommands.watchThreadPullRequest")( + function* (input: WatchThreadPullRequestInput) { + return yield* dispatch({ + ...input, + type: "thread.pull-request.watch", + commandId: yield* allocateCommandId(input), + }); + }, +); export const unlinkThreadPullRequest = Effect.fn("EnvironmentCommands.unlinkThreadPullRequest")( function* (input: UnlinkThreadPullRequestInput) { return yield* dispatch({ diff --git a/packages/client-runtime/src/rpc/client.test.ts b/packages/client-runtime/src/rpc/client.test.ts index 032603f28a96..8dda4440e673 100644 --- a/packages/client-runtime/src/rpc/client.test.ts +++ b/packages/client-runtime/src/rpc/client.test.ts @@ -1,5 +1,6 @@ import { DEFAULT_SERVER_SETTINGS, + EnvironmentAuthorizationError, EnvironmentId, PreviewTabId, ThreadId, @@ -618,6 +619,114 @@ describe("environment RPC", () => { }), ); + it.effect("doubles the retry delay for repeated failures and resets it after a value", () => + Effect.gen(function* () { + const domainError = new Error("thread not hydrated yet"); + const subscriptions = yield* Queue.unbounded(); + const failures = yield* Queue.unbounded(); + let attempts = 0; + const client = { + [WS_METHODS.subscribeTerminalEvents]: () => + Stream.unwrap( + Effect.sync(() => { + attempts += 1; + return attempts; + }).pipe( + Effect.tap((attempt) => Queue.offer(subscriptions, attempt)), + Effect.map((attempt) => { + if (attempt <= 2) return Stream.fail(domainError); + if (attempt === 3) + return Stream.concat(Stream.make("event"), Stream.fail(domainError)); + return Stream.never; + }), + ), + ), + } as unknown as WsRpcProtocolClient; + const { activeSession, supervisor } = yield* makeHarness(); + + yield* SubscriptionRef.set(activeSession, Option.some(session(client))); + const subscriptionFiber = yield* subscribe( + WS_METHODS.subscribeTerminalEvents, + {}, + { + onExpectedFailure: () => Queue.offer(failures, undefined), + retryExpectedFailureAfter: "100 millis", + }, + ).pipe( + Stream.runDrain, + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.forkChild, + ); + + expect(yield* Queue.take(subscriptions)).toBe(1); + yield* Queue.take(failures); + yield* TestClock.adjust("100 millis"); + expect(yield* Queue.take(subscriptions)).toBe(2); + yield* Queue.take(failures); + // The second retry waits 200ms, so 100ms is not enough. + yield* TestClock.adjust("100 millis"); + expect(Option.isNone(yield* Queue.poll(subscriptions))).toBe(true); + yield* TestClock.adjust("100 millis"); + expect(yield* Queue.take(subscriptions)).toBe(3); + // Attempt 3 delivered a value before failing, so the delay is back to 100ms. + yield* Queue.take(failures); + yield* TestClock.adjust("100 millis"); + expect(yield* Queue.take(subscriptions)).toBe(4); + yield* Fiber.interrupt(subscriptionFiber); + }), + ); + + it.effect("waits for the next session after an authorization failure", () => + Effect.gen(function* () { + const subscriptions = yield* Queue.unbounded(); + const failed = yield* Deferred.make(); + let attempts = 0; + const client = { + [WS_METHODS.subscribeTerminalEvents]: () => + Stream.unwrap( + Queue.offer(subscriptions, undefined).pipe( + Effect.map(() => { + attempts += 1; + return attempts === 1 + ? Stream.fail( + new EnvironmentAuthorizationError({ + message: "Missing scope", + requiredScope: "orchestration:read", + }), + ) + : Stream.never; + }), + ), + ), + } as unknown as WsRpcProtocolClient; + const { activeSession, supervisor } = yield* makeHarness(); + + yield* SubscriptionRef.set(activeSession, Option.some(session(client))); + const subscriptionFiber = yield* subscribe( + WS_METHODS.subscribeTerminalEvents, + {}, + { + onExpectedFailure: () => Deferred.succeed(failed, undefined).pipe(Effect.asVoid), + retryExpectedFailureAfter: "100 millis", + }, + ).pipe( + Stream.runDrain, + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.forkChild, + ); + + yield* Queue.take(subscriptions); + yield* Deferred.await(failed); + yield* TestClock.adjust("1 minute"); + expect(Option.isNone(yield* Queue.poll(subscriptions))).toBe(true); + + yield* SubscriptionRef.set(activeSession, Option.some(session(client))); + yield* Queue.take(subscriptions); + yield* Fiber.interrupt(subscriptionFiber); + expect(attempts).toBe(2); + }), + ); + it.effect.each(["input", "stream"] as const)( "does not classify %s subscription defects as expected failures", (where) => diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index 426ea63482ea..4ce45cf3ff1b 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -1,7 +1,11 @@ -import { ORCHESTRATION_V2_WS_METHODS, WS_METHODS } from "@t3tools/contracts"; +import { + EnvironmentAuthorizationError, + ORCHESTRATION_V2_WS_METHODS, + WS_METHODS, +} from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; -import type * as Duration from "effect/Duration"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; @@ -92,6 +96,10 @@ export class EnvironmentRpcSubscriptionObserver extends Context.Reference<{ }) {} export const isRpcClientError = Schema.is(RpcClientError.RpcClientError); +const isEnvironmentAuthorizationError = Schema.is(EnvironmentAuthorizationError); + +/** Ceiling for the doubling delay between same-session expected-failure retries. */ +const MAX_EXPECTED_FAILURE_RETRY_DELAY_MS = 30_000; export type EnvironmentRpcInput = Parameters>[0]; @@ -192,6 +200,12 @@ interface SubscriptionOptions { readonly onExpectedFailure?: ( cause: Cause.Cause>, ) => Effect.Effect; + /** + * First delay before resubscribing on the same session after an expected + * failure. Each consecutive failure doubles it up to 30 seconds, and the + * first value from a healthy stream resets it. Authorization failures are + * not retried; they wait for the next session or `resubscribe` signal. + */ readonly retryExpectedFailureAfter?: Duration.Input; readonly resubscribe?: Stream.Stream; } @@ -238,6 +252,9 @@ function subscribeDynamicMapped( EnvironmentRpcStreamValue, EnvironmentRpcStreamFailure >; + // Consecutive expected-failure retries on this session. Reset by + // the first value a resubscribed stream delivers. + let expectedFailureRetries = 0; const subscribeToSession = (): Stream.Stream> => Stream.suspend(() => Stream.unwrap( @@ -248,7 +265,13 @@ function subscribeDynamicMapped( method: tag, input, }); - const stream = mapStream(session, method(input)); + const stream = mapStream(session, method(input)).pipe( + Stream.onFirst(() => + Effect.sync(() => { + expectedFailureRetries = 0; + }), + ), + ); // An evicted preview host completes its registration stream. // Re-register only after completion; failures still follow the // session recovery policy and browser actions are never replayed. @@ -296,14 +319,27 @@ function subscribeDynamicMapped( const handled = Stream.fromEffect(options.onExpectedFailure(cause)).pipe( Stream.drain, ); - if (options.retryExpectedFailureAfter === undefined) { + const isAuthorizationFailure = cause.reasons.some( + (reason) => + reason._tag === "Fail" && isEnvironmentAuthorizationError(reason.error), + ); + if ( + options.retryExpectedFailureAfter === undefined || + isAuthorizationFailure + ) { return handled; } + const retryDelay = Duration.millis( + Math.min( + Duration.toMillis(options.retryExpectedFailureAfter) * + 2 ** expectedFailureRetries, + MAX_EXPECTED_FAILURE_RETRY_DELAY_MS, + ), + ); + expectedFailureRetries += 1; return handled.pipe( Stream.concat( - Stream.fromEffect(Effect.sleep(options.retryExpectedFailureAfter)).pipe( - Stream.drain, - ), + Stream.fromEffect(Effect.sleep(retryDelay)).pipe(Stream.drain), ), Stream.concat(subscribeToSession()), ); diff --git a/packages/client-runtime/src/rpc/session.test.ts b/packages/client-runtime/src/rpc/session.test.ts index d2baa5927475..246e4f585181 100644 --- a/packages/client-runtime/src/rpc/session.test.ts +++ b/packages/client-runtime/src/rpc/session.test.ts @@ -429,59 +429,55 @@ describe("RpcSessionFactory", () => { ), ); - for (const options of [ + it.effect.each([ { environmentThemes: true }, { usageLimitSources: true }, { environmentThemes: true, usageLimitSources: true }, - ]) { - it.effect( - `shares only a config subscription with the same opt-ins: ${JSON.stringify(options)}`, - () => - Effect.scoped( - Effect.gen(function* () { - const { factory, sockets } = yield* makeFactory(options); - const session = yield* factory.connect(PREPARED); - const readyFiber = yield* Effect.forkChild(session.ready); - const socket = yield* awaitSocket(sockets); - socket.open(); - yield* completeInitialConfig(socket, ENCODED_THEME_SERVER_CONFIG, options); - yield* Fiber.join(readyFiber); - - const shared = yield* session.subscribeServerConfig(options).pipe(Stream.runHead); - expect(shared).toMatchObject({ _tag: "Some", value: { type: "snapshot" } }); - expect( - socket.sent.map((message) => decodeJson(message)).filter(isRpcRequest), - ).toHaveLength(1); - - const fallbackFiber = yield* session - .subscribeServerConfig({}) - .pipe(Stream.runHead, Effect.forkChild); - const fallbackRequest = yield* awaitRequest(socket, 1); - expect(fallbackRequest).toMatchObject({ - tag: WS_METHODS.subscribeServerConfig, - payload: {}, - }); - socket.serverMessage( - encodeJson({ - _tag: "Chunk", - requestId: fallbackRequest.id, - values: [ - { - version: 1, - type: "snapshot", - config: ENCODED_THEME_SERVER_CONFIG, - }, - ], - }), - ); - expect(yield* Fiber.join(fallbackFiber)).toMatchObject({ - _tag: "Some", - value: { type: "snapshot" }, - }); + ])("shares only a config subscription with the same opt-ins: %j", (options) => + Effect.scoped( + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory(options); + const session = yield* factory.connect(PREPARED); + const readyFiber = yield* Effect.forkChild(session.ready); + const socket = yield* awaitSocket(sockets); + socket.open(); + yield* completeInitialConfig(socket, ENCODED_THEME_SERVER_CONFIG, options); + yield* Fiber.join(readyFiber); + + const shared = yield* session.subscribeServerConfig(options).pipe(Stream.runHead); + expect(shared).toMatchObject({ _tag: "Some", value: { type: "snapshot" } }); + expect(socket.sent.map((message) => decodeJson(message)).filter(isRpcRequest)).toHaveLength( + 1, + ); + + const fallbackFiber = yield* session + .subscribeServerConfig({}) + .pipe(Stream.runHead, Effect.forkChild); + const fallbackRequest = yield* awaitRequest(socket, 1); + expect(fallbackRequest).toMatchObject({ + tag: WS_METHODS.subscribeServerConfig, + payload: {}, + }); + socket.serverMessage( + encodeJson({ + _tag: "Chunk", + requestId: fallbackRequest.id, + values: [ + { + version: 1, + type: "snapshot", + config: ENCODED_THEME_SERVER_CONFIG, + }, + ], }), - ), - ); - } + ); + expect(yield* Fiber.join(fallbackFiber)).toMatchObject({ + _tag: "Some", + value: { type: "snapshot" }, + }); + }), + ), + ); it.effect.each([ { usageLimitSources: true }, @@ -1191,37 +1187,38 @@ describe("RpcSessionFactory", () => { ), ); - for (const relay of [false, true]) { - it.effect(`fails readiness when the ${relay ? "relay" : "direct"} websocket never opens`, () => - Effect.gen(function* () { - const { factory, sockets } = yield* makeFactory(); + it.effect.each([ + { relay: false, label: "direct" }, + { relay: true, label: "relay" }, + ])("fails readiness when the $label websocket never opens", ({ relay }) => + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory(); - const error = yield* Effect.scoped( - Effect.gen(function* () { - const session = yield* factory.connect({ - ...PREPARED, - target: relay - ? new RelayConnectionTarget({ - environmentId: TARGET.environmentId, - label: TARGET.label, - }) - : TARGET, - }); - const readyFiber = yield* Effect.forkChild(Effect.flip(session.ready)); - yield* awaitSocket(sockets); - - yield* TestClock.adjust("15 seconds"); - return yield* Fiber.join(readyFiber); - }), - ); + const error = yield* Effect.scoped( + Effect.gen(function* () { + const session = yield* factory.connect({ + ...PREPARED, + target: relay + ? new RelayConnectionTarget({ + environmentId: TARGET.environmentId, + label: TARGET.label, + }) + : TARGET, + }); + const readyFiber = yield* Effect.forkChild(Effect.flip(session.ready)); + yield* awaitSocket(sockets); - expect(error).toBeInstanceOf(ConnectionTransientError); - expect(error).toMatchObject({ - reason: "transport", - message: `Test environment could not establish a WebSocket connection.${relay ? ` ${NETWORK_BLOCKING_HINT}` : ""}`, - }); - expect(sockets[0]?.readyState).toBe(TestWebSocket.CLOSED); - }).pipe(Effect.provide(TestClock.layer())), - ); - } + yield* TestClock.adjust("15 seconds"); + return yield* Fiber.join(readyFiber); + }), + ); + + expect(error).toBeInstanceOf(ConnectionTransientError); + expect(error).toMatchObject({ + reason: "transport", + message: `Test environment could not establish a WebSocket connection.${relay ? ` ${NETWORK_BLOCKING_HINT}` : ""}`, + }); + expect(sockets[0]?.readyState).toBe(TestWebSocket.CLOSED); + }).pipe(Effect.provide(TestClock.layer())), + ); }); diff --git a/packages/client-runtime/src/state/assets.test.ts b/packages/client-runtime/src/state/assets.test.ts index 95c7622c8c1c..d1407fe79598 100644 --- a/packages/client-runtime/src/state/assets.test.ts +++ b/packages/client-runtime/src/state/assets.test.ts @@ -60,7 +60,7 @@ describe("asset collection keys", () => { }); describe("createAssetEnvironmentAtoms", () => { - for (const scenario of [ + it.effect.each([ { name: "missing video", path: "/tmp/clip.mp4", fallback: true }, { name: "literal filename characters", path: "/tmp/frame#one?two.png", fallback: true }, { name: "windows path", path: "C:\\Users\\demo\\clip.mp4", fallback: true }, @@ -73,113 +73,111 @@ describe("createAssetEnvironmentAtoms", () => { { name: "same environment", path: "/tmp/clip.mp4", primary: "same" }, { name: "non-media", path: "/tmp/report.html" }, { name: "authorization failure", path: "/tmp/clip.mp4", error: "auth" }, - ]) { - it.effect(`uses the correct environment for ${scenario.name}`, () => - Effect.gen(function* () { - const remoteId = EnvironmentId.make("remote"); - const localId = EnvironmentId.make("local"); - const resource = { - _tag: "media-file" as const, - threadId: ThreadId.make("foreign-thread"), - path: scenario.path, - }; - const error = - scenario.error === "auth" - ? new EnvironmentAuthorizationError({ - message: "denied", - requiredScope: "orchestration:read", - }) - : scenario.error === "inspection" - ? new AssetWorkspaceAssetInspectionError({ resource, cause: new Error("unreadable") }) - : scenario.error === "context" - ? new AssetWorkspaceContextNotFoundError({ resource }) - : new AssetWorkspaceAssetNotFoundError({ resource }); - const calls: EnvironmentId[] = []; - const supervisors = new Map< - EnvironmentId, - EnvironmentSupervisor.EnvironmentSupervisor["Service"] - >(); - for (const environmentId of [remoteId, localId]) { - const client = { - [WS_METHODS.assetsCreateUrl]: () => { - calls.push(environmentId); - return environmentId === remoteId && !scenario.success - ? Effect.fail(error) - : Effect.succeed({ - relativeUrl: `/api/assets/${environmentId}/media`, - expiresAt: 999999, - }); - }, - } as unknown as WsRpcProtocolClient; - const session = { client } as RpcSession; - supervisors.set( - environmentId, - EnvironmentSupervisor.EnvironmentSupervisor.of({ - target: new PrimaryConnectionTarget({ - environmentId, - label: environmentId, - httpBaseUrl: `https://${environmentId}.test`, - wsBaseUrl: `wss://${environmentId}.test`, - }), - state: yield* SubscriptionRef.make({ - ...AVAILABLE_CONNECTION_STATE, - phase: "connected" as const, - }), - session: yield* SubscriptionRef.make(Option.some(session)), - prepared: yield* SubscriptionRef.make(Option.none()), - connect: Effect.void, - disconnect: Effect.void, - retryNow: Effect.void, + ])("uses the correct environment for $name", (scenario) => + Effect.gen(function* () { + const remoteId = EnvironmentId.make("remote"); + const localId = EnvironmentId.make("local"); + const resource = { + _tag: "media-file" as const, + threadId: ThreadId.make("foreign-thread"), + path: scenario.path, + }; + const error = + scenario.error === "auth" + ? new EnvironmentAuthorizationError({ + message: "denied", + requiredScope: "orchestration:read", + }) + : scenario.error === "inspection" + ? new AssetWorkspaceAssetInspectionError({ resource, cause: new Error("unreadable") }) + : scenario.error === "context" + ? new AssetWorkspaceContextNotFoundError({ resource }) + : new AssetWorkspaceAssetNotFoundError({ resource }); + const calls: EnvironmentId[] = []; + const supervisors = new Map< + EnvironmentId, + EnvironmentSupervisor.EnvironmentSupervisor["Service"] + >(); + for (const environmentId of [remoteId, localId]) { + const client = { + [WS_METHODS.assetsCreateUrl]: () => { + calls.push(environmentId); + return environmentId === remoteId && !scenario.success + ? Effect.fail(error) + : Effect.succeed({ + relativeUrl: `/api/assets/${environmentId}/media`, + expiresAt: 999999, + }); + }, + } as unknown as WsRpcProtocolClient; + const session = { client } as RpcSession; + supervisors.set( + environmentId, + EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: new PrimaryConnectionTarget({ + environmentId, + label: environmentId, + httpBaseUrl: `https://${environmentId}.test`, + wsBaseUrl: `wss://${environmentId}.test`, + }), + state: yield* SubscriptionRef.make({ + ...AVAILABLE_CONNECTION_STATE, + phase: "connected" as const, }), - ); - } - const environments = EnvironmentRegistry.EnvironmentRegistry.of({ - run: (id, effect) => - Effect.provideService( - effect, - EnvironmentSupervisor.EnvironmentSupervisor, - supervisors.get(id)!, - ), - followStream: (id, stream) => - Stream.provideService( - stream, - EnvironmentSupervisor.EnvironmentSupervisor, - supervisors.get(id)!, - ), - } as EnvironmentRegistry.EnvironmentRegistry["Service"]); - const registry = AtomRegistry.make(); - yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose())); - const localTarget = { - environmentId: scenario.primary === "same" ? remoteId : localId, - httpBaseUrl: "https://local.test", - }; - const localEnvironment = Atom.make( - scenario.primary === "none" || scenario.primary === "reconnecting" ? null : localTarget, + session: yield* SubscriptionRef.make(Option.some(session)), + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + }), ); - const assets = createAssetEnvironmentAtoms( - Atom.runtime(Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, environments)), - localEnvironment, + } + const environments = EnvironmentRegistry.EnvironmentRegistry.of({ + run: (id, effect) => + Effect.provideService( + effect, + EnvironmentSupervisor.EnvironmentSupervisor, + supervisors.get(id)!, + ), + followStream: (id, stream) => + Stream.provideService( + stream, + EnvironmentSupervisor.EnvironmentSupervisor, + supervisors.get(id)!, + ), + } as EnvironmentRegistry.EnvironmentRegistry["Service"]); + const registry = AtomRegistry.make(); + yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose())); + const localTarget = { + environmentId: scenario.primary === "same" ? remoteId : localId, + httpBaseUrl: "https://local.test", + }; + const localEnvironment = Atom.make( + scenario.primary === "none" || scenario.primary === "reconnecting" ? null : localTarget, + ); + const assets = createAssetEnvironmentAtoms( + Atom.runtime(Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, environments)), + localEnvironment, + ); + const query = assets.createUrl({ environmentId: remoteId, input: { resource } }); + const result = AtomRegistry.getResult(registry, query, { suspendOnWaiting: true }); + if (scenario.fallback || scenario.success) { + expect((yield* result).relativeUrl).toBe( + scenario.fallback + ? "https://local.test/api/assets/local/media" + : "/api/assets/remote/media", ); - const query = assets.createUrl({ environmentId: remoteId, input: { resource } }); - const result = AtomRegistry.getResult(registry, query, { suspendOnWaiting: true }); - if (scenario.fallback || scenario.success) { - expect((yield* result).relativeUrl).toBe( - scenario.fallback - ? "https://local.test/api/assets/local/media" - : "/api/assets/remote/media", - ); - } else { - expect(yield* Effect.flip(result)).toEqual(error); - } - expect(calls).toEqual(scenario.fallback ? [remoteId, localId] : [remoteId]); - if (scenario.primary === "reconnecting") { - registry.set(localEnvironment, localTarget); - expect((yield* result).relativeUrl).toBe("https://local.test/api/assets/local/media"); - expect(calls).toEqual([remoteId, remoteId, localId]); - } - }).pipe(Effect.scoped), - ); - } + } else { + expect(yield* Effect.flip(result)).toEqual(error); + } + expect(calls).toEqual(scenario.fallback ? [remoteId, localId] : [remoteId]); + if (scenario.primary === "reconnecting") { + registry.set(localEnvironment, localTarget); + expect((yield* result).relativeUrl).toBe("https://local.test/api/assets/local/media"); + expect(calls).toEqual([remoteId, remoteId, localId]); + } + }).pipe(Effect.scoped), + ); it("keys asset URL queries by environment and resource", () => { const runtime = Atom.runtime(Layer.empty) as unknown as Atom.AtomRuntime< diff --git a/packages/client-runtime/src/state/entities.test.ts b/packages/client-runtime/src/state/entities.test.ts index 736e05c83562..1a2581609bcd 100644 --- a/packages/client-runtime/src/state/entities.test.ts +++ b/packages/client-runtime/src/state/entities.test.ts @@ -2,8 +2,10 @@ import { EnvironmentId, MessageId, NodeId, + ProviderDriverKind, ProviderInstanceId, ProviderSessionId, + ProviderThreadId, RunId, RuntimeRequestId, TurnItemId, @@ -140,7 +142,7 @@ describe("V2 client presentation", () => { latestRunId: runId, activeRunId: null, status: "completed", - pendingBackgroundTasks: [{ taskId: "bg-1", description: "sleep 20", kind: "command" }], + pendingBackgroundTasks: [{ taskId: "bg-1", description: "Watch build", kind: "monitor" }], }); expect(shell.latestRun).toMatchObject({ runId, status: "completed" }); @@ -149,10 +151,50 @@ describe("V2 client presentation", () => { activeRunId: null, }); expect(shell.pendingBackgroundTasks).toEqual([ - { taskId: "bg-1", description: "sleep 20", kind: "command" }, + { taskId: "bg-1", description: "Watch build", kind: "monitor" }, ]); }); + it.each([ + { kinds: ["command"], expected: "completed" }, + { kinds: ["command", "subagent"], expected: "idle" }, + { kinds: ["background_task"], expected: "idle" }, + ] as const)("presents a completed shell with $kinds as $expected", ({ kinds, expected }) => { + const pendingBackgroundTasks = kinds.map((kind, index) => ({ + taskId: `bg-${index}`, + kind, + })); + const shell = presentThreadShell(environmentId, { + ...v2ThreadShell, + latestRunId: RunId.make("run-completed"), + activeRunId: null, + status: "completed", + pendingBackgroundTasks, + }); + + expect(shell.latestRun?.status).toBe("completed"); + expect(shell.runtime).toMatchObject({ status: expected, activeRunId: null }); + expect(shell.pendingBackgroundTasks).toEqual(pendingBackgroundTasks); + }); + + it.each(["running", "waiting"] as const)( + "preserves shell %s while only commands remain in the roster", + (status) => { + const runId = RunId.make("run-command"); + const shell = presentThreadShell(environmentId, { + ...v2ThreadShell, + latestRunId: runId, + activeRunId: runId, + status, + pendingBackgroundTasks: [{ taskId: "dev-server", kind: "command" }], + }); + + expect(shell.latestRun?.status).toBe(status); + expect(shell.runtime).toMatchObject({ status, activeRunId: runId }); + expect(shell.pendingBackgroundTasks).toEqual([{ taskId: "dev-server", kind: "command" }]); + }, + ); + it("keeps a failed latest run failed while background tasks are still pending", () => { const shell = presentThreadShell(environmentId, { ...v2ThreadShell, @@ -160,7 +202,7 @@ describe("V2 client presentation", () => { activeRunId: null, status: "failed", lastError: "Provider turn failed", - pendingBackgroundTasks: [{ taskId: "bg-1", description: "sleep 20", kind: "command" }], + pendingBackgroundTasks: [{ taskId: "bg-1", description: "Watch build", kind: "monitor" }], }); // Sidebar and mobile list read runtime "idle" as Waiting before failure. @@ -286,7 +328,7 @@ describe("V2 client presentation", () => { // Stale: server already projected a post-settlement roster, but shell // status still says running (packaged orchestrator-v2 bug). status: "running", - pendingBackgroundTasks: [{ taskId: "bg-1", description: "sleep 20", kind: "command" }], + pendingBackgroundTasks: [{ taskId: "bg-1", description: "Watch build", kind: "monitor" }], }); expect(shell.latestRun).toMatchObject({ runId, status: "running" }); @@ -295,7 +337,7 @@ describe("V2 client presentation", () => { activeRunId: runId, }); expect(shell.pendingBackgroundTasks).toEqual([ - { taskId: "bg-1", description: "sleep 20", kind: "command" }, + { taskId: "bg-1", description: "Watch build", kind: "monitor" }, ]); }); @@ -307,7 +349,7 @@ describe("V2 client presentation", () => { activeRunId: runId, // Stale: checkpoint-oriented waiting masks post-settlement background work. status: "waiting", - pendingBackgroundTasks: [{ taskId: "bg-2", description: "background bash", kind: "command" }], + pendingBackgroundTasks: [{ taskId: "bg-2", description: "Watch build", kind: "monitor" }], }); expect(shell.latestRun).toMatchObject({ runId, status: "waiting" }); @@ -316,7 +358,7 @@ describe("V2 client presentation", () => { activeRunId: runId, }); expect(shell.pendingBackgroundTasks).toEqual([ - { taskId: "bg-2", description: "background bash", kind: "command" }, + { taskId: "bg-2", description: "Watch build", kind: "monitor" }, ]); }); @@ -443,7 +485,7 @@ describe("V2 client presentation", () => { contextHandoffId: null, }; const backgroundItem = { - id: TurnItemId.make("item-background-command"), + id: TurnItemId.make("item-background-subagent"), threadId: v2Projection.thread.id, runId, nodeId: null, @@ -453,12 +495,18 @@ describe("V2 client presentation", () => { parentItemId: null, ordinal: 0, status: "running" as const, - title: "Background command", + title: "Background review", startedAt: now, completedAt: null, updatedAt: now, - type: "command_execution" as const, - input: "sleep 20", + type: "subagent" as const, + subagentId: NodeId.make("subagent-review"), + origin: "provider_native" as const, + driver: ProviderDriverKind.make("codex"), + providerInstanceId: v2Projection.thread.providerInstanceId, + childThreadId: null, + prompt: "Review the changes", + result: null, }; expect( @@ -488,6 +536,59 @@ describe("V2 client presentation", () => { turnItems: [backgroundItem], }), ).toMatchObject({ status: "failed", activeRunId: null }); + const commandItem = { + ...backgroundItem, + id: TurnItemId.make("item-background-command"), + type: "command_execution" as const, + input: "npm run dev", + }; + expect( + deriveThreadRuntime({ ...v2Projection, runs: [run], turnItems: [commandItem] }), + ).toMatchObject({ status: "waiting", activeRunId: null }); + + for (const [turnItems, status] of [ + [[commandItem], "completed"], + [[backgroundItem], "idle"], + [[commandItem, backgroundItem], "idle"], + ] as const) { + expect( + deriveThreadRuntime({ + ...v2Projection, + runs: [{ ...run, status: "completed", completedAt: now }], + turnItems, + }), + ).toMatchObject({ status, activeRunId: null }); + } + + for (const kind of ["monitor", "background_task"] as const) { + const providerThread = { + id: ProviderThreadId.make("provider-thread-background"), + driver: ProviderDriverKind.make("claudeCode"), + providerInstanceId: v2Projection.thread.providerInstanceId, + providerSessionId: null, + appThreadId: v2Projection.thread.id, + ownerNodeId: null, + nativeThreadRef: null, + nativeConversationHeadRef: null, + status: "idle" as const, + firstRunOrdinal: 1, + lastRunOrdinal: 1, + handoffIds: [], + forkedFrom: null, + pendingBackgroundTasks: [{ taskId: "background-task", kind }], + createdAt: now, + updatedAt: now, + }; + for (const status of ["completed", "failed"] as const) { + expect( + deriveThreadRuntime({ + ...v2Projection, + runs: [{ ...run, status, completedAt: now }], + providerThreads: [providerThread], + }), + ).toMatchObject({ status: status === "failed" ? "failed" : "idle", activeRunId: null }); + } + } }); it("joins pending request entities to their native turn-item display data", () => { diff --git a/packages/client-runtime/src/state/models.ts b/packages/client-runtime/src/state/models.ts index 7163bd79a820..dc74aff6ff58 100644 --- a/packages/client-runtime/src/state/models.ts +++ b/packages/client-runtime/src/state/models.ts @@ -1,3 +1,4 @@ +import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import { threadPullRequestsOf } from "@t3tools/shared/threadPullRequests"; import type { ThreadLinkedPullRequest, @@ -159,15 +160,19 @@ function terminalRunStatus(status: OrchestrationV2RunStatus): boolean { ); } -// Park runtime at idle when the post-settlement background roster is nonempty -// so #4415 waiting-presentation Waiting (session.idle) can consume CTM runtime. +// Park runtime at idle when the post-settlement background roster holds the +// run's completion, so #4415 waiting-presentation Waiting (session.idle) can +// consume CTM runtime. Only work that wakes the agent holds it: commands it +// left running, such as a dev server, present the run's own status (#14872). // The server suppresses the roster while an interruptible activity run exists, // so a remaining roster is stronger than checkpoint-oriented waiting. // latestRun keeps the latest run's status for history presentation. // A failed latest run outranks the roster, so the failure stays visible. function shellRuntime(thread: OrchestrationV2ThreadShell): ThreadRuntimeSummary | null { if (thread.latestRunId === null && thread.activeProviderThreadId === null) return null; - const parkAtIdle = (thread.pendingBackgroundTasks?.length ?? 0) > 0 && thread.status !== "failed"; + const parkAtIdle = + backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks ?? []) && + thread.status !== "failed"; const status = parkAtIdle ? "idle" : (thread.activityRunStatus ?? thread.status); return { status, @@ -296,7 +301,10 @@ export function resolveThreadProviderStack( return [...previous.slice(-(THREAD_PROVIDER_STACK_LIMIT - 1)), current]; } -/** Both shell and detail timers use the activity-owning run, never last activity. */ +/** + * Both shell and detail timers count from the activity-owning run's work + * start, never last activity. A wake keeps the start of the work it continues. + */ export function resolveThreadWorkingStartedAt(input: { readonly latestRun: Pick< ThreadRunSummary, diff --git a/packages/client-runtime/src/state/outdatedServerUpdate.ts b/packages/client-runtime/src/state/outdatedServerUpdate.ts new file mode 100644 index 000000000000..36d7ba1f6713 --- /dev/null +++ b/packages/client-runtime/src/state/outdatedServerUpdate.ts @@ -0,0 +1,78 @@ +import type { EnvironmentId, ServerSelfUpdateInput } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import type * as HttpClient from "effect/unstable/http/HttpClient"; +import type { Atom } from "effect/unstable/reactivity"; +import type * as Socket from "effect/unstable/socket/Socket"; + +import { updateOutdatedHost } from "../connection/outdatedHostUpdate.ts"; +import type * as ConnectionResolver from "../connection/resolver.ts"; +import type * as EnvironmentRegistry from "../connection/registry.ts"; +import type * as RelayEnvironmentDiscovery from "../relay/discovery.ts"; +import { createAtomCommandScheduler, createRuntimeCommand } from "./runtime.ts"; +import { + serverUpdateFailureMessage, + serverUpdateStateAtom, + type ServerUpdateStage, +} from "./server.ts"; + +export interface OutdatedServerUpdateTarget { + readonly environmentId: EnvironmentId; + readonly input: ServerSelfUpdateInput; + /** From the host descriptor when known; an outdated host never delivers a server config. */ + readonly fromVersion?: string; +} + +/** + * Updates a host too old for this client to connect to. Progress lands in the + * same per-environment update state as a normal server update. + */ +export function createOutdatedServerUpdateCommand( + runtime: Atom.AtomRuntime< + | EnvironmentRegistry.EnvironmentRegistry + | ConnectionResolver.ConnectionResolver + | RelayEnvironmentDiscovery.RelayEnvironmentDiscovery + | Socket.WebSocketConstructor + | HttpClient.HttpClient, + E + >, +) { + return createRuntimeCommand(runtime, { + label: "environment-data:server:update-outdated-server", + scheduler: createAtomCommandScheduler(), + concurrency: { + mode: "singleFlight", + key: ({ environmentId }: OutdatedServerUpdateTarget) => environmentId, + }, + execute: (target: OutdatedServerUpdateTarget, atomRegistry) => { + const stateAtom = serverUpdateStateAtom(target.environmentId); + const targetVersion = target.input.targetVersion; + const fromVersion = target.fromVersion ?? targetVersion; + let currentStage: ServerUpdateStage = "downloading"; + const setStage = (stage: ServerUpdateStage) => + Effect.sync(() => { + currentStage = stage; + atomRegistry.set(stateAtom, { status: "running", stage, fromVersion, targetVersion }); + }); + return setStage(currentStage).pipe( + Effect.andThen(updateOutdatedHost(target.environmentId, target.input, setStage)), + Effect.onExit((exit) => + Effect.sync(() => { + if (Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause)) { + atomRegistry.set(stateAtom, { status: "idle" }); + return; + } + atomRegistry.set(stateAtom, { + status: "failed", + stage: currentStage, + fromVersion, + targetVersion, + message: serverUpdateFailureMessage(Cause.squash(exit.cause)), + }); + }), + ), + ); + }, + }); +} diff --git a/packages/client-runtime/src/state/pullRequests.test.ts b/packages/client-runtime/src/state/pullRequests.test.ts index 86e4ad51196f..c33623bbe1bf 100644 --- a/packages/client-runtime/src/state/pullRequests.test.ts +++ b/packages/client-runtime/src/state/pullRequests.test.ts @@ -236,8 +236,9 @@ for (const scenario of [ ); } -for (const provider of ["github", "gitlab", "bitbucket", "azure-devops"] as const) { - it.effect(`routes ${provider} viewed marks to their storage environment`, () => +it.effect.each(["github", "gitlab", "bitbucket", "azure-devops"] as const)( + "routes %s viewed marks to their storage environment", + (provider) => Effect.scoped( Effect.gen(function* () { const reference = { @@ -311,8 +312,7 @@ for (const provider of ["github", "gitlab", "bitbucket", "azure-devops"] as cons } }), ), - ); -} +); const TARGET = new PrimaryConnectionTarget({ environmentId: EnvironmentId.make("environment-1"), @@ -420,8 +420,9 @@ const makeTestRuntime = Effect.fn("makeTestRuntime")(function* ( return { runtime, atoms, registry, environmentRegistry, supervisor }; }); -for (const permission of ["default", "origin-off", "destination-off", "read-only"] as const) { - it.effect(`does not probe another environment with ${permission} routing permission`, () => +it.effect.each(["default", "origin-off", "destination-off", "read-only"] as const)( + "does not probe another environment with %s routing permission", + (permission) => Effect.scoped( Effect.gen(function* () { const calls: string[] = []; @@ -480,125 +481,128 @@ for (const permission of ["default", "origin-off", "destination-off", "read-only ); }), ), - ); -} +); -for (const side of ["origin", "destination"] as const) { - for (const stored of ["matching", "changed", "missing", "unavailable", "failed"] as const) { - it.effect(`checks the current ${side} SSH profile before routing with ${stored} storage`, () => - Effect.scoped( - Effect.gen(function* () { - const calls: string[] = []; - const identity = { - host: "github.com", - provider: "github", - viewer: "maria", - accountId: "123", - }; - const client = { - [WS_METHODS.pullRequestsRouting]: () => - Effect.sync(() => { - calls.push("source-probe"); - return identity; - }), - [WS_METHODS.pullRequestsRunAction]: (input: { expectedAccountId?: string }) => - Effect.gen(function* () { - if (input.expectedAccountId !== undefined) { - return yield* new PullRequestOperationError({ - operation: "routeIdentity", - detail: "Source guard refused.", - }); - } - calls.push("source-write"); - }), - [WS_METHODS.pullRequestsInvalidate]: () => Effect.void, - } as unknown as WsRpcProtocolClient; - const alternate = { - [WS_METHODS.pullRequestsRoutingIdentity]: () => - Effect.sync(() => { - calls.push("alternate-probe"); - return identity; - }), - [WS_METHODS.pullRequestsRunAction]: () => - Effect.sync(() => { - calls.push("alternate-write"); - }), - [WS_METHODS.pullRequestsInvalidate]: () => Effect.void, - } as unknown as WsRpcProtocolClient; - const { environmentRegistry, supervisor } = yield* makeTestRuntime(client, alternate); - const environmentId = - side === "origin" ? TARGET.environmentId : EnvironmentId.make("local-environment"); - const profile = new SshConnectionProfile({ - connectionId: "ssh-1", +it.effect.each( + (["origin", "destination"] as const).flatMap((side) => + (["matching", "changed", "missing", "unavailable", "failed"] as const).map((stored) => ({ + side, + stored, + })), + ), +)("checks the current $side SSH profile before routing with $stored storage", ({ side, stored }) => + Effect.scoped( + Effect.gen(function* () { + const calls: string[] = []; + const identity = { + host: "github.com", + provider: "github", + viewer: "maria", + accountId: "123", + }; + const client = { + [WS_METHODS.pullRequestsRouting]: () => + Effect.sync(() => { + calls.push("source-probe"); + return identity; + }), + [WS_METHODS.pullRequestsRunAction]: (input: { expectedAccountId?: string }) => + Effect.gen(function* () { + if (input.expectedAccountId !== undefined) { + return yield* new PullRequestOperationError({ + operation: "routeIdentity", + detail: "Source guard refused.", + }); + } + calls.push("source-write"); + }), + [WS_METHODS.pullRequestsInvalidate]: () => Effect.void, + } as unknown as WsRpcProtocolClient; + const alternate = { + [WS_METHODS.pullRequestsRoutingIdentity]: () => + Effect.sync(() => { + calls.push("alternate-probe"); + return identity; + }), + [WS_METHODS.pullRequestsRunAction]: () => + Effect.sync(() => { + calls.push("alternate-write"); + }), + [WS_METHODS.pullRequestsInvalidate]: () => Effect.void, + } as unknown as WsRpcProtocolClient; + const { environmentRegistry, supervisor } = yield* makeTestRuntime(client, alternate); + const environmentId = + side === "origin" ? TARGET.environmentId : EnvironmentId.make("local-environment"); + const profile = new SshConnectionProfile({ + connectionId: "ssh-1", + environmentId, + label: "SSH", + target: { alias: "work", hostname: "work.example.test", username: "maria", port: 22 }, + }); + yield* SubscriptionRef.update(environmentRegistry.entries, (entries) => + new Map(entries).set(environmentId, { + target: new SshConnectionTarget({ environmentId, + connectionId: profile.connectionId, label: "SSH", - target: { alias: "work", hostname: "work.example.test", username: "maria", port: 22 }, - }); - yield* SubscriptionRef.update(environmentRegistry.entries, (entries) => - new Map(entries).set(environmentId, { - target: new SshConnectionTarget({ - environmentId, - connectionId: profile.connectionId, - label: "SSH", + }), + profile: Option.some(profile), + enabled: true, + }), + ); + const read = Effect.suspend(() => + stored === "failed" + ? Effect.fail( + new ConnectionTransientError({ + reason: "remote-unavailable", + detail: "Profile storage unavailable.", }), - profile: Option.some(profile), - enabled: true, + ) + : Effect.succeed( + stored === "missing" + ? Option.none() + : Option.some( + stored === "changed" + ? new SshConnectionProfile({ + ...profile, + target: { ...profile.target, hostname: "replacement.example.test" }, + }) + : profile, + ), + ), + ); + const route = createPullRequestRouter()(WS_METHODS.pullRequestsRunAction, { + projectId: ProjectId.make("project-1"), + repository: "private/repo", + number: 7, + action: "merge", + }).pipe( + Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + // This also represents a user enabling the stale catalog entry after re-resolution. + Effect.provideService(GitHubRoutingPermissions, trustedRouting), + ); + yield* stored === "unavailable" + ? route + : route.pipe( + Effect.provideService(ConnectionProfileStore.ConnectionProfileStore, { + get: () => read, + put: () => Effect.die("unused"), + remove: () => Effect.die("unused"), }), ); - const read = Effect.suspend(() => - stored === "failed" - ? Effect.fail( - new ConnectionTransientError({ - reason: "remote-unavailable", - detail: "Profile storage unavailable.", - }), - ) - : Effect.succeed( - stored === "missing" - ? Option.none() - : Option.some( - stored === "changed" - ? new SshConnectionProfile({ - ...profile, - target: { ...profile.target, hostname: "replacement.example.test" }, - }) - : profile, - ), - ), - ); - const route = createPullRequestRouter()(WS_METHODS.pullRequestsRunAction, { - projectId: ProjectId.make("project-1"), - repository: "private/repo", - number: 7, - action: "merge", - }).pipe( - Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), - Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), - // This also represents a user enabling the stale catalog entry after re-resolution. - Effect.provideService(GitHubRoutingPermissions, trustedRouting), - ); - yield* stored === "unavailable" - ? route - : route.pipe( - Effect.provideService(ConnectionProfileStore.ConnectionProfileStore, { - get: () => read, - put: () => Effect.die("unused"), - remove: () => Effect.die("unused"), - }), - ); - expect(calls).toEqual( - stored === "matching" - ? ["source-probe", "alternate-probe", "alternate-write"] - : ["source-write"], - ); - }), - ), - ); - } -} + expect(calls).toEqual( + stored === "matching" + ? ["source-probe", "alternate-probe", "alternate-write"] + : ["source-write"], + ); + }), + ), +); -for (const probe of ["origin", "alternate"] as const) { - it.live(`bounds a stalled ${probe} metadata probe without repeating a strict source read`, () => +it.live.each(["origin", "alternate"] as const)( + "bounds a stalled %s metadata probe without repeating a strict source read", + (probe) => Effect.scoped( Effect.gen(function* () { let sourceReads = 0; @@ -638,81 +642,81 @@ for (const probe of ["origin", "alternate"] as const) { expect(sourceReads).toBe(1); }), ), - ); -} +); -for (const source of ["pending", "pending-local", "failed-local", "failed", "offline"] as const) { - it.effect( - source === "offline" - ? "returns held source data only after both fresh paths fail" - : `uses one shared reader with a ${source} source`, - () => - Effect.scoped( - Effect.gen(function* () { - const started = yield* Deferred.make(); - const calls: string[] = []; - const clientFor = (local: boolean) => - ({ - [local ? WS_METHODS.pullRequestsRoutingIdentity : WS_METHODS.pullRequestsRouting]: - () => - Effect.succeed({ - host: "github.com", - provider: "github", - viewer: "maria-rcks", - accountId: "123", - }), - [WS_METHODS.pullRequestsSummary]: (input: { allowStale?: boolean }) => - Effect.gen(function* () { - calls.push(local ? "local" : input.allowStale === false ? "origin" : "held"); - if (source === "offline" && !local && input.allowStale === undefined) { - return { state: "open" }; - } - expect(input.allowStale).toBe(false); - if (local && source !== "offline") return null; - if (source !== "pending" && source !== "pending-local") - return yield* new PullRequestOperationError({ - operation: "summary", - detail: "github unreachable", - }); - yield* Deferred.succeed(started, undefined); - return yield* Effect.never; - }), - }) as unknown as WsRpcProtocolClient; - const { environmentRegistry, supervisor } = yield* makeTestRuntime( - clientFor(false), - clientFor(true), - source === "failed-local" || source === "pending-local", - ); - const request = createPullRequestRouter()(WS_METHODS.pullRequestsSummary, { - projectId: ProjectId.make("project-1"), - repository: "acme/web", - number: 7, - }).pipe( - Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), - Effect.provideService(GitHubRoutingPermissions, trustedRouting), - Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), - ); - const fiber = yield* request.pipe(Effect.forkChild); - if (source === "pending-local") { - yield* Deferred.await(started); - yield* TestClock.adjust("30 seconds"); - } - const result = yield* Fiber.join(fiber); - if (source === "offline") { - expect(result).toEqual({ state: "open" }); - expect(calls).toEqual(["local", "origin", "held"]); - } else { - expect(result).toBeNull(); - expect(calls).toEqual( - source === "failed-local" || source === "pending-local" - ? ["origin", "local"] - : ["local"], - ); - } - }), - ), - ); -} +it.effect.each( + (["pending", "pending-local", "failed-local", "failed", "offline"] as const).map( + (source) => + [ + source === "offline" + ? "returns held source data only after both fresh paths fail" + : `uses one shared reader with a ${source} source`, + source, + ] as const, + ), +)("%s", ([, source]) => + Effect.scoped( + Effect.gen(function* () { + const started = yield* Deferred.make(); + const calls: string[] = []; + const clientFor = (local: boolean) => + ({ + [local ? WS_METHODS.pullRequestsRoutingIdentity : WS_METHODS.pullRequestsRouting]: () => + Effect.succeed({ + host: "github.com", + provider: "github", + viewer: "maria-rcks", + accountId: "123", + }), + [WS_METHODS.pullRequestsSummary]: (input: { allowStale?: boolean }) => + Effect.gen(function* () { + calls.push(local ? "local" : input.allowStale === false ? "origin" : "held"); + if (source === "offline" && !local && input.allowStale === undefined) { + return { state: "open" }; + } + expect(input.allowStale).toBe(false); + if (local && source !== "offline") return null; + if (source !== "pending" && source !== "pending-local") + return yield* new PullRequestOperationError({ + operation: "summary", + detail: "github unreachable", + }); + yield* Deferred.succeed(started, undefined); + return yield* Effect.never; + }), + }) as unknown as WsRpcProtocolClient; + const { environmentRegistry, supervisor } = yield* makeTestRuntime( + clientFor(false), + clientFor(true), + source === "failed-local" || source === "pending-local", + ); + const request = createPullRequestRouter()(WS_METHODS.pullRequestsSummary, { + projectId: ProjectId.make("project-1"), + repository: "acme/web", + number: 7, + }).pipe( + Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), + Effect.provideService(GitHubRoutingPermissions, trustedRouting), + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + ); + const fiber = yield* request.pipe(Effect.forkChild); + if (source === "pending-local") { + yield* Deferred.await(started); + yield* TestClock.adjust("30 seconds"); + } + const result = yield* Fiber.join(fiber); + if (source === "offline") { + expect(result).toEqual({ state: "open" }); + expect(calls).toEqual(["local", "origin", "held"]); + } else { + expect(result).toBeNull(); + expect(calls).toEqual( + source === "failed-local" || source === "pending-local" ? ["origin", "local"] : ["local"], + ); + } + }), + ), +); it.live("keeps source workspace metadata when an alternate answers a detail read", () => Effect.scoped( @@ -1191,8 +1195,9 @@ it.effect("refreshes checks without refreshing full detail", () => ), ); -for (const oldAlternate of [false, true]) { - it.effect(`routes checks through one reader with old alternate: ${oldAlternate}`, () => +it.effect.each([false, true])( + "routes checks through one reader with old alternate: %s", + (oldAlternate) => Effect.scoped( Effect.gen(function* () { const calls: string[] = []; @@ -1236,8 +1241,7 @@ for (const oldAlternate of [false, true]) { expect(calls).toEqual(oldAlternate ? ["origin"] : ["local"]); }), ), - ); -} +); it.effect("keeps live detail reads separate from reads that allow stale data", () => Effect.scoped( diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index ee6ae1fe8afd..ab8be14bba28 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -84,7 +84,8 @@ const IDLE_SERVER_UPDATE_STATE: ServerUpdateState = { status: "idle" }; const EMPTY_SERVER_UPDATE_STATE_ATOM = Atom.make(IDLE_SERVER_UPDATE_STATE).pipe( Atom.withLabel("environment-data:server:update-state:empty"), ); -const serverUpdateStateAtom = Atom.family((environmentId: EnvironmentId) => +/** Shared with the outdated-host update, which reports through the same state. */ +export const serverUpdateStateAtom = Atom.family((environmentId: EnvironmentId) => Atom.make(IDLE_SERVER_UPDATE_STATE).pipe( Atom.withLabel(`environment-data:server:update-state:${environmentId}`), ), @@ -178,9 +179,9 @@ export function validateServerUpdateReadyEvent( * Keeps reconnect attempts ~1s apart for the whole update restart. * * A restart takes the server down for ~15 seconds, but the supervisor's normal - * backoff ladder (1/2/4/8/16s) assumes an unexpected failure and lands attempts - * at ~3, 5, 9, 17 and 33 seconds — so a 15-second restart is observed as a - * 33-second "Resuming". Nudging on every backoff entry (not just the first) + * backoff assumes an unexpected failure and doubles its delay after each failed + * attempt, so a 15-second restart can be observed as a ~30-second "Resuming". + * Nudging on every backoff entry (not just the first) * holds the retry cadence flat until the server answers again. The sleep before * each nudge is the pacer: a connection that fails instantly re-enters backoff * immediately and would otherwise spin a tight retry loop. @@ -307,7 +308,7 @@ export function serverUpdateStateForServerVersion( : IDLE_SERVER_UPDATE_STATE; } -function serverUpdateFailureMessage(error: unknown): string { +export function serverUpdateFailureMessage(error: unknown): string { return error instanceof Error ? error.message : "Server update failed."; } @@ -935,12 +936,17 @@ export function createServerEnvironmentAtoms( }).pipe(Atom.withLabel(`environment-data:server:usage-prices:${environmentId}`)), ); const usageScanSettingsAtom = Atom.family((environmentId: EnvironmentId) => - Atom.make((get) => - JSON.stringify([ + Atom.make((get) => { + const settings = get(settingsValueAtom(environmentId)); + const aliases = settings?.usageModelAliases ?? {}; + return JSON.stringify([ get(usagePricesAtom(environmentId)), - get(settingsValueAtom(environmentId))?.cursorKeychainUsageEnabled ?? false, - ]), - ).pipe(Atom.withLabel(`environment-data:server:usage-scan-settings:${environmentId}`)), + Object.keys(aliases) + .sort() + .map((model) => [model, aliases[model]]), + settings?.cursorKeychainUsageEnabled ?? false, + ]); + }).pipe(Atom.withLabel(`environment-data:server:usage-scan-settings:${environmentId}`)), ); const providersValueAtom = Atom.family((environmentId: EnvironmentId) => Atom.make((get) => get(configValueAtom(environmentId))?.providers ?? null).pipe( diff --git a/packages/client-runtime/src/state/subagentDisplay.test.ts b/packages/client-runtime/src/state/subagentDisplay.test.ts index 11184d369469..3485cd4f2da2 100644 --- a/packages/client-runtime/src/state/subagentDisplay.test.ts +++ b/packages/client-runtime/src/state/subagentDisplay.test.ts @@ -1,6 +1,11 @@ +import { ProjectId, ProviderDriverKind } from "@t3tools/contracts"; import type { OrchestrationV2TurnItemStatus } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { subagentGroupSummary } from "./subagentDisplay.js"; +import { + subagentGroupSummary, + resolveSubagentMetadata, + subagentDetailPreview, +} from "./subagentDisplay.js"; describe("subagentGroupSummary", () => { it.each(["pending", "running", "waiting"] as const)( @@ -36,3 +41,126 @@ describe("subagentGroupSummary", () => { }); }); }); + +describe("resolveSubagentMetadata", () => { + it("resolves provider aliases to catalog names, including custom models", () => { + expect( + resolveSubagentMetadata({ + model: "claude-haiku-4-5-20251001", + provider: { + driver: ProviderDriverKind.make("claudeAgent"), + models: [ + { + slug: "claude-haiku-4-5", + name: "Claude Haiku 4.5", + shortName: "Haiku 4.5", + aliases: ["claude-haiku-4-5-20251001"], + isCustom: false, + capabilities: null, + }, + ], + }, + }).modelLabel, + ).toBe("Haiku 4.5"); + expect( + resolveSubagentMetadata({ + model: "my-model", + provider: { + driver: ProviderDriverKind.make("acpRegistry"), + models: [ + { + slug: "my-model", + name: "Cloud+ / My custom model", + subProvider: "Cloud+", + isCustom: true, + capabilities: null, + }, + ], + }, + }).modelLabel, + ).toBe("My custom model"); + }); + + it("keeps unknown model identities and does not invent an unreported model", () => { + expect(resolveSubagentMetadata({ model: " custom/model " }).modelLabel).toBe("custom/model"); + expect( + resolveSubagentMetadata({ + model: null, + provider: { driver: ProviderDriverKind.make("codex"), models: [] }, + }).modelLabel, + ).toBe("Not reported"); + expect(resolveSubagentMetadata({ model: " " }).modelLabel).toBe("Not reported"); + }); + + const parentThread = { projectId: ProjectId.make("parent"), worktreePath: null }; + const parentProject = { workspaceRoot: "/repo" }; + + it("shows another project and its branch when the child has a different workspace", () => { + expect( + resolveSubagentMetadata({ + model: null, + parentThread, + parentProject, + childThread: { branch: "fix/agents", worktreePath: "/worktrees/agents" }, + childProject: { + id: ProjectId.make("child"), + title: "Other project", + workspaceRoot: "/other", + }, + }).workspace, + ).toEqual([ + { label: "Project", value: "Other project" }, + { label: "Branch", value: "fix/agents" }, + ]); + }); + + it("labels a detached worktree or another project workspace without a branch", () => { + expect( + resolveSubagentMetadata({ + model: null, + parentThread, + parentProject, + childThread: { branch: null, worktreePath: "/worktrees/agents" }, + }).workspace, + ).toEqual([{ label: "Worktree", value: "agents" }]); + expect( + resolveSubagentMetadata({ + model: null, + parentThread, + parentProject, + childProject: { + id: parentThread.projectId, + title: "Same project", + workspaceRoot: "/other", + }, + }).workspace, + ).toEqual([{ label: "Workspace", value: "other" }]); + }); + + it("hides redundant workspace metadata and tolerates unavailable child shells", () => { + expect( + resolveSubagentMetadata({ + model: null, + parentThread, + parentProject, + childThread: { branch: "main", worktreePath: "/repo" }, + childProject: { id: parentThread.projectId, title: "Same project", workspaceRoot: "/repo" }, + }).workspace, + ).toEqual([]); + expect(resolveSubagentMetadata({ model: null, parentThread, parentProject }).workspace).toEqual( + [], + ); + }); +}); + +describe("subagentDetailPreview", () => { + it("prefers progress for live work and results for settled work", () => { + const details = { progress: "Reading files", result: "Found two\n problems" }; + expect(subagentDetailPreview({ ...details, status: "running" })).toBe("Reading files"); + expect(subagentDetailPreview({ ...details, status: "completed" })).toBe("Found two problems"); + expect( + subagentDetailPreview({ status: "failed", progress: "Last progress", result: " " }), + ).toBe("Last progress"); + expect(subagentDetailPreview({ status: "pending" })).toBeNull(); + }); +}); diff --git a/packages/client-runtime/src/state/subagentDisplay.ts b/packages/client-runtime/src/state/subagentDisplay.ts index 4d1d2c5cd15d..0a456e7a2ddc 100644 --- a/packages/client-runtime/src/state/subagentDisplay.ts +++ b/packages/client-runtime/src/state/subagentDisplay.ts @@ -1,4 +1,12 @@ -import type { OrchestrationV2TurnItemStatus } from "@t3tools/contracts"; +import type { + OrchestrationV2TurnItemStatus, + OrchestrationV2ThreadShell, + OrchestrationProjectShell, + ServerProvider, +} from "@t3tools/contracts"; +import { formatModelSlugName, resolveSelectableModel } from "@t3tools/shared/model"; +import { fileBasename } from "../markdownLinks.ts"; +import { isTerminalSubagentStatus } from "./subagentRuntime.ts"; /** Summarizes one adjacent group, without changing its member identities or order. */ export function subagentGroupSummary( @@ -44,3 +52,81 @@ export function formatSubagentDisplayTitle(title: string): string { const name = path[1]!.replace(/[_\s]+/gu, " ").trim(); return name.replace(/(^|\s)\S/gu, (letter) => letter.toUpperCase()) || displayTitle; } + +/** Match desktop's model resolution and show only changes from the parent's workspace. */ +export function resolveSubagentMetadata(input: { + readonly model: string | null; + readonly provider?: Pick | null | undefined; + readonly parentThread?: + | Pick + | null + | undefined; + readonly childThread?: + | Pick + | null + | undefined; + readonly parentProject?: Pick | null | undefined; + readonly childProject?: + | Pick + | null + | undefined; +}) { + const model = input.model?.trim(); + const slug = input.provider + ? resolveSelectableModel(input.provider.driver, model, input.provider.models) + : model; + const catalogModel = input.provider?.models.find((candidate) => candidate.slug === slug); + const reportedLabel = catalogModel + ? catalogModel.shortName || catalogModel.name + : model + ? formatModelSlugName(model) + : "Not reported"; + const qualifier = catalogModel?.subProvider?.trim(); + const modelLabel = qualifier + ? reportedLabel + .replace( + new RegExp( + `^${qualifier.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:\\s*[.:/-]\\s*|\\s+)`, + "iu", + ), + "", + ) + .trim() || reportedLabel + : reportedLabel; + const parentWorkspace = input.parentThread?.worktreePath ?? input.parentProject?.workspaceRoot; + const childWorkspace = input.childThread?.worktreePath ?? input.childProject?.workspaceRoot; + const workspace = [ + ...(input.parentThread && + input.childProject && + input.childProject.id !== input.parentThread.projectId + ? [{ label: "Project", value: input.childProject.title }] + : []), + ...(parentWorkspace && childWorkspace && parentWorkspace !== childWorkspace + ? [ + { + label: input.childThread?.branch + ? "Branch" + : input.childThread?.worktreePath + ? "Worktree" + : "Workspace", + value: input.childThread?.branch ?? fileBasename(childWorkspace), + }, + ] + : []), + ]; + return { modelLabel, workspace }; +} + +/** Live work leads with progress; settled work leads with its result. */ +export function subagentDetailPreview(input: { + readonly status: OrchestrationV2TurnItemStatus; + readonly result?: string | null | undefined; + readonly progress?: string | null | undefined; +}): string | null { + const result = input.result?.trim(); + const progress = input.progress?.trim(); + const detail = + (isTerminalSubagentStatus(input.status) ? result || progress : progress || result) || ""; + const compact = detail.replace(/\s+/gu, " "); + return compact.length > 280 ? `${compact.slice(0, 280).trimEnd()}…` : compact || null; +} diff --git a/packages/client-runtime/src/state/threadCommands.test.ts b/packages/client-runtime/src/state/threadCommands.test.ts index 14ff5efbeabe..c0c45a1e2e3c 100644 --- a/packages/client-runtime/src/state/threadCommands.test.ts +++ b/packages/client-runtime/src/state/threadCommands.test.ts @@ -137,8 +137,9 @@ describe("remote thread lifecycle commands", () => { ["reorderActive", { orderKey: "b" }, { activeOrderKey: "b" }], ] as const; - for (const [action, input, expected] of actions) { - it.effect(`shows ${action} before a delayed remote reply and rolls back a rejection`, () => + it.effect.each(actions)( + "shows %s before a delayed remote reply and rolls back a rejection", + ([action, input, expected]) => Effect.gen(function* () { const h = yield* makeHarness(); const source = h.snapshotAtom(ENVIRONMENT_ID); @@ -179,8 +180,7 @@ describe("remote thread lifecycle commands", () => { expect((yield* Effect.promise(() => result))._tag).toBe("Failure"); expect(h.registry.get(h.visibleAtom)).toBe(initial); }), - ); - } + ); it.effect("keeps the preview after acknowledgement until the matching shell update arrives", () => Effect.gen(function* () { @@ -290,44 +290,45 @@ describe("remote thread lifecycle commands", () => { }), ); - for (const action of ["settle", "snooze"] as const) { - it.effect(`restores a confirmed ${action} when a queued undo fails`, () => - Effect.gen(function* () { - const h = yield* makeHarness(); - const parked = - action === "settle" ? { settledOverride: "settled" as const } : { snoozedUntil: FUTURE }; - const awake = action === "settle" ? { settledOverride: "active" } : { snoozedUntil: null }; - const result = h.commands[action].run(h.registry, { - environmentId: ENVIRONMENT_ID, - input: { threadId: THREAD_ID, snoozedUntil: "2099-01-01T00:00:00.000Z" }, - }); - const first = yield* Queue.take(h.requests); - const undo = h.commands[action === "settle" ? "unsettle" : "unsnooze"].run(h.registry, { - environmentId: ENVIRONMENT_ID, - input: { threadId: THREAD_ID, reason: "user" }, - }); - expect(h.registry.get(h.visibleAtom)?.threads[0]).toMatchObject(awake); - yield* Deferred.succeed(first.reply, { sequence: 2 }); - expect((yield* Effect.promise(() => result))._tag).toBe("Success"); - expect(h.registry.get(h.visibleAtom)?.threads[0]).toMatchObject(awake); - const confirmed = { - ...SNAPSHOT, - snapshotSequence: 2, - threads: [{ ...SNAPSHOT.threads[0]!, ...parked }], - }; - h.registry.set(h.snapshotAtom(ENVIRONMENT_ID), confirmed); - expect(h.registry.get(h.visibleAtom)?.threads[0]).toMatchObject(awake); - const second = yield* Queue.take(h.requests); - expect(second.command.type).toBe( - action === "settle" ? "thread.unsettle" : "thread.unsnooze", - ); - yield* Deferred.fail(second.reply, new Error("Undo rejected")); - expect((yield* Effect.promise(() => undo))._tag).toBe("Failure"); - expect(h.registry.get(h.visibleAtom)).toBe(confirmed); - }), - ); + const undoableActions = ["settle", "snooze"] as const; - it.effect(`preserves a newer approval when the ${action} reply arrives after the shell`, () => + it.effect.each(undoableActions)("restores a confirmed %s when a queued undo fails", (action) => + Effect.gen(function* () { + const h = yield* makeHarness(); + const parked = + action === "settle" ? { settledOverride: "settled" as const } : { snoozedUntil: FUTURE }; + const awake = action === "settle" ? { settledOverride: "active" } : { snoozedUntil: null }; + const result = h.commands[action].run(h.registry, { + environmentId: ENVIRONMENT_ID, + input: { threadId: THREAD_ID, snoozedUntil: "2099-01-01T00:00:00.000Z" }, + }); + const first = yield* Queue.take(h.requests); + const undo = h.commands[action === "settle" ? "unsettle" : "unsnooze"].run(h.registry, { + environmentId: ENVIRONMENT_ID, + input: { threadId: THREAD_ID, reason: "user" }, + }); + expect(h.registry.get(h.visibleAtom)?.threads[0]).toMatchObject(awake); + yield* Deferred.succeed(first.reply, { sequence: 2 }); + expect((yield* Effect.promise(() => result))._tag).toBe("Success"); + expect(h.registry.get(h.visibleAtom)?.threads[0]).toMatchObject(awake); + const confirmed = { + ...SNAPSHOT, + snapshotSequence: 2, + threads: [{ ...SNAPSHOT.threads[0]!, ...parked }], + }; + h.registry.set(h.snapshotAtom(ENVIRONMENT_ID), confirmed); + expect(h.registry.get(h.visibleAtom)?.threads[0]).toMatchObject(awake); + const second = yield* Queue.take(h.requests); + expect(second.command.type).toBe(action === "settle" ? "thread.unsettle" : "thread.unsnooze"); + yield* Deferred.fail(second.reply, new Error("Undo rejected")); + expect((yield* Effect.promise(() => undo))._tag).toBe("Failure"); + expect(h.registry.get(h.visibleAtom)).toBe(confirmed); + }), + ); + + it.effect.each(undoableActions)( + "preserves a newer approval when the %s reply arrives after the shell", + (action) => Effect.gen(function* () { const h = yield* makeHarness(); const result = h.commands[action].run(h.registry, { @@ -346,9 +347,11 @@ describe("remote thread lifecycle commands", () => { expect((yield* Effect.promise(() => result))._tag).toBe("Success"); expect(h.registry.get(h.visibleAtom)).toBe(newer); }), - ); + ); - it.effect(`shows an accepted ${action} while the shell still has an old input request`, () => + it.effect.each(undoableActions)( + "shows an accepted %s while the shell still has an old input request", + (action) => Effect.gen(function* () { const h = yield* makeHarness(); const stale = { @@ -370,6 +373,5 @@ describe("remote thread lifecycle commands", () => { expect(h.registry.get(h.visibleAtom)?.threads[0]?.pendingRuntimeRequest).toBeNull(); expect(h.registry.get(h.snapshotAtom(ENVIRONMENT_ID))).toBe(stale); }), - ); - } + ); }); diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index 685ccc454a08..e3ad5c07f583 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -48,6 +48,7 @@ import { type UnarchiveThreadInput, type UnlinkThreadPullRequestInput, type UnpinThreadInput, + type WatchThreadPullRequestInput, type UnsettleThreadInput, type UnsnoozeThreadInput, type UpdateThreadMetadataInput, @@ -86,6 +87,7 @@ import { unsnoozeThread, updateThreadMetadata, visitThread, + watchThreadPullRequest, } from "../operations/commands.ts"; import type { EnvironmentRegistry } from "../connection/registry.ts"; import * as EnvironmentSupervisor from "../connection/supervisor.ts"; @@ -130,6 +132,7 @@ export type { UnsnoozeThreadInput, UpdateThreadMetadataInput, VisitThreadInput, + WatchThreadPullRequestInput, } from "../operations/commands.ts"; export function createThreadEnvironmentAtoms( @@ -251,6 +254,12 @@ export function createThreadEnvironmentAtoms( scheduler, concurrency, }), + watchPullRequest: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:watch-pull-request", + execute: (input: WatchThreadPullRequestInput) => watchThreadPullRequest(input), + scheduler, + concurrency, + }), setRuntimeMode: createEnvironmentCommand(runtime, { label: "environment-data:commands:thread:set-runtime-mode", execute: (input: SetThreadRuntimeModeInput) => setThreadRuntimeMode(input), diff --git a/packages/client-runtime/src/state/threadExecution.test.ts b/packages/client-runtime/src/state/threadExecution.test.ts index 70ed818cadc3..6ee64d2eaf66 100644 --- a/packages/client-runtime/src/state/threadExecution.test.ts +++ b/packages/client-runtime/src/state/threadExecution.test.ts @@ -3,6 +3,9 @@ import { NodeId, MessageId, ProviderInstanceId, + ProviderThreadId, + ProviderSessionId, + ProviderDriverKind, RunId, ThreadId, type OrchestrationV2ExecutionNode, @@ -15,6 +18,7 @@ import { describe, expect, it } from "vite-plus/test"; import { v2Projection } from "./orchestrationV2TestFixtures.ts"; import { presentPendingBackgroundWork, + deriveReportedModelSelection, deriveLatestThreadRun, deriveProviderSubagentStatus, formatModelSelectionEffort, @@ -96,6 +100,19 @@ describe("thread execution presentation", () => { ).toMatchObject({ status: "running", lastError: null, lastErrorClass: null }); }); + it("counts a wake run's activity from the start of the work it continues", () => { + const workStartedAt = DateTime.makeUnsafe("2026-07-28T09:20:00.000Z"); + const wake = { ...run("wake", 2, "running"), workStartedAt }; + expect( + deriveThreadRuntime({ ...v2Projection, runs: [run("prompt", 1, "completed"), wake] }) + ?.activityStartedAt, + ).toBe("2026-07-28T09:20:00.000Z"); + expect( + deriveThreadRuntime({ ...v2Projection, runs: [run("prompt", 1, "running")] }) + ?.activityStartedAt, + ).toBe("2026-07-28T10:00:00.000Z"); + }); + it("keeps a subscription limit visible while later messages stay queued", () => { const failed = { ...run("limited", 1, "failed"), @@ -478,18 +495,87 @@ describe("threadRuntimeCanArchive", () => { }); describe("presentPendingBackgroundWork", () => { + it.each(["Subagent:", "Subagent: "])( + "falls back to the subagent noun when %s has no display name", + (description) => { + const presentation = presentPendingBackgroundWork([ + { taskId: "unnamed", kind: "subagent", description }, + ]); + + expect(presentation?.title).toBe("Waiting on a subagent"); + expect(presentation?.items[0]?.label).toBe("subagent"); + }, + ); + + it.each([ + "/root/luna_window_properties", + "Subagent: /root/luna_window_properties", + "/root/parent/luna_window_properties", + ])("uses the subagent display name for %s", (description) => { + const childThreadId = ThreadId.make("thread:luna"); + const presentation = presentPendingBackgroundWork([ + { taskId: "luna", kind: "subagent", description, childThreadId }, + ]); + + expect(presentation).toEqual({ + title: "Waiting on subagent Luna Window Properties", + items: [{ taskId: "luna", kind: "subagent", label: "Luna Window Properties", childThreadId }], + waiting: true, + }); + }); + + it("formats subagent names in a mixed roster and preserves command descriptions", () => { + const presentation = presentPendingBackgroundWork([ + { taskId: "cmd", kind: "command", description: "/root/run_tests" }, + { taskId: "luna", kind: "subagent", description: "/root/luna_window_properties" }, + { taskId: "review", kind: "subagent", description: "Review src/math.ts" }, + ]); + + expect(presentation?.title).toBe("Waiting on 2 subagents and 1 command"); + expect(presentation?.items.map((item) => item.label)).toEqual([ + "Luna Window Properties", + "Review src/math.ts", + "/root/run_tests", + ]); + }); + it("names a single piece of work by kind", () => { expect( presentPendingBackgroundWork([ { taskId: "a", kind: "subagent", description: "Review src/math.ts" }, ])?.title, ).toBe("Waiting on subagent Review src/math.ts"); - expect(presentPendingBackgroundWork([{ taskId: "a", kind: "command" }])?.title).toBe( - "Waiting on a command", + expect(presentPendingBackgroundWork([{ taskId: "a", kind: "monitor" }])?.title).toBe( + "Waiting on a monitor", ); expect(presentPendingBackgroundWork([])).toBeNull(); }); + // A command left running, such as a dev server, does not wake the agent. + it("says only commands are running, not waited on", () => { + expect( + presentPendingBackgroundWork([ + { taskId: "dev", kind: "command", description: "Start the shared dev server" }, + ]), + ).toMatchObject({ title: "Running: Start the shared dev server", waiting: false }); + expect(presentPendingBackgroundWork([{ taskId: "a", kind: "command" }])).toMatchObject({ + title: "Running a command", + waiting: false, + }); + expect( + presentPendingBackgroundWork([ + { taskId: "a", kind: "command", description: "vp run dev" }, + { taskId: "b", kind: "command", description: "tailscale serve" }, + ]), + ).toMatchObject({ title: "Running 2 commands", waiting: false }); + expect( + presentPendingBackgroundWork([ + { taskId: "a", kind: "command", description: "vp run dev" }, + { taskId: "b", kind: "monitor", description: "Watch PR checks" }, + ]), + ).toMatchObject({ title: "Waiting on 1 command and 1 monitor", waiting: true }); + }); + it("groups work by kind, subagents first, and keeps each name", () => { const presentation = presentPendingBackgroundWork([ { taskId: "cmd", kind: "command", description: "npm test" }, @@ -522,3 +608,72 @@ describe("presentPendingBackgroundWork", () => { ); }); }); + +describe("provider-reported model selection", () => { + const selected = v2Projection.thread.modelSelection; + const reported = { ...selected, options: [{ id: "reasoningEffort", value: "default" }] }; + const providerThread = { + id: ProviderThreadId.make("active"), + driver: ProviderDriverKind.make("codex"), + providerInstanceId: selected.instanceId, + providerSessionId: ProviderSessionId.make("session"), + appThreadId: v2Projection.thread.id, + ownerNodeId: null, + nativeThreadRef: null, + nativeConversationHeadRef: null, + status: "idle" as const, + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + nativeMetadata: { modelSelection: reported }, + }; + const projection = { + ...v2Projection, + thread: { ...v2Projection.thread, activeProviderThreadId: providerThread.id }, + providerThreads: [providerThread], + }; + + it("reads only the active provider thread's reported selection", () => { + expect(deriveReportedModelSelection(projection)).toBe(reported); + expect( + deriveReportedModelSelection({ + ...projection, + thread: { ...projection.thread, activeProviderThreadId: null }, + }), + ).toBeNull(); + expect( + deriveReportedModelSelection({ + ...projection, + providerThreads: [ + { ...providerThread, providerInstanceId: ProviderInstanceId.make("other") }, + ], + }), + ).toBeNull(); + }); + + it("shows the reported default in a subagent's effort label", () => { + const models = [ + { + slug: selected.model, + name: selected.model, + isCustom: false, + capabilities: { + optionDescriptors: [ + { + id: "variant", + label: "Reasoning", + type: "select" as const, + options: [{ id: "high", label: "High" }], + }, + ], + }, + }, + ]; + const variantReport = { ...selected, options: [{ id: "variant", value: "default" }] }; + expect(formatModelSelectionEffort(selected, models, variantReport)).toBe("Default"); + expect(formatModelSelectionEffort(selected, models)).toBe("Unknown"); + }); +}); diff --git a/packages/client-runtime/src/state/threadExecution.ts b/packages/client-runtime/src/state/threadExecution.ts index 0893fadb62c2..a7722af0391b 100644 --- a/packages/client-runtime/src/state/threadExecution.ts +++ b/packages/client-runtime/src/state/threadExecution.ts @@ -13,9 +13,13 @@ import { type ServerProviderModel, type OrchestrationV2ExecutionNode, type OrchestrationV2ThreadProjection, + orchestrationV2RunWorkStartedAt, type ThreadId, } from "@t3tools/contracts"; -import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; +import { + backgroundWorkHoldsCompletion, + derivePendingBackgroundWork, +} from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import { getProviderOptionCurrentLabel, getProviderOptionDescriptors } from "@t3tools/shared/model"; import { formatDuration } from "@t3tools/shared/orchestrationTiming"; import * as DateTime from "effect/DateTime"; @@ -25,6 +29,7 @@ import { type ThreadRunSummary, type ThreadRuntimeSummary, } from "./models.ts"; +import { formatSubagentDisplayTitle } from "./subagentDisplay.ts"; const ACTIVITY_RUN_STATUSES = new Set(["preparing", "starting", "running", "waiting"]); const INTERRUPTIBLE_RUN_STATUSES = new Set(["preparing", "starting", "running"]); @@ -140,6 +145,18 @@ export function deriveProviderSubagentStatus( }; } +/** The observed selection belongs to the active provider thread, never a previous handoff. */ +export function deriveReportedModelSelection( + projection: OrchestrationV2ThreadProjection, +): ModelSelection | null { + const providerThread = projection.providerThreads.find( + (candidate) => + candidate.id === projection.thread.activeProviderThreadId && + candidate.providerInstanceId === projection.thread.modelSelection.instanceId, + ); + return providerThread?.nativeMetadata?.modelSelection ?? null; +} + // Option ids providers use for reasoning effort (Codex, Claude, Grok/ACP, OpenCode). const REASONING_EFFORT_OPTION_IDS = ["reasoningEffort", "effort", "reasoning", "variant"] as const; @@ -153,6 +170,7 @@ const REASONING_EFFORT_OPTION_IDS = ["reasoningEffort", "effort", "reasoning", " export function formatModelSelectionEffort( selection: ModelSelection, models: ReadonlyArray = [], + reportedSelection?: ModelSelection | null, ): string | null { const caps = models.find((model) => model.slug === selection.model)?.capabilities; if (!caps) return null; @@ -160,7 +178,7 @@ export function formatModelSelectionEffort( for (const id of REASONING_EFFORT_OPTION_IDS) { const descriptor = descriptors.find((candidate) => candidate.id === id); if (descriptor?.type !== "select") continue; - const label = getProviderOptionCurrentLabel(descriptor); + const label = getProviderOptionCurrentLabel(descriptor, selection, reportedSelection); if (label) return label; } return null; @@ -212,28 +230,34 @@ export function deriveThreadRuntime( const usageLimitedRun = presentedUsageLimitRun(projection); const latestRunProjection = presentedLatestRun(projection); const activityRun = deriveThreadActivityRun(projection); + const liveActivityRun = latestMatchingRun(projection, (run) => + ACTIVITY_RUN_STATUSES.has(run.status), + ); if (latestRun === null && projection.thread.activeProviderThreadId === null) return null; const activeRunId = latestMatchingRun(projection, (run) => INTERRUPTIBLE_RUN_STATUSES.has(run.status))?.id ?? null; - const hasPendingBackgroundTasks = + // Same rule as the shell runtime: only background work that holds the + // completion parks the thread at idle; a dev server left running does not. + const backgroundWorkHoldsRun = backgroundWorkHoldsCompletion( derivePendingBackgroundWork({ latestRun: latestRunProjection, providerThreads: projection.providerThreads, turnItems: projection.turnItems, activeProviderThreadId: projection.thread.activeProviderThreadId, runs: projection.runs, - }).length > 0; + }), + ); return { status: usageLimitedRun ? "failed" - : hasPendingBackgroundTasks && latestRunProjection?.status !== "failed" + : backgroundWorkHoldsRun && latestRunProjection?.status !== "failed" ? "idle" : (activityRun?.status ?? "idle"), activeRunId, activityStartedAt: - activityRun !== null && ACTIVITY_RUN_STATUSES.has(activityRun.status) - ? (activityRun.startedAt ?? activityRun.requestedAt) - : null, + liveActivityRun === null + ? null + : DateTime.formatIso(orchestrationV2RunWorkStartedAt(liveActivityRun)), providerInstanceId: projection.thread.providerInstanceId, providerName: providerSession?.driver ?? null, ...threadErrorSummary( @@ -277,9 +301,17 @@ export interface PendingBackgroundWorkItem { } export interface PendingBackgroundWorkPresentation { - /** "Waiting on subagent Review src/math.ts", "Waiting on 2 subagents and 1 command". */ + /** + * "Waiting on subagent Review src/math.ts", "Waiting on 2 subagents and 1 command", + * or "Running: Start the dev server" when only commands remain. + */ readonly title: string; readonly items: ReadonlyArray; + /** + * True when the work will wake the agent (subagents, monitors). False when + * only commands remain, such as a dev server: the agent is done. + */ + readonly waiting: boolean; } function joinWithAnd(parts: ReadonlyArray): string { @@ -287,21 +319,26 @@ function joinWithAnd(parts: ReadonlyArray): string { return `${parts.slice(0, -1).join(", ")} and ${parts.at(-1)}`; } -/** Names what a settled thread is still waiting on, grouped by kind, for the composer strip. */ +/** Names what a settled thread still runs, grouped by kind, for the composer strip. */ export function presentPendingBackgroundWork( tasks: ReadonlyArray, ): PendingBackgroundWorkPresentation | null { if (tasks.length === 0) return null; + const waiting = backgroundWorkHoldsCompletion(tasks); const items = tasks .map((task): PendingBackgroundWorkItem => { const description = task.description?.trim(); + const label = + task.kind === "subagent" && description !== undefined + ? formatSubagentDisplayTitle(description).trim() + : description; return { taskId: task.taskId, kind: task.kind, label: - description === undefined || description.length === 0 + label === undefined || label.length === 0 ? BACKGROUND_WORK_KINDS[task.kind].singular - : description, + : label, childThreadId: task.kind === "subagent" ? task.childThreadId : undefined, }; }) @@ -313,10 +350,15 @@ export function presentPendingBackgroundWork( const [only] = items; if (items.length === 1 && only !== undefined) { const noun = BACKGROUND_WORK_KINDS[only.kind].singular; - return { - title: only.label === noun ? `Waiting on a ${noun}` : `Waiting on ${noun} ${only.label}`, - items, - }; + const named = only.label !== noun; + const title = waiting + ? named + ? `Waiting on ${noun} ${only.label}` + : `Waiting on a ${noun}` + : named + ? `Running: ${only.label}` + : `Running a ${noun}`; + return { title, items, waiting }; } const counts = new Map(); for (const item of items) counts.set(item.kind, (counts.get(item.kind) ?? 0) + 1); @@ -324,7 +366,7 @@ export function presentPendingBackgroundWork( const { singular, plural } = BACKGROUND_WORK_KINDS[kind]; return `${count} ${count === 1 ? singular : plural}`; }); - return { title: `Waiting on ${joinWithAnd(groups)}`, items }; + return { title: `${waiting ? "Waiting on" : "Running"} ${joinWithAnd(groups)}`, items, waiting }; } /** The thread a notification row opens: that of the one subagent or delegated task it reports. */ diff --git a/packages/client-runtime/src/state/threadInbox.test.ts b/packages/client-runtime/src/state/threadInbox.test.ts new file mode 100644 index 000000000000..aade72fc85b9 --- /dev/null +++ b/packages/client-runtime/src/state/threadInbox.test.ts @@ -0,0 +1,59 @@ +import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { createInboxReturnTracker } from "./threadInbox.ts"; + +const environmentId = EnvironmentId.make("environment-1"); + +function thread(id: string, working: boolean) { + return { + id: ThreadId.make(id), + environmentId, + createdAt: "2026-06-01T00:00:00.000Z", + unsettledAt: null, + latestRun: null, + hasActionableProposedPlan: false, + hasPendingApprovals: false, + hasPendingUserInput: false, + interactionMode: "default" as const, + runtime: working + ? { + status: "running" as const, + activeRunId: null, + providerInstanceId: ProviderInstanceId.make("codex"), + providerName: "Codex", + lastError: null, + updatedAt: "2026-06-01T00:00:00.000Z", + } + : null, + }; +} + +describe("createInboxReturnTracker", () => { + it("stamps a thread when it stops working, but never on the first observation", () => { + const tracker = createInboxReturnTracker(); + tracker.observe([thread("a", true), thread("b", false)]); + expect(tracker.returnedAt(thread("a", true))).toBeUndefined(); + expect(tracker.returnedAt(thread("b", false))).toBeUndefined(); + + tracker.observe([thread("a", false), thread("b", false)]); + expect(tracker.returnedAt(thread("a", false))).toBeDefined(); + expect(tracker.returnedAt(thread("b", false))).toBeUndefined(); + }); + + it("forgets deleted threads and resets when the beta turns off", () => { + const tracker = createInboxReturnTracker(); + tracker.observe([thread("a", true), thread("b", true)]); + tracker.observe([thread("a", false), thread("b", false)]); + tracker.observe([thread("b", false)]); + expect(tracker.returnedAt(thread("a", false))).toBeUndefined(); + expect(tracker.returnedAt(thread("b", false))).toBeDefined(); + + tracker.observe(null); + expect(tracker.returnedAt(thread("b", false))).toBeUndefined(); + // After a reset the next call is a fresh baseline again. + tracker.observe([thread("b", true)]); + tracker.observe([thread("b", false)]); + expect(tracker.returnedAt(thread("b", false))).toBeDefined(); + }); +}); diff --git a/packages/client-runtime/src/state/threadInbox.ts b/packages/client-runtime/src/state/threadInbox.ts new file mode 100644 index 000000000000..1a71bc189ec7 --- /dev/null +++ b/packages/client-runtime/src/state/threadInbox.ts @@ -0,0 +1,111 @@ +import { threadRuntimeIsActive, type EnvironmentThreadShell } from "./models.ts"; +import { toSortableTimestamp } from "./threadSort.ts"; + +// Working section beta, shared so web and mobile fold and order the inbox the +// same way. Off by default; each client owns its own toggle. + +type WorkingThreadInput = Pick< + EnvironmentThreadShell, + | "hasActionableProposedPlan" + | "hasPendingApprovals" + | "hasPendingUserInput" + | "interactionMode" + | "latestRun" + | "runtime" +>; + +/** Threads busy with work that does not need the user fold into the Working + section: a running run, or one stopped with background work that will wake + it. Approvals, questions, plan prompts, and failures stay in the inbox. */ +export function isThreadWorking(thread: WorkingThreadInput): boolean { + if (thread.hasPendingApprovals || thread.hasPendingUserInput) return false; + if (!threadRuntimeIsActive(thread.runtime) && thread.runtime?.status !== "idle") return false; + // A plan prompt outranks lingering background work: the user has to act on it. + const run = thread.latestRun; + const runSettled = + run !== null && + run.status !== "preparing" && + run.status !== "queued" && + run.status !== "starting" && + run.status !== "running" && + run.status !== "waiting" && + thread.runtime?.activeRunId !== run.runId; + return !(thread.interactionMode === "plan" && thread.hasActionableProposedPlan && runSettled); +} + +type InboxThreadInput = Pick< + EnvironmentThreadShell, + "id" | "environmentId" | "createdAt" | "unsettledAt" | "latestRun" +>; + +/** The inbox lists threads newest first by when each last came back to the + user, so a thread that leaves the Working section lands on top. + `observedReturnAt` adds returns the server does not stamp, such as an + approval request mid-turn or background work ending. */ +export function sortInboxThreadsByReturn( + threads: readonly T[], + observedReturnAt?: (thread: T) => number | undefined, +): T[] { + const timestamps = new Map( + threads.map((thread) => [ + thread, + Math.max( + toSortableTimestamp(thread.createdAt) ?? 0, + toSortableTimestamp(thread.unsettledAt ?? undefined) ?? 0, + toSortableTimestamp(thread.latestRun?.requestedAt ?? undefined) ?? 0, + toSortableTimestamp(thread.latestRun?.completedAt ?? undefined) ?? 0, + observedReturnAt?.(thread) ?? 0, + ), + ]), + ); + return [...threads].sort( + (left, right) => + timestamps.get(right)! - timestamps.get(left)! || + left.id.localeCompare(right.id) || + left.environmentId.localeCompare(right.environmentId), + ); +} + +/** + * Remembers when this client saw each thread leave the Working section. Keep + * one at module scope so the inbox order survives routes that unmount the + * list. Call `observe` with every thread shell on each list rebuild, or with + * null to reset while the beta is off. The first call only takes a baseline, + * so mounting never reshuffles the inbox. + */ +export function createInboxReturnTracker() { + const keyOf = (thread: Pick) => + `${thread.environmentId}:${thread.id}`; + let lastWorkingKeys: ReadonlySet | null = null; + const returns = new Map(); + return { + observe(threads: ReadonlyArray | null): void { + if (threads === null) { + lastWorkingKeys = null; + returns.clear(); + return; + } + const working = new Set(); + const present = new Set(); + for (const thread of threads) { + const key = keyOf(thread); + present.add(key); + if (isThreadWorking(thread)) working.add(key); + } + // Drop deleted threads so the map stays bounded by the live thread list. + for (const key of returns.keys()) { + if (!present.has(key)) returns.delete(key); + } + // The moment this client saw the change is the data; there is no + // server stamp to read instead. + // @effect-diagnostics-next-line globalDate:off + const at = Date.now(); + for (const key of lastWorkingKeys ?? []) { + if (present.has(key) && !working.has(key)) returns.set(key, at); + } + lastWorkingKeys = working; + }, + returnedAt: (thread: Pick) => + returns.get(keyOf(thread)), + }; +} diff --git a/packages/client-runtime/src/state/threadRelationships.test.ts b/packages/client-runtime/src/state/threadRelationships.test.ts index 7455d9148c09..d96fba5aab22 100644 --- a/packages/client-runtime/src/state/threadRelationships.test.ts +++ b/packages/client-runtime/src/state/threadRelationships.test.ts @@ -187,6 +187,37 @@ describe("thread relationships", () => { ); }); + it.each([ + ["running", "running"], + [null, "completed"], + ])( + "shows a subagent whose child activity is %s as %s after its delegated task settled", + (childActivity, expected) => { + const parent = ThreadId.make("thread-parent"); + const child = ThreadId.make("thread-child"); + const graph = deriveThreadRelationshipGraph({ + threads: [ + { id: parent, status: "completed", forkedFrom: null, lineage: { parentThreadId: null } }, + { + id: child, + status: childActivity ?? "completed", + activityRunStatus: childActivity, + forkedFrom: null, + lineage: { parentThreadId: parent, relationshipToParent: "subagent" }, + }, + ] as never, + projection: { + thread: { id: parent }, + subagents: [{ childThreadId: child, status: "completed" }], + contextTransfers: [], + } as never, + }); + + const row = immediateThreadRelationships(graph, parent)[0]!; + expect(threadRelationshipRowStatus(graph, row)).toBe(expected); + }, + ); + it("keeps the live shell when an archived snapshot contains the same thread id", () => { const parent = ThreadId.make("thread-parent"); const staleParent = ThreadId.make("thread-stale-parent"); diff --git a/packages/client-runtime/src/state/threadRelationships.ts b/packages/client-runtime/src/state/threadRelationships.ts index 93ed8e9973f3..4ef792a6ada7 100644 --- a/packages/client-runtime/src/state/threadRelationships.ts +++ b/packages/client-runtime/src/state/threadRelationships.ts @@ -92,11 +92,14 @@ export function deriveThreadRelationshipGraph(input: { const ownerThreadId = input.projection.thread.id; for (const subagent of input.projection.subagents) { if (subagent.childThreadId === null) continue; + // The subagent record settles with the delegated task's first run, but the + // parent can keep sending the child follow-ups. A live run on the child + // thread outranks that settled status. addEdge({ sourceThreadId: ownerThreadId, targetThreadId: subagent.childThreadId, kind: "subagent", - status: subagent.status, + status: threadsById.get(subagent.childThreadId)?.activityRunStatus ?? subagent.status, }); } for (const transfer of input.projection.contextTransfers) { diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index 0c1619a52b19..61bd092a4b2e 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -315,8 +315,9 @@ const deleted = (sequence = 3): OrchestrationV2ThreadStreamItem => { }; describe("EnvironmentThreads", () => { - for (const source of ["disk", "HTTP"] as const) { - it.effect(`does not rewrite an unchanged ${source} snapshot on navigation or warm return`, () => + it.effect.each(["disk", "HTTP"] as const)( + "does not rewrite an unchanged %s snapshot on navigation or warm return", + (source) => Effect.gen(function* () { const resumeCache: NonNullable[1]> = { snapshot: undefined, @@ -350,8 +351,7 @@ describe("EnvironmentThreads", () => { ); expect(yield* Ref.get(nextSaved)).toEqual([]); }), - ); - } + ); it.effect("persists a complete bounded HTTP window only once", () => Effect.gen(function* () { @@ -1012,8 +1012,9 @@ describe("EnvironmentThreads", () => { }), ); - for (const cacheKind of ["disk", "retained"] as const) { - it.effect(`retains paging support through a complete bounded ${cacheKind} cache`, () => + it.effect.each(["disk", "retained"] as const)( + "retains paging support through a complete bounded %s cache", + (cacheKind) => Effect.gen(function* () { const resumeCache: NonNullable[1]> = { snapshot: undefined, @@ -1071,11 +1072,11 @@ describe("EnvironmentThreads", () => { expect(yield* Ref.get(warm.lastSubscribeAfterSequence)).toBe(5); expect(yield* Ref.get(warm.lastAcceptBoundedSnapshot)).toBe(true); }), - ); - } + ); - for (const historyPaging of ["no-http", "no-controller"] as const) { - it.effect(`does not negotiate bounded fallbacks with ${historyPaging}`, () => + it.effect.each(["no-http", "no-controller"] as const)( + "does not negotiate bounded fallbacks with %s", + (historyPaging) => Effect.gen(function* () { for (const source of ["cache", "http"] as const) { const history = { @@ -1105,8 +1106,7 @@ describe("EnvironmentThreads", () => { expect(yield* Ref.get(harness.lastAcceptBoundedSnapshot)).toBeUndefined(); } }), - ); - } + ); it.effect("socket snapshot clears progressive history meta left from a bounded window", () => Effect.gen(function* () { diff --git a/packages/client-runtime/src/t3ToolSummary.ts b/packages/client-runtime/src/t3ToolSummary.ts index 79577892a697..c6b40e5a62ff 100644 --- a/packages/client-runtime/src/t3ToolSummary.ts +++ b/packages/client-runtime/src/t3ToolSummary.ts @@ -372,6 +372,16 @@ export function summarizeT3ToolCalls( case "unlink-pr": label = phrase("Unlinked", "unlink", quantity(selected.length, "pull request")); break; + case "watch-pr": + label = phrase("Watching", "watch", quantity(selected.length, "pull request")); + break; + case "unwatch-pr": + label = phrase( + "Stopped watching", + "stop watching", + quantity(selected.length, "pull request"), + ); + break; case "list-prs": label = phrase( "Checked", diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index fdaf28dcf3b7..84229d3bac7f 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -85,6 +85,8 @@ export type ToolGroupAction = | "link-pr" | "unlink-pr" | "list-prs" + | "watch-pr" + | "unwatch-pr" | "read" | "edit" | "command" @@ -156,7 +158,9 @@ function resolveT3McpToolPresentation( const actionKind = definition.summaryAction === "link-pr" || definition.summaryAction === "unlink-pr" || - definition.summaryAction === "list-prs" + definition.summaryAction === "list-prs" || + definition.summaryAction === "watch-pr" || + definition.summaryAction === "unwatch-pr" ? definition.summaryAction : undefined; const payload = asRecord(data); @@ -539,6 +543,10 @@ function toolGroupActionLabel(action: ToolGroupAction, count: number): string { return `Linked ${count} ${count === 1 ? "pull request" : "pull requests"}`; case "unlink-pr": return `Unlinked ${count} ${count === 1 ? "pull request" : "pull requests"}`; + case "watch-pr": + return `Watching ${count} ${count === 1 ? "pull request" : "pull requests"}`; + case "unwatch-pr": + return `Stopped watching ${count} ${count === 1 ? "pull request" : "pull requests"}`; case "list-prs": return count === 1 ? "Checked linked pull requests" diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index a649cfe38de0..9a31f0888e67 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -136,6 +136,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ usageLimitSources: Schema.optionalKey(Schema.Boolean), /** Server persists custom model rates and applies them to usage summaries. */ usagePriceOverrides: Schema.optionalKey(Schema.Boolean), + /** Server persists model mappings and folds mapped usage into the target model. */ + usageModelAliases: Schema.optionalKey(Schema.Boolean), /** Server understands thread.pin / thread.unpin commands. Same version-skew contract as threadSettlement. */ threadPinning: Schema.optionalKey(Schema.Boolean), @@ -162,6 +164,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ shaping and validation when this is absent. */ serverResolvedCommandContext: Schema.optionalKey(Schema.Boolean), threadPullRequests: Schema.optionalKey(Schema.Boolean), + /** Server understands thread.pull-request.watch and wakes agents on pull request changes. */ + threadPullRequestWatch: Schema.optionalKey(Schema.Boolean), pullRequestStackActions: Schema.optionalKey(Schema.Boolean), /** The update path clients should offer for this server. Absent on servers that must be relaunched manually (dev checkouts, Windows diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 2efd79f5baa2..0698529d10c0 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -235,6 +235,17 @@ const VcsStatusLocalShape = { insertions: NonNegativeInt, deletions: NonNegativeInt, }), + /** + * Totals for the diff panel's Changes view: merge-base with the base branch to the + * working tree, untracked files included. Absent on older servers. + */ + branchChanges: Schema.optional( + Schema.Struct({ + baseRef: Schema.NullOr(TrimmedNonEmptyStringSchema), + insertions: NonNegativeInt, + deletions: NonNegativeInt, + }), + ), }; const VcsStatusRemoteShape = { diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 06ac4fb83820..e827ba8270fd 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -87,6 +87,7 @@ export const STATIC_KEYBINDING_COMMANDS = [ "composer.stash", "composer.sendAlternate", "composer.sendBackground", + "composer.sendAndNewThread", "composer.host", "composer.effort", "composer.mode", diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index cb220a5b4ccd..6004ab119917 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -46,6 +46,7 @@ import { ThreadPullRequestLinkSource, ThreadPullRequestSnapshot, ThreadPullRequestStack, + ThreadPullRequestWatch, } from "./threadPullRequest.ts"; import { ProviderApprovalDecision, @@ -528,6 +529,12 @@ export const OrchestrationV2Run = Schema.Struct({ contextHandoffId: Schema.NullOr(ContextHandoffId), /** Links server-generated restart continuations to the interrupted run. */ restartContinuationOfRunId: Schema.optional(RunId), + /** + * Set on wake runs (background notifications, delegated task results, + * restart continuations): when the work they continue started. Read it + * through orchestrationV2RunWorkStartedAt. + */ + workStartedAt: Schema.optional(Schema.DateTimeUtc), /** * Set by restart recovery on the thread's latest started run. Delivered to * the provider with the first later run that reaches a provider turn. @@ -545,6 +552,16 @@ export const OrchestrationV2Run = Schema.Struct({ }); export type OrchestrationV2Run = typeof OrchestrationV2Run.Type; +/** + * When the work a run belongs to started. A wake does not start new work, so + * working timers count from the prompt that did, not from the latest wake. + */ +export function orchestrationV2RunWorkStartedAt( + run: Pick, +): OrchestrationV2Run["requestedAt"] { + return run.workStartedAt ?? run.startedAt ?? run.requestedAt; +} + export const OrchestrationV2RunAttempt = Schema.Struct({ id: RunAttemptId, // Provider-thread rows can be reused after recovery; retain the native input destination. @@ -780,6 +797,8 @@ export type OrchestrationV2PendingBackgroundTask = typeof OrchestrationV2Pending /** Provider and adapter metadata that should not overwrite the app thread's title. */ export const OrchestrationV2ProviderThreadNativeMetadata = Schema.Struct({ + /** Provider-reported selection for display, separate from the app's saved preferences. */ + modelSelection: Schema.optional(ModelSelection), title: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), updatedAt: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), /** Version 2 scopes provider-derived item ids by provider instance. */ @@ -1688,7 +1707,10 @@ export const OrchestrationV2ThreadShell = Schema.Struct({ latestRunStartedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), latestRunCompletedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), activeRunId: Schema.NullOr(RunId), - /** Start of the activity-owning run; request time while it is preparing. */ + /** + * orchestrationV2RunWorkStartedAt of the activity-owning run: a wake keeps + * the start of the work it continues; request time while preparing. + */ activityRunStartedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), activityRunStatus: Schema.optional( Schema.NullOr(Schema.Literals(["preparing", "starting", "running", "waiting"])), @@ -1841,6 +1863,7 @@ export const OrchestrationV2RunJson = OrchestrationV2Run.mapFields((fields) => ( requestedAt: Schema.DateTimeUtcFromString, startedAt: Schema.NullOr(Schema.DateTimeUtcFromString), completedAt: Schema.NullOr(Schema.DateTimeUtcFromString), + workStartedAt: Schema.optional(Schema.DateTimeUtcFromString), })); export type OrchestrationV2RunJson = typeof OrchestrationV2RunJson.Type; @@ -2578,6 +2601,18 @@ export const OrchestrationV2Command = Schema.Union([ snapshot: ThreadPullRequestSnapshot, stack: Schema.NullOr(ThreadPullRequestStack), }), + /** Start or stop the server watching a linked pull request for this thread's agent. */ + Schema.Struct({ + type: Schema.Literal("thread.pull-request.watch"), + commandId: CommandId, + threadId: ThreadId, + ...ThreadPullRequestKey.fields, + watching: Schema.Boolean, + /** Links the pull request first when starting a watch on one the thread has not linked. */ + link: Schema.optional( + Schema.Struct({ url: TrimmedNonEmptyString, source: ThreadPullRequestLinkSource }), + ), + }), Schema.Struct({ type: Schema.Literal("thread.pull-request.sync"), commandId: CommandId, @@ -2837,6 +2872,27 @@ export type OrchestrationV2Command = typeof OrchestrationV2Command.Type; * send them. */ const OrchestrationV2InternalCommand = Schema.Union([ + /** + * Records what a pull request watch saw, and wakes the agent in the same transaction when + * `wake` is set. Rejected once the watch started at `startedAt` has ended, and a wake is + * rejected on a settled or archived thread, so a read that raced either changes nothing. + */ + Schema.Struct({ + type: Schema.Literal("thread.pull-request-watch.sync"), + commandId: CommandId, + threadId: ThreadId, + ...ThreadPullRequestKey.fields, + startedAt: IsoDateTime, + /** The watch to record, or null to end it. */ + watch: Schema.NullOr(ThreadPullRequestWatch), + wake: Schema.optional( + Schema.Struct({ + messageId: MessageId, + text: Schema.String, + notification: OrchestrationV2Notification, + }), + ), + }), /** Records that the provider rollback `requestId` failed for good. */ Schema.Struct({ type: Schema.Literal("checkpoint.rollback.fail"), diff --git a/packages/contracts/src/providerInstance.test.ts b/packages/contracts/src/providerInstance.test.ts index 441bc902d23f..b10b01cb5a54 100644 --- a/packages/contracts/src/providerInstance.test.ts +++ b/packages/contracts/src/providerInstance.test.ts @@ -23,39 +23,37 @@ describe("provider slug validation (shared by driver + instance ids)", () => { { schemaName: "ProviderDriverKind", decode: decodeProviderDriverKind }, ] as const; - for (const { schemaName, decode } of cases) { - describe(schemaName, () => { - it.each(["codex", "codex_personal", "codex-work", "claudeAgent", "x", "abc123", "ollama"])( - "accepts %s", - (id) => { - expect(decode(id)).toBe(id); - }, - ); - - it.each([ - ["empty string", ""], - ["leading digit", "1codex"], - ["leading dash", "-codex"], - ["leading underscore", "_codex"], - ["whitespace inside", "codex personal"], - ["dot inside", "codex.personal"], - ["slash inside", "codex/personal"], - ])("rejects %s", (_label, value) => { - expect(() => decode(value)).toThrow(); - }); - - it("trims surrounding whitespace before validating", () => { - expect(decode(" codex_work ")).toBe("codex_work"); - }); - - it("rejects ids longer than 64 characters", () => { - const tooLong = "a".repeat(65); - expect(() => decode(tooLong)).toThrow(); - const justRight = "a".repeat(64); - expect(decode(justRight)).toBe(justRight); - }); + describe.each(cases)("$schemaName", ({ decode }) => { + it.each(["codex", "codex_personal", "codex-work", "claudeAgent", "x", "abc123", "ollama"])( + "accepts %s", + (id) => { + expect(decode(id)).toBe(id); + }, + ); + + it.each([ + ["empty string", ""], + ["leading digit", "1codex"], + ["leading dash", "-codex"], + ["leading underscore", "_codex"], + ["whitespace inside", "codex personal"], + ["dot inside", "codex.personal"], + ["slash inside", "codex/personal"], + ])("rejects %s", (_label, value) => { + expect(() => decode(value)).toThrow(); + }); + + it("trims surrounding whitespace before validating", () => { + expect(decode(" codex_work ")).toBe("codex_work"); + }); + + it("rejects ids longer than 64 characters", () => { + const tooLong = "a".repeat(65); + expect(() => decode(tooLong)).toThrow(); + const justRight = "a".repeat(64); + expect(decode(justRight)).toBe(justRight); }); - } + }); }); describe("ProviderInstanceRef", () => { diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index fc8d38b22766..8fd6bfc9df1a 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -152,6 +152,8 @@ export const PullRequestCheck = Schema.Struct({ status: PullRequestCheckStatus, description: Schema.NullOr(Schema.String), url: Schema.NullOr(Schema.String), + /** The base branch requires this check to merge. Absent where the host does not say. */ + required: Schema.optional(Schema.Boolean), }); export type PullRequestCheck = typeof PullRequestCheck.Type; @@ -856,6 +858,8 @@ export const PullRequestDetail = Schema.Struct({ changedFiles: NonNegativeInt, headBranch: TrimmedNonEmptyString, headRepositoryNameWithOwner: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + /** The head commit, where the host reports it with the detail. */ + headSha: Schema.optional(TrimmedNonEmptyString), baseBranch: TrimmedNonEmptyString, createdAt: IsoDateTime, updatedAt: IsoDateTime, @@ -1434,6 +1438,7 @@ export class PullRequestOperationError extends Schema.TaggedError()( + "t3/contracts/RpcScopeAuthorization", + { error: EnvironmentAuthorizationError }, +) {} + export const WsRpcGroup = RpcGroup.make( WsServerProbeRpc, WsServerGetConfigRpc, @@ -1865,4 +1876,4 @@ export const WsRpcGroup = RpcGroup.make( WsOrchestrationV2SubscribeArchivedShellRpc, WsOrchestrationV2SubscribeShellRpc, WsOrchestrationV2SubscribeThreadRpc, -); +).middleware(RpcScopeAuthorization); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index e84ed36c9718..082459efc3df 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1404,6 +1404,13 @@ export const ServerSettings = Schema.Struct({ usagePriceOverrides: Schema.Record(TrimmedNonEmptyString, UsageModelPriceOverride).pipe( Schema.withDecodingDefault(Effect.succeed({})), ), + /** + * Exact model ID to the model its usage counts as, such as a preview slug to + * its released name. The mapped model is priced and reported as its target. + */ + usageModelAliases: Schema.Record(TrimmedNonEmptyString, TrimmedNonEmptyString).pipe( + Schema.withDecodingDefault(Effect.succeed({})), + ), }); export type ServerSettings = typeof ServerSettings.Type; @@ -1700,6 +1707,10 @@ export const ServerSettingsPatch = Schema.Struct({ usagePriceOverrides: Schema.optionalKey( Schema.Record(TrimmedNonEmptyString, Schema.NullOr(UsageModelPriceOverride)), ), + /** Each entry replaces one model's mapping; `null` removes it. */ + usageModelAliases: Schema.optionalKey( + Schema.Record(TrimmedNonEmptyString, Schema.NullOr(TrimmedNonEmptyString)), + ), }); export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; diff --git a/packages/contracts/src/threadPullRequest.ts b/packages/contracts/src/threadPullRequest.ts index 119503c2a85d..9df5cbe9670a 100644 --- a/packages/contracts/src/threadPullRequest.ts +++ b/packages/contracts/src/threadPullRequest.ts @@ -92,6 +92,30 @@ export const ThreadPullRequestKey = Schema.Struct({ }); export type ThreadPullRequestKey = typeof ThreadPullRequestKey.Type; +/** + * Present while the server watches the pull request for its thread. The server wakes the + * thread's agent when checks finish on the head commit, someone else comments, or the branch + * starts to conflict. The other fields record what the agent was last told, so each change is + * reported once. + */ +export const ThreadPullRequestWatch = Schema.Struct({ + startedAt: IsoDateTime, + /** Head commit at the last pass; null where the host does not report one. */ + headSha: Schema.NullOr(TrimmedNonEmptyString), + /** Failed checks on that commit the agent was told about; a rerun that fails again is news. */ + failedChecks: Schema.Array(TrimmedNonEmptyString), + /** The agent was told the required checks on that commit passed. */ + passed: Schema.Boolean, + /** Remarks from others created up to this host time were reported. */ + remarksThrough: IsoDateTime, + /** Remarks created exactly at `remarksThrough` that were reported, so a late one still counts. */ + remarkIds: Schema.Array(TrimmedNonEmptyString), + conflicting: Schema.Boolean, + /** Comment-only wakes in a row. Watching stops at a limit, so bots cannot loop it. */ + wakes: NonNegativeInt, +}); +export type ThreadPullRequestWatch = typeof ThreadPullRequestWatch.Type; + export const ThreadPullRequestLink = Schema.Struct({ ...ThreadPullRequestKey.fields, url: TrimmedNonEmptyString, @@ -99,5 +123,6 @@ export const ThreadPullRequestLink = Schema.Struct({ linkedAt: IsoDateTime, snapshot: Schema.NullOr(ThreadPullRequestSnapshot), stack: Schema.NullOr(ThreadPullRequestStack), + watch: Schema.optional(ThreadPullRequestWatch), }); export type ThreadPullRequestLink = typeof ThreadPullRequestLink.Type; diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index 97db8594379a..f6282b7b2935 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -18,7 +18,8 @@ import { ForwardCompatibleArray, NonNegativeInt, TrimmedNonEmptyString } from ". * client renders partial coverage when an environment reports an older version * rather than failing the whole page. * Adding providers or other array-element variants is additive: unknown - * entries are skipped on decode and do not require a version bump. + * entries are skipped on decode and do not require a version bump. So are + * optional bucket fields, which older clients ignore. */ export const USAGE_CONTRACT_VERSION = 6 as const; @@ -84,6 +85,18 @@ export const UsageTokenTotals = Schema.Struct({ }); export type UsageTokenTotals = typeof UsageTokenTotals.Type; +/** + * A bucket's cost split by token category, in USD. A provider-reported cost is + * split in proportion to the model's list rates. + */ +export const UsageCategoryCost = Schema.Struct({ + input: Schema.Number, + cacheRead: Schema.Number, + cacheWrite: Schema.Number, + output: Schema.Number, +}); +export type UsageCategoryCost = typeof UsageCategoryCost.Type; + /** * One `(day, hourStart?, provider, model)` cell. `hourStart` is the UTC start * instant of a rolling bucket and is present only for hourly requests. @@ -108,6 +121,16 @@ export const UsageBucket = Schema.Struct({ * rather than derived on the client. */ cacheSavingsUsd: Schema.Number, + /** + * `costUsd` by token category. Cost with no known rates stays out of it, and + * it is absent when nothing could be split or the server predates it. + */ + categoryCostUsd: Schema.optional(UsageCategoryCost), + /** Cost of fast and ultrafast requests. Absent when zero; the rest is standard. */ + fastCostUsd: Schema.optional(Schema.Number), + ultrafastCostUsd: Schema.optional(Schema.Number), + /** What fast and ultrafast requests cost above the standard rate. Absent when zero. */ + speedPremiumUsd: Schema.optional(Schema.Number), costSource: UsageCostSource, /** Distinct assistant responses, after de-duplication. */ records: NonNegativeInt, diff --git a/packages/effect-acp/src/protocol.test.ts b/packages/effect-acp/src/protocol.test.ts index d42fc08c3f48..051f03627f03 100644 --- a/packages/effect-acp/src/protocol.test.ts +++ b/packages/effect-acp/src/protocol.test.ts @@ -1088,8 +1088,9 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { }), ); - for (const operation of ["request", "notification"] as const) { - it.effect(`rejects a ${operation} if the connection ends while its logger is running`, () => + it.effect.each(["request", "notification"] as const)( + "rejects a %s if the connection ends while its logger is running", + (operation) => Effect.gen(function* () { const { stdio, input, output } = yield* makeInMemoryStdio(); const writeStarted = yield* Deferred.make(); @@ -1127,6 +1128,5 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { assert.strictEqual(failure, error); assert.equal(yield* Queue.size(output), 0); }), - ); - } + ); }); diff --git a/packages/shared/src/filePreview.test.ts b/packages/shared/src/filePreview.test.ts index 34d619e7a782..602fb8299cdd 100644 --- a/packages/shared/src/filePreview.test.ts +++ b/packages/shared/src/filePreview.test.ts @@ -14,7 +14,7 @@ import { } from "./filePreview.ts"; describe("workspace file previews", () => { - it.each(["report.html", "report.HTM", "document.pdf?download=1"])( + it.each(["report.html", "report.HTM", "document#draft.pdf", "reports?old/document.pdf"])( "recognizes browser preview path %s", (path) => { expect(isWorkspaceBrowserPreviewPath(path)).toBe(true); @@ -26,7 +26,9 @@ describe("workspace file previews", () => { "icon.png", "photo.JPEG", "animation.gif", - "vector.svg#mark", + "vector#mark.svg", + "photo?edited.JPEG", + "images#archive/icon.png", "texture.webp", "image.avif", ])("recognizes image preview path %s", (path) => { @@ -34,12 +36,19 @@ describe("workspace file previews", () => { expect(isWorkspacePreviewEntryPath(path)).toBe(true); }); - it.each(["README.md", "src/index.ts", "image.png.ts", "png"])( - "rejects non-preview path %s", - (path) => { - expect(isWorkspacePreviewEntryPath(path)).toBe(false); - }, - ); + it.each([ + "README.md", + "src/index.ts", + "image.png.ts", + "png", + "image.png#notes.txt", + "image.svg?notes.txt", + "document.pdf?download=1", + "report.html#notes.txt", + "image%2Epng", + ])("rejects non-preview path %s", (path) => { + expect(isWorkspacePreviewEntryPath(path)).toBe(false); + }); it("serves audio in place from the host like video and browser documents", () => { expect(isWorkspaceAudioPreviewPath("notes/recording.WAV")).toBe(true); diff --git a/packages/shared/src/filePreview.ts b/packages/shared/src/filePreview.ts index da62d2e689ec..04e285eff224 100644 --- a/packages/shared/src/filePreview.ts +++ b/packages/shared/src/filePreview.ts @@ -171,8 +171,8 @@ export function mediaKindFromPath(path: string): "image" | "video" | null { } function hasPreviewExtension(path: string, extensions: ReadonlyArray): boolean { - const pathWithoutQuery = path.split(/[?#]/, 1)[0]?.toLowerCase() ?? ""; - return extensions.some((extension) => pathWithoutQuery.endsWith(extension)); + const literalPath = path.toLowerCase(); + return extensions.some((extension) => literalPath.endsWith(extension)); } export function isWorkspaceBrowserPreviewPath(path: string): boolean { diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index e866ac05812e..d5866d32e5d1 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -373,6 +373,7 @@ function toLocalStatusPart(status: VcsStatusResult): VcsStatusLocalResult { refName: status.refName, hasWorkingTreeChanges: status.hasWorkingTreeChanges, workingTree: status.workingTree, + ...(status.branchChanges ? { branchChanges: status.branchChanges } : {}), }; } diff --git a/packages/shared/src/gitPatchPath.test.ts b/packages/shared/src/gitPatchPath.test.ts index 20c5065729f8..c1592a92acf7 100644 --- a/packages/shared/src/gitPatchPath.test.ts +++ b/packages/shared/src/gitPatchPath.test.ts @@ -46,15 +46,13 @@ describe("a name written into a header and read back out", () => { `every\t\n\r"\\${BELL}.txt`, ]; - for (const name of names) { - it(`is the name that went in: ${JSON.stringify(name)}`, () => { - const written = quoteGitPatchPath(name); - expect(unquoteGitPatchPath(written)).toBe(name); - // A parser that takes the quotes off itself, as the clients' one does, gets there too. - const unwrapped = written.startsWith('"') ? written.slice(1, -1) : written; - expect(unquoteGitPatchPath(unwrapped)).toBe(name); - }); - } + it.each(names)("is the name that went in: %j", (name) => { + const written = quoteGitPatchPath(name); + expect(unquoteGitPatchPath(written)).toBe(name); + // A parser that takes the quotes off itself, as the clients' one does, gets there too. + const unwrapped = written.startsWith('"') ? written.slice(1, -1) : written; + expect(unquoteGitPatchPath(unwrapped)).toBe(name); + }); it("carries the whole name past the first thing a header stops at", () => { const written = quoteGitPatchPath("tab\tfile.txt"); diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 447d42560365..634945fe0e64 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -48,11 +48,21 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+shift+enter", command: "thread.steerQueuedMessage", when: "!terminalFocus" }, { key: "alt+arrowup", command: "thread.editQueuedMessage", when: "composerFocus" }, { key: "mod+enter", command: "composer.sendAlternate", when: "composerFocus && turnRunning" }, + { + key: "mod+enter", + command: "composer.sendBackground", + when: "composerFocus && draftThreadRoute", + }, { key: "mod+alt+enter", command: "composer.sendBackground", when: "composerFocus && draftThreadRoute", }, + { + key: "mod+alt+enter", + command: "composer.sendAndNewThread", + when: "composerFocus && !draftThreadRoute", + }, { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index c0c60620dc2a..32691d242e6e 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -9,6 +9,7 @@ import { createModelSelection, formatCodexModelName, formatModelSlugName, + getProviderOptionCurrentLabel, getModelSelectionBooleanOptionValue, getModelSelectionStringOptionValue, getProviderOptionDescriptors, @@ -302,3 +303,63 @@ describe("readCustomModelEntries", () => { }); }); }); + +describe("provider-reported option display", () => { + const selection = createModelSelection(ProviderInstanceId.make("opencode"), "ling"); + const reported = { ...selection, options: [{ id: "variant", value: "default" }] }; + const descriptor = { + id: "variant", + label: "Reasoning", + type: "select" as const, + options: [ + { id: "none", label: "None" }, + { id: "thinking", label: "Thinking" }, + ], + }; + + it("shows explicit reports without adding a choice or a dispatch option", () => { + expect(getProviderOptionCurrentLabel(descriptor, selection, reported)).toBe("Default"); + expect( + getProviderOptionCurrentLabel(descriptor, selection, { + ...reported, + options: [{ id: "variant", value: "thinking" }], + }), + ).toBe("Thinking"); + expect( + getProviderOptionCurrentLabel( + { ...descriptor, currentValue: "none" }, + { ...selection, options: [{ id: "variant", value: "none" }] }, + reported, + ), + ).toBe("None"); + expect(descriptor.options.map((option) => option.id)).toEqual(["none", "thinking"]); + expect(buildProviderOptionSelectionsFromDescriptors([descriptor])).toBeUndefined(); + expect(getProviderOptionCurrentLabel(descriptor, selection)).toBe("Unknown"); + const effortDescriptor = { ...descriptor, id: "effort", currentValue: "default" }; + expect(getProviderOptionCurrentLabel(effortDescriptor, selection)).toBeUndefined(); + expect( + getProviderOptionCurrentLabel(effortDescriptor, selection, { + ...reported, + model: "other", + options: [{ id: "effort", value: "default" }], + }), + ).toBeUndefined(); + expect( + getProviderOptionCurrentLabel(effortDescriptor, selection, { + ...reported, + options: [{ id: "effort", value: "default" }], + }), + ).toBe("Default"); + expect( + getProviderOptionCurrentLabel({ ...descriptor, currentValue: "thinking" }, selection), + ).toBe("Unknown"); + }); + + it.each([ + { ...selection, model: "other" }, + { ...selection, instanceId: ProviderInstanceId.make("other") }, + { ...selection, options: [{ id: "variant", value: "none" }] }, + ])("ignores reports after changing the model, instance, or option: %j", (selected) => { + expect(getProviderOptionCurrentLabel(descriptor, selected, reported)).toBe("Unknown"); + }); +}); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 58973046b107..0e05fb6926cf 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -202,12 +202,35 @@ export function getProviderOptionDescriptors(input: { ); } +function getReportedOptionValue( + id: string, + selection?: ModelSelection | null, + reportedSelection?: ModelSelection | null, +) { + if ( + !selection || + !reportedSelection || + selection.instanceId !== reportedSelection.instanceId || + selection.model !== reportedSelection.model || + selection.options?.some((option) => option.id === id) + ) + return undefined; + return getRawSelectionValueById(reportedSelection.options, id); +} + export function getProviderOptionCurrentValue( descriptor: ProviderOptionDescriptor | null | undefined, + selection?: ModelSelection | null, + reportedSelection?: ModelSelection | null, ): string | boolean | undefined { if (!descriptor) { return undefined; } + const hasExplicitOption = selection?.options?.some((option) => option.id === descriptor.id); + // Reported values are display-only; callers that build dispatch options omit this context. + const reportedValue = getReportedOptionValue(descriptor.id, selection, reportedSelection); + if (reportedValue !== undefined) return reportedValue; + if (descriptor.id === "variant" && selection && !hasExplicitOption) return undefined; if (descriptor.type === "boolean") { return descriptor.currentValue; } @@ -219,6 +242,8 @@ export function getProviderOptionCurrentValue( export function getProviderOptionCurrentLabel( descriptor: ProviderOptionDescriptor | null | undefined, + selection?: ModelSelection | null, + reportedSelection?: ModelSelection | null, ): string | undefined { if (!descriptor) { return undefined; @@ -230,11 +255,15 @@ export function getProviderOptionCurrentLabel( : "Off" : undefined; } - const currentValue = getProviderOptionCurrentValue(descriptor); - if (typeof currentValue !== "string") { - return undefined; - } - return descriptor.options.find((option) => option.id === currentValue)?.label; + const currentValue = getProviderOptionCurrentValue(descriptor, selection, reportedSelection); + return ( + descriptor.options.find((option) => option.id === currentValue)?.label ?? + (getReportedOptionValue(descriptor.id, selection, reportedSelection) === "default" + ? "Default" + : descriptor.id === "variant" + ? "Unknown" + : undefined) + ); } export function buildProviderOptionSelectionsFromDescriptors( diff --git a/packages/shared/src/orchestrationV2ThreadError.ts b/packages/shared/src/orchestrationV2ThreadError.ts index 2abec628c615..ca8dcceaa912 100644 --- a/packages/shared/src/orchestrationV2ThreadError.ts +++ b/packages/shared/src/orchestrationV2ThreadError.ts @@ -55,11 +55,28 @@ export function latestExecutedRun( for (const run of runs) { if (run.status === "queued") continue; if (run.status === "cancelled" && run.startedAt === null) continue; - if (latest === null || run.ordinal > latest.ordinal) latest = run; + if (latest === null || runRanAfter(run, latest)) latest = run; } return latest; } +/** + * Whether started `run` ran after `other`. Ordinals follow submission, but a + * run can start ahead of a held queue (a restart continuation, or a message + * sent while the queue is held), so a queued run resumed later can have a + * lower ordinal than one that already ended. An unfinished run is the latest. + */ +export function runRanAfter( + run: Pick, + other: Pick, +): boolean { + const end = (candidate: typeof run) => + !candidate.completedAt + ? Number.POSITIVE_INFINITY + : DateTime.toEpochMillis(candidate.completedAt); + return end(run) === end(other) ? run.ordinal > other.ordinal : end(run) > end(other); +} + /** * The latest run that actually started, when it stopped because the * subscription limit was reached. Queued messages after that run must stay diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 37dca2c5c273..a3e0ffb23df3 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -280,6 +280,7 @@ export function applyServerSettingsPatch( // Merged per entry below; its `null` removals must not reach deepMerge. usageLimitSources: usageLimitSourcesPatch, usagePriceOverrides: usagePriceOverridesPatch, + usageModelAliases: usageModelAliasesPatch, // Entry replacement: deepMerge would keep keys the client meant to clear. projectSettingsOverrides: projectSettingsOverridesPatch, // Already translated into `projectSettingsOverrides` above; the legacy @@ -394,6 +395,14 @@ export function applyServerSettingsPatch( ), } : {}), + ...(usageModelAliasesPatch !== undefined + ? { + usageModelAliases: mergeSettingsEntries( + current.usageModelAliases, + usageModelAliasesPatch, + ), + } + : {}), ...(patch.sourceControlWriterModelSelection !== undefined ? { sourceControlWriterModelSelection: patch.sourceControlWriterModelSelection } : {}), diff --git a/packages/shared/src/symlink.ts b/packages/shared/src/symlink.ts index a3a4841fec17..f1e3ffed0fdb 100644 --- a/packages/shared/src/symlink.ts +++ b/packages/shared/src/symlink.ts @@ -1,13 +1,23 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; const MAX_SYMLINK_HOPS = 40; +const isNotFound = (error: PlatformError.PlatformError) => error.reason._tag === "NotFound"; + +const isNotASymlink = (error: PlatformError.PlatformError) => + isNotFound(error) || + (error.cause instanceof Error && "code" in error.cause && error.cause.code === "EINVAL"); + /** * Follows a chain of symlinks to the file it finally names, which may not exist * yet. Any path that is not a symlink resolves to itself. Atomic writers rename - * onto this path so a linked file keeps its link. + * onto this path so a linked file keeps its link. Fails 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* () { @@ -15,11 +25,25 @@ export const resolveSymlinkTarget = (filePath: string) => 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.option); - if (link._tag === "None") { + const link = yield* fs.readLink(current).pipe( + Effect.map(Option.some), + Effect.catchIf(isNotASymlink, () => Effect.succeedNone), + ); + if (Option.isNone(link)) { return current; } - current = path.resolve(path.dirname(current), link.value); + // 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 current; + return yield* PlatformError.systemError({ + _tag: "Unknown", + module: "FileSystem", + method: "readLink", + description: "Too many levels of symbolic links", + pathOrDescriptor: filePath, + }); }); diff --git a/packages/shared/src/t3McpToolPresentation.ts b/packages/shared/src/t3McpToolPresentation.ts index dd38709bd0e6..d85104cf4bc1 100644 --- a/packages/shared/src/t3McpToolPresentation.ts +++ b/packages/shared/src/t3McpToolPresentation.ts @@ -55,6 +55,8 @@ export type T3McpToolSummaryAction = | "link-pr" | "unlink-pr" | "list-prs" + | "watch-pr" + | "unwatch-pr" | "browser" | "device"; @@ -93,6 +95,16 @@ const T3_MCP_TOOLS: Readonly> = { "list-prs", "pull-request", ), + watch_pull_request: tool( + ["Watch", "Watching", "Watching", "a pull request"], + "watch-pr", + "pull-request", + ), + unwatch_pull_request: tool( + ["Stop watching", "Stopping watching", "Stopped watching", "a pull request"], + "unwatch-pr", + "pull-request", + ), orchestrator_capabilities: tool( ["Get", "Getting", "Got", "orchestration capabilities"], "capabilities", diff --git a/packages/shared/src/toolActivity.test.ts b/packages/shared/src/toolActivity.test.ts index 9a9c67d2e36c..fe19052fa5bc 100644 --- a/packages/shared/src/toolActivity.test.ts +++ b/packages/shared/src/toolActivity.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from "vite-plus/test"; import { + claudeSkillInvocation, classifyToolActivity, collectToolFilePaths, deriveToolActivityPresentation, + dynamicToolTitle, formatReadToolLabel, formatSearchToolLabel, mergeToolActivityData, @@ -120,4 +122,14 @@ describe("toolActivity", () => { mergeToolActivityData({ rawInput: { path: "src/a.ts" } }, { rawInput: { startLine: 4 } }), ).toEqual({ rawInput: { path: "src/a.ts", startLine: 4 } }); }); + + it("titles Claude skill calls with the skill they load", () => { + expect(dynamicToolTitle("Skill", { skill: "full-send" })).toBe("Skill: full-send"); + expect(claudeSkillInvocation("Skill", { skill: "claude-api", args: " pricing " })).toEqual({ + name: "claude-api", + args: "pricing", + }); + expect(dynamicToolTitle("Skill", { skill: " " })).toBeUndefined(); + expect(dynamicToolTitle("Read", { skill: "full-send" })).toBeUndefined(); + }); }); diff --git a/packages/shared/src/toolActivity.ts b/packages/shared/src/toolActivity.ts index 04f746a01d1a..d281462cb08d 100644 --- a/packages/shared/src/toolActivity.ts +++ b/packages/shared/src/toolActivity.ts @@ -14,13 +14,28 @@ function asTrimmedString(value: unknown): string | undefined { return trimmed.length > 0 ? trimmed : undefined; } -/** CUA's `title` describes the action shown in the activity log. */ -export function computerUseToolTitle( +/** A Claude `Skill` call: the skill it loads and the arguments it passes, if any. */ +export function claudeSkillInvocation( + toolName: string | null | undefined, + input: unknown, +): { readonly name: string; readonly args: string | undefined } | undefined { + if (toolName !== "Skill") return undefined; + const record = asRecord(input); + const name = asTrimmedString(record?.skill); + return name === undefined ? undefined : { name, args: asTrimmedString(record?.args) }; +} + +/** + * Activity log heading a dynamic tool derives from its input: CUA's `title`, + * or the skill a Claude `Skill` call loads. + */ +export function dynamicToolTitle( toolName: string | null | undefined, input: unknown, ): string | undefined { - if (toolName !== "cua_repl.js") return undefined; - return asTrimmedString(asRecord(input)?.title); + if (toolName === "cua_repl.js") return asTrimmedString(asRecord(input)?.title); + const skill = claudeSkillInvocation(toolName, input); + return skill === undefined ? undefined : `Skill: ${skill.name}`; } function recordHasKeys( diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index 81d1e8565a6c..8208e5b7ed1c 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -514,6 +514,44 @@ describe("mergeUsage", () => { expect(merged.costQuality.cacheSavingsUsd).toBe(4); }); + it("derives model token shares independently of their cost shares", () => { + const merged = mergeUsage( + [ + environment( + "env-a", + summary( + [ + bucket({ costUsd: 90 }), + bucket({ + provider: "codex", + model: "gpt-5.6-sol", + costUsd: 10, + totals: { + uncachedInputTokens: 3 * 1160, + cachedInputTokens: 0, + cacheCreationTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + }, + }), + ], + [ + { provider: "claude", hostId: "mac", homePath: "/a/.claude" }, + { provider: "codex", hostId: "mac", homePath: "/a/.codex" }, + ], + ), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + const byModel = Object.fromEntries(merged.models.map((model) => [model.model, model])); + expect(byModel["claude-fable-5"]?.costShare).toBeCloseTo(0.9, 5); + expect(byModel["claude-fable-5"]?.tokenShare).toBeCloseTo(0.25, 5); + expect(byModel["gpt-5.6-sol"]?.costShare).toBeCloseTo(0.1, 5); + expect(byModel["gpt-5.6-sol"]?.tokenShare).toBeCloseTo(0.75, 5); + }); + it("marks a model with no known rates as unpriced rather than free", () => { const merged = mergeUsage( [ @@ -546,6 +584,67 @@ describe("mergeUsage", () => { ]); }); + it("splits cost by category and speed, counting older servers as unsplit standard cost", () => { + const merged = mergeUsage( + [ + environment( + "env-a", + summary( + [ + bucket({ + costUsd: 10, + categoryCostUsd: { input: 1, cacheRead: 2, cacheWrite: 3, output: 4 }, + fastCostUsd: 6, + speedPremiumUsd: 3, + }), + bucket({ + provider: "codex", + model: "unknown-model", + costUsd: 0, + costSource: "unpriced", + unpricedRecords: 5, + }), + // Reported cost on one record, no rates for the other four. + bucket({ + provider: "codex", + model: "partly-reported", + costUsd: 0, + unpricedRecords: 4, + }), + ], + [ + { provider: "claude", hostId: "mac", homePath: "/a/.claude" }, + { provider: "codex", hostId: "mac", homePath: "/a/.codex" }, + ], + ), + ), + environment( + "env-b", + summary( + [bucket({ costUsd: 5 })], + [{ provider: "claude", hostId: "linux", homePath: "/b/.claude" }], + USAGE_MERGE_COMPATIBLE_SINCE, + ), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.categoryCost).toEqual({ + input: 1, + cacheRead: 2, + cacheWrite: 3, + output: 4, + unsplit: 5, + }); + expect(merged.speedCost).toEqual({ standard: 9, fast: 6, ultrafast: 0, premium: 3 }); + expect(merged.models.map(({ model, unpricedTokens }) => [model, unpricedTokens])).toEqual([ + ["claude-fable-5", 0], + ["unknown-model", 1160], + ["partly-reported", 928], + ]); + }); + it("orders models by cost descending", () => { const merged = mergeUsage( [ diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts index 90c9a1ff4261..07718c099bef 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -14,6 +14,7 @@ import { type UsageSource, type UsageSourceFingerprint, type UsageSummary, + type UsageTokenTotals, } from "@t3tools/contracts"; export interface EnvironmentUsage { @@ -37,13 +38,20 @@ export interface ModelTotals { readonly provider: UsageProviderKind; readonly costUsd: number; readonly totalTokens: number; + readonly tokens: UsageTokenTotals; readonly records: number; /** * Records whose tokens are counted here but which contributed nothing to * `costUsd`. When it equals `records` the cost is unknown, not zero. */ readonly unpricedRecords: number; + /** + * Tokens with no known rates, which a custom price would cover. A cell that + * mixes these with reported costs counts its tokens by record share. + */ + readonly unpricedTokens: number; readonly costShare: number; + readonly tokenShare: number; } /** @@ -76,6 +84,27 @@ export interface CostQuality { readonly cacheSavingsUsd: number; } +/** + * `costUsd` by token category. `unsplit` is cost no rates could split, + * including all cost from servers that predate the split. + */ +export interface CategoryCost { + readonly input: number; + readonly cacheRead: number; + readonly cacheWrite: number; + readonly output: number; + readonly unsplit: number; +} + +/** `costUsd` by request speed. Servers that predate speeds count as standard. */ +export interface SpeedCost { + readonly standard: number; + readonly fast: number; + readonly ultrafast: number; + /** What fast and ultrafast requests cost above the standard rate. */ + readonly premium: number; +} + export interface UsageContractMismatch { readonly environmentId: EnvironmentId; readonly direction: "serverBehind" | "clientBehind"; @@ -97,6 +126,8 @@ export interface MergedUsage { readonly daily: readonly DailyTotals[]; readonly hourly: readonly HourlyTotals[]; readonly costQuality: CostQuality; + readonly categoryCost: CategoryCost; + readonly speedCost: SpeedCost; /** Environments whose data was dropped as a duplicate of another's. */ readonly duplicateSources: readonly string[]; readonly contributingEnvironments: readonly EnvironmentId[]; @@ -307,6 +338,8 @@ const EMPTY_MERGED: MergedUsage = { unpricedShare: 0, cacheSavingsUsd: 0, }, + categoryCost: { input: 0, cacheRead: 0, cacheWrite: 0, output: 0, unsplit: 0 }, + speedCost: { standard: 0, fast: 0, ultrafast: 0, premium: 0 }, duplicateSources: [], contributingEnvironments: [], contractMismatches: [], @@ -364,6 +397,8 @@ export function mergeUsage( let cacheSavingsUsd = 0; let providerReportedRecords = 0; let unpricedRecords = 0; + const categoryCost = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 }; + const speedCost = { fast: 0, ultrafast: 0, premium: 0 }; const providerAccumulator = new Map< UsageProviderKind, @@ -375,8 +410,10 @@ export function mergeUsage( provider: UsageProviderKind; costUsd: number; totalTokens: number; + tokens: UsageTokenTotals; records: number; unpricedRecords: number; + unpricedTokens: number; } >(); const dailyAccumulator = new Map< @@ -434,6 +471,15 @@ export function mergeUsage( records += bucket.records; unpricedRecords += bucket.unpricedRecords; if (bucket.costSource === "providerReported") providerReportedRecords += bucket.records; + if (bucket.categoryCostUsd !== undefined) { + categoryCost.input += bucket.categoryCostUsd.input; + categoryCost.cacheRead += bucket.categoryCostUsd.cacheRead; + categoryCost.cacheWrite += bucket.categoryCostUsd.cacheWrite; + categoryCost.output += bucket.categoryCostUsd.output; + } + speedCost.fast += bucket.fastCostUsd ?? 0; + speedCost.ultrafast += bucket.ultrafastCostUsd ?? 0; + speedCost.premium += bucket.speedPremiumUsd ?? 0; const provider = providerAccumulator.get(bucket.provider) ?? { costUsd: 0, @@ -451,13 +497,31 @@ export function mergeUsage( provider: bucket.provider, costUsd: 0, totalTokens: 0, + tokens: { + uncachedInputTokens: 0, + cachedInputTokens: 0, + cacheCreationTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + }, records: 0, unpricedRecords: 0, + unpricedTokens: 0, }; model.costUsd += bucket.costUsd; model.totalTokens += tokens; + model.tokens = { + uncachedInputTokens: model.tokens.uncachedInputTokens + bucket.totals.uncachedInputTokens, + cachedInputTokens: model.tokens.cachedInputTokens + bucket.totals.cachedInputTokens, + cacheCreationTokens: model.tokens.cacheCreationTokens + bucket.totals.cacheCreationTokens, + outputTokens: model.tokens.outputTokens + bucket.totals.outputTokens, + reasoningTokens: model.tokens.reasoningTokens + bucket.totals.reasoningTokens, + }; model.records += bucket.records; model.unpricedRecords += bucket.unpricedRecords; + if (bucket.records > 0) { + model.unpricedTokens += (tokens * bucket.unpricedRecords) / bucket.records; + } modelAccumulator.set(modelKey, model); const day = dailyAccumulator.get(bucket.day) ?? { @@ -515,9 +579,12 @@ export function mergeUsage( provider: totals.provider, costUsd: totals.costUsd, totalTokens: totals.totalTokens, + tokens: totals.tokens, records: totals.records, unpricedRecords: totals.unpricedRecords, + unpricedTokens: totals.unpricedTokens, costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd, + tokenShare: totalTokens === 0 ? 0 : totals.totalTokens / totalTokens, })) .sort((a, b) => b.costUsd - a.costUsd || b.totalTokens - a.totalTokens); @@ -555,6 +622,22 @@ export function mergeUsage( records === 0 ? 0 : (records - providerReportedRecords - unpricedRecords) / records, cacheSavingsUsd, }, + // Clamped so float error never shows as a negative remainder. + categoryCost: { + ...categoryCost, + unsplit: Math.max( + 0, + costUsd - + categoryCost.input - + categoryCost.cacheRead - + categoryCost.cacheWrite - + categoryCost.output, + ), + }, + speedCost: { + ...speedCost, + standard: Math.max(0, costUsd - speedCost.fast - speedCost.ultrafast), + }, duplicateSources: duplicates, contributingEnvironments, contractMismatches, diff --git a/patches/expo-widgets@58.0.11.patch b/patches/expo-widgets@58.0.11.patch index f2632f180c84..cb8aef9f06dd 100644 --- a/patches/expo-widgets@58.0.11.patch +++ b/patches/expo-widgets@58.0.11.patch @@ -1,5 +1,5 @@ diff --git a/build/Widgets.types.d.ts b/build/Widgets.types.d.ts -index c9f4dfe..dc0792e 100644 +index c9f4dfe1f10a3a65ab72bd7e639f6f6d148e5f95..dc0792e1cd7b6762394de5e3fefa0863a253f70c 100644 --- a/build/Widgets.types.d.ts +++ b/build/Widgets.types.d.ts @@ -93,6 +93,10 @@ export type LiveActivityEnvironment = { @@ -13,8 +13,27 @@ index c9f4dfe..dc0792e 100644 /** * Whether the activity is displayed in a context with reduced luminance. * @platform iOS 16+ +diff --git a/ios/LiveActivityFactory.swift b/ios/LiveActivityFactory.swift +index 29e4f1566d188665e301d32bc1ad85640f83e68c..7143f9a1c06245d16649b981d7f5cde1d8868217 100644 +--- a/ios/LiveActivityFactory.swift ++++ b/ios/LiveActivityFactory.swift +@@ -19,6 +19,14 @@ final class LiveActivityFactory: SharedObject { + throw LiveActivitiesNotSupportedException() + } + ++ // iOS ends an activity after 8 hours but leaves it frozen on the Lock Screen for up to ++ // 4 more. getInstances() can't see it, so a caller starting a replacement would leave ++ // both cards visible. Dismiss the ended ones now. ++ for activity in Activity.activities ++ where activity.content.state.name == name && activity.activityState == .ended { ++ Task { await activity.end(nil, dismissalPolicy: .immediate) } ++ } ++ + do { + let initialState = LiveActivityAttributes.ContentState(name: name, props: props) + let activity = try Activity.request( diff --git a/ios/Widgets/Utils.swift b/ios/Widgets/Utils.swift -index 76b2199..6cb52c3 100644 +index 76b2199c2d3f879463fed5d2bd49038b0428a2e7..6cb52c3de4ad9696bc0c51ad62401cce96f405e7 100644 --- a/ios/Widgets/Utils.swift +++ b/ios/Widgets/Utils.swift @@ -125,6 +125,10 @@ func getLiveActivityEnvironment(for environment: EnvironmentValues, in context: @@ -29,7 +48,7 @@ index 76b2199..6cb52c3 100644 env["isActivityUpdateReduced"] = environment.isActivityUpdateReduced env["activityFamily"] = "\(environment.activityFamily)" diff --git a/ios/Widgets/WidgetLiveActivity.swift b/ios/Widgets/WidgetLiveActivity.swift -index 4d135c4..dae4744 100644 +index 4d135c4e4eb1f846315654ed9d835a935d120d42..dae47445b27516fa2464a25b68bddc8f6dec3e1e 100644 --- a/ios/Widgets/WidgetLiveActivity.swift +++ b/ios/Widgets/WidgetLiveActivity.swift @@ -19,53 +19,41 @@ struct LiveActivityAttributes: ActivityAttributes { @@ -132,7 +151,7 @@ index 4d135c4..dae4744 100644 LiveActivityBanner(context: context, nodes: nodes) } else if let node = nodes["banner"] as? [String: Any] { diff --git a/scripts/build-layout-registry.mjs b/scripts/build-layout-registry.mjs -index d52a60e..fa9bd77 100644 +index d52a60e342c8e0a8a1d78f7174da5e60e6bd1c53..fa9bd77c5c04d459038a5b4a00e1391780b406fa 100644 --- a/scripts/build-layout-registry.mjs +++ b/scripts/build-layout-registry.mjs @@ -154,6 +154,8 @@ function createLayoutRegistry(expectedWidgets, capturedLayouts) { @@ -146,7 +165,7 @@ index d52a60e..fa9bd77 100644 await main(); } diff --git a/src/Widgets.types.ts b/src/Widgets.types.ts -index 0770613..924fa00 100644 +index 07706139379384dae27befef4e3e5ae09d04a0db..924fa0029243664e369ce08c81e504b6300bfc7f 100644 --- a/src/Widgets.types.ts +++ b/src/Widgets.types.ts @@ -107,6 +107,10 @@ export type LiveActivityEnvironment = { diff --git a/patches/react-native-keyboard-controller@1.22.4.patch b/patches/react-native-keyboard-controller@1.22.6.patch similarity index 88% rename from patches/react-native-keyboard-controller@1.22.4.patch rename to patches/react-native-keyboard-controller@1.22.6.patch index 01c1760bf33a..8b9170240d4e 100644 --- a/patches/react-native-keyboard-controller@1.22.4.patch +++ b/patches/react-native-keyboard-controller@1.22.6.patch @@ -1,8 +1,8 @@ diff --git a/lib/commonjs/components/KeyboardChatScrollView/index.js b/lib/commonjs/components/KeyboardChatScrollView/index.js -index 5617394..4f2222a 100644 +index 0b78ff3..98b3b6c 100644 --- a/lib/commonjs/components/KeyboardChatScrollView/index.js +++ b/lib/commonjs/components/KeyboardChatScrollView/index.js -@@ -27,9 +27,12 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ +@@ -28,9 +28,12 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ offset = 0, extraContentPadding = ZERO_CONTENT_PADDING, blankSpace = ZERO_BLANK_SPACE, @@ -15,17 +15,16 @@ index 5617394..4f2222a 100644 onEndVisible, ...rest }, ref) => { -@@ -51,13 +54,17 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ - freeze: freezeSV, +@@ -54,6 +57,8 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ offset, blankSpace, -- extraContentPadding -+ extraContentPadding, + extraContentPadding, + adjustedInsetCompensation, -+ adjustedStartInsetCompensation ++ adjustedStartInsetCompensation, + initialContentOffsetY: (_rest$contentOffset = rest.contentOffset) === null || _rest$contentOffset === void 0 ? void 0 : _rest$contentOffset.y }); (0, _useExtraContentPadding.useExtraContentPadding)({ - scrollViewRef, +@@ -61,6 +66,8 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ extraContentPadding, keyboardPadding: padding, blankSpace, @@ -34,19 +33,19 @@ index 5617394..4f2222a 100644 scroll, layout, size, -@@ -88,10 +95,26 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ +@@ -91,10 +98,26 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ // a bug for you, please open an issue. - const totalPadding = (0, _reactNativeReanimated.useDerivedValue)(() => Math.min(layout.value.height, Math.max(blankSpace.value, padding.value + extraContentPadding.value))); + const totalPadding = (0, _reanimated.useDerivedValue)(() => Math.min(layout.value.height, Math.max(blankSpace.value, padding.value + extraContentPadding.value))); + // iOS applies the destination contentInset and contentOffset together at + // keyboard-animation start. Keep that native target behavior, but report + // the presentation height so virtualized-list layout follows the keyboard. -+ const reportedPadding = (0, _reactNativeReanimated.useDerivedValue)(() => _reactNative.Platform.OS === "ios" ? Math.min(layout.value.height, Math.max(blankSpace.value, currentHeight.value + extraContentPadding.value)) : totalPadding.value); ++ const reportedPadding = (0, _reanimated.useDerivedValue)(() => _reactNative.Platform.OS === "ios" ? Math.min(layout.value.height, Math.max(blankSpace.value, currentHeight.value + extraContentPadding.value)) : totalPadding.value); + + // Mirror the effective bottom padding (keyboard + composer + blank floor) + // to the consumer - a virtualized list needs it in its own scroll math or + // its end/maintain targets point at the under-the-keyboard resting offset. -+ (0, _reactNativeReanimated.useAnimatedReaction)(() => reportedPadding.value, (current, previous) => { ++ (0, _reanimated.useAnimatedReaction)(() => reportedPadding.value, (current, previous) => { + if (onContentInsetChange && current !== previous) { + (0, _reactNativeReanimated.runOnJS)(onContentInsetChange)({ + bottom: current @@ -57,8 +56,8 @@ index 5617394..4f2222a 100644 // Scroll indicator inset = keyboard + extraContentPadding (excludes blankSpace). // Apps that render into the unsafe area can supply a negative // scrollIndicatorInsets adjustment at the application layer. -- const indicatorPadding = (0, _reactNativeReanimated.useDerivedValue)(() => padding.value + extraContentPadding.value); -+ const indicatorPadding = (0, _reactNativeReanimated.useDerivedValue)(() => padding.value); +- const indicatorPadding = (0, _reanimated.useDerivedValue)(() => padding.value + extraContentPadding.value); ++ const indicatorPadding = (0, _reanimated.useDerivedValue)(() => padding.value); const onLayout = (0, _react.useCallback)(e => { onLayoutInternal(e); onLayoutProp === null || onLayoutProp === void 0 || onLayoutProp(e); @@ -89,7 +88,7 @@ index 4d33b3f..6a787d3 100644 //# sourceMappingURL=helpers.js.map \ No newline at end of file diff --git a/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js b/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js -index cecee07..533a9e9 100644 +index 6481228..0dc5dd1 100644 --- a/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js +++ b/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js @@ -32,7 +32,9 @@ function useChatKeyboard(scrollViewRef, options) { @@ -188,10 +187,10 @@ index cecee07..533a9e9 100644 padding, currentHeight, diff --git a/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js b/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js -index 54a1c57..00c2941 100644 +index 0215bef..5307446 100644 --- a/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js +++ b/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js -@@ -31,6 +31,8 @@ function useExtraContentPadding(options) { +@@ -32,6 +32,8 @@ function useExtraContentPadding(options) { extraContentPadding, keyboardPadding, blankSpace, @@ -200,7 +199,7 @@ index 54a1c57..00c2941 100644 scroll, layout, size, -@@ -50,8 +52,13 @@ function useExtraContentPadding(options) { +@@ -51,8 +53,13 @@ function useExtraContentPadding(options) { // otherwise the native ScrollView clamps to the old range. requestAnimationFrame(() => { // check that view is still mounted and ref is actual @@ -216,7 +215,7 @@ index 54a1c57..00c2941 100644 return; } (0, _reactNativeReanimated.scrollTo)(scrollViewRef, 0, target, false); -@@ -70,8 +77,8 @@ function useExtraContentPadding(options) { +@@ -71,8 +78,8 @@ function useExtraContentPadding(options) { } // Compute effective delta considering blankSpace floor @@ -227,12 +226,15 @@ index 54a1c57..00c2941 100644 const effectiveDelta = currentTotal - previousTotal; if (effectiveDelta === 0) { // blankSpace absorbed the change -@@ -90,10 +97,11 @@ function useExtraContentPadding(options) { +@@ -91,13 +98,11 @@ function useExtraContentPadding(options) { const target = Math.max(scroll.value - effectiveDelta, -currentTotal); scrollToTarget(target); } else { - const maxScroll = Math.max(size.value.height - layout.value.height + currentTotal, 0); -- const target = Math.min(scroll.value + effectiveDelta, maxScroll); +- // Clamp at 0 as well: when the content is shorter than the viewport a +- // shrinking padding makes `effectiveDelta` negative while `scroll.value` +- // is already 0, so the target would go below the top of the content. +- const target = Math.max(Math.min(scroll.value + effectiveDelta, maxScroll), 0); + const minScroll = -adjustedStartInsetCompensation; + const maxScroll = Math.max(size.value.height - layout.value.height + currentTotal, minScroll); + const target = Math.max(minScroll, Math.min(scroll.value + effectiveDelta, maxScroll)); @@ -244,20 +246,23 @@ index 54a1c57..00c2941 100644 //# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/lib/module/components/KeyboardChatScrollView/index.js b/lib/module/components/KeyboardChatScrollView/index.js -index d085394..a57c2f9 100644 +index e0c8391..8df802a 100644 --- a/lib/module/components/KeyboardChatScrollView/index.js +++ b/lib/module/components/KeyboardChatScrollView/index.js -@@ -1,7 +1,7 @@ +@@ -1,9 +1,9 @@ function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } import React, { forwardRef, useCallback, useMemo } from "react"; -import { StyleSheet } from "react-native"; --import { makeMutable, useAnimatedRef, useAnimatedStyle, useDerivedValue } from "react-native-reanimated"; +-import { makeMutable, useAnimatedRef } from "react-native-reanimated"; +import { Platform, StyleSheet } from "react-native"; -+import { makeMutable, runOnJS, useAnimatedReaction, useAnimatedRef, useAnimatedStyle, useDerivedValue } from "react-native-reanimated"; ++import { makeMutable, runOnJS, useAnimatedRef } from "react-native-reanimated"; import Reanimated from "react-native-reanimated"; +-import { useAnimatedStyle, useDerivedValue } from "../../reanimated"; ++import { useAnimatedReaction, useAnimatedStyle, useDerivedValue } from "../../reanimated"; import useCombinedRef from "../hooks/useCombinedRef"; import ScrollViewWithBottomPadding from "../ScrollViewWithBottomPadding"; -@@ -20,9 +20,12 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ + import { useChatKeyboard } from "./useChatKeyboard"; +@@ -21,9 +21,12 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ offset = 0, extraContentPadding = ZERO_CONTENT_PADDING, blankSpace = ZERO_BLANK_SPACE, @@ -270,17 +275,16 @@ index d085394..a57c2f9 100644 onEndVisible, ...rest }, ref) => { -@@ -44,13 +47,17 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ - freeze: freezeSV, +@@ -47,6 +50,8 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ offset, blankSpace, -- extraContentPadding -+ extraContentPadding, + extraContentPadding, + adjustedInsetCompensation, -+ adjustedStartInsetCompensation ++ adjustedStartInsetCompensation, + initialContentOffsetY: (_rest$contentOffset = rest.contentOffset) === null || _rest$contentOffset === void 0 ? void 0 : _rest$contentOffset.y }); useExtraContentPadding({ - scrollViewRef, +@@ -54,6 +59,8 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ extraContentPadding, keyboardPadding: padding, blankSpace, @@ -289,7 +293,7 @@ index d085394..a57c2f9 100644 scroll, layout, size, -@@ -81,10 +88,26 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ +@@ -84,10 +91,26 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ // a bug for you, please open an issue. const totalPadding = useDerivedValue(() => Math.min(layout.value.height, Math.max(blankSpace.value, padding.value + extraContentPadding.value))); @@ -343,7 +347,7 @@ index 295e221..2f7b87e 100644 //# sourceMappingURL=helpers.js.map \ No newline at end of file diff --git a/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js b/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js -index 407275d..19427d7 100644 +index 043b973..df5a408 100644 --- a/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js +++ b/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js @@ -25,7 +25,9 @@ function useChatKeyboard(scrollViewRef, options) { @@ -442,10 +446,10 @@ index 407275d..19427d7 100644 padding, currentHeight, diff --git a/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js b/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js -index 5885415..8b3c4f0 100644 +index 5a9b91d..a11bfdb 100644 --- a/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js +++ b/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js -@@ -25,6 +25,8 @@ function useExtraContentPadding(options) { +@@ -26,6 +26,8 @@ function useExtraContentPadding(options) { extraContentPadding, keyboardPadding, blankSpace, @@ -454,7 +458,7 @@ index 5885415..8b3c4f0 100644 scroll, layout, size, -@@ -44,8 +46,13 @@ function useExtraContentPadding(options) { +@@ -45,8 +47,13 @@ function useExtraContentPadding(options) { // otherwise the native ScrollView clamps to the old range. requestAnimationFrame(() => { // check that view is still mounted and ref is actual @@ -470,7 +474,7 @@ index 5885415..8b3c4f0 100644 return; } scrollTo(scrollViewRef, 0, target, false); -@@ -64,8 +71,8 @@ function useExtraContentPadding(options) { +@@ -65,8 +72,8 @@ function useExtraContentPadding(options) { } // Compute effective delta considering blankSpace floor @@ -481,12 +485,15 @@ index 5885415..8b3c4f0 100644 const effectiveDelta = currentTotal - previousTotal; if (effectiveDelta === 0) { // blankSpace absorbed the change -@@ -84,11 +91,12 @@ function useExtraContentPadding(options) { +@@ -85,14 +92,12 @@ function useExtraContentPadding(options) { const target = Math.max(scroll.value - effectiveDelta, -currentTotal); scrollToTarget(target); } else { - const maxScroll = Math.max(size.value.height - layout.value.height + currentTotal, 0); -- const target = Math.min(scroll.value + effectiveDelta, maxScroll); +- // Clamp at 0 as well: when the content is shorter than the viewport a +- // shrinking padding makes `effectiveDelta` negative while `scroll.value` +- // is already 0, so the target would go below the top of the content. +- const target = Math.max(Math.min(scroll.value + effectiveDelta, maxScroll), 0); + const minScroll = -adjustedStartInsetCompensation; + const maxScroll = Math.max(size.value.height - layout.value.height + currentTotal, minScroll); + const target = Math.max(minScroll, Math.min(scroll.value + effectiveDelta, maxScroll)); @@ -524,10 +531,10 @@ index 2b51cc4..3acec1f 100644 -export declare const computeIOSContentOffset: (relativeScroll: number, keyboardHeight: number, contentHeight: number, layoutHeight: number, inverted: boolean, totalPaddingForMaxScroll?: number) => number; +export declare const computeIOSContentOffset: (relativeScroll: number, keyboardHeight: number, contentHeight: number, layoutHeight: number, inverted: boolean, totalPaddingForMaxScroll?: number, startInsetCompensation?: number) => number; diff --git a/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts b/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts -index aff9b5a..fdd8395 100644 +index 8d358bc..911f3d9 100644 --- a/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts +++ b/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts -@@ -9,6 +9,10 @@ type UseChatKeyboardOptions = { +@@ -11,6 +11,10 @@ type UseChatKeyboardOptions = { blankSpace: SharedValue; /** Extra content padding shared value — needed on iOS to correctly clamp contentOffset. */ extraContentPadding: SharedValue; @@ -554,21 +561,27 @@ index ec73f70..45b3874 100644 scroll: SharedValue; /** Visible viewport dimensions. */ diff --git a/src/components/KeyboardChatScrollView/index.tsx b/src/components/KeyboardChatScrollView/index.tsx -index 554b38c..725d546 100644 +index 935bc71..d411dc4 100644 --- a/src/components/KeyboardChatScrollView/index.tsx +++ b/src/components/KeyboardChatScrollView/index.tsx -@@ -1,7 +1,9 @@ +@@ -1,9 +1,13 @@ import React, { forwardRef, useCallback, useMemo } from "react"; -import { StyleSheet } from "react-native"; +-import { makeMutable, useAnimatedRef } from "react-native-reanimated"; +import { Platform, StyleSheet } from "react-native"; - import { - makeMutable, -+ runOnJS, ++import { makeMutable, runOnJS, useAnimatedRef } from "react-native-reanimated"; + import Reanimated from "react-native-reanimated"; + +-import { useAnimatedStyle, useDerivedValue } from "../../reanimated"; ++import { + useAnimatedReaction, - useAnimatedRef, - useAnimatedStyle, - useDerivedValue, -@@ -39,9 +41,12 @@ const KeyboardChatScrollView: React.ForwardRefExoticComponent< ++ useAnimatedStyle, ++ useDerivedValue, ++} from "../../reanimated"; + import useCombinedRef from "../hooks/useCombinedRef"; + import ScrollViewWithBottomPadding from "../ScrollViewWithBottomPadding"; + +@@ -35,9 +39,12 @@ const KeyboardChatScrollView: React.ForwardRefExoticComponent< offset = 0, extraContentPadding = ZERO_CONTENT_PADDING, blankSpace = ZERO_BLANK_SPACE, @@ -581,16 +594,16 @@ index 554b38c..725d546 100644 onEndVisible, ...rest }, -@@ -68,6 +73,8 @@ const KeyboardChatScrollView: React.ForwardRefExoticComponent< +@@ -64,6 +71,8 @@ const KeyboardChatScrollView: React.ForwardRefExoticComponent< offset, blankSpace, extraContentPadding, + adjustedInsetCompensation, + adjustedStartInsetCompensation, + initialContentOffsetY: rest.contentOffset?.y, }); - useExtraContentPadding({ -@@ -75,6 +82,8 @@ const KeyboardChatScrollView: React.ForwardRefExoticComponent< +@@ -72,6 +81,8 @@ const KeyboardChatScrollView: React.ForwardRefExoticComponent< extraContentPadding, keyboardPadding: padding, blankSpace, @@ -599,7 +612,7 @@ index 554b38c..725d546 100644 scroll, layout, size, -@@ -112,13 +121,40 @@ const KeyboardChatScrollView: React.ForwardRefExoticComponent< +@@ -109,13 +120,40 @@ const KeyboardChatScrollView: React.ForwardRefExoticComponent< ), ); @@ -697,7 +710,7 @@ index 37eacd1..3d77ab2 100644 + ); }; diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts -index 129a74b..168ce1a 100644 +index 1ae9b81..208505b 100644 --- a/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts +++ b/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts @@ -43,6 +43,8 @@ function useChatKeyboard( @@ -827,10 +840,10 @@ index 129a74b..168ce1a 100644 return { diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts -index 02abf5c..4ee0ddd 100644 +index f89b7c8..02e0e19 100644 --- a/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts +++ b/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts -@@ -11,6 +11,10 @@ type UseChatKeyboardOptions = { +@@ -13,6 +13,10 @@ type UseChatKeyboardOptions = { blankSpace: SharedValue; /** Extra content padding shared value — needed on iOS to correctly clamp contentOffset. */ extraContentPadding: SharedValue; @@ -842,10 +855,10 @@ index 02abf5c..4ee0ddd 100644 type UseChatKeyboardReturn = { diff --git a/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts b/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts -index c9c93f3..02f95c0 100644 +index de7d6f7..631cdfb 100644 --- a/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts +++ b/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts -@@ -16,6 +16,10 @@ type UseExtraContentPaddingOptions = { +@@ -17,6 +17,10 @@ type UseExtraContentPaddingOptions = { keyboardPadding: SharedValue; /** Minimum inset floor — used to absorb keyboard and extraContentPadding changes. */ blankSpace: SharedValue; @@ -856,7 +869,7 @@ index c9c93f3..02f95c0 100644 /** Current vertical scroll offset. */ scroll: SharedValue; /** Visible viewport dimensions. */ -@@ -51,6 +55,8 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { +@@ -52,6 +56,8 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { extraContentPadding, keyboardPadding, blankSpace, @@ -865,7 +878,7 @@ index c9c93f3..02f95c0 100644 scroll, layout, size, -@@ -72,8 +78,13 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { +@@ -73,8 +79,13 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { // otherwise the native ScrollView clamps to the old range. requestAnimationFrame(() => { // check that view is still mounted and ref is actual @@ -881,7 +894,7 @@ index c9c93f3..02f95c0 100644 return; } scrollTo(scrollViewRef, 0, target, false); -@@ -99,14 +110,12 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { +@@ -100,14 +111,12 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { } // Compute effective delta considering blankSpace floor @@ -902,7 +915,7 @@ index c9c93f3..02f95c0 100644 const effectiveDelta = currentTotal - previousTotal; if (effectiveDelta === 0) { -@@ -139,16 +148,25 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { +@@ -140,22 +149,28 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { scrollToTarget(target); } else { @@ -911,12 +924,18 @@ index c9c93f3..02f95c0 100644 size.value.height - layout.value.height + currentTotal, - 0, + minScroll, -+ ); -+ const target = Math.max( + ); +- // Clamp at 0 as well: when the content is shorter than the viewport a +- // shrinking padding makes `effectiveDelta` negative while `scroll.value` +- // is already 0, so the target would go below the top of the content. ++ // Clamp at the top as well: when the content is shorter than the ++ // viewport a shrinking padding makes `effectiveDelta` negative while ++ // `scroll.value` is already at the top. + const target = Math.max( + minScroll, -+ Math.min(scroll.value + effectiveDelta, maxScroll), + Math.min(scroll.value + effectiveDelta, maxScroll), +- 0, ); -- const target = Math.min(scroll.value + effectiveDelta, maxScroll); scrollToTarget(target); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b81b9a7fc528..b19007c778a4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -108,10 +108,10 @@ patchedDependencies: expo-blur@58.0.3: d0311b0c0b05bd94393bd9a84653cb42503d5f3c8a19eb2627c34ace08bedf18 expo-glass-effect@58.0.3: f750041c45ab838a3530451dc7b50d6640a79141037a1b0499c5e3412a860c57 expo-sharing@58.0.13: fe40d857765bcd6a77aac1fed275e5b8f9415c29b972decdce05fb96de5665e6 - expo-widgets@58.0.11: 94de7964c5a67ab40e6b79d9bdf4b7bfc50ae97b70722ee8e5a35105ca83386b + expo-widgets@58.0.11: d00838b5b215a4c45f89b8b7d34878d158528756fbcf281bae1cbd6f0752deed node-pty@1.2.0-beta.15: f2fe901c61cde17986240002d05c172d5d0272d83ffaab8d0ebeb922763be414 react-native-gesture-handler@3.2.1: d518a937e3cb2df5e7d67f1ab6b265c74a8639e4572d1b032b7b747e9b1c01a9 - react-native-keyboard-controller@1.22.4: c631278492f0113f42fa8bed43c7d8ed5779424dd12af0cefc55132c2ee1d84c + react-native-keyboard-controller@1.22.6: 6e448411781347cd0b87868ee279117514a399801c18f233ddca4fa3935be75d react-native-nitro-markdown@0.5.8: 642b47830730acff3761fe29cf54271d18b493c0070c750097850b7ea032e00c react-native-nitro-modules@0.35.9: daf4a639ce3f951af630a3711e4082d58e3a11e5a79f45efb0b59925327ebde2 react-native-screens@4.28.0: cc1301436ee62c39860acc9ee1a1e60b7d47e6abd211c42effa62c056d165483 @@ -431,7 +431,7 @@ importers: version: 58.0.4(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@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)) expo-widgets: specifier: 58.0.11 - version: 58.0.11(patch_hash=94de7964c5a67ab40e6b79d9bdf4b7bfc50ae97b70722ee8e5a35105ca83386b)(@babel/core@7.29.7)(expo@58.0.2)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@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-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@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: 58.0.11(patch_hash=d00838b5b215a4c45f89b8b7d34878d158528756fbcf281bae1cbd6f0752deed)(@babel/core@7.29.7)(expo@58.0.2)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@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-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@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: specifier: 19.3.0 version: 19.3.0 @@ -448,8 +448,8 @@ importers: specifier: ^0.2.2 version: 0.2.2(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@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.4 - version: 1.22.4(patch_hash=c631278492f0113f42fa8bed43c7d8ed5779424dd12af0cefc55132c2ee1d84c)(react-native-reanimated@4.7.0(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@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-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@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-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@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) + specifier: 1.22.6 + version: 1.22.6(patch_hash=6e448411781347cd0b87868ee279117514a399801c18f233ddca4fa3935be75d)(react-native-reanimated@4.7.0(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@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-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@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-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@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-nitro-markdown: specifier: ^0.5.0 version: 0.5.8(patch_hash=642b47830730acff3761fe29cf54271d18b493c0070c750097850b7ea032e00c)(react-native-nitro-modules@0.35.9(patch_hash=daf4a639ce3f951af630a3711e4082d58e3a11e5a79f45efb0b59925327ebde2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@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-svg@15.15.5(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@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-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@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) @@ -640,7 +640,7 @@ importers: version: 0.9.0 '@legendapp/list': specifier: 'catalog:' - version: 3.3.5(patch_hash=50016a0e88f22b96cd8a947ac875700163b4aa05595981075f6049ed573bc6f8)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 3.3.5(patch_hash=50016a0e88f22b96cd8a947ac875700163b4aa05595981075f6049ed573bc6f8)(react-dom@19.2.6(react@19.2.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@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.2.6)(utf-8-validate@6.0.6))(react@19.2.6) '@noble/hashes': specifier: 'catalog:' version: 1.8.0 @@ -692,6 +692,9 @@ importers: culori: specifier: ^4.0.2 version: 4.0.2 + dompurify: + specifier: ^3.4.16 + version: 3.4.16 effect: specifier: 4.0.0-rc.115 version: 4.0.0-rc.115(patch_hash=0dfc4bb8ebd80fb3e06b91ef61346f5259517ab0f2437644fe95ae531084b1f5) @@ -713,9 +716,18 @@ importers: jszip: specifier: 3.10.1 version: 3.10.1 + lucide: + specifier: ^0.564.0 + version: 0.564.0 lucide-react: specifier: ^0.564.0 version: 0.564.0(react@19.2.6) + mermaid: + specifier: ^11.17.2 + version: 11.17.2 + morphicons: + specifier: ^1.7.1 + version: 1.7.1(react-native-svg@15.15.5(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@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.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@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.2.6)(utf-8-validate@6.0.6))(react@19.2.6) react: specifier: 19.2.6 version: 19.2.6 @@ -1169,6 +1181,9 @@ packages: react-devtools-core: optional: true + '@antfu/install-pkg@2.1.0': + resolution: {integrity: sha512-sdg9NxU3zR4Mnawfbc/x6GB5Wf17WYud5qOuEuxXjaKpYpMkISSJEjItGebXJ2bQ4DIcly4NYH23mtkGJjvKUw==} + '@anthropic-ai/claude-agent-sdk@0.3.276': resolution: {integrity: sha512-Dic43v4uuGLhibPAArWy3RSfq3zHdn+4Oh2AI2/18a5tlbNKkvChEpazyp8c+grUJvzxkzt0jETCa1wrADbkyw==} engines: {node: '>=18.0.0'} @@ -1816,6 +1831,9 @@ packages: '@blazediff/core@1.10.0': resolution: {integrity: sha512-AOQff0zgR7cGsZL+4E7hVkmujoPUpm0J9xzWGWZj5wCjd3gmxESXAPfKyuzs93VdpQNFhHlBhfOjrcZ+XTERtQ==} + '@braintree/sanitize-url@7.1.2': + resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} + '@bramus/specificity@2.4.2': resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} hasBin: true @@ -1876,6 +1894,9 @@ packages: resolution: {integrity: sha512-CuNiSqg7+e1cO/GjffyMOm5Tt2jUF9CWHHnvQ/UkqvtkGfHdgwEC0wpmq7fkN3gxwpRnrAN0WzO3vREKmNolMQ==} engines: {node: '>=18'} + '@chevrotain/types@11.1.2': + resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} + '@clack/core@1.5.1': resolution: {integrity: sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ==} engines: {node: '>= 20.12.0'} @@ -3084,6 +3105,12 @@ packages: '@iarna/toml@2.2.5': resolution: {integrity: sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==} + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@iconify/utils@3.1.7': + resolution: {integrity: sha512-JZHlwdID+dy+lTgbYC8NEC4zeugqeYsc6jewvzb4c58kHauJn+X7rNwQjxz5p2qSjqaEeQoLkCIQ9v/H4PK0/w==} + '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} @@ -3403,6 +3430,9 @@ packages: '@material/material-color-utilities@0.3.0': resolution: {integrity: sha512-ztmtTd6xwnuh2/xu+Vb01btgV8SQWYCaK56CkRK8gEkWe5TuDyBcYJ0wgkMRn+2VcE9KUmhvkz+N9GHrqw/C0g==} + '@mermaid-js/parser@1.2.1': + resolution: {integrity: sha512-n12NohV3mrUyUL2o93IgG/ifeW9FTyeJn3zDxkhwa8MJ9Fxg3HQMlA3RiGmD/3UnJvheztkjjQAjA2T4LmUcpw==} + '@modelcontextprotocol/sdk@1.29.0': resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} engines: {node: '>=18'} @@ -5341,6 +5371,99 @@ packages: '@types/culori@4.0.1': resolution: {integrity: sha512-43M51r/22CjhbOXyGT361GZ9vncSVQ39u62x5eJdBQFviI8zWp2X5jzqg7k4M6PVgDQAClpy2bUe2dtwEgEDVQ==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-axis@3.0.6': + resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==} + + '@types/d3-brush@3.0.6': + resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==} + + '@types/d3-chord@3.0.6': + resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-contour@3.0.6': + resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==} + + '@types/d3-delaunay@6.0.4': + resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==} + + '@types/d3-dispatch@3.0.7': + resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-dsv@3.0.7': + resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-fetch@3.0.7': + resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==} + + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + + '@types/d3-format@3.0.4': + resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} + + '@types/d3-geo@3.1.1': + resolution: {integrity: sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==} + + '@types/d3-hierarchy@3.1.7': + resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-polygon@3.0.2': + resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==} + + '@types/d3-quadtree@3.0.6': + resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==} + + '@types/d3-random@3.0.4': + resolution: {integrity: sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==} + + '@types/d3-scale-chromatic@3.1.0': + resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-selection@3.0.12': + resolution: {integrity: sha512-Qe/KWYhEiIIxGs7HrAAjMfShxKldx19SJtr5zu53f3afPsdZNz7HHtdTLXo/kqeiWNXVycI24kSnfzBYkTzpgw==} + + '@types/d3-shape@3.2.0': + resolution: {integrity: sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==} + + '@types/d3-time-format@4.0.3': + resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.9': + resolution: {integrity: sha512-0sE1406XBYJGiqD3AusTl9ZqC//2mIXix51tbom25gDCA8ri4xnSZg28CaSE8Srl6FClqABUKfsn0qghgdepMA==} + + '@types/d3@7.4.3': + resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} @@ -5365,6 +5488,9 @@ packages: '@types/fs-extra@9.0.13': resolution: {integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==} + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} @@ -5448,6 +5574,9 @@ packages: '@types/three@0.180.0': resolution: {integrity: sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -5601,6 +5730,9 @@ packages: '@ungap/structured-clone@1.3.1': resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==} + '@upsetjs/venn.js@2.0.0': + resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} + '@vercel/config@0.3.0': resolution: {integrity: sha512-Tf5k5y2F478oTiQcU5R8Ntix1UejE6NdduZnI7aa1XXxjCtifX1XdRS/D2uTjiQAwIL3pLa1LSAN80ABbba+TQ==} hasBin: true @@ -6657,6 +6789,10 @@ packages: resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} engines: {node: '>= 10'} + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + commander@9.5.0: resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} engines: {node: ^12.20.0 || >=14} @@ -6738,6 +6874,12 @@ packages: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} + cose-base@1.0.3: + resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} + + cose-base@2.2.0: + resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} + cross-dirname@0.1.0: resolution: {integrity: sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==} @@ -6794,6 +6936,162 @@ packages: resolution: {integrity: sha512-1+BhOB8ahCn4O0cep0Sh2l9KCOfOdY+BXJnKMHFFzDEouSr/el18QwXEMRlOj9UY5nCeA8UN3a/82rUWRBeyBw==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + cytoscape-cose-bilkent@4.1.0: + resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape-fcose@2.2.0: + resolution: {integrity: sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape@3.34.3: + resolution: {integrity: sha512-yfYGhRcGAntq6YBD583j4n0Eg3jIxvWmZtz/5uz9UYkeIStSlMxuUja+ec5j3iBD8nv1rwaOAYMW09tBdkSeaQ==} + engines: {node: '>=0.10'} + + d3-array@2.12.1: + resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-axis@3.0.0: + resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==} + engines: {node: '>=12'} + + d3-brush@3.0.0: + resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==} + engines: {node: '>=12'} + + d3-chord@3.0.1: + resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-contour@4.0.2: + resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==} + engines: {node: '>=12'} + + d3-delaunay@6.0.4: + resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-dsv@3.0.1: + resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} + engines: {node: '>=12'} + hasBin: true + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-fetch@3.0.1: + resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==} + engines: {node: '>=12'} + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-geo@3.1.1: + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} + engines: {node: '>=12'} + + d3-hierarchy@3.1.2: + resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@1.0.9: + resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-polygon@3.0.1: + resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-random@3.0.1: + resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} + engines: {node: '>=12'} + + d3-sankey@0.12.3: + resolution: {integrity: sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==} + + d3-scale-chromatic@3.1.0: + resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-shape@1.3.7: + resolution: {integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + d3@7.9.0: + resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} + engines: {node: '>=12'} + + dagre-d3-es@7.0.14: + resolution: {integrity: sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==} + data-urls@7.0.0: resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -6801,6 +7099,9 @@ packages: date-fns@4.4.0: resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} + dayjs@1.11.23: + resolution: {integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==} + dbus-next@0.10.2: resolution: {integrity: sha512-kLNQoadPstLgKKGIXKrnRsMgtAK/o+ix3ZmcfTfvBHzghiO9yHXpoKImGnB50EXwnfSFaSAullW/7UrSkAISSQ==} @@ -6873,6 +7174,9 @@ packages: defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + delaunator@5.1.0: + resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -6943,6 +7247,9 @@ packages: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} + dompurify@3.4.16: + resolution: {integrity: sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==} + domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} @@ -7237,6 +7544,9 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} + es-toolkit@1.52.0: + resolution: {integrity: sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==} + es6-error@4.1.1: resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} @@ -7682,6 +7992,9 @@ packages: resolution: {integrity: sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==} hasBin: true + fastdom@1.0.12: + resolution: {integrity: sha512-LB+xjSTEbjHE1cWsxu+tN2Xqr1kpi+V9aADI7sVM5ZMaXyYGPHULQMzpJMYqOTULK/73pUkWVzzObFRBkPr+hg==} + fastest-levenshtein@1.0.16: resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} engines: {node: '>= 4.9.1'} @@ -7913,6 +8226,9 @@ packages: h3@1.15.11: resolution: {integrity: sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==} + hachure-fill@0.5.2: + resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} + has-flag@3.0.0: resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} engines: {node: '>=4'} @@ -8051,6 +8367,9 @@ packages: immediate@3.0.6: resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. @@ -8061,6 +8380,13 @@ packages: inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + internmap@1.0.1: + resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} @@ -8292,9 +8618,16 @@ packages: jszip@3.10.1: resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + katex@0.16.47: + resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==} + hasBin: true + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + khroma@2.1.0: + resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} + kleur@3.0.3: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} @@ -8312,6 +8645,12 @@ packages: resolution: {integrity: sha512-ONPnazC96VKDntab9j9JKwIWhZ4ZUceB4A9Epu4Ssg0hYFmtHZSeQ+n15nIwTFmcBUKtExOer8WTJ4GF9MO64A==} hasBin: true + layout-base@1.0.2: + resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==} + + layout-base@2.0.1: + resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==} + lazy-val@1.0.5: resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==} @@ -8414,6 +8753,9 @@ packages: resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==} engines: {node: '>=6'} + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} @@ -8478,6 +8820,9 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lucide@0.564.0: + resolution: {integrity: sha512-FasyXKHWon773WIl3HeCQpd5xS6E0aLjqxiQStlHNKktni+HDncc1sqY+6vRUbCfmDsIaKQz43EEQLAUDLZO0g==} + lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true @@ -8500,6 +8845,11 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + marked@16.4.2: + resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} + engines: {node: '>= 20'} + hasBin: true + matcher@3.0.0: resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==} engines: {node: '>=10'} @@ -8585,6 +8935,9 @@ packages: merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + mermaid@11.17.2: + resolution: {integrity: sha512-V6K3C8EBdEsPFZXSKMJe6ppQOENxuHARr9GvHX4hh47lAbhMRD9qf4oEK7LoaRQxULMa80/qt5gHO73aCleBBg==} + meshoptimizer@0.22.0: resolution: {integrity: sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==} @@ -8856,6 +9209,26 @@ packages: socks: optional: true + morphicons@1.7.1: + resolution: {integrity: sha512-q5ylxy5/d7vBg0OAzanlooXf05PekovMYDuuQVpr6vAQZxl99lrJbaIi+jJ32PXQf9WEEaDO2pbNBsx1ZhEnFQ==} + peerDependencies: + react: '>=18' + react-native: '>=0.71' + react-native-svg: '>=14' + svelte: '>=5' + vue: '>=3.3' + peerDependenciesMeta: + react: + optional: true + react-native: + optional: true + react-native-svg: + optional: true + svelte: + optional: true + vue: + optional: true + mrmime@2.0.1: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} @@ -9187,6 +9560,9 @@ packages: path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + path-data-parser@0.1.0: + resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} + path-exists@3.0.0: resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} engines: {node: '>=4'} @@ -9317,6 +9693,12 @@ packages: resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} engines: {node: '>=14.19.0'} + points-on-curve@0.2.0: + resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} + + points-on-path@0.2.1: + resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} + postcss@8.5.28: resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} engines: {node: ^10 || ^12 || >=14} @@ -9561,8 +9943,8 @@ packages: react: '*' react-native: '*' - react-native-keyboard-controller@1.22.4: - resolution: {integrity: sha512-2by3oJBQqKH9UAydlBeiEpivxIyTbt345bmverLym7vbEp4NXckWQLF0STnVuXYccRak5/sd3wMYDJPhv/9+3g==} + react-native-keyboard-controller@1.22.6: + resolution: {integrity: sha512-gwJAI3Bhbqs/Ja3uaeBz7EFQSFcem8q4wsXkrC9sy1CL14gjWhBICLu2Ecn7HmezpIu33uhLbirLGyqSawxRtg==} peerDependencies: react: '*' react-native: '*' @@ -9816,6 +10198,9 @@ packages: resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} engines: {node: '>=8.0'} + robust-predicates@3.0.3: + resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} + rolldown@1.0.0-rc.17: resolution: {integrity: sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -9829,10 +10214,16 @@ packages: rope-sequence@1.3.4: resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==} + roughjs@4.6.6: + resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} + router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} + rw@1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + safe-buffer@5.1.2: resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} @@ -10132,6 +10523,9 @@ packages: resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==} engines: {node: '>=4'} + strictdom@1.0.1: + resolution: {integrity: sha512-cEmp9QeXXRmjj/rVp9oyiqcvyocWab/HaoN4+bwFeZ7QzykJD6L3yD4v12K1x0tHpqRqVpJevN3gW7kyM39Bqg==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -10178,6 +10572,9 @@ packages: style-to-object@1.0.14: resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + stylis@4.4.0: + resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} + sumchecker@3.0.1: resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} engines: {node: '>= 8.0'} @@ -10358,6 +10755,10 @@ packages: ts-algebra@2.0.0: resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + ts-dedent@2.3.0: + resolution: {integrity: sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==} + engines: {node: '>=6.10'} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -10624,6 +11025,10 @@ packages: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} + uuid@14.0.2: + resolution: {integrity: sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==} + hasBin: true + uuid@7.0.3: resolution: {integrity: sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==} deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). @@ -11145,6 +11550,11 @@ snapshots: '@types/react': 19.3.0 react-devtools-core: 6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6) + '@antfu/install-pkg@2.1.0': + dependencies: + package-manager-detector: 1.8.0 + tinyexec: 1.3.1 + '@anthropic-ai/claude-agent-sdk@0.3.276(@anthropic-ai/sdk@0.93.0(zod@4.6.5))(@modelcontextprotocol/sdk@1.29.0(zod@4.6.5))(zod@4.6.5)': dependencies: '@anthropic-ai/sdk': 0.93.0(zod@4.6.5) @@ -12029,6 +12439,8 @@ snapshots: '@blazediff/core@1.10.0': {} + '@braintree/sanitize-url@7.1.2': {} + '@bramus/specificity@2.4.2': dependencies: css-tree: 3.2.1 @@ -12070,6 +12482,8 @@ snapshots: dependencies: fontkitten: 1.0.3 + '@chevrotain/types@11.1.2': {} + '@clack/core@1.5.1': dependencies: fast-wrap-ansi: 0.2.2 @@ -13406,6 +13820,14 @@ snapshots: '@iarna/toml@2.2.5': {} + '@iconify/types@2.0.0': {} + + '@iconify/utils@3.1.7': + dependencies: + '@antfu/install-pkg': 2.1.0 + '@iconify/types': 2.0.0 + import-meta-resolve: 4.2.0 + '@img/colour@1.1.0': {} '@img/sharp-darwin-arm64@0.35.4': @@ -13595,12 +14017,13 @@ snapshots: dependencies: jsbi: 4.3.2 - '@legendapp/list@3.3.5(patch_hash=50016a0e88f22b96cd8a947ac875700163b4aa05595981075f6049ed573bc6f8)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@legendapp/list@3.3.5(patch_hash=50016a0e88f22b96cd8a947ac875700163b4aa05595981075f6049ed573bc6f8)(react-dom@19.2.6(react@19.2.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@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.2.6)(utf-8-validate@6.0.6))(react@19.2.6)': dependencies: react: 19.2.6 use-sync-external-store: 1.6.0(react@19.2.6) optionalDependencies: react-dom: 19.2.6(react@19.2.6) + react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@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.2.6)(utf-8-validate@6.0.6) '@legendapp/list@3.3.5(patch_hash=50016a0e88f22b96cd8a947ac875700163b4aa05595981075f6049ed573bc6f8)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@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: @@ -13683,6 +14106,10 @@ snapshots: '@material/material-color-utilities@0.3.0': {} + '@mermaid-js/parser@1.2.1': + dependencies: + '@chevrotain/types': 11.1.2 + '@modelcontextprotocol/sdk@1.29.0(zod@4.6.5)': dependencies: '@hono/node-server': 1.19.17(hono@4.13.7) @@ -14524,6 +14951,16 @@ snapshots: '@react-native/normalize-colors@0.88.0-rc.3': {} + '@react-native/virtualized-lists@0.88.0-rc.3(@types/react@19.3.0)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@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.2.6)(utf-8-validate@6.0.6))(react@19.2.6)': + dependencies: + invariant: 2.2.4 + nullthrows: 1.1.1 + react: 19.2.6 + react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@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.2.6)(utf-8-validate@6.0.6) + optionalDependencies: + '@types/react': 19.3.0 + optional: true + '@react-native/virtualized-lists@0.88.0-rc.3(@types/react@19.3.0)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@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: invariant: 2.2.4 @@ -14773,7 +15210,7 @@ snapshots: '@shikijs/types@4.3.0': dependencies: '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@shikijs/vscode-textmate@10.0.2': {} @@ -15314,6 +15751,123 @@ snapshots: '@types/culori@4.0.1': {} + '@types/d3-array@3.2.2': {} + + '@types/d3-axis@3.0.6': + dependencies: + '@types/d3-selection': 3.0.12 + + '@types/d3-brush@3.0.6': + dependencies: + '@types/d3-selection': 3.0.12 + + '@types/d3-chord@3.0.6': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-contour@3.0.6': + dependencies: + '@types/d3-array': 3.2.2 + '@types/geojson': 7946.0.16 + + '@types/d3-delaunay@6.0.4': {} + + '@types/d3-dispatch@3.0.7': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.12 + + '@types/d3-dsv@3.0.7': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-fetch@3.0.7': + dependencies: + '@types/d3-dsv': 3.0.7 + + '@types/d3-force@3.0.10': {} + + '@types/d3-format@3.0.4': {} + + '@types/d3-geo@3.1.1': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/d3-hierarchy@3.1.7': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-polygon@3.0.2': {} + + '@types/d3-quadtree@3.0.6': {} + + '@types/d3-random@3.0.4': {} + + '@types/d3-scale-chromatic@3.1.0': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-selection@3.0.12': {} + + '@types/d3-shape@3.2.0': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time-format@4.0.3': {} + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.12 + + '@types/d3-zoom@3.0.9': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.12 + + '@types/d3@7.4.3': + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-axis': 3.0.6 + '@types/d3-brush': 3.0.6 + '@types/d3-chord': 3.0.6 + '@types/d3-color': 3.1.3 + '@types/d3-contour': 3.0.6 + '@types/d3-delaunay': 6.0.4 + '@types/d3-dispatch': 3.0.7 + '@types/d3-drag': 3.0.7 + '@types/d3-dsv': 3.0.7 + '@types/d3-ease': 3.0.2 + '@types/d3-fetch': 3.0.7 + '@types/d3-force': 3.0.10 + '@types/d3-format': 3.0.4 + '@types/d3-geo': 3.1.1 + '@types/d3-hierarchy': 3.1.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-path': 3.1.1 + '@types/d3-polygon': 3.0.2 + '@types/d3-quadtree': 3.0.6 + '@types/d3-random': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-scale-chromatic': 3.1.0 + '@types/d3-selection': 3.0.12 + '@types/d3-shape': 3.2.0 + '@types/d3-time': 3.0.4 + '@types/d3-time-format': 4.0.3 + '@types/d3-timer': 3.0.2 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.9 + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 @@ -15345,6 +15899,8 @@ snapshots: dependencies: '@types/node': 24.12.4 + '@types/geojson@7946.0.16': {} + '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 @@ -15441,6 +15997,9 @@ snapshots: fflate: 0.8.3 meshoptimizer: 0.22.0 + '@types/trusted-types@2.0.7': + optional: true + '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} @@ -15533,6 +16092,11 @@ snapshots: '@ungap/structured-clone@1.3.1': {} + '@upsetjs/venn.js@2.0.0': + optionalDependencies: + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + '@vercel/config@0.3.0': dependencies: '@vercel/routing-utils': 6.2.0 @@ -16305,7 +16869,7 @@ snapshots: optionalDependencies: '@babel/runtime': 7.29.7 expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) - expo-widgets: 58.0.11(patch_hash=94de7964c5a67ab40e6b79d9bdf4b7bfc50ae97b70722ee8e5a35105ca83386b)(@babel/core@7.29.7)(expo@58.0.2)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@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-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@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) + expo-widgets: 58.0.11(patch_hash=d00838b5b215a4c45f89b8b7d34878d158528756fbcf281bae1cbd6f0752deed)(@babel/core@7.29.7)(expo@58.0.2)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@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-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@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) transitivePeerDependencies: - '@babel/core' - supports-color @@ -16618,6 +17182,8 @@ snapshots: commander@7.2.0: {} + commander@8.3.0: {} + commander@9.5.0: optional: true @@ -16699,6 +17265,14 @@ snapshots: object-assign: 4.1.1 vary: 1.1.2 + cose-base@1.0.3: + dependencies: + layout-base: 1.0.2 + + cose-base@2.2.0: + dependencies: + layout-base: 2.0.1 + cross-dirname@0.1.0: optional: true @@ -16762,6 +17336,190 @@ snapshots: culori@4.0.2: {} + cytoscape-cose-bilkent@4.1.0(cytoscape@3.34.3): + dependencies: + cose-base: 1.0.3 + cytoscape: 3.34.3 + + cytoscape-fcose@2.2.0(cytoscape@3.34.3): + dependencies: + cose-base: 2.2.0 + cytoscape: 3.34.3 + + cytoscape@3.34.3: {} + + d3-array@2.12.1: + dependencies: + internmap: 1.0.1 + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-axis@3.0.0: {} + + d3-brush@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3-chord@3.0.1: + dependencies: + d3-path: 3.1.0 + + d3-color@3.1.0: {} + + d3-contour@4.0.2: + dependencies: + d3-array: 3.2.4 + + d3-delaunay@6.0.4: + dependencies: + delaunator: 5.1.0 + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-dsv@3.0.1: + dependencies: + commander: 7.2.0 + iconv-lite: 0.6.3 + rw: 1.3.3 + + d3-ease@3.0.1: {} + + d3-fetch@3.0.1: + dependencies: + d3-dsv: 3.0.1 + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-format@3.1.2: {} + + d3-geo@3.1.1: + dependencies: + d3-array: 3.2.4 + + d3-hierarchy@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@1.0.9: {} + + d3-path@3.1.0: {} + + d3-polygon@3.0.1: {} + + d3-quadtree@3.0.1: {} + + d3-random@3.0.1: {} + + d3-sankey@0.12.3: + dependencies: + d3-array: 2.12.1 + d3-shape: 1.3.7 + + d3-scale-chromatic@3.1.0: + dependencies: + d3-color: 3.1.0 + d3-interpolate: 3.0.1 + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-selection@3.0.0: {} + + d3-shape@1.3.7: + dependencies: + d3-path: 1.0.9 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3@7.9.0: + dependencies: + d3-array: 3.2.4 + d3-axis: 3.0.0 + d3-brush: 3.0.0 + d3-chord: 3.0.1 + d3-color: 3.1.0 + d3-contour: 4.0.2 + d3-delaunay: 6.0.4 + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-dsv: 3.0.1 + d3-ease: 3.0.1 + d3-fetch: 3.0.1 + d3-force: 3.0.0 + d3-format: 3.1.2 + d3-geo: 3.1.1 + d3-hierarchy: 3.1.2 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-polygon: 3.0.1 + d3-quadtree: 3.0.1 + d3-random: 3.0.1 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.1.0 + d3-selection: 3.0.0 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + d3-timer: 3.0.1 + d3-transition: 3.0.1(d3-selection@3.0.0) + d3-zoom: 3.0.0 + + dagre-d3-es@7.0.14: + dependencies: + d3: 7.9.0 + lodash-es: 4.18.1 + data-urls@7.0.0(@noble/hashes@1.8.0): dependencies: whatwg-mimetype: 5.0.0 @@ -16771,6 +17529,8 @@ snapshots: date-fns@4.4.0: {} + dayjs@1.11.23: {} + dbus-next@0.10.2(patch_hash=cfff57561b0ee59b5addb3b2e6c6f20906e967507a530ab67e8db8108e520ba4): dependencies: '@nornagon/put': 0.0.8 @@ -16835,6 +17595,10 @@ snapshots: defu@6.1.7: {} + delaunator@5.1.0: + dependencies: + robust-predicates: 3.0.3 + delayed-stream@1.0.0: {} denque@2.1.0: @@ -16896,6 +17660,10 @@ snapshots: dependencies: domelementtype: 2.3.0 + dompurify@3.4.16: + optionalDependencies: + '@types/trusted-types': 2.0.7 + domutils@3.2.2: dependencies: dom-serializer: 2.0.0 @@ -17097,6 +17865,8 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.4 + es-toolkit@1.52.0: {} + es6-error@4.1.1: optional: true @@ -17535,7 +18305,7 @@ snapshots: expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@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) - expo-widgets@58.0.11(patch_hash=94de7964c5a67ab40e6b79d9bdf4b7bfc50ae97b70722ee8e5a35105ca83386b)(@babel/core@7.29.7)(expo@58.0.2)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@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-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@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): + expo-widgets@58.0.11(patch_hash=d00838b5b215a4c45f89b8b7d34878d158528756fbcf281bae1cbd6f0752deed)(@babel/core@7.29.7)(expo@58.0.2)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@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-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@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: '@expo/plist': 0.10.1 '@expo/ui': 58.0.11(@babel/core@7.29.7)(expo@58.0.2)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@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-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@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) @@ -17676,6 +18446,10 @@ snapshots: path-expression-matcher: 1.6.2 strnum: 2.4.2 + fastdom@1.0.12: + dependencies: + strictdom: 1.0.1 + fastest-levenshtein@1.0.16: {} fb-dotslash@0.5.8: {} @@ -17952,6 +18726,8 @@ snapshots: ufo: 1.6.4 uncrypto: 0.1.3 + hachure-fill@0.5.2: {} + has-flag@3.0.0: {} has-flag@4.0.0: {} @@ -18135,7 +18911,6 @@ snapshots: iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 - optional: true iconv-lite@0.7.2: dependencies: @@ -18150,6 +18925,8 @@ snapshots: immediate@3.0.6: {} + import-meta-resolve@4.2.0: {} + inflight@1.0.6: dependencies: once: 1.4.0 @@ -18159,6 +18936,10 @@ snapshots: inline-style-parser@0.2.7: {} + internmap@1.0.1: {} + + internmap@2.0.3: {} + invariant@2.2.4: dependencies: loose-envify: 1.4.0 @@ -18372,10 +19153,16 @@ snapshots: readable-stream: 2.3.8 setimmediate: 1.0.5 + katex@0.16.47: + dependencies: + commander: 8.3.0 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 + khroma@2.1.0: {} + kleur@3.0.3: {} kleur@4.1.5: {} @@ -18398,6 +19185,10 @@ snapshots: lan-network@0.2.1: {} + layout-base@1.0.2: {} + + layout-base@2.0.1: {} + lazy-val@1.0.5: {} leven@3.1.0: {} @@ -18483,6 +19274,8 @@ snapshots: p-locate: 3.0.0 path-exists: 3.0.0 + lodash-es@4.18.1: {} + lodash.debounce@4.0.8: {} lodash.escaperegexp@4.1.2: {} @@ -18532,6 +19325,8 @@ snapshots: dependencies: react: 19.2.6 + lucide@0.564.0: {} + lz-string@1.5.0: {} magic-string@0.30.21: @@ -18556,6 +19351,8 @@ snapshots: markdown-table@3.0.4: {} + marked@16.4.2: {} + matcher@3.0.0: dependencies: escape-string-regexp: 4.0.0 @@ -18752,6 +19549,31 @@ snapshots: merge-stream@2.0.0: {} + mermaid@11.17.2: + dependencies: + '@braintree/sanitize-url': 7.1.2 + '@iconify/utils': 3.1.7 + '@mermaid-js/parser': 1.2.1 + '@types/d3': 7.4.3 + '@upsetjs/venn.js': 2.0.0 + cytoscape: 3.34.3 + cytoscape-cose-bilkent: 4.1.0(cytoscape@3.34.3) + cytoscape-fcose: 2.2.0(cytoscape@3.34.3) + d3: 7.9.0 + d3-sankey: 0.12.3 + dagre-d3-es: 7.0.14 + dayjs: 1.11.23 + dompurify: 3.4.16 + es-toolkit: 1.52.0 + fastdom: 1.0.12 + katex: 0.16.47 + khroma: 2.1.0 + marked: 16.4.2 + roughjs: 4.6.6 + stylis: 4.4.0 + ts-dedent: 2.3.0 + uuid: 14.0.2 + meshoptimizer@0.22.0: {} metro-babel-transformer@0.87.1: @@ -19206,6 +20028,12 @@ snapshots: socks: 2.8.9 optional: true + morphicons@1.7.1(react-native-svg@15.15.5(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@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.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@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.2.6)(utf-8-validate@6.0.6))(react@19.2.6): + optionalDependencies: + react: 19.2.6 + react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@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.2.6)(utf-8-validate@6.0.6) + react-native-svg: 15.15.5(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@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.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + mrmime@2.0.1: {} ms@2.0.0: {} @@ -19653,6 +20481,8 @@ snapshots: path-browserify@1.0.1: {} + path-data-parser@0.1.0: {} + path-exists@3.0.0: {} path-expression-matcher@1.6.2: {} @@ -19769,6 +20599,13 @@ snapshots: pngjs@7.0.0: {} + points-on-curve@0.2.0: {} + + points-on-path@0.2.1: + dependencies: + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + postcss@8.5.28: dependencies: nanoid: 3.3.19 @@ -20049,7 +20886,7 @@ snapshots: react: 19.3.0 react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@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-keyboard-controller@1.22.4(patch_hash=c631278492f0113f42fa8bed43c7d8ed5779424dd12af0cefc55132c2ee1d84c)(react-native-reanimated@4.7.0(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@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-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@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-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@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@1.22.6(patch_hash=6e448411781347cd0b87868ee279117514a399801c18f233ddca4fa3935be75d)(react-native-reanimated@4.7.0(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@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-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@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-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@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-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@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) @@ -20096,6 +20933,14 @@ snapshots: react: 19.3.0 react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@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-svg@15.15.5(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@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.2.6)(utf-8-validate@6.0.6))(react@19.2.6): + dependencies: + css-select: 5.2.2 + css-tree: 1.1.3 + react: 19.2.6 + react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@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.2.6)(utf-8-validate@6.0.6) + optional: true + react-native-svg@15.15.5(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@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: css-select: 5.2.2 @@ -20139,6 +20984,49 @@ snapshots: transitivePeerDependencies: - supports-color + react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@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.2.6)(utf-8-validate@6.0.6): + dependencies: + '@react-native/asset-utils': 0.88.0-rc.3 + '@react-native/codegen': 0.88.0-rc.3(@babel/core@7.29.7) + '@react-native/community-cli-plugin': 0.88.0-rc.3(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bufferutil@4.1.0)(utf-8-validate@6.0.6) + '@react-native/gradle-plugin': 0.88.0-rc.3 + '@react-native/normalize-colors': 0.88.0-rc.3 + '@react-native/virtualized-lists': 0.88.0-rc.3(@types/react@19.3.0)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@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.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + anser: 1.4.10 + ansi-regex: 5.0.1 + base64-js: 1.5.1 + flow-enums-runtime: 0.0.6 + flow-parser: 0.327.0 + hermes-compiler: 260318099.0.4 + invariant: 2.2.4 + memoize-one: 5.2.1 + metro-runtime: 0.87.1 + metro-source-map: 0.87.1 + nullthrows: 1.1.1 + pretty-format: 29.7.0 + promise: 8.3.0 + react: 19.2.6 + react-devtools-core: 6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6) + react-refresh: 0.14.2 + regenerator-runtime: 0.13.11 + scheduler: 0.28.0 + semver: 7.8.5 + stacktrace-parser: 0.1.11 + tinyglobby: 0.2.17 + whatwg-fetch: 3.6.20 + ws: 7.5.11(bufferutil@4.1.0)(utf-8-validate@6.0.6) + yargs: 17.7.2 + optionalDependencies: + '@types/react': 19.3.0 + transitivePeerDependencies: + - '@babel/core' + - '@react-native-community/cli' + - '@react-native/metro-config' + - bufferutil + - supports-color + - utf-8-validate + optional: true + react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@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): dependencies: '@react-native/asset-utils': 0.88.0-rc.3 @@ -20386,6 +21274,8 @@ snapshots: sprintf-js: 1.1.3 optional: true + robust-predicates@3.0.3: {} + rolldown@1.0.0-rc.17: dependencies: '@oxc-project/types': 0.127.0 @@ -20431,6 +21321,13 @@ snapshots: rope-sequence@1.3.4: {} + roughjs@4.6.6: + dependencies: + hachure-fill: 0.5.2 + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + points-on-path: 0.2.1 + router@2.2.0: dependencies: debug: 4.4.3 @@ -20441,6 +21338,8 @@ snapshots: transitivePeerDependencies: - supports-color + rw@1.3.3: {} + safe-buffer@5.1.2: {} safe-buffer@5.2.1: {} @@ -20791,6 +21690,8 @@ snapshots: strict-uri-encode@2.0.0: {} + strictdom@1.0.1: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -20845,6 +21746,8 @@ snapshots: dependencies: inline-style-parser: 0.2.7 + stylis@4.4.0: {} + sumchecker@3.0.1: dependencies: debug: 4.4.3 @@ -21018,6 +21921,8 @@ snapshots: ts-algebra@2.0.0: {} + ts-dedent@2.3.0: {} + tslib@2.8.1: {} type-fest@0.13.1: @@ -21240,6 +22145,8 @@ snapshots: utils-merge@1.0.1: {} + uuid@14.0.2: {} + uuid@7.0.3: {} valibot@1.2.0(typescript@7.0.2): diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3c0ef065701e..5bd65fcacbbb 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -143,7 +143,7 @@ minimumReleaseAgeExclude: - "react@19.3.0" - "react-dom@19.3.0" - "react-native@0.88.0-rc.3" - - "react-native-keyboard-controller@1.22.4" + - "react-native-keyboard-controller@1.22.6" - "react-native-worklets@0.13.0" - "react-native-reanimated@4.7.0" - "react-native-screens@4.28.0" @@ -260,7 +260,7 @@ patchedDependencies: # Link http(s) URLs with a port or a single-label host. md4c otherwise requires a dotted host and stops at ':'. react-native-nitro-markdown@0.5.8: patches/react-native-nitro-markdown@0.5.8.patch react-native-gesture-handler@3.2.1: patches/react-native-gesture-handler@3.2.1.patch - react-native-keyboard-controller@1.22.4: patches/react-native-keyboard-controller@1.22.4.patch + react-native-keyboard-controller@1.22.6: patches/react-native-keyboard-controller@1.22.6.patch react-native-nitro-modules@0.35.9: patches/react-native-nitro-modules@0.35.9.patch react-native-screens@4.28.0: patches/react-native-screens@4.28.0.patch uniwind@1.11.0: patches/uniwind@1.11.0.patch diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index e69017ccfbcd..38417a2e4f08 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -1269,8 +1269,9 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { ).pipe(Effect.provideService(HostProcessPlatform, "linux")), ); - for (const targetArch of ["x64", "arm64"] as const) { - it.effect(`accepts an embedded archive with the Linux ${targetArch} node-pty prebuild`, () => + it.effect.each(["x64", "arm64"] as const)( + "accepts an embedded archive with the Linux %s node-pty prebuild", + (targetArch) => Effect.scoped( Effect.gen(function* () { const fixture = yield* makeWindowsPayloadFixture({ @@ -1290,33 +1291,32 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { assert.equal(result.packagedAppDir, fixture.packagedAppDir); }), ).pipe(Effect.provideService(HostProcessPlatform, "linux")), - ); + ); - it.effect( - `rejects a node-pty prebuild for the wrong architecture in a Linux ${targetArch} archive`, - () => - Effect.scoped( - Effect.gen(function* () { - const fixture = yield* makeWindowsPayloadFixture({ - copyUnpackedNatives: true, - wslRuntime: "valid", - targetArch, - ptyPrebuildArch: targetArch === "x64" ? "arm64" : "x64", - }); - const error = yield* validateWindowsPackagedPayload({ - stageDistDir: fixture.stageDistDir, - appExecutableName: fixture.appExecutableName, - targetArch, - appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION, - expectWslRuntime: true, - }).pipe(Effect.flip); - - assert.instanceOf(error, WindowsPackagedPayloadValidationError); - assert.equal(error.reason, "wsl-runtime-invalid"); - }), - ), - ); - } + it.effect.each(["x64", "arm64"] as const)( + "rejects a node-pty prebuild for the wrong architecture in a Linux %s archive", + (targetArch) => + Effect.scoped( + Effect.gen(function* () { + const fixture = yield* makeWindowsPayloadFixture({ + copyUnpackedNatives: true, + wslRuntime: "valid", + targetArch, + ptyPrebuildArch: targetArch === "x64" ? "arm64" : "x64", + }); + const error = yield* validateWindowsPackagedPayload({ + stageDistDir: fixture.stageDistDir, + appExecutableName: fixture.appExecutableName, + targetArch, + appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION, + expectWslRuntime: true, + }).pipe(Effect.flip); + + assert.instanceOf(error, WindowsPackagedPayloadValidationError); + assert.equal(error.reason, "wsl-runtime-invalid"); + }), + ), + ); it.effect("rejects an embedded archive built for a different release version", () => Effect.scoped( diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index 5a270386a30d..be58bcecde08 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -414,8 +414,9 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { // Browser dev is single-origin: Vite proxies the backend, and the client // resolves it from window.location.origin. Baking a localhost URL here is // what breaks sharing a dev server to another device. - for (const mode of ["dev", "dev:web"] as const) { - it.effect(`leaves the client backend URLs unset in ${mode} mode`, () => + it.effect.each(["dev", "dev:web"] as const)( + "leaves the client backend URLs unset in %s mode", + (mode) => Effect.gen(function* () { const env = yield* createDevRunnerEnv({ mode, @@ -442,8 +443,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { // the intent has to be stated positively. assert.equal(env.T3CODE_SINGLE_ORIGIN_DEV, "1"); }), - ); - } + ); // Desktop pins the renderer at loopback deliberately; an ambient marker // must not make Vite discard those URLs. @@ -492,27 +492,25 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { // HOST is Vite's bind address and gates the HMR pin in vite.config.ts. An // inherited one would survive into browser dev and point HMR at the wrong // interface — invisible over a shared origin, since the page still loads. - for (const mode of ["dev", "dev:web"] as const) { - it.effect(`drops an inherited HOST in ${mode} mode`, () => - Effect.gen(function* () { - const env = yield* createDevRunnerEnv({ - mode, - baseEnv: { HOST: "0.0.0.0" }, - serverOffset: 0, - webOffset: 0, - t3Home: undefined, - browser: undefined, - autoBootstrapProjectFromCwd: undefined, - logWebSocketEvents: undefined, - host: undefined, - port: undefined, - devUrl: undefined, - }); + it.effect.each(["dev", "dev:web"] as const)("drops an inherited HOST in %s mode", (mode) => + Effect.gen(function* () { + const env = yield* createDevRunnerEnv({ + mode, + baseEnv: { HOST: "0.0.0.0" }, + serverOffset: 0, + webOffset: 0, + t3Home: undefined, + browser: undefined, + autoBootstrapProjectFromCwd: undefined, + logWebSocketEvents: undefined, + host: undefined, + port: undefined, + devUrl: undefined, + }); - assert.equal(env.HOST, undefined); - }), - ); - } + assert.equal(env.HOST, undefined); + }), + ); // --host configures the *backend* (T3CODE_HOST). It must not become Vite's // bind address by way of an inherited HOST that happens to agree with it. diff --git a/scripts/legend-list-initial-reveal.test.ts b/scripts/legend-list-initial-reveal.test.ts index 15ebc3f597e5..00e3e30594af 100644 --- a/scripts/legend-list-initial-reveal.test.ts +++ b/scripts/legend-list-initial-reveal.test.ts @@ -78,8 +78,9 @@ function createList(bundle: string) { }; } -for (const bundle of ["react-native.js", "react-native.mjs"]) { - describe(`initial inset end reveal (${bundle})`, () => { +describe.each(["react-native.js", "react-native.mjs"])( + "initial inset end reveal (%s)", + (bundle) => { it("waits for the native offset instead of the optimistic scroll target", () => { const list = createList(bundle); list.start(); @@ -210,5 +211,5 @@ for (const bundle of ["react-native.js", "react-native.mjs"]) { empty.complete(); expect(empty.ready()).toBe(true); }); - }); -} + }, +); diff --git a/scripts/mobile-showcase-environment.ts b/scripts/mobile-showcase-environment.ts index 9153819ecbdf..044c92029c51 100644 --- a/scripts/mobile-showcase-environment.ts +++ b/scripts/mobile-showcase-environment.ts @@ -427,7 +427,11 @@ function insertThread( .run(input.id, isWorking ? "running" : "ready", isWorking ? turnId : null, updatedAt); } -const SEEDED_PROJECTION_TABLES = [ +// V1 tables this seed owns. `projection_projects` is not listed: V2 still +// stores projects there, so the seed upserts its own rows instead. V2 clients +// do not read the V1 thread rows; moving the seed to V2 is tracked in +// https://github.com/pingdotgg/t3code/issues/15013. +const SEEDED_V1_TABLES = [ "projection_pending_approvals", "projection_thread_proposed_plans", "projection_thread_activities", @@ -435,10 +439,11 @@ const SEEDED_PROJECTION_TABLES = [ "projection_thread_sessions", "projection_turns", "projection_threads", - "projection_projects", "projection_state", ] as const; +const SEEDED_PROJECTION_TABLES = [...SEEDED_V1_TABLES, "projection_projects"] as const; + const SEEDED_THREAD_COLUMNS = ["snoozed_until", "snoozed_at"] as const; function hasSeedableSchema(dbPath: string): boolean { @@ -491,11 +496,11 @@ function seedDatabase( const database = new NodeSqlite.DatabaseSync(dbPath, { timeout: 30_000 }); try { database.exec("BEGIN IMMEDIATE"); - for (const table of SEEDED_PROJECTION_TABLES) { + for (const table of SEEDED_V1_TABLES) { database.exec(`DELETE FROM ${table}`); } const insertProject = database.prepare( - `INSERT INTO projection_projects ( + `INSERT OR REPLACE INTO projection_projects ( project_id, title, workspace_root, default_model_selection_json, scripts_json, created_at, updated_at, deleted_at ) VALUES (?, ?, ?, ?, ?, ?, ?, NULL)`, diff --git a/third-party-licenses.config.json b/third-party-licenses.config.json index 7e9e4f5db83c..3376a841e78d 100644 --- a/third-party-licenses.config.json +++ b/third-party-licenses.config.json @@ -599,6 +599,33 @@ "licenseId": "MIT", "copyrights": ["Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)"] } + }, + { + "license": "MIT", + "name": "fastdom", + "sourceUrl": "https://github.com/wilsonpage/fastdom", + "generatedNotice": { + "licenseId": "MIT", + "copyrights": ["Copyright (c) 2016 Wilson Page"] + } + }, + { + "license": "MIT", + "name": "strictdom", + "sourceUrl": "https://github.com/wilsonpage/strictdom", + "generatedNotice": { + "licenseId": "MIT", + "copyrights": ["Copyright (c) 2013 Wilson Page"] + } + }, + { + "license": "MIT", + "name": "khroma", + "sourceUrl": "https://github.com/fabiospampinato/khroma", + "generatedNotice": { + "licenseId": "MIT", + "copyrights": ["Copyright (c) 2019-present Fabio Spampinato, Andrew Maney"] + } } ] } diff --git a/vite.config.ts b/vite.config.ts index 6845af150941..0eee88e2ad08 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -167,6 +167,8 @@ export default defineConfig({ "t3code/no-inline-schema-compile": "warn", "t3code/no-manual-effect-runtime-in-tests": "error", "t3code/no-native-title-tooltip": "error", + "t3code/no-test-in-loop": "error", + "t3code/no-unscoped-has": "error", "t3code/namespace-node-imports": "error", }, overrides: [ @@ -332,27 +334,6 @@ export default defineConfig({ "t3code/no-mobile-uniwind-theme-escape-hatches": ["error", { allowUniwindTheme: true }], }, }, - // Legacy manual Effect runners tracked as debt: no net-new occurrences. - // Lower a ceiling when you migrate a file, and delete its entry at zero. - ...Object.entries({ - "apps/server/src/orchestration/Layers/CheckpointReactor.test.ts": 42, - "apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts": 5, - "apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts": 4, - "apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts": 66, - "apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts": 29, - "apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts": 2, - "apps/server/src/orchestration/commandInvariants.test.ts": 5, - "apps/server/src/orchestration/projector.test.ts": 20, - "apps/server/src/provider/Layers/CodexAdapter.test.ts": 1, - "apps/server/src/provider/Layers/CursorAdapter.test.ts": 1, - "apps/server/src/provider/Layers/CursorProvider.test.ts": 1, - "apps/server/src/provider/Layers/ProviderService.test.ts": 2, - "apps/server/src/provider/Layers/ProviderSessionReaper.test.ts": 12, - "apps/server/src/provider/acp/CursorAcpSupport.test.ts": 1, - }).map(([file, maxOccurrences]) => { - const rule: ["error", { maxOccurrences: number }] = ["error", { maxOccurrences }]; - return { files: [file], rules: { "t3code/no-manual-effect-runtime-in-tests": rule } }; - }), ], options: { reportUnusedDisableDirectives: "error",