diff --git a/.agents/references/local-development.md b/.agents/references/local-development.md index 7a694641b6..1bca4973c1 100644 --- a/.agents/references/local-development.md +++ b/.agents/references/local-development.md @@ -23,9 +23,11 @@ Use populated fixtures when the affected flow needs them. For an authorized snap ```bash mkdir -p .t3/userdata # Use a fresh destination; VACUUM INTO refuses to overwrite an existing file. - bun -e "new (require('bun:sqlite').Database)(process.env.HOME + '/.pylon-code/userdata/state.sqlite', { readonly: true }).run(\"VACUUM INTO '.t3/userdata/state.sqlite'\")" + bun -e "new (require('bun:sqlite').Database)(process.env.HOME + '/.pylon-code/userdata/statev2.sqlite', { readonly: true }).run(\"VACUUM INTO '.t3/userdata/statev2.sqlite'\")" ``` + The server and `vp run migrate-dev-db` use `statev2.sqlite`. An install created before orchestration v2 may only have `state.sqlite`; `migrate-dev-db` then stops safely with a source-missing error, so pass `--source ` explicitly. + 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. - Bring `secrets` and `settings.json` only if the flow under test needs them. diff --git a/apps/desktop/scripts/browser-secret-native.test.mjs b/apps/desktop/scripts/browser-secret-native.test.mjs index 754a91f134..bba047636b 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 b7c94f165c..c5b8df6553 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -309,8 +309,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 }); @@ -388,5 +389,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 8eedabeb3e..a5807a6cc1 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -102,8 +102,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, { @@ -153,8 +154,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 e8da83f059..511e346289 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts @@ -75,43 +75,41 @@ 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 = "pylon-code.desktop"; - let desktopEntry = previousEntry; - setDesktopNameMock.mockImplementation((name: string) => { - desktopName = name; - }); - writeFileSyncMock.mockImplementation((path: string, contents: string) => { - if (path === "/xdg/applications/com.pylon.code.desktop") desktopEntry = contents; - }); + it.effect.each([ + { previousEntry: undefined, name: "missing" }, + { previousEntry: 'Exec="/Applications/deleted-previous.AppImage" %U', name: "stale" }, + ])("prepares a $name 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 = "pylon-code.desktop"; + let desktopEntry = previousEntry; + setDesktopNameMock.mockImplementation((name: string) => { + desktopName = name; + }); + writeFileSyncMock.mockImplementation((path: string, contents: string) => { + if (path === "/xdg/applications/com.pylon.code.desktop") desktopEntry = contents; + }); - return Effect.scoped( - Effect.gen(function* () { - const portalIdentity = Promise.resolve().then(() => ({ desktopName, desktopEntry })); - yield* Layer.build( - DesktopPreReadyPlatform.layer.pipe( - Layer.provide(Layer.succeed(HostProcessPlatform, "linux")), - ), - ); - const identity = yield* Effect.promise(() => portalIdentity); - assert.equal(identity.desktopName, "com.pylon.code.desktop"); - assert.include(identity.desktopEntry ?? "", 'Exec="/Applications/current.AppImage" %U'); - assert.include(identity.desktopEntry ?? "", "Name=Pylon (Alpha)"); - assert.include(identity.desktopEntry ?? "", "MimeType=x-scheme-handler/pylon-code;"); - // Same icon path the URL handler installs, so its later check sees an identical entry. - assert.include(identity.desktopEntry ?? "", "Icon=/xdg/com.pylon.code/icon.png"); - }), - ).pipe(Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs()))); - }, - ); - } + return Effect.scoped( + Effect.gen(function* () { + const portalIdentity = Promise.resolve().then(() => ({ desktopName, desktopEntry })); + yield* Layer.build( + DesktopPreReadyPlatform.layer.pipe( + Layer.provide(Layer.succeed(HostProcessPlatform, "linux")), + ), + ); + const identity = yield* Effect.promise(() => portalIdentity); + assert.equal(identity.desktopName, "com.pylon.code.desktop"); + assert.include(identity.desktopEntry ?? "", 'Exec="/Applications/current.AppImage" %U'); + assert.include(identity.desktopEntry ?? "", "Name=Pylon (Alpha)"); + assert.include(identity.desktopEntry ?? "", "MimeType=x-scheme-handler/pylon-code;"); + // Same icon path the URL handler installs, so its later check sees an identical entry. + assert.include(identity.desktopEntry ?? "", "Icon=/xdg/com.pylon.code/icon.png"); + }), + ).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 12a1304bd0..56d19e1645 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 ["pylon-code", "Pylon (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, "pylon-code-v2"); - const state = '{"os_crypt":{"encrypted_key":"test-encrypted-key"}}'; - yield* fs.makeDirectory(path.join(directory, "Pylon (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(["pylon-code", "Pylon (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, "pylon-code-v2"); + const state = '{"os_crypt":{"encrypted_key":"test-encrypted-key"}}'; + yield* fs.makeDirectory(path.join(directory, "Pylon (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/electron/ElectronNotification.test.ts b/apps/desktop/src/electron/ElectronNotification.test.ts index c4484a30d3..b59fd948e4 100644 --- a/apps/desktop/src/electron/ElectronNotification.test.ts +++ b/apps/desktop/src/electron/ElectronNotification.test.ts @@ -47,31 +47,29 @@ describe("ElectronNotification lifetime", () => { assert.strictEqual(NodeV8.queryObjects(NativeNotification), baseline); }), ); - for (const event of ["click", "close", "failed"]) { - it.effect(`releases a notification after ${event}`, () => - Effect.gen(function* () { - const baseline = NodeV8.queryObjects(NativeNotification); - let clicked = 0; - NativeNotification.eventOnShow = event; - try { - yield* Effect.gen(function* () { - const service = yield* ElectronNotification.ElectronNotification; - yield* service.show({ - title: "Thread", - body: "Done", - onClick: () => { - clicked++; - }, - }); - assert.strictEqual(NodeV8.queryObjects(NativeNotification), baseline); - assert.strictEqual(clicked, event === "click" ? 1 : 0); - }).pipe(Effect.provide(ElectronNotification.layer), Effect.scoped); - } finally { - NativeNotification.eventOnShow = undefined; - } - }), - ); - } + it.effect.each(["click", "close", "failed"])("releases a notification after %s", (event) => + Effect.gen(function* () { + const baseline = NodeV8.queryObjects(NativeNotification); + let clicked = 0; + NativeNotification.eventOnShow = event; + try { + yield* Effect.gen(function* () { + const service = yield* ElectronNotification.ElectronNotification; + yield* service.show({ + title: "Thread", + body: "Done", + onClick: () => { + clicked++; + }, + }); + assert.strictEqual(NodeV8.queryObjects(NativeNotification), baseline); + assert.strictEqual(clicked, event === "click" ? 1 : 0); + }).pipe(Effect.provide(ElectronNotification.layer), Effect.scoped); + } finally { + NativeNotification.eventOnShow = undefined; + } + }), + ); it.effect("retains a Windows notification moved into Action Center", () => Effect.gen(function* () { const baseline = NodeV8.queryObjects(NativeNotification); diff --git a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts index 84e7678cce..17f9ff9b98 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 fb33a63230..89b5cf4c91 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.test.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.test.ts @@ -745,8 +745,9 @@ describe("listSourceProfiles Firefox fallback", () => { { platform: "win32" as const, profileDirectory: "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; @@ -782,8 +783,7 @@ describe("listSourceProfiles Firefox fallback", () => { ]); }), ), - ); - } + ); it.effect("scans for profiles when profiles.ini declares only ones without cookies", () => run( @@ -1129,8 +1129,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(); @@ -1144,8 +1145,7 @@ describe("Safari profiles", () => { assert.isTrue(yield* isSourceInstalled(safari, context)); }), ), - ); - } + ); it.effect("keeps Safari without profiles available", () => run( diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 896ad67eae..df1d91f88f 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -158,58 +158,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( @@ -312,37 +310,35 @@ describe("DesktopClientSettings", () => { ), ); - for (const document of [ + it.effect.each([ { label: "direct", contents: '{"fontSizeCode":"large","timestampFormat":"12-hour"}' }, { label: "legacy", contents: '{"settings":{"fontSizeCode":"large","timestampFormat":"12-hour"}}', }, - ]) { - it.effect(`keeps readable ${document.label} settings beside an undecodable value`, () => - 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); + ])("keeps readable $label settings beside an undecodable value", (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); - assert.deepEqual( - yield* settings.get, - Option.some({ - ...(yield* decodeClientSettingsJson("{}")), - timestampFormat: "12-hour" as const, - }), - ); - assert.equal( - yield* fileSystem.readFileString(environment.clientSettingsPath), - document.contents, - ); - }), - ), - ); - } + assert.deepEqual( + yield* settings.get, + Option.some({ + ...(yield* decodeClientSettingsJson("{}")), + timestampFormat: "12-hour" as const, + }), + ); + assert.equal( + yield* fileSystem.readFileString(environment.clientSettingsPath), + document.contents, + ); + }), + ), + ); it.effect("leaves an undecodable value in the file until that setting changes", () => withClientSettings( diff --git a/apps/desktop/src/snapShot/DesktopSnapShot.test.ts b/apps/desktop/src/snapShot/DesktopSnapShot.test.ts index ec880349eb..0d3c5f3639 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.test.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.test.ts @@ -3898,8 +3898,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, @@ -3940,8 +3941,8 @@ for (const fails of [false, true]) { }), ), ); - }); -} + }, +); it.effect("rejects macOS test capture on other platforms", () => Effect.scoped( diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index d0d8f9bd0f..3afaf7b008 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -989,59 +989,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/server/scripts/migrate-dev-db.test.ts b/apps/server/scripts/migrate-dev-db.test.ts index 500999d734..9a21a6dc40 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 }); }), ); @@ -253,7 +292,7 @@ it.layer(NodeServices.layer)("migrate-dev-db", (it) => { if (error._tag === "MigrateDevDbSourceMissingError") { assert.equal( error.sourcePath, - path.join(homeDir, ".pylon-code", "userdata", "state.sqlite"), + path.join(homeDir, ".pylon-code", "userdata", "statev2.sqlite"), ); } }), diff --git a/apps/server/scripts/migrate-dev-db.ts b/apps/server/scripts/migrate-dev-db.ts index ca7ecd9ec9..4ed0d795f7 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 (`~/.pylon-code/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 `~/.pylon-code/userdata/state.sqlite`. */ + /** Source database. Defaults to `~/.pylon-code/userdata/statev2.sqlite`. */ readonly source?: string | undefined; readonly projects: number; readonly threadsPerProject: number; @@ -237,31 +240,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. @@ -270,7 +298,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 @@ -279,42 +307,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`; @@ -325,7 +367,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 }>` @@ -373,7 +416,7 @@ export const runMigrateDevDb = Effect.fn("runMigrateDevDb")(function* ( // first query. const sourceHome = path.resolve(options.sharedHome ?? path.join(homeDir, ".pylon-code")); const sourcePath = path.resolve( - input.source ?? path.join(sourceHome, "userdata", "state.sqlite"), + input.source ?? path.join(sourceHome, "userdata", "statev2.sqlite"), ); // Which directories `--base-dir` may not name. Deliberately *not* the same @@ -486,7 +529,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), ); @@ -540,7 +587,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, @@ -548,7 +597,7 @@ export const migrateDevDbCommand = Command.make( ), source: Flag.String("source").pipe( Flag.optional, - Flag.withDescription("Source database. Defaults to ~/.pylon-code/userdata/state.sqlite."), + Flag.withDescription("Source database. Defaults to ~/.pylon-code/userdata/statev2.sqlite."), ), }, ({ projects, threadsPerProject, baseDir, source }) => diff --git a/apps/server/src/assets/GitHubMediaFetch.test.ts b/apps/server/src/assets/GitHubMediaFetch.test.ts index 0b63f7ebc9..6b16f4d9ec 100644 --- a/apps/server/src/assets/GitHubMediaFetch.test.ts +++ b/apps/server/src/assets/GitHubMediaFetch.test.ts @@ -83,74 +83,70 @@ it.effect("strips credentials on signed CDN redirects and streams video ranges", ); }); -for (const location of [ +it.effect.each([ "https://127.0.0.1/private", "https://internal.example/private", "http://raw.githubusercontent.com/x", "https://raw.githubusercontent.com:8443/x", "https://[", "https://user:password@raw.githubusercontent.com/x", -]) { - it.effect(`refuses an unsafe media redirect: ${location}`, () => { - let requests = 0; - return githubMediaResponse(asset, {}).pipe( - Effect.tap((response) => - Effect.sync(() => { - expect(response.status).toBe(502); - expect(requests).toBe(1); - }), - ), - Effect.provide(github), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make((request) => { - requests++; - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - new Response(null, { status: 302, headers: { location } }), - ), - ); - }), - ), - Effect.scoped, - ); - }); -} +])("refuses an unsafe media redirect: %s", (location) => { + let requests = 0; + return githubMediaResponse(asset, {}).pipe( + Effect.tap((response) => + Effect.sync(() => { + expect(response.status).toBe(502); + expect(requests).toBe(1); + }), + ), + Effect.provide(github), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => { + requests++; + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(null, { status: 302, headers: { location } }), + ), + ); + }), + ), + Effect.scoped, + ); +}); -for (const scenario of [ +it.effect.each([ { type: "text/html", status: 200, expected: 415 }, { type: "image/svg+xml", status: 200, expected: 200 }, { type: "text/html", status: 404, expected: 404 }, { type: "text/html", status: 503, expected: 502 }, -]) { - it.effect(`handles media content type ${scenario.type} and status ${scenario.status}`, () => - githubMediaResponse(asset, {}).pipe( - Effect.tap((response) => - Effect.sync(() => { - expect(response.status).toBe(scenario.expected); - expect(response.headers["x-content-type-options"]).toBe("nosniff"); - if (scenario.type === "image/svg+xml") - expect(response.headers["content-security-policy"]).toContain("sandbox"); - else expect(response.headers["content-length"]).toBeUndefined(); - }), - ), - Effect.provide(github), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make((request) => - Effect.succeed( - HttpClientResponse.fromWeb( - request, - new Response(null, { - status: scenario.status, - headers: { "content-type": scenario.type, "content-length": "200" }, - }), - ), +])("handles media content type $type and status $status", (scenario) => + githubMediaResponse(asset, {}).pipe( + Effect.tap((response) => + Effect.sync(() => { + expect(response.status).toBe(scenario.expected); + expect(response.headers["x-content-type-options"]).toBe("nosniff"); + if (scenario.type === "image/svg+xml") + expect(response.headers["content-security-policy"]).toContain("sandbox"); + else expect(response.headers["content-length"]).toBeUndefined(); + }), + ), + Effect.provide(github), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(null, { + status: scenario.status, + headers: { "content-type": scenario.type, "content-length": "200" }, + }), ), ), ), - Effect.scoped, ), - ); -} + Effect.scoped, + ), +); diff --git a/apps/server/src/cli/app.test.ts b/apps/server/src/cli/app.test.ts index 74116ab58b..266cca8d5c 100644 --- a/apps/server/src/cli/app.test.ts +++ b/apps/server/src/cli/app.test.ts @@ -257,8 +257,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); @@ -303,6 +304,5 @@ describe("t3 app", () => { } }).pipe(Effect.scoped), ), - ); - } + ); }); diff --git a/apps/server/src/computer/CuaService.test.ts b/apps/server/src/computer/CuaService.test.ts index 5187556610..7fd9c9f524 100644 --- a/apps/server/src/computer/CuaService.test.ts +++ b/apps/server/src/computer/CuaService.test.ts @@ -166,35 +166,33 @@ it.effect("Cua tool errors remain errors and malformed results are rejected", () ), ); -for (const revoke of ["thread", "all"] as const) { - it.effect(`queued calls cannot survive ${revoke} revocation`, () => - Effect.scoped( - Effect.gen(function* () { - const f = yield* fixture; - yield* f.cua.tools(owner); - const entered = yield* Deferred.make(); - const release = Promise.withResolvers(); - Object.assign(f.connections[0]!, { - callTool: async () => { - Deferred.doneUnsafe(entered, Effect.void); - await release.promise; - return imageResult; - }, - }); - const active = yield* f.cua.call(owner, "get_window_state", {}).pipe(Effect.forkChild); - yield* Deferred.await(entered); - const queued = yield* f.cua.tools(owner).pipe(expectFailure, Effect.forkChild); - // Let the request enter the semaphore wait; no desktop or timer is involved. - yield* Effect.yieldNow; - yield* revoke === "thread" ? f.cua.closeThread(owner.threadId) : f.cua.closeAll; - release.resolve(); - yield* Fiber.join(active); - expect((yield* Fiber.join(queued)).message).toContain("no longer"); - expect(f.starts()).toBe(1); - }), - ), - ); -} +it.effect.each(["thread", "all"] as const)("queued calls cannot survive %s revocation", (revoke) => + Effect.scoped( + Effect.gen(function* () { + const f = yield* fixture; + yield* f.cua.tools(owner); + const entered = yield* Deferred.make(); + const release = Promise.withResolvers(); + Object.assign(f.connections[0]!, { + callTool: async () => { + Deferred.doneUnsafe(entered, Effect.void); + await release.promise; + return imageResult; + }, + }); + const active = yield* f.cua.call(owner, "get_window_state", {}).pipe(Effect.forkChild); + yield* Deferred.await(entered); + const queued = yield* f.cua.tools(owner).pipe(expectFailure, Effect.forkChild); + // Let the request enter the semaphore wait; no desktop or timer is involved. + yield* Effect.yieldNow; + yield* revoke === "thread" ? f.cua.closeThread(owner.threadId) : f.cua.closeAll; + release.resolve(); + yield* Fiber.join(active); + expect((yield* Fiber.join(queued)).message).toContain("no longer"); + expect(f.starts()).toBe(1); + }), + ), +); it.effect( "background policy blocks foreground calls before dispatch and supports explicit opt-in", @@ -248,8 +246,9 @@ it.effect("foreground revocation during connect is enforced before subscriber no ), ); -for (const failure of ["transport", "catalog"] as const) { - it.effect(`discovery recovers from ${failure} failure without closing on unknown names`, () => +it.effect.each(["transport", "catalog"] as const)( + "discovery recovers from %s failure without closing on unknown names", + (failure) => Effect.scoped( Effect.gen(function* () { const f = yield* fixture; @@ -268,8 +267,7 @@ for (const failure of ["transport", "catalog"] as const) { expect(f.starts()).toBe(2); }), ), - ); -} +); it.effect("does not read settings or start a driver during server layer construction", () => Effect.scoped( diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index 1520c5a95c..d3d5084b9e 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -390,29 +390,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/mcp/ComputerToolkit.test.ts b/apps/server/src/mcp/ComputerToolkit.test.ts index f3e55d93c0..3b95d3b3a6 100644 --- a/apps/server/src/mcp/ComputerToolkit.test.ts +++ b/apps/server/src/mcp/ComputerToolkit.test.ts @@ -24,74 +24,72 @@ const providers = ["codex", "claude", "cursor", "grok", "opencode", "antigravity const encodeResult = Schema.encodeEffect(McpSchema.CallToolResult); -for (const provider of providers) { - it.effect(`exposes discovery and native screenshot results to ${provider}`, () => - Effect.scoped( - Effect.gen(function* () { - const owners: string[] = []; - const CuaMock = Layer.mock(CuaService)({ - tools: (owner) => - Effect.sync(() => { - owners.push(owner.providerSessionId); - return { tools: ["snapshot"] }; - }), - call: (owner, name, args) => - Effect.gen(function* () { - owners.push(owner.providerSessionId); - expect(name).toBe("snapshot"); - expect(args).toEqual({ pid: 123 }); - return yield* decodeCuaResult({ - content: [{ type: "image", mimeType: "image/png", data: "cG5n" }], - isError: false, - }).pipe(Effect.orDie); - }), - }); - const layer = ComputerToolkit.layer.pipe( - Layer.provideMerge(McpServer.McpServer.layer), - Layer.provide(CuaMock), +it.effect.each(providers)("exposes discovery and native screenshot results to %s", (provider) => + Effect.scoped( + Effect.gen(function* () { + const owners: string[] = []; + const CuaMock = Layer.mock(CuaService)({ + tools: (owner) => + Effect.sync(() => { + owners.push(owner.providerSessionId); + return { tools: ["snapshot"] }; + }), + call: (owner, name, args) => + Effect.gen(function* () { + owners.push(owner.providerSessionId); + expect(name).toBe("snapshot"); + expect(args).toEqual({ pid: 123 }); + return yield* decodeCuaResult({ + content: [{ type: "image", mimeType: "image/png", data: "cG5n" }], + isError: false, + }).pipe(Effect.orDie); + }), + }); + const layer = ComputerToolkit.layer.pipe( + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provide(CuaMock), + ); + yield* Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const scope = { + environmentId: EnvironmentId.make("env"), + threadId: ThreadId.make("thread"), + providerInstanceId: ProviderInstanceId.make(provider), + providerSessionId: `${provider}-session`, + capabilities: new Set(["computer"]), + issuedAt: 1, + }; + const call = ( + name: string, + args: Record, + capabilities = scope.capabilities, + ) => + server + .callTool({ name, arguments: args }) + .pipe( + Effect.provideService(McpInvocationContext, { ...scope, capabilities }), + Effect.provideService(McpSchema.McpServerClient, client), + ); + expect(server.tools.map(({ tool }) => tool.name).sort()).toEqual([ + "computer_call", + "computer_tools", + ]); + const denied = yield* call( + "computer_call", + { name: "snapshot", arguments: { pid: 123 } }, + new Set(), ); - yield* Effect.gen(function* () { - const server = yield* McpServer.McpServer; - const scope = { - environmentId: EnvironmentId.make("env"), - threadId: ThreadId.make("thread"), - providerInstanceId: ProviderInstanceId.make(provider), - providerSessionId: `${provider}-session`, - capabilities: new Set(["computer"]), - issuedAt: 1, - }; - const call = ( - name: string, - args: Record, - capabilities = scope.capabilities, - ) => - server - .callTool({ name, arguments: args }) - .pipe( - Effect.provideService(McpInvocationContext, { ...scope, capabilities }), - Effect.provideService(McpSchema.McpServerClient, client), - ); - expect(server.tools.map(({ tool }) => tool.name).sort()).toEqual([ - "computer_call", - "computer_tools", - ]); - const denied = yield* call( - "computer_call", - { name: "snapshot", arguments: { pid: 123 } }, - new Set(), - ); - expect(denied.isError).toBe(true); - expect(owners).toEqual([]); - yield* call("computer_tools", {}); - const result = yield* call("computer_call", { - name: "snapshot", - arguments: { pid: 123 }, - }); - const encoded = yield* encodeResult(result); - expect(encoded.content).toEqual([{ type: "image", mimeType: "image/png", data: "cG5n" }]); - expect(owners).toEqual([`${provider}-session`, `${provider}-session`]); - }).pipe(Effect.provide(layer)); - }), - ), - ); -} + expect(denied.isError).toBe(true); + expect(owners).toEqual([]); + yield* call("computer_tools", {}); + const result = yield* call("computer_call", { + name: "snapshot", + arguments: { pid: 123 }, + }); + const encoded = yield* encodeResult(result); + expect(encoded.content).toEqual([{ type: "image", mimeType: "image/png", data: "cG5n" }]); + expect(owners).toEqual([`${provider}-session`, `${provider}-session`]); + }).pipe(Effect.provide(layer)); + }), + ), +); diff --git a/apps/server/src/mcp/McpSessionRegistry.test.ts b/apps/server/src/mcp/McpSessionRegistry.test.ts index 01c1942c12..921e5c5d2c 100644 --- a/apps/server/src/mcp/McpSessionRegistry.test.ts +++ b/apps/server/src/mcp/McpSessionRegistry.test.ts @@ -199,8 +199,9 @@ it.effect("keeps the current exact credential when retired issue and cleanup arr }), ); -for (const action of ["thread", "all", "replace", "expire"] as const) { - it.effect(`retires Cua credential IDs on ${action}`, () => +it.effect.each(["thread", "all", "replace", "expire"] as const)( + "retires Cua credential IDs on %s", + (action) => Effect.gen(function* () { let timestamp = 1000; const retired: string[] = []; @@ -241,5 +242,4 @@ for (const action of ["thread", "all", "replace", "expire"] as const) { expect(retired).toContain(first.config.providerSessionId); expect(yield* registry.resolve(oldToken)).toBeUndefined(); }), - ); -} +); diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index cbe2a0ebce..29197658a1 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* () { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 467d58702d..d69c492d9f 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -2251,78 +2251,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* () { @@ -3186,7 +3186,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - for (const terminalReason of [ + it.effect.each([ "api_error", "malformed_tool_use_exhausted", "budget_exhausted", @@ -3199,56 +3199,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( diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 200b4d505c..768ccc9b6f 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* () { @@ -2930,8 +2930,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}`; @@ -3091,8 +3092,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.deepEqual(assistantMessages(harness.events), []); }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), ), - ); - } + ); const finalAnswerTranscript = ( scenario: string, @@ -3718,9 +3718,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), @@ -3800,9 +3801,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) => @@ -3856,140 +3862,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 @@ -5510,7 +5513,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); - for (const scenario of [ + it.effect.each([ { name: "usage", code: "usageLimitExceeded", @@ -5561,192 +5564,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"; @@ -6515,7 +6516,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); - for (const [nativeStatus, expectedStatus] of [ + it.effect.each([ ["pendingInit", "pending"], ["running", "running"], ["interrupted", "interrupted"], @@ -6527,8 +6528,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(); @@ -6674,8 +6676,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/CursorAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.test.ts index d31e91eac0..3a172baa67 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 60be8b82f0..281882c9eb 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 e321a1369c..b437ae9cf9 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -3090,81 +3090,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 +3373,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* () { diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts index 3a3f9a2af6..eca4aa9115 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 9d1f11d6e2..fb912714f1 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 2d448e3b7a..9dc9fa4526 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/ContextHandoffBudget.test.ts b/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts index 067b851d6a..2c6c878b25 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/EffectOutbox.ts b/apps/server/src/orchestration-v2/EffectOutbox.ts index 4841a57624..9f63d8c568 100644 --- a/apps/server/src/orchestration-v2/EffectOutbox.ts +++ b/apps/server/src/orchestration-v2/EffectOutbox.ts @@ -278,9 +278,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 @@ -292,7 +299,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' @@ -492,7 +510,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.ts b/apps/server/src/orchestration-v2/EffectWorker.ts index 48dcfa78e9..1abf070b87 100644 --- a/apps/server/src/orchestration-v2/EffectWorker.ts +++ b/apps/server/src/orchestration-v2/EffectWorker.ts @@ -244,7 +244,12 @@ export const executorLayer: Layer.Layer< text: message.text, ...(message.context ? { context: message.context } : {}), attachments: message.attachments, - modelSelection: run.modelSelection, + // A user's follow-up starts on the thread's saved selection, + // which already holds the steer's choice. A delegated + // completion stays pinned to the run it reports to. + ...(message.delegatedCompletion === undefined + ? {} + : { modelSelection: run.modelSelection }), dispatchMode: { type: message.delegatedCompletion === undefined diff --git a/apps/server/src/orchestration-v2/EventSink.ts b/apps/server/src/orchestration-v2/EventSink.ts index c61ce6ba8b..71b6f0ea99 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"; @@ -224,6 +225,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. @@ -332,7 +367,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 @@ -347,13 +382,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")( @@ -365,7 +401,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; @@ -403,12 +439,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; }, ); @@ -424,7 +456,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; @@ -464,12 +496,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) => @@ -490,7 +518,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, @@ -538,15 +566,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, @@ -593,7 +621,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, @@ -613,10 +641,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 b7f6e642cf..dc7eacc59b 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 f003aea73d..ad50b410c9 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 eb1fb20342..024b94c242 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 df9805d504..28296f9d7b 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -39,6 +39,7 @@ import { type OrchestrationV2Subagent, type OrchestrationV2ThreadProjection, type OrchestrationV2TurnItem, + orchestrationV2RunWorkStartedAt, ProviderInstanceId, type ProviderSessionId, RunId, @@ -304,6 +305,37 @@ 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) }; +} + function isNativeMaintenanceCommand(message: { readonly text: string; readonly attachments: ReadonlyArray; @@ -1459,6 +1491,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 ?? { @@ -3631,17 +3668,25 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ); if (steeringPolicy === "active_steering") { + // The steer's selection becomes the saved next-turn choice, even when it + // matches the running run again. A delegated completion carries the + // run's selection, not a user choice, so it never replaces the saved one. + // The saved choice may name another instance, so it moves with the steer. + const instanceChanged = + input.projection.thread.providerInstanceId !== input.modelSelection.instanceId; if ( - selectionChanged && - !modelSelectionsEqual(input.projection.thread.modelSelection, input.modelSelection) + input.delegatedCompletion === undefined && + (instanceChanged || + !modelSelectionsEqual(input.projection.thread.modelSelection, input.modelSelection)) ) { yield* emitEvent({ - type: "thread.model-selection-updated", + type: instanceChanged ? "thread.provider-switched" : "thread.model-selection-updated", threadId: input.command.threadId, providerInstanceId: input.modelSelection.instanceId, occurredAt: now, payload: { ...input.projection.thread, + providerInstanceId: input.modelSelection.instanceId, modelSelection: input.modelSelection, updatedAt: now, }, @@ -4941,6 +4986,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(command.restartContinuationOfRunId === undefined ? {} : { restartContinuationOfRunId: command.restartContinuationOfRunId }), + ...wakeWorkStartedAt(projection.runs, command), }; const attempt: OrchestrationV2RunAttempt = { id: attemptId, @@ -5634,6 +5680,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(command.restartContinuationOfRunId === undefined ? {} : { restartContinuationOfRunId: command.restartContinuationOfRunId }), + ...wakeWorkStartedAt(projection.runs, command), }; const attempt: OrchestrationV2RunAttempt = { id: attemptId, @@ -7982,32 +8029,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, diff --git a/apps/server/src/orchestration-v2/ProjectCommands.test.ts b/apps/server/src/orchestration-v2/ProjectCommands.test.ts index 55f2218fbd..bcbc5a8895 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 221c773149..4889a48b3d 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/ProjectionSettlement.test.ts b/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts index 4fb806e61e..60149fe8a2 100644 --- a/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts @@ -140,159 +140,157 @@ 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: "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]), + ); + 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), + resolveAutoSettlementAt({ ...settings, thread: candidate }), + resolveAutoSettlementAt({ ...settings, thread: expected }), ); - assert.equal( - (yield* store.getThread(ThreadId.make("missing")).pipe(Effect.flip))._tag, - "ProjectionStoreThreadNotFoundError", - ); - }).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)), +); const pullRequestLink = (number: number) => ({ host: "github.com", @@ -305,11 +303,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 +335,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 f3b6348c5d..c64fece9db 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -1982,6 +1982,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), + ); + } }), ); diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 6718240d9e..ac0624b058 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, @@ -348,12 +349,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, @@ -1368,7 +1369,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), @@ -4782,7 +4784,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') @@ -5126,12 +5133,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 @@ -5576,13 +5585,14 @@ export const layerMemory: Layer.Layer = Layer.effect( 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, diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts index f1470125a8..7b73b7d7f1 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts @@ -724,8 +724,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; @@ -909,8 +910,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", diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index 63d4c57fb6..e42c1cdeba 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -20,6 +20,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"; @@ -79,6 +80,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, @@ -358,15 +405,19 @@ 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; 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, @@ -2291,6 +2342,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); @@ -3007,8 +3351,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(); @@ -3053,8 +3398,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 786eb5a6bf..c1039c2a02 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -17,11 +17,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"; @@ -361,6 +363,9 @@ export const layerWithOptions = ( ); const layerScope = yield* Effect.scope; 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. @@ -608,6 +613,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; @@ -629,7 +636,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) { @@ -708,6 +716,125 @@ 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.busyCount > 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; @@ -717,23 +844,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.busyCount > 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) => @@ -794,21 +906,25 @@ 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); } }), }), - (entry) => + ([entry]) => Option.match(entry, { onNone: () => Effect.void, onSome: (entry) => @@ -1495,7 +1611,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) @@ -1516,7 +1632,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), @@ -1705,7 +1821,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 b3a9fe9d61..d946bcd197 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 746fbe235d..fdb45f1931 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 781491ac7f..ce2088d911 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 diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts index 7871b3ed6e..6febdb9bbc 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 670d401004..1b526d8f53 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/RestartContinuation.test.ts b/apps/server/src/orchestration-v2/RestartContinuation.test.ts index f43f4cd016..c282fa7a26 100644 --- a/apps/server/src/orchestration-v2/RestartContinuation.test.ts +++ b/apps/server/src/orchestration-v2/RestartContinuation.test.ts @@ -259,67 +259,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* () { diff --git a/apps/server/src/orchestration-v2/RunExecutionService.test.ts b/apps/server/src/orchestration-v2/RunExecutionService.test.ts index ec292a02b8..3d6dca6151 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.test.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.test.ts @@ -50,6 +50,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"; @@ -590,6 +591,113 @@ it.effect("rechecks run ownership immediately before calling the provider", () = }).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", () => @@ -862,8 +970,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"); @@ -1030,8 +1139,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* () { @@ -3149,8 +3257,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}`, @@ -3162,8 +3271,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* () { @@ -3254,7 +3381,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?: ( @@ -3278,6 +3405,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( @@ -3292,22 +3430,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 71211c1265..6aff5da24f 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"; @@ -47,6 +48,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"; @@ -511,8 +513,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; @@ -557,7 +559,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; @@ -1286,15 +1288,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({ @@ -1328,10 +1327,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; } @@ -1373,7 +1375,7 @@ export const layer: Layer.Layer< }), )) : input.session.startTurn(turnInput); - yield* startTurn.pipe( + yield* Effect.andThen(shouldStart, startTurn).pipe( Effect.catchCause((cause) => Effect.logError("orchestration V2 provider turn start failed", { runId: input.run.id, @@ -1392,20 +1394,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 31b0039876..a2e8f50901 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 414478b390..012fb9c3c7 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 9f9f17dedf..6a8dded63c 100644 --- a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts +++ b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts @@ -10,6 +10,7 @@ import { ProviderThreadId, ProviderTurnId, ThreadId, + type ModelSelection, type OrchestrationV2DomainEvent, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; @@ -36,399 +37,44 @@ 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( - 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 now = yield* DateTime.now; - return { - instanceId, - 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}`), - 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] }, - }, - }, - }, - { - 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, - }); - 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 steers live but cannot interrupt-and-restart. A composer whose model -// options differ from the running run (an explicit `fastMode: false`) must still -// steer, keeping the new selection for the next turn. -it.effect("steers a changed turn-scoped selection into a provider that cannot restart", () => +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-selection-change"); + const cwd = yield* checkpointWorkspace(`steering-completion-${timing.replaceAll(" ", "-")}`); const events = yield* Queue.unbounded(); const started: ProviderAdapterV2TurnInput[] = []; - const steered: string[] = []; + const steerEntered = yield* Deferred.make(); + const rejectSteer = yield* Deferred.make(); + let steerCalls = 0; const capabilities = { ...CodexProviderCapabilitiesV2, turns: { ...CodexProviderCapabilitiesV2.turns, - supportsActiveSteering: true, - supportsSteeringByInterruptRestart: false, + supportsActiveSteering: timing !== "without native steering", }, }; - const runSelection = { - instanceId, - model: "test-model", - options: [{ id: "effort", value: "xhigh" }], - }; - const composerSelection = { - ...runSelection, - options: [...runSelection.options, { id: "fastMode", value: false }], - }; const adapter: ProviderAdapterV2Shape = { instanceId, driver, @@ -447,7 +93,7 @@ it.effect("steers a changed turn-scoped selection into a provider that cannot re providerInstanceId: instanceId, status: "ready", cwd, - model: runSelection.model, + model: modelSelection.model, capabilities, createdAt: now, updatedAt: now, @@ -497,8 +143,17 @@ it.effect("steers a changed turn-scoped selection into a provider that cannot re }); }), steerTurn: (turn) => - Effect.sync(() => { - steered.push(turn.message.text); + 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, @@ -511,14 +166,21 @@ it.effect("steers a changed turn-scoped selection into a provider that cannot re yield* Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; - const threadId = ThreadId.make("thread:steering-selection-change"); + 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-selection-change"), - title: "Steer with changed options", - modelSelection: runSelection, + projectId: ProjectId.make("project:steering-completion"), + title: "Steering race", + modelSelection, runtimeMode: "full-access", interactionMode: "default", branch: null, @@ -526,14 +188,6 @@ it.effect("steers a changed turn-scoped selection into a provider that cannot re createdBy: "user", creationSource: "web", }); - const running = yield* orchestrator.streamDomainEvents.pipe( - Stream.filter( - (event) => event.type === "provider-turn.updated" && event.payload.status === "running", - ), - Stream.take(1), - Stream.runDrain, - Effect.forkScoped, - ); yield* orchestrator.dispatch({ type: "message.dispatch", commandId: CommandId.make("first"), @@ -541,44 +195,510 @@ it.effect("steers a changed turn-scoped selection into a provider that cannot re messageId: MessageId.make("message:first"), text: "first", attachments: [], - modelSelection: runSelection, 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, + }, + }, + ], + }); + } + 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. +const runSelection = { + instanceId, + model: "test-model", + options: [{ id: "effort", value: "xhigh" }], +}; +const composerSelection = { + ...runSelection, + options: [...runSelection.options, { id: "fastMode", value: false }], +}; + +const nextTurnSelectionHarness = Effect.fn("nextTurnSelectionHarness")(function* (name: string) { + const cwd = yield* checkpointWorkspace(name); + const events = yield* Queue.unbounded(); + const started: ProviderAdapterV2TurnInput[] = []; + const steered: string[] = []; + const capabilities = { + ...CodexProviderCapabilitiesV2, + turns: { + ...CodexProviderCapabilitiesV2.turns, + supportsActiveSteering: true, + supportsSteeringByInterruptRestart: false, + }, + }; + const adapter: ProviderAdapterV2Shape = { + instanceId, + driver, + getCapabilities: () => Effect.succeed(capabilities), + 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: runSelection.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* () { + 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.sync(() => { + steered.push(turn.message.text); + }), + interruptTurn: () => Effect.void, + respondToRuntimeRequest: () => Effect.void, + readThreadSnapshot: () => Effect.die("unused"), + rollbackThread: () => Effect.die("unused"), + forkThread: () => Effect.die("unused"), + }; + }), + }; + const layer = makeOrchestratorV2ReplayLayerWithRegistry( + { name }, + ProviderAdapterRegistry.makeSingleLayer(adapter), + { runEffectWorker: false }, + ); + // Creates the thread and starts its first turn on `runSelection`. + const startFirstTurn = Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = ThreadId.make(`thread:${name}`); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create"), + threadId, + projectId: ProjectId.make(`project:${name}`), + title: "Steer with changed options", + modelSelection: runSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + createdBy: "user", + creationSource: "web", + }); + const running = yield* orchestrator.streamDomainEvents.pipe( + Stream.filter( + (event) => event.type === "provider-turn.updated" && event.payload.status === "running", + ), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped, + ); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("first"), + threadId, + messageId: MessageId.make("message:first"), + text: "first", + attachments: [], + modelSelection: runSelection, + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + yield* worker.drain(); + yield* Fiber.join(running); + return threadId; + }); + return { events, started, steered, layer, startFirstTurn }; +}); +it.effect("steers a changed turn-scoped selection into a provider that cannot restart", () => + Effect.scoped( + Effect.gen(function* () { + const { started, steered, layer, startFirstTurn } = yield* nextTurnSelectionHarness( + "steering-selection-change", + ); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = yield* startFirstTurn; + const steer = (id: string, selection: ModelSelection) => + orchestrator + .dispatch({ + type: "message.dispatch", + commandId: CommandId.make(id), + threadId, + messageId: MessageId.make(`message:${id}`), + text: id, + attachments: [], + modelSelection: selection, + dispatchMode: { type: "steer_active", targetRunId: started[0]!.runId }, + createdBy: "user", + creationSource: "web", + }) + .pipe(Effect.andThen(worker.drain())); + + yield* steer("steer-changed", composerSelection); + const changed = yield* orchestrator.getThreadProjection(threadId); + assert.deepEqual(steered, ["steer-changed"]); + assert.equal(started.length, 1); + assert.lengthOf(changed.attempts, 1); + assert.equal(changed.runs[0]?.status, "running"); + assert.deepEqual(changed.runs[0]?.modelSelection, runSelection); + assert.deepEqual(changed.thread.modelSelection, composerSelection); + + // Choosing the running run's selection again replaces the saved choice. + yield* steer("steer-reverted", runSelection); + const reverted = yield* orchestrator.getThreadProjection(threadId); + assert.deepEqual(steered, ["steer-changed", "steer-reverted"]); + assert.lengthOf(reverted.attempts, 1); + assert.deepEqual(reverted.thread.modelSelection, runSelection); + + // The saved choice moves to another instance while the run keeps going. + // Steering with the run's selection brings the thread's instance back too. + const otherSelection = { + instanceId: ProviderInstanceId.make("codex-work"), + model: "other", + }; + const sink = yield* EventSink.EventSinkV2; + yield* sink.write({ + events: [ + { + id: EventId.make("switched-away"), + type: "thread.provider-switched", + threadId, + providerInstanceId: otherSelection.instanceId, + occurredAt: yield* DateTime.now, + payload: { + ...reverted.thread, + providerInstanceId: otherSelection.instanceId, + modelSelection: otherSelection, + }, + }, + ], + }); + yield* steer("steer-back", runSelection); + const back = yield* orchestrator.getThreadProjection(threadId); + assert.deepEqual(steered, ["steer-changed", "steer-reverted", "steer-back"]); + assert.equal(back.thread.providerInstanceId, instanceId); + assert.deepEqual(back.thread.modelSelection, runSelection); + }).pipe(Effect.provide(layer)); + }), + ), +); + +it.effect("starts a steer that missed the turn on the saved next-turn selection", () => + Effect.scoped( + Effect.gen(function* () { + const { events, started, steered, layer, startFirstTurn } = yield* nextTurnSelectionHarness( + "steering-selection-follow-up", + ); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = yield* startFirstTurn; + const first = started[0]!; yield* orchestrator.dispatch({ type: "message.dispatch", commandId: CommandId.make("steer"), threadId, messageId: MessageId.make("message:steer"), - text: "use the File component", + text: "late steer", attachments: [], modelSelection: composerSelection, - dispatchMode: { type: "steer_active", targetRunId: started[0]!.runId }, + dispatchMode: { type: "steer_active", targetRunId: first.runId }, createdBy: "user", creationSource: "web", }); + // The turn ends before the worker delivers the steer, so the steer + // becomes a follow-up turn. + const settled = yield* orchestrator.streamDomainEvents.pipe( + Stream.filter( + (event) => + event.type === "run.updated" && + event.payload.id === first.runId && + event.payload.status === "waiting", + ), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped, + ); + const turn = (yield* orchestrator.getThreadProjection(threadId)).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(settled); + yield* worker.drain(); + yield* orchestrator.resumeQueuedRuns; yield* worker.drain(); const projection = yield* orchestrator.getThreadProjection(threadId); - assert.deepEqual(steered, ["use the File component"]); - assert.equal(started.length, 1); - assert.lengthOf(projection.attempts, 1); - assert.equal(projection.runs[0]?.status, "running"); - assert.deepEqual(projection.runs[0]?.modelSelection, runSelection); + assert.deepEqual(steered, []); + assert.equal(started.length, 2); + assert.equal(started[1]?.message.messageId, MessageId.make("message:steer")); + assert.deepEqual(started[1]?.modelSelection, composerSelection); assert.deepEqual(projection.thread.modelSelection, composerSelection); - }).pipe( - Effect.provide( - makeOrchestratorV2ReplayLayerWithRegistry( - { name: "steering-selection-change" }, - ProviderAdapterRegistry.makeSingleLayer(adapter), - { runEffectWorker: false }, - ), - ), - ); + }).pipe(Effect.provide(layer)); }), ), ); diff --git a/apps/server/src/orchestration-v2/TODO.md b/apps/server/src/orchestration-v2/TODO.md deleted file mode 100644 index a4ac59fc8c..0000000000 --- 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 63bc1550b2..1badbe8dcc 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 79261cb377..a384c3e21b 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* () { @@ -1323,47 +1323,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* () { @@ -2019,61 +2017,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 1df1c24270..7ff557c338 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/ThreadTitleRegenerationService.test.ts b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts index 6b9d4a43cc..21a22a60dd 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/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index c71f84709b..d077f7e209 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -2599,210 +2599,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", @@ -2993,112 +3107,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/testkit/OrchestratorReplayFixtures.integration.test.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts index b60a5b553c..dd417ef162 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/ProviderSwitch.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts index cba4cc6fbf..9d01f22fe6 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 92837e4974..e22d847037 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/persistence/Layers/OrchestrationEventStore.test.ts b/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts index b053c24034..fc43700b06 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/provider/CodexChatGptAuth.test.ts b/apps/server/src/provider/CodexChatGptAuth.test.ts index 267bace830..c4e3fe3ad5 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 a0d22decc0..2cc54cf832 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 33ccaa13ea..494ff88a22 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/Drivers/CodexDriver.test.ts b/apps/server/src/provider/Drivers/CodexDriver.test.ts index 61db899290..0b1128d5a2 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.test.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.test.ts @@ -101,57 +101,58 @@ const noSpawn = ChildProcessSpawner.make(() => const encodeCredentials = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); it.layer(testLayer)("CodexDriver", (it) => { - for (const scenario of [ - { - name: "reads fresh limits before returning", - readFails: false, - switchBefore: false, - switchOnConsume: false, - }, - { - name: "preserves a confirmed result when refresh fails", - readFails: true, - switchBefore: false, - switchOnConsume: false, - }, - { - name: "rejects a changed live account before consuming", - readFails: false, - switchBefore: true, - switchOnConsume: false, - }, - { - name: "does not cache a result after the account changes mid-reset", - readFails: false, - switchBefore: false, - switchOnConsume: true, - }, - ]) { - it.effect(`redeems through the initialized CLI and ${scenario.name}`, () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const directory = yield* fs.makeTempDirectoryScoped({ prefix: "pylon-reset-credit-" }); - const logPath = NodePath.join(directory, "requests.jsonl"); - const accountPath = NodePath.join(directory, "account.txt"); - yield* fs.writeFileString(accountPath, "owner@example.test"); - const priorCache = process.env[SHARED_USAGE_CACHE_DIR_ENV]; - yield* Effect.acquireRelease( + it.effect.each( + [ + { + name: "reads fresh limits before returning", + readFails: false, + switchBefore: false, + switchOnConsume: false, + }, + { + name: "preserves a confirmed result when refresh fails", + readFails: true, + switchBefore: false, + switchOnConsume: false, + }, + { + name: "rejects a changed live account before consuming", + readFails: false, + switchBefore: true, + switchOnConsume: false, + }, + { + name: "does not cache a result after the account changes mid-reset", + readFails: false, + switchBefore: false, + switchOnConsume: true, + }, + ].map((scenario) => [scenario.name, scenario] as const), + )("redeems through the initialized CLI and %s", ([, scenario]) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "pylon-reset-credit-" }); + const logPath = NodePath.join(directory, "requests.jsonl"); + const accountPath = NodePath.join(directory, "account.txt"); + yield* fs.writeFileString(accountPath, "owner@example.test"); + const priorCache = process.env[SHARED_USAGE_CACHE_DIR_ENV]; + yield* Effect.acquireRelease( + Effect.sync(() => { + process.env[SHARED_USAGE_CACHE_DIR_ENV] = NodePath.join(directory, "usage-cache"); + }), + () => Effect.sync(() => { - process.env[SHARED_USAGE_CACHE_DIR_ENV] = NodePath.join(directory, "usage-cache"); + if (priorCache === undefined) delete process.env[SHARED_USAGE_CACHE_DIR_ENV]; + else process.env[SHARED_USAGE_CACHE_DIR_ENV] = priorCache; }), - () => - Effect.sync(() => { - if (priorCache === undefined) delete process.env[SHARED_USAGE_CACHE_DIR_ENV]; - else process.env[SHARED_USAGE_CACHE_DIR_ENV] = priorCache; - }), - ); - const quotedLogPath = yield* encodeFixtureString(logPath); - const quotedDirectory = yield* encodeFixtureString(directory); - const quotedAccountPath = yield* encodeFixtureString(accountPath); - const binaryPath = writeFakeCli({ - directory, - name: "codex", - source: ` + ); + const quotedLogPath = yield* encodeFixtureString(logPath); + const quotedDirectory = yield* encodeFixtureString(directory); + const quotedAccountPath = yield* encodeFixtureString(accountPath); + const binaryPath = writeFakeCli({ + directory, + name: "codex", + source: ` import { appendFileSync, readFileSync, writeFileSync } from "node:fs"; import { createInterface } from "node:readline"; const lines = createInterface({ input: process.stdin }); @@ -177,61 +178,56 @@ it.layer(testLayer)("CodexDriver", (it) => { process.stdout.write(JSON.stringify({ id: request.id, result }) + "\\n"); }); `, - }); - const instance = yield* CodexDriver.create({ - instanceId: ProviderInstanceId.make("codex-reset-test"), - displayName: "Reset test", - enabled: true, - environment: [], - config: { ...CodexDriver.defaultConfig(), binaryPath, homePath: directory }, - }); - yield* instance.snapshot.refresh; - const accountCacheKey = sharedUsageReadKey([ - "codex", - directory, - "email:owner@example.test", - ]); - const cacheBefore = yield* readSharedUsageEntry( - NodePath.join(directory, "usage-cache"), - accountCacheKey, - ); - if (scenario.switchBefore) yield* fs.writeFileString(accountPath, "other@example.test"); - const result = yield* instance.consumeResetCredit!({ - requestId: "stable-reset-attempt", - }).pipe(Effect.exit); - if (scenario.switchBefore) expect(result._tag).toBe("Failure"); - else { - expect(result._tag).toBe("Success"); - if (result._tag === "Success") { - expect(result.value.outcome).toBe("reset"); - if (scenario.readFails) - expect(result.value.warning).toContain("updated limits could not be read"); - else if (scenario.switchOnConsume) - expect(result.value.warning).toContain("account changed"); - else expect(result.value.warning).toBeUndefined(); - } - } - const requests = yield* fs.readFileString(logPath); - expect(requests).toContain('"method":"initialized"'); - if (scenario.switchBefore) { - expect(requests).not.toContain('"method":"account/rateLimitResetCredit/consume"'); - } else { - expect(requests).toContain('"idempotencyKey":"stable-reset-attempt"'); - expect(requests.indexOf('"method":"initialize"')).toBeLessThan( - requests.indexOf('"method":"account/rateLimitResetCredit/consume"'), - ); - expect(requests.indexOf('"method":"account/rateLimitResetCredit/consume"')).toBeLessThan( - requests.lastIndexOf('"method":"account/rateLimits/read"'), - ); - } - if (scenario.switchBefore || scenario.switchOnConsume) { - expect( - yield* readSharedUsageEntry(NodePath.join(directory, "usage-cache"), accountCacheKey), - ).toEqual(cacheBefore); + }); + const instance = yield* CodexDriver.create({ + instanceId: ProviderInstanceId.make("codex-reset-test"), + displayName: "Reset test", + enabled: true, + environment: [], + config: { ...CodexDriver.defaultConfig(), binaryPath, homePath: directory }, + }); + yield* instance.snapshot.refresh; + const accountCacheKey = sharedUsageReadKey(["codex", directory, "email:owner@example.test"]); + const cacheBefore = yield* readSharedUsageEntry( + NodePath.join(directory, "usage-cache"), + accountCacheKey, + ); + if (scenario.switchBefore) yield* fs.writeFileString(accountPath, "other@example.test"); + const result = yield* instance.consumeResetCredit!({ + requestId: "stable-reset-attempt", + }).pipe(Effect.exit); + if (scenario.switchBefore) expect(result._tag).toBe("Failure"); + else { + expect(result._tag).toBe("Success"); + if (result._tag === "Success") { + expect(result.value.outcome).toBe("reset"); + if (scenario.readFails) + expect(result.value.warning).toContain("updated limits could not be read"); + else if (scenario.switchOnConsume) + expect(result.value.warning).toContain("account changed"); + else expect(result.value.warning).toBeUndefined(); } - }).pipe(Effect.scoped), - ); - } + } + const requests = yield* fs.readFileString(logPath); + expect(requests).toContain('"method":"initialized"'); + if (scenario.switchBefore) { + expect(requests).not.toContain('"method":"account/rateLimitResetCredit/consume"'); + } else { + expect(requests).toContain('"idempotencyKey":"stable-reset-attempt"'); + expect(requests.indexOf('"method":"initialize"')).toBeLessThan( + requests.indexOf('"method":"account/rateLimitResetCredit/consume"'), + ); + expect(requests.indexOf('"method":"account/rateLimitResetCredit/consume"')).toBeLessThan( + requests.lastIndexOf('"method":"account/rateLimits/read"'), + ); + } + if (scenario.switchBefore || scenario.switchOnConsume) { + expect( + yield* readSharedUsageEntry(NodePath.join(directory, "usage-cache"), accountCacheKey), + ).toEqual(cacheBefore); + } + }).pipe(Effect.scoped), + ); it.effect("disconnect refreshes a restored managed account while its auth flow is idle", () => Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/CodexAbsoluteHistory.test.ts b/apps/server/src/provider/Layers/CodexAbsoluteHistory.test.ts index 2e8b75b698..23bc64b454 100644 --- a/apps/server/src/provider/Layers/CodexAbsoluteHistory.test.ts +++ b/apps/server/src/provider/Layers/CodexAbsoluteHistory.test.ts @@ -296,52 +296,49 @@ describe("readCodexConversation", () => { }), ); - for (const update of [ - { id: "foreign" }, - { cwd: "/tmp/foreign" }, - { ephemeral: true }, - { status: { type: "active", activeFlags: [] } }, - { status: { type: "systemError" } }, - { historyMode: "unknown" }, - ]) { - it.effect(`rejects unverified metadata ${JSON.stringify(update)}`, () => - Effect.gen(function* () { - const fixture = makeClient(); - fixture.hooks.response = (_method, _params, response) => replaceThread(response, update); - NodeAssert.equal( - (yield* Effect.flip(readSource(fixture.client)))._tag, - "CodexAppServerRequestError", - ); - }), - ); - } + it.effect.each( + [ + { id: "foreign" }, + { cwd: "/tmp/foreign" }, + { ephemeral: true }, + { status: { type: "active", activeFlags: [] } }, + { status: { type: "systemError" } }, + { historyMode: "unknown" }, + ].map((update) => [JSON.stringify(update), update] as const), + )("rejects unverified metadata %s", ([, update]) => + Effect.gen(function* () { + const fixture = makeClient(); + fixture.hooks.response = (_method, _params, response) => replaceThread(response, update); + NodeAssert.equal( + (yield* Effect.flip(readSource(fixture.client)))._tag, + "CodexAppServerRequestError", + ); + }), + ); - for (const turns of [ - [turn("one", { status: "inProgress" })], - [turn("one", { itemsView: "summary" })], - [turn("one", { itemsView: "notLoaded" })], - [turn("one"), turn("one")], - [{ id: "one", status: "completed" }], - [turn("one", { items: [{ type: "agentMessage", text: "missing id" }] })], + it.effect.each( [ - turn("one", { - items: [ - { id: "same", type: "plan" }, - { id: "same", type: "plan" }, - ], - }), - ], - ]) { - it.effect(`rejects incomplete or ambiguous history ${JSON.stringify(turns)}`, () => - Effect.gen(function* () { - const { client } = makeClient(turns); - NodeAssert.equal( - (yield* Effect.flip(readSource(client)))._tag, - "CodexAppServerRequestError", - ); - }), - ); - } + [turn("one", { status: "inProgress" })], + [turn("one", { itemsView: "summary" })], + [turn("one", { itemsView: "notLoaded" })], + [turn("one"), turn("one")], + [{ id: "one", status: "completed" }], + [turn("one", { items: [{ type: "agentMessage", text: "missing id" }] })], + [ + turn("one", { + items: [ + { id: "same", type: "plan" }, + { id: "same", type: "plan" }, + ], + }), + ], + ].map((turns) => [JSON.stringify(turns), turns] as const), + )("rejects incomplete or ambiguous history %s", ([, turns]) => + Effect.gen(function* () { + const { client } = makeClient(turns); + NodeAssert.equal((yield* Effect.flip(readSource(client)))._tag, "CodexAppServerRequestError"); + }), + ); it.effect("rejects pagination cycles before requesting the same page again", () => Effect.gen(function* () { @@ -463,8 +460,9 @@ describe("forkCodexConversation", () => { }), ); - for (const boundary of ["missing", "failed", "interrupted"]) { - it.effect(`rejects the non-completed boundary ${boundary} before issuing a fork`, () => + it.effect.each(["missing", "failed", "interrupted"])( + "rejects the non-completed boundary %s before issuing a fork", + (boundary) => Effect.gen(function* () { const fixture = makeClient([ turn("completed"), @@ -483,73 +481,72 @@ describe("forkCodexConversation", () => { false, ); }), - ); - } - - for (const update of [ - { id: "source" }, - { forkedFromId: "foreign" }, - { cwd: "/tmp/other" }, - { ephemeral: true }, - { status: { type: "active", activeFlags: [] } }, - { status: { type: "notLoaded" } }, - ]) { - it.effect(`rejects fork metadata ${JSON.stringify(update)}`, () => - Effect.gen(function* () { - const fixture = makeClient(); - const source = yield* readSource(fixture.client); - fixture.hooks.response = (method, _params, response) => - method === "thread/fork" ? replaceThread(response, update) : response; - NodeAssert.equal( - (yield* Effect.flip(forkCodexConversation(fixture.client, { source })))._tag, - "CodexAppServerRequestError", - ); - }), - ); - } + ); - for (const changed of [ - [turn("renamed"), turn("turn-2")], + it.effect.each( [ - turn("turn-1", { - items: [ - { - id: "renamed-item", - type: "userMessage", - content: [{ type: "text", text: "same prompt" }], - }, - ], - }), - turn("turn-2"), - ], + { id: "source" }, + { forkedFromId: "foreign" }, + { cwd: "/tmp/other" }, + { ephemeral: true }, + { status: { type: "active", activeFlags: [] } }, + { status: { type: "notLoaded" } }, + ].map((update) => [JSON.stringify(update), update] as const), + )("rejects fork metadata %s", ([, update]) => + Effect.gen(function* () { + const fixture = makeClient(); + const source = yield* readSource(fixture.client); + fixture.hooks.response = (method, _params, response) => + method === "thread/fork" ? replaceThread(response, update) : response; + NodeAssert.equal( + (yield* Effect.flip(forkCodexConversation(fixture.client, { source })))._tag, + "CodexAppServerRequestError", + ); + }), + ); + + it.effect.each( [ - turn("turn-1", { - items: [ - { - id: "turn-1-user", - type: "userMessage", - content: [{ type: "text", text: "changed prompt" }], - }, - ], - }), - turn("turn-2"), - ], - [turn("turn-2"), turn("turn-1")], - [turn("turn-1", { futureRelationship: "foreign-item" }), turn("turn-2")], - ]) { - it.effect(`rejects equal-length fork history changes ${JSON.stringify(changed)}`, () => - Effect.gen(function* () { - const fixture = makeClient(); - const source = yield* readSource(fixture.client); - fixture.hooks.response = (method, _params, response) => - method === "thread/fork" ? replaceThread(response, { turns: changed }) : response; - NodeAssert.match( - (yield* Effect.flip(forkCodexConversation(fixture.client, { source }))).message, - /changed retained history/, - ); - }), - ); - } + [turn("renamed"), turn("turn-2")], + [ + turn("turn-1", { + items: [ + { + id: "renamed-item", + type: "userMessage", + content: [{ type: "text", text: "same prompt" }], + }, + ], + }), + turn("turn-2"), + ], + [ + turn("turn-1", { + items: [ + { + id: "turn-1-user", + type: "userMessage", + content: [{ type: "text", text: "changed prompt" }], + }, + ], + }), + turn("turn-2"), + ], + [turn("turn-2"), turn("turn-1")], + [turn("turn-1", { futureRelationship: "foreign-item" }), turn("turn-2")], + ].map((changed) => [JSON.stringify(changed), changed] as const), + )("rejects equal-length fork history changes %s", ([, changed]) => + Effect.gen(function* () { + const fixture = makeClient(); + const source = yield* readSource(fixture.client); + fixture.hooks.response = (method, _params, response) => + method === "thread/fork" ? replaceThread(response, { turns: changed }) : response; + NodeAssert.match( + (yield* Effect.flip(forkCodexConversation(fixture.client, { source }))).message, + /changed retained history/, + ); + }), + ); it.effect("does not trust a correct fork response when rereading reveals different history", () => Effect.gen(function* () { @@ -701,8 +698,9 @@ describe("native conversation proof", () => { }), ); - for (const hidden of hiddenContexts) { - it.effect(`includes ${String(hidden.type)} context omitted by the public transcript`, () => + it.effect.each(hiddenContexts.map((hidden) => ({ hidden, name: String(hidden.type) })))( + "includes $name context omitted by the public transcript", + ({ hidden }) => Effect.gen(function* () { const fixture = makeClient(); const sourceThread = fixture.threads.get("source")!; @@ -729,8 +727,7 @@ describe("native conversation proof", () => { /changed retained turn content or identities/, ); }), - ); - } + ); it.effect( "retains opaque records after the terminal event through the exact next native start", @@ -823,30 +820,33 @@ describe("native conversation proof", () => { }), ); - for (const metadata of [ - { id: "foreign" }, - { cwd: "/tmp/foreign" }, - { history_base: { thread_id: "unread-base", end_ordinal_exclusive: 4, end_byte_offset: 100 } }, - { context_window: { window_id: 7 } }, - ]) - it.effect(`rejects unprovable native session metadata ${JSON.stringify(metadata)}`, () => - Effect.gen(function* () { - const fixture = makeClient(); - fixture.threads.get("source")!.nativeRecords = [ - nativeRecord("session_meta", { - id: "source", - session_id: "source", - timestamp: "2026-01-01T00:00:00.000Z", - cwd: CWD, - ...metadata, - }), - ]; - NodeAssert.equal( - (yield* Effect.flip(readSource(fixture.client)))._tag, - "CodexAppServerRequestError", - ); - }), - ); + it.effect.each( + [ + { id: "foreign" }, + { cwd: "/tmp/foreign" }, + { + history_base: { thread_id: "unread-base", end_ordinal_exclusive: 4, end_byte_offset: 100 }, + }, + { context_window: { window_id: 7 } }, + ].map((metadata) => [JSON.stringify(metadata), metadata] as const), + )("rejects unprovable native session metadata %s", ([, metadata]) => + Effect.gen(function* () { + const fixture = makeClient(); + fixture.threads.get("source")!.nativeRecords = [ + nativeRecord("session_meta", { + id: "source", + session_id: "source", + timestamp: "2026-01-01T00:00:00.000Z", + cwd: CWD, + ...metadata, + }), + ]; + NodeAssert.equal( + (yield* Effect.flip(readSource(fixture.client)))._tag, + "CodexAppServerRequestError", + ); + }), + ); it.effect("fails closed without a provider-owned rollout reader", () => Effect.gen(function* () { @@ -858,37 +858,39 @@ describe("native conversation proof", () => { }), ); - for (const path of [null, "relative.jsonl", "", "/tmp/invalid\0.jsonl"]) - it.effect(`rejects native path ${JSON.stringify(path)}`, () => - Effect.gen(function* () { - const fixture = makeClient(); - fixture.hooks.response = (method, _params, response) => - method === "thread/read" ? replaceThread(response, { path }) : response; - NodeAssert.match( - (yield* Effect.flip(readSource(fixture.client))).message, - /proof is unavailable/, - ); - }), - ); + it.effect.each( + [null, "relative.jsonl", "", "/tmp/invalid\0.jsonl"].map( + (path) => [JSON.stringify(path), path] as const, + ), + )("rejects native path %s", ([, path]) => + Effect.gen(function* () { + const fixture = makeClient(); + fixture.hooks.response = (method, _params, response) => + method === "thread/read" ? replaceThread(response, { path }) : response; + NodeAssert.match( + (yield* Effect.flip(readSource(fixture.client))).message, + /proof is unavailable/, + ); + }), + ); - for (const [label, invalid] of [ - ["truncated", '{"type":"session_meta"}'], - ["malformed", "not JSON\n"], - ["empty", "\n"], - ["missing envelope", "{}\n"], - ["oversized line", `${" ".repeat(1024 * 1024 + 1)}\n`], - ["oversized file", `${" ".repeat(16 * 1024 * 1024)}\n`], - ["too many records", "{}\n".repeat(100_001)], - ]) - it.effect(`rejects ${label} native JSONL without disclosing content`, () => - Effect.gen(function* () { - const fixture = makeClient(); - fixture.hooks.rollout = () => invalid!; - const error = yield* Effect.flip(readSource(fixture.client)); - NodeAssert.equal(error._tag, "CodexAppServerRequestError"); - NodeAssert.doesNotMatch(error.message, /native system instructions|retained native memory/); - }), - ); + it.effect.each([ + { label: "truncated", invalid: '{"type":"session_meta"}' }, + { label: "malformed", invalid: "not JSON\n" }, + { label: "empty", invalid: "\n" }, + { label: "missing envelope", invalid: "{}\n" }, + { label: "oversized line", invalid: `${" ".repeat(1024 * 1024 + 1)}\n` }, + { label: "oversized file", invalid: `${" ".repeat(16 * 1024 * 1024)}\n` }, + { label: "too many records", invalid: "{}\n".repeat(100_001) }, + ])("rejects $label native JSONL without disclosing content", ({ invalid }) => + Effect.gen(function* () { + const fixture = makeClient(); + fixture.hooks.rollout = () => invalid; + const error = yield* Effect.flip(readSource(fixture.client)); + NodeAssert.equal(error._tag, "CodexAppServerRequestError"); + NodeAssert.doesNotMatch(error.message, /native system instructions|retained native memory/); + }), + ); it.effect("preserves unknown header context instead of treating it as generated identity", () => Effect.gen(function* () { @@ -926,8 +928,9 @@ describe("native goal proof", () => { unknown: { futureGoalState: true }, }); - for (const status of ["paused", "blocked", "usageLimited", "budgetLimited", "complete"]) - it.effect(`retains an idle ${status} goal through native deferred copying`, () => + it.effect.each(["paused", "blocked", "usageLimited", "budgetLimited", "complete"])( + "retains an idle %s goal through native deferred copying", + (status) => Effect.gen(function* () { const fixture = makeClient(); fixture.threads.get("source")!.nativeGoal = goal(status); @@ -942,25 +945,26 @@ describe("native goal proof", () => { false, ); }), - ); + ); - for (const nativeGoal of [ - goal("active"), - goal("futureActive"), - { ...goal("paused"), threadId: "foreign" }, - { threadId: "source", status: "paused" }, - { ...goal("paused"), tokensUsed: "unknown" }, - ]) - it.effect(`rejects unproven native goal ${JSON.stringify(nativeGoal)}`, () => - Effect.gen(function* () { - const fixture = makeClient(); - fixture.threads.get("source")!.nativeGoal = nativeGoal; - NodeAssert.equal( - (yield* Effect.flip(readSource(fixture.client)))._tag, - "CodexAppServerRequestError", - ); - }), - ); + it.effect.each( + [ + goal("active"), + goal("futureActive"), + { ...goal("paused"), threadId: "foreign" }, + { threadId: "source", status: "paused" }, + { ...goal("paused"), tokensUsed: "unknown" }, + ].map((nativeGoal) => [JSON.stringify(nativeGoal), nativeGoal] as const), + )("rejects unproven native goal %s", ([, nativeGoal]) => + Effect.gen(function* () { + const fixture = makeClient(); + fixture.threads.get("source")!.nativeGoal = nativeGoal; + NodeAssert.equal( + (yield* Effect.flip(readSource(fixture.client)))._tag, + "CodexAppServerRequestError", + ); + }), + ); it.effect( "does not establish exact eligibility when native goal introspection is unsupported", diff --git a/apps/server/src/provider/Layers/antigravityUsageLimits.test.ts b/apps/server/src/provider/Layers/antigravityUsageLimits.test.ts index 3dcf8c3bcd..3b40ca54cd 100644 --- a/apps/server/src/provider/Layers/antigravityUsageLimits.test.ts +++ b/apps/server/src/provider/Layers/antigravityUsageLimits.test.ts @@ -67,7 +67,7 @@ describe("native Antigravity quota schema", () => { }); it.layer(NodeServices.layer)("ACP profile quota reads", (it) => { - for (const mode of [ + it.effect.each([ "consumer", "enterprise", "missing", @@ -76,78 +76,76 @@ it.layer(NodeServices.layer)("ACP profile quota reads", (it) => { "oversize", "account-change", "api-key", - ] as const) { - it.effect(mode, () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped(); - yield* fs.makeDirectory(path.join(root, "antigravity-acp")); - const tokenPath = path.join(root, "antigravity-acp", "acp_token.json"); - const credential = encodeJson({ - token_uri: - mode === "wrong-uri" - ? "https://example.invalid/token" - : "https://oauth2.googleapis.com/token", - client_id: "profile-client", - client_secret: "fixture-secret", - refresh_token: "profile-refresh", - project_id: "saved-project", - }); - if (mode !== "missing") yield* fs.writeFileString(tokenPath, credential); - const urls: string[] = []; - const http = HttpClient.make((request) => - Effect.gen(function* () { - urls.push(request.url); - if (request.url.endsWith("/token")) { - expect(request.body._tag).toBe("Uint8Array"); - if (request.body._tag === "Uint8Array") - expect(new TextDecoder().decode(request.body.body)).toContain( - "refresh_token=profile-refresh", - ); - return HttpClientResponse.fromWeb( - request, - Response.json({ access_token: "fixture-access" }), + ] as const)("%s", (mode) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped(); + yield* fs.makeDirectory(path.join(root, "antigravity-acp")); + const tokenPath = path.join(root, "antigravity-acp", "acp_token.json"); + const credential = encodeJson({ + token_uri: + mode === "wrong-uri" + ? "https://example.invalid/token" + : "https://oauth2.googleapis.com/token", + client_id: "profile-client", + client_secret: "fixture-secret", + refresh_token: "profile-refresh", + project_id: "saved-project", + }); + if (mode !== "missing") yield* fs.writeFileString(tokenPath, credential); + const urls: string[] = []; + const http = HttpClient.make((request) => + Effect.gen(function* () { + urls.push(request.url); + if (request.url.endsWith("/token")) { + expect(request.body._tag).toBe("Uint8Array"); + if (request.body._tag === "Uint8Array") + expect(new TextDecoder().decode(request.body.body)).toContain( + "refresh_token=profile-refresh", ); - } - expect(request.headers.authorization).toBe("Bearer fixture-access"); - expect(request.headers["user-agent"]).toContain("antigravity/acp/1.1.1 (aidev_client;"); - if (request.url.endsWith("loadCodeAssist")) - return HttpClientResponse.fromWeb( - request, - Response.json({ - cloudaicompanionProject: "account-project", - paidTier: { usesGcpTos: mode === "enterprise" }, - }), - ); - if (mode === "account-change") - yield* fs.writeFileString(tokenPath, credential + " ").pipe(Effect.orDie); - const body = mode === "oversize" ? " ".repeat(300_000) : encodeJson(summary); return HttpClientResponse.fromWeb( request, - new Response(body, { status: mode === "http-error" ? 403 : 200 }), + Response.json({ access_token: "fixture-access" }), ); - }), - ); - const limits = yield* readAntigravityUsageLimits({ - profileDirectory: root, - authMethod: mode === "api-key" ? "gemini-api-key" : "oauth-personal", - runtimeVersion: "1.1.1", - }).pipe(Effect.provideService(HttpClient.HttpClient, http)); - if (mode === "consumer" || mode === "enterprise") { - expect(limits.windows).toHaveLength(3); - expect(urls[2]).toBe( - `https://${mode === "enterprise" ? "cloudcode-pa" : "daily-cloudcode-pa"}.googleapis.com/v1internal:retrieveUserQuotaSummary`, + } + expect(request.headers.authorization).toBe("Bearer fixture-access"); + expect(request.headers["user-agent"]).toContain("antigravity/acp/1.1.1 (aidev_client;"); + if (request.url.endsWith("loadCodeAssist")) + return HttpClientResponse.fromWeb( + request, + Response.json({ + cloudaicompanionProject: "account-project", + paidTier: { usesGcpTos: mode === "enterprise" }, + }), + ); + if (mode === "account-change") + yield* fs.writeFileString(tokenPath, credential + " ").pipe(Effect.orDie); + const body = mode === "oversize" ? " ".repeat(300_000) : encodeJson(summary); + return HttpClientResponse.fromWeb( + request, + new Response(body, { status: mode === "http-error" ? 403 : 200 }), ); - } else { - expect(limits.windows).toEqual([]); - expect(limits.unavailable).toBeDefined(); - } - if (mode === "missing" || mode === "wrong-uri" || mode === "api-key") - expect(urls).toEqual([]); - if (mode !== "missing" && mode !== "account-change") - expect(yield* fs.readFileString(tokenPath)).toBe(credential); - }), - ); - } + }), + ); + const limits = yield* readAntigravityUsageLimits({ + profileDirectory: root, + authMethod: mode === "api-key" ? "gemini-api-key" : "oauth-personal", + runtimeVersion: "1.1.1", + }).pipe(Effect.provideService(HttpClient.HttpClient, http)); + if (mode === "consumer" || mode === "enterprise") { + expect(limits.windows).toHaveLength(3); + expect(urls[2]).toBe( + `https://${mode === "enterprise" ? "cloudcode-pa" : "daily-cloudcode-pa"}.googleapis.com/v1internal:retrieveUserQuotaSummary`, + ); + } else { + expect(limits.windows).toEqual([]); + expect(limits.unavailable).toBeDefined(); + } + if (mode === "missing" || mode === "wrong-uri" || mode === "api-key") + expect(urls).toEqual([]); + if (mode !== "missing" && mode !== "account-change") + expect(yield* fs.readFileString(tokenPath)).toBe(credential); + }), + ); }); diff --git a/apps/server/src/provider/ModelManifest.test.ts b/apps/server/src/provider/ModelManifest.test.ts index 368c4d6161..8fe75c7dda 100644 --- a/apps/server/src/provider/ModelManifest.test.ts +++ b/apps/server/src/provider/ModelManifest.test.ts @@ -699,39 +699,47 @@ describe("compatibility policy by release channel", () => { ], }; - for (const [channel, t3CodeVersion, expected, openCode2Status] of [ - [ - "preview builds keep the bundled policy", - "0.0.44-preview.20260929.1", - { ...remote, compatibility: ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility }, - "supported", - ], - ["stable builds adopt the fetched policy", "0.0.44", remote, "broken"], - ["nightly builds adopt the fetched policy", "0.0.44-nightly.20260929.1", remote, "broken"], - ] as const) { - it.live(channel, () => - Effect.gen(function* () { - const openCode2 = (manifest: ModelManifest.ModelManifestData) => - resolveProviderCompatibility(manifest.compatibility, OPENCODE, "2.0.18", t3CodeVersion) - ?.status; - const refreshed = yield* (yield* ModelManifest.makeForVersion(t3CodeVersion)).refresh; - assert.deepStrictEqual(refreshed, expected); - assert.strictEqual(openCode2(refreshed), openCode2Status); - - // A restart reads the fetched manifest back from the disk cache. - const rebooted = yield* (yield* ModelManifest.makeForVersion(t3CodeVersion)).current; - assert.deepStrictEqual(rebooted, expected); - }).pipe( - Effect.scoped, - Effect.provide( - serviceLayers({ - prefix: "model-manifest-channel-compatibility-test", - response: () => Response.json(remote), - }), - ), + it.live.each([ + { + channel: "preview builds keep the bundled policy", + t3CodeVersion: "0.0.44-preview.20260929.1", + expected: { ...remote, compatibility: ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility }, + openCode2Status: "supported", + }, + { + channel: "stable builds adopt the fetched policy", + t3CodeVersion: "0.0.44", + expected: remote, + openCode2Status: "broken", + }, + { + channel: "nightly builds adopt the fetched policy", + t3CodeVersion: "0.0.44-nightly.20260929.1", + expected: remote, + openCode2Status: "broken", + }, + ] as const)("$channel", ({ t3CodeVersion, expected, openCode2Status }) => + Effect.gen(function* () { + const openCode2 = (manifest: ModelManifest.ModelManifestData) => + resolveProviderCompatibility(manifest.compatibility, OPENCODE, "2.0.18", t3CodeVersion) + ?.status; + const refreshed = yield* (yield* ModelManifest.makeForVersion(t3CodeVersion)).refresh; + assert.deepStrictEqual(refreshed, expected); + assert.strictEqual(openCode2(refreshed), openCode2Status); + + // A restart reads the fetched manifest back from the disk cache. + const rebooted = yield* (yield* ModelManifest.makeForVersion(t3CodeVersion)).current; + assert.deepStrictEqual(rebooted, expected); + }).pipe( + Effect.scoped, + Effect.provide( + serviceLayers({ + prefix: "model-manifest-channel-compatibility-test", + response: () => Response.json(remote), + }), ), - ); - } + ), + ); }); it.effect("caches valid compatibility policies and keeps them after a malformed refresh", () => { diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index eb0b9f6b48..a4dbcb3b7d 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/provider/acp/AcpSessionRuntime.test.ts b/apps/server/src/provider/acp/AcpSessionRuntime.test.ts index 14f658c6a9..26b48f9b69 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.test.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.test.ts @@ -307,64 +307,64 @@ it.effect("exempts watchdog from prompt inactivity timeout when tool calls are i }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); -for (const scenario of [ - { - name: "refreshes the deadline on root tool progress", - method: "tool-progress", - remaining: 2000, - }, - { - name: "does not refresh the deadline on foreign progress", - method: "foreign-progress", - remaining: 1250, - }, - { - name: "restores the shorter timeout after tool completion", - method: "tool-complete", - remaining: 500, - }, -]) { - it.effect(`prompt watchdog ${scenario.name}`, () => - Effect.gen(function* () { - const toolStarted = yield* Deferred.make(); - const runtime = yield* make({ - spawn: { - command: process.execPath, - args: mockAgentArgs, - env: { T3_ACP_EMIT_ACTIVE_TOOL_THEN_HANG: "1" }, - }, - cwd: process.cwd(), - promptInactivityTimeout: "500 millis", - toolInactivityTimeout: "2 seconds", - clientInfo: { name: "watchdog-progress-test", version: "0.0.0" }, - }); - yield* runtime.getEvents().pipe( - Stream.runForEach((event) => { - if (event._tag === "EventStreamBarrier") { - return Deferred.succeed(event.acknowledge, undefined).pipe(Effect.asVoid); - } - return event._tag === "ToolCallUpdated" && event.toolCall.status === "inProgress" - ? Deferred.succeed(toolStarted, undefined).pipe(Effect.asVoid) - : Effect.void; - }), - Effect.forkScoped, - ); - yield* runtime.start(); - const promptFiber = yield* runtime - .prompt({ prompt: [{ type: "text", text: "run tool" }] }) - .pipe(Effect.forkChild); - yield* Deferred.await(toolStarted); - yield* TestClock.adjust("750 millis"); - yield* runtime.request(`_test/watchdog-${scenario.method}`, {}); - yield* runtime.drainEvents; - yield* TestClock.adjust(scenario.remaining - 1); - assert.isUndefined(promptFiber.pollUnsafe()); - yield* TestClock.adjust(1); - const error = yield* Fiber.join(promptFiber).pipe(Effect.flip); - assert.equal(error._tag, "AcpTransportError"); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); -} +it.effect.each( + [ + { + name: "refreshes the deadline on root tool progress", + method: "tool-progress", + remaining: 2000, + }, + { + name: "does not refresh the deadline on foreign progress", + method: "foreign-progress", + remaining: 1250, + }, + { + name: "restores the shorter timeout after tool completion", + method: "tool-complete", + remaining: 500, + }, + ].map((scenario) => [scenario.name, scenario] as const), +)("prompt watchdog %s", ([, scenario]) => + Effect.gen(function* () { + const toolStarted = yield* Deferred.make(); + const runtime = yield* make({ + spawn: { + command: process.execPath, + args: mockAgentArgs, + env: { T3_ACP_EMIT_ACTIVE_TOOL_THEN_HANG: "1" }, + }, + cwd: process.cwd(), + promptInactivityTimeout: "500 millis", + toolInactivityTimeout: "2 seconds", + clientInfo: { name: "watchdog-progress-test", version: "0.0.0" }, + }); + yield* runtime.getEvents().pipe( + Stream.runForEach((event) => { + if (event._tag === "EventStreamBarrier") { + return Deferred.succeed(event.acknowledge, undefined).pipe(Effect.asVoid); + } + return event._tag === "ToolCallUpdated" && event.toolCall.status === "inProgress" + ? Deferred.succeed(toolStarted, undefined).pipe(Effect.asVoid) + : Effect.void; + }), + Effect.forkScoped, + ); + yield* runtime.start(); + const promptFiber = yield* runtime + .prompt({ prompt: [{ type: "text", text: "run tool" }] }) + .pipe(Effect.forkChild); + yield* Deferred.await(toolStarted); + yield* TestClock.adjust("750 millis"); + yield* runtime.request(`_test/watchdog-${scenario.method}`, {}); + yield* runtime.drainEvents; + yield* TestClock.adjust(scenario.remaining - 1); + assert.isUndefined(promptFiber.pollUnsafe()); + yield* TestClock.adjust(1); + const error = yield* Fiber.join(promptFiber).pipe(Effect.flip); + assert.equal(error._tag, "AcpTransportError"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); it.effect("Antigravity bounds silent tools without applying the shorter prompt timeout", () => Effect.gen(function* () { diff --git a/apps/server/src/provider/legacy/LegacyAdapterV2Bridge.test.ts b/apps/server/src/provider/legacy/LegacyAdapterV2Bridge.test.ts index 5954d2d846..0262a84288 100644 --- a/apps/server/src/provider/legacy/LegacyAdapterV2Bridge.test.ts +++ b/apps/server/src/provider/legacy/LegacyAdapterV2Bridge.test.ts @@ -432,8 +432,9 @@ describe("legacy runtime event projector", () => { }); }); - for (const status of ["completed", "interrupted", "cancelled", "failed"] as const) { - it(`preserves the ${status} terminal disposition`, () => { + it.each(["completed", "interrupted", "cancelled", "failed"] as const)( + "preserves the %s terminal disposition", + (status) => { const { projector } = fixture(); projector.project(event("turn.started", {})); const output = projector.project( @@ -447,8 +448,8 @@ describe("legacy runtime event projector", () => { expect(output.filter((row) => row.type === "turn.terminal")).toEqual([ expect.objectContaining({ status, threadDisposition: "reusable" }), ]); - }); - } + }, + ); it("projects aborts and starts a later run without combining assistant messages", () => { const { projector, providerThread } = fixture(); @@ -796,81 +797,82 @@ describe("legacy v2 runtime", () => { ), ); - for (const stopFails of [false, true]) - it.effect( - `keeps maintenance quarantined after native teardown ${stopFails ? "failure" : "timeout"} until cleanup proof`, - () => - Effect.scoped( - Effect.gen(function* () { - let hasOwnership = false; - const maintenance = yield* Maintenance.make({ - inspectNativeOwnership: () => - Effect.succeed({ hasOwnership, quiescent: !hasOwnership }), - }); - const scope = yield* Scope.make(); - const adapter = legacyAdapter({ - startSession: (input) => - Effect.sync(() => { - hasOwnership = true; - return { - provider: driver, - providerInstanceId: instanceId, - threadId: input.threadId, - status: "ready" as const, - runtimeMode: "full-access" as const, - cwd: "/workspace", - model: "prime-model", - createdAt: time, - updatedAt: time, - }; - }), - stopSession: () => - stopFails - ? Effect.fail( - new ProviderAdapterValidationError({ - provider: driver, - operation: "stopSession", - issue: "Native cleanup failed", - }), - ) - : Effect.void, - }); - yield* makeLegacyAdapterV2({ instanceId, adapter, maintenance }) - .openSession({ - threadId, - providerSessionId, - modelSelection: { instanceId, model: "prime-model" }, - runtimePolicy: { - runtimeMode: "full-access", - interactionMode: "default", + it.effect.each( + [false, true].map((stopFails) => ({ stopFails, name: stopFails ? "failure" : "timeout" })), + )( + "keeps maintenance quarantined after native teardown $name until cleanup proof", + ({ stopFails }) => + Effect.scoped( + Effect.gen(function* () { + let hasOwnership = false; + const maintenance = yield* Maintenance.make({ + inspectNativeOwnership: () => + Effect.succeed({ hasOwnership, quiescent: !hasOwnership }), + }); + const scope = yield* Scope.make(); + const adapter = legacyAdapter({ + startSession: (input) => + Effect.sync(() => { + hasOwnership = true; + return { + provider: driver, + providerInstanceId: instanceId, + threadId: input.threadId, + status: "ready" as const, + runtimeMode: "full-access" as const, cwd: "/workspace", - }, - }) - .pipe(Scope.provide(scope)); - const drained = yield* maintenance.subscribeDrainedInstances; - const drainReceipt = yield* drained.pipe( - Stream.take(1), - Stream.runCollect, - Effect.forkScoped({ startImmediately: true }), - ); - yield* Scope.close(scope, Exit.void); - expect(yield* maintenance.reserveProviderMaintenance(instanceId)).toMatchObject({ - status: "busy", - }); - expect(yield* maintenance.acquireRuntime(instanceId).pipe(Effect.result)).toMatchObject( - { _tag: "Failure" }, - ); - hasOwnership = false; - const next = yield* Scope.make(); - yield* maintenance.acquireRuntime(instanceId).pipe(Scope.provide(next)); - yield* Scope.close(next, Exit.void); - expect(yield* Fiber.join(drainReceipt)).toEqual([instanceId]); - expect(yield* maintenance.reserveProviderMaintenance(instanceId)).toMatchObject({ - status: "reserved", - }); - }), - ), - ); + model: "prime-model", + createdAt: time, + updatedAt: time, + }; + }), + stopSession: () => + stopFails + ? Effect.fail( + new ProviderAdapterValidationError({ + provider: driver, + operation: "stopSession", + issue: "Native cleanup failed", + }), + ) + : Effect.void, + }); + yield* makeLegacyAdapterV2({ instanceId, adapter, maintenance }) + .openSession({ + threadId, + providerSessionId, + modelSelection: { instanceId, model: "prime-model" }, + runtimePolicy: { + runtimeMode: "full-access", + interactionMode: "default", + cwd: "/workspace", + }, + }) + .pipe(Scope.provide(scope)); + const drained = yield* maintenance.subscribeDrainedInstances; + const drainReceipt = yield* drained.pipe( + Stream.take(1), + Stream.runCollect, + Effect.forkScoped({ startImmediately: true }), + ); + yield* Scope.close(scope, Exit.void); + expect(yield* maintenance.reserveProviderMaintenance(instanceId)).toMatchObject({ + status: "busy", + }); + expect(yield* maintenance.acquireRuntime(instanceId).pipe(Effect.result)).toMatchObject({ + _tag: "Failure", + }); + hasOwnership = false; + const next = yield* Scope.make(); + yield* maintenance.acquireRuntime(instanceId).pipe(Scope.provide(next)); + yield* Scope.close(next, Exit.void); + expect(yield* Fiber.join(drainReceipt)).toEqual([instanceId]); + expect(yield* maintenance.reserveProviderMaintenance(instanceId)).toMatchObject({ + status: "reserved", + }); + }), + ), + ); it("keeps native background ownership alive until queues, compaction and tasks drain", () => { const { projector } = fixture(); diff --git a/apps/server/src/provider/legacy/LegacyAdapterV2SessionManager.test.ts b/apps/server/src/provider/legacy/LegacyAdapterV2SessionManager.test.ts new file mode 100644 index 0000000000..76c5880b38 --- /dev/null +++ b/apps/server/src/provider/legacy/LegacyAdapterV2SessionManager.test.ts @@ -0,0 +1,361 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + EnvironmentId, + IsoDateTime, + type ModelSelection, + type OrchestrationV2AppThread, + ProviderDriverKind, + ProviderInstanceId, + type ProviderSession, + ThreadId, +} from "@t3tools/contracts"; +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 Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import { TestClock } from "effect/testing"; +import { HttpServer } from "effect/unstable/http"; +import * as NetAddress from "effect/unstable/net/NetAddress"; + +import * as ServerEnvironment from "../../environment/ServerEnvironment.ts"; +import * as McpSessionRegistry from "../../mcp/McpSessionRegistry.ts"; +import * as EventSink from "../../orchestration-v2/EventSink.ts"; +import * as EventStore from "../../orchestration-v2/EventStore.ts"; +import * as IdAllocator from "../../orchestration-v2/IdAllocator.ts"; +import * as ProjectionStore from "../../orchestration-v2/ProjectionStore.ts"; +import type { ProviderAdapterV2RuntimePolicy } from "../../orchestration-v2/ProviderAdapter.ts"; +import * as ProviderAdapterRegistry from "../../orchestration-v2/ProviderAdapterRegistry.ts"; +import * as ProviderEventIngestor from "../../orchestration-v2/ProviderEventIngestor.ts"; +import * as ProviderSessionManager from "../../orchestration-v2/ProviderSessionManager.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { makeLegacyAdapterV2 } from "./LegacyAdapterV2Bridge.ts"; +import * as Maintenance from "./LegacyAdapterV2Maintenance.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +// Pylon regression coverage: the Prime v1->v2 bridge acquires a maintenance +// runtime fence inside the v2 session scope. Releasing that scope through the +// upstream ProviderSessionManager must stop the legacy runtime and publish the +// drain receipt, including when the release records fail to persist. + +const driver = ProviderDriverKind.make("primeAgent"); +const instanceId = ProviderInstanceId.make("prime-session-manager"); +const time = IsoDateTime.make("2026-10-03T12:00:00.000Z"); +const modelSelection = { instanceId, model: "prime-model" } satisfies ModelSelection; +const runtimePolicy = { + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), +} satisfies ProviderAdapterV2RuntimePolicy; + +type FailingReleaseWrites = Ref.Ref; + +interface LegacyRuntime { + readonly stopped: Queue.Queue; + readonly failStream: Deferred.Deferred; +} + +function legacyAdapter(runtime: LegacyRuntime): ProviderAdapterShape { + const session = (threadId: ThreadId): ProviderSession => ({ + provider: driver, + providerInstanceId: instanceId, + threadId, + status: "ready", + runtimeMode: "full-access", + cwd: process.cwd(), + model: "prime-model", + createdAt: time, + updatedAt: time, + }); + return { + provider: driver, + capabilities: { sessionModelSwitch: "in-session", conversationRollback: "unsupported" }, + startSession: (input) => Effect.succeed(session(input.threadId)), + sendTurn: (input) => Effect.die(`unused sendTurn ${input.threadId}`), + interruptTurn: () => Effect.void, + respondToRequest: () => Effect.void, + respondToUserInput: () => Effect.void, + readThread: (threadId) => Effect.succeed({ threadId, turns: [] }), + rollbackThread: (threadId) => Effect.succeed({ threadId, turns: [] }), + stopSession: (threadId) => Queue.offer(runtime.stopped, threadId).pipe(Effect.asVoid), + hasSession: () => Effect.succeed(true), + listSessions: () => Effect.succeed([]), + stopAll: () => Effect.void, + // The daemon stream stays open until a test makes it fail, as when the + // Prime daemon exits underneath a live session. + streamEvents: Stream.fromEffect(Deferred.await(runtime.failStream)).pipe( + Stream.flatMap(() => Stream.die(new Error("Prime daemon exited"))), + ), + }; +} + +const TestDatabaseLayer = SqlitePersistenceMemory; +const TestStoresLayer = Layer.merge(EventStore.layer, ProjectionStore.layer).pipe( + Layer.provide(TestDatabaseLayer), +); +const TestEventSinkLayer = EventSink.layer.pipe( + Layer.provide(Layer.mergeAll(TestStoresLayer, TestDatabaseLayer)), +); + +// Fails stopped/error session writes with a defect, as a failed SQL commit does. +const makeFlakyEventSinkLayer = (failing: FailingReleaseWrites) => + Layer.effect( + EventSink.EventSinkV2, + Effect.gen(function* () { + const delegate = yield* EventSink.EventSinkV2; + return EventSink.EventSinkV2.of({ + ...delegate, + write: (input) => + Effect.gen(function* () { + const fails = + (yield* Ref.get(failing)) && + input.events.some( + (event) => + event.type === "provider-session.updated" && + (event.payload.status === "stopped" || event.payload.status === "error"), + ); + if (fails) return yield* Effect.die(new Error("simulated commit failure")); + return yield* delegate.write(input); + }), + }); + }), + ).pipe(Layer.provide(TestEventSinkLayer)); + +const TestMcpRegistryLayer = Layer.effect( + McpSessionRegistry.McpSessionRegistry, + McpSessionRegistry.__testing.make(), +).pipe( + Layer.provide( + Layer.succeed( + HttpServer.HttpServer, + HttpServer.HttpServer.of({ + address: NetAddress.inetAddressFromIpStringUnsafe("127.0.0.1", 43124), + serve: (() => Effect.void) as HttpServer.HttpServer["Service"]["serve"], + }), + ), + ), + Layer.provide( + Layer.succeed( + ServerEnvironment.ServerEnvironment, + ServerEnvironment.ServerEnvironment.of({ + getEnvironmentId: Effect.succeed(EnvironmentId.make("environment-prime-session-manager")), + getDescriptor: Effect.die("unused"), + }), + ), + ), + Layer.provide(NodeServices.layer), +); + +function makeTestLayer(input: { + readonly runtime: LegacyRuntime; + readonly maintenance: Maintenance.LegacyAdapterV2MaintenanceShape; + readonly failing: FailingReleaseWrites; +}) { + const eventSinkLayer = makeFlakyEventSinkLayer(input.failing); + const registryLayer = ProviderAdapterRegistry.makeSingleLayer( + makeLegacyAdapterV2({ + instanceId, + adapter: legacyAdapter(input.runtime), + maintenance: input.maintenance, + }), + ); + const ingestorLayer = ProviderEventIngestor.layer.pipe( + Layer.provide(Layer.mergeAll(eventSinkLayer, IdAllocator.layer, TestStoresLayer)), + ); + return Layer.mergeAll( + TestStoresLayer, + eventSinkLayer, + IdAllocator.layer, + ProviderSessionManager.layerWithOptions({ idleTimeoutMs: 60_000 }).pipe( + Layer.provide( + Layer.mergeAll( + registryLayer, + eventSinkLayer, + IdAllocator.layer, + ingestorLayer, + TestMcpRegistryLayer, + TestStoresLayer, + ), + ), + ), + ).pipe(Layer.provide(NodeServices.layer)); +} + +const writeThread = (threadId: ThreadId) => + Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const now = yield* DateTime.now; + const thread: OrchestrationV2AppThread = { + createdBy: "user", + creationSource: "web", + id: threadId, + projectId: yield* idAllocator.allocate.project({ fixtureName: "prime-session-manager" }), + title: "Prime session manager", + providerInstanceId: instanceId, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }; + yield* eventSink.write({ + events: [ + { + id: yield* idAllocator.allocate.event({ threadId }), + type: "thread.created", + threadId, + occurredAt: now, + payload: thread, + }, + ], + }); + return yield* idAllocator.allocate.providerSession({ + providerInstanceId: instanceId, + threadId, + }); + }); + +/** Waits for the next stopped/error session record after the current sequence. */ +const nextSessionRecord = (threadId: ThreadId) => + Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + return yield* eventSink + .stream({ + threadId, + afterSequence: yield* eventSink.latestSequence({ threadId }), + eventType: "provider-session.updated", + }) + .pipe( + Stream.filter( + ({ event }) => + event.type === "provider-session.updated" && + (event.payload.status === "stopped" || event.payload.status === "error"), + ), + Stream.runHead, + Effect.forkScoped({ startImmediately: true }), + ); + }); + +const makeHarness = Effect.gen(function* () { + const runtime: LegacyRuntime = { + stopped: yield* Queue.unbounded(), + failStream: yield* Deferred.make(), + }; + const maintenance = yield* Maintenance.make(); + const failing = yield* Ref.make(false); + const drained = yield* maintenance.subscribeDrainedInstances; + return { runtime, maintenance, failing, drained }; +}); + +it.effect("a closed Prime bridge session drains its maintenance fence", () => + Effect.gen(function* () { + const { runtime, maintenance, failing, drained } = yield* makeHarness; + yield* Effect.gen(function* () { + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const threadId = ThreadId.make("thread-prime-session-manager-close"); + const providerSessionId = yield* writeThread(threadId); + yield* manager.open({ threadId, providerSessionId, modelSelection, runtimePolicy }); + + // A live bridge runtime blocks provider maintenance. + assert.equal((yield* maintenance.reserveProviderMaintenance(instanceId)).status, "busy"); + + yield* manager.close(providerSessionId); + assert.equal(yield* Queue.take(runtime.stopped), threadId); + const drainedInstance = yield* Stream.runHead(drained.pipe(Stream.take(1))); + assert.isTrue(drainedInstance._tag === "Some" && drainedInstance.value === instanceId); + + const reservation = yield* maintenance.reserveProviderMaintenance(instanceId); + assert.equal(reservation.status, "reserved"); + // A reservation fences new runtime admission through the manager. + const admission = yield* Effect.exit( + manager.open({ threadId, providerSessionId, modelSelection, runtimePolicy }), + ); + assert.isTrue(Exit.isFailure(admission)); + if (reservation.status === "reserved") { + yield* maintenance.releaseProviderMaintenance(reservation.reservation); + } + yield* manager.open({ threadId, providerSessionId, modelSelection, runtimePolicy }); + assert.equal((yield* maintenance.reserveProviderMaintenance(instanceId)).status, "busy"); + }).pipe(Effect.provide(makeTestLayer({ runtime, maintenance, failing }))); + }).pipe(Effect.scoped), +); + +it.effect("a Prime bridge release whose records fail still stops and drains the runtime", () => + Effect.gen(function* () { + const { runtime, maintenance, failing, drained } = yield* makeHarness; + yield* Effect.gen(function* () { + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const projectionStore = yield* ProjectionStore.ProjectionStoreV2; + const threadId = ThreadId.make("thread-prime-session-manager-failed-records"); + const providerSessionId = yield* writeThread(threadId); + yield* manager.open({ threadId, providerSessionId, modelSelection, runtimePolicy }); + + yield* Ref.set(failing, true); + assert.isTrue(Exit.isFailure(yield* Effect.exit(manager.close(providerSessionId)))); + // The fence is released even though the stopped record did not persist. + assert.equal(yield* Queue.take(runtime.stopped), threadId); + assert.isTrue((yield* Stream.runHead(drained.pipe(Stream.take(1))))._tag === "Some"); + const reservation = yield* maintenance.reserveProviderMaintenance(instanceId); + assert.equal(reservation.status, "reserved"); + if (reservation.status === "reserved") { + yield* maintenance.releaseProviderMaintenance(reservation.reservation); + } + assert.equal( + (yield* projectionStore.getThreadProjection(threadId)).providerSessions.at(-1)?.status, + "ready", + ); + + // The upstream retry records the stop once the store recovers, without + // reopening or stopping the legacy runtime again. + yield* Ref.set(failing, false); + const recorded = yield* nextSessionRecord(threadId); + yield* TestClock.adjust("1 second"); + yield* Fiber.join(recorded); + assert.equal( + (yield* projectionStore.getThreadProjection(threadId)).providerSessions.at(-1)?.status, + "stopped", + ); + assert.equal(yield* Queue.size(runtime.stopped), 0); + }).pipe(Effect.provide(makeTestLayer({ runtime, maintenance, failing }))); + }).pipe(Effect.scoped), +); + +it.effect("a failed Prime daemon stream releases the session as an error and drains", () => + Effect.gen(function* () { + const { runtime, maintenance, failing, drained } = yield* makeHarness; + yield* Effect.gen(function* () { + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const projectionStore = yield* ProjectionStore.ProjectionStoreV2; + const threadId = ThreadId.make("thread-prime-session-manager-stream-failure"); + const providerSessionId = yield* writeThread(threadId); + yield* manager.open({ threadId, providerSessionId, modelSelection, runtimePolicy }); + + const recorded = yield* nextSessionRecord(threadId); + yield* Deferred.succeed(runtime.failStream, undefined); + assert.equal(yield* Queue.take(runtime.stopped), threadId); + assert.isTrue((yield* Stream.runHead(drained.pipe(Stream.take(1))))._tag === "Some"); + yield* Fiber.join(recorded); + assert.equal( + (yield* projectionStore.getThreadProjection(threadId)).providerSessions.at(-1)?.status, + "error", + ); + assert.equal((yield* maintenance.reserveProviderMaintenance(instanceId)).status, "reserved"); + }).pipe(Effect.provide(makeTestLayer({ runtime, maintenance, failing }))); + }).pipe(Effect.scoped), +); diff --git a/apps/server/src/provider/opencodeRuntime.inventory.test.ts b/apps/server/src/provider/opencodeRuntime.inventory.test.ts index 353f0210f0..0b00914f4f 100644 --- a/apps/server/src/provider/opencodeRuntime.inventory.test.ts +++ b/apps/server/src/provider/opencodeRuntime.inventory.test.ts @@ -294,86 +294,84 @@ it.layer(testLayer)("OpenCodeRuntime inventory", (it) => { ); }); -for (const retry of [false, true]) { - it.effect( - `runs CLI inventory commands without overlapping database owners (retry=${retry})`, - () => - Effect.gen(function* () { - const started = yield* Queue.make<{ - args: readonly string[]; - finish: Deferred.Deferred; - }>(); - let active = 0; - let maximumActive = 0; - const spawner = ChildProcessSpawner.make((command) => - Effect.gen(function* () { - NodeAssert.equal(ChildProcess.isStandardCommand(command), true); - if (!ChildProcess.isStandardCommand(command)) - return yield* Effect.die("Expected standard command"); - active += 1; - maximumActive = Math.max(maximumActive, active); - const finish = yield* Deferred.make(); - yield* Queue.offer(started, { args: command.args, finish }); - return ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(1), - exitCode: Deferred.await(finish).pipe( - Effect.tap(() => - Effect.sync(() => { - active -= 1; - }), - ), - Effect.map(ChildProcessSpawner.ExitCode), +it.effect.each([false, true])( + "runs CLI inventory commands without overlapping database owners (retry=%s)", + (retry) => + Effect.gen(function* () { + const started = yield* Queue.make<{ + args: readonly string[]; + finish: Deferred.Deferred; + }>(); + let active = 0; + let maximumActive = 0; + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + NodeAssert.equal(ChildProcess.isStandardCommand(command), true); + if (!ChildProcess.isStandardCommand(command)) + return yield* Effect.die("Expected standard command"); + active += 1; + maximumActive = Math.max(maximumActive, active); + const finish = yield* Deferred.make(); + yield* Queue.offer(started, { args: command.args, finish }); + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Deferred.await(finish).pipe( + Effect.tap(() => + Effect.sync(() => { + active -= 1; + }), ), - isRunning: Effect.succeed(true), - kill: () => Effect.void, - unref: Effect.succeed(Effect.void), - stdin: Sink.drain, - stdout: Stream.make( - new TextEncoder().encode( - command.args[0] === "models" - ? 'openai/gpt-test\n{"id":"gpt-test","providerID":"openai","name":"GPT Test"}\n' - : command.args[0] === "debug" - ? "[]" - : "", - ), + Effect.map(ChildProcessSpawner.ExitCode), + ), + isRunning: Effect.succeed(true), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.make( + new TextEncoder().encode( + command.args[0] === "models" + ? 'openai/gpt-test\n{"id":"gpt-test","providerID":"openai","name":"GPT Test"}\n' + : command.args[0] === "debug" + ? "[]" + : "", ), - stderr: Stream.empty, - all: Stream.empty, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }); - }), - ); - const runtime = yield* OpenCodeRuntime.OpenCodeRuntime.pipe( - Effect.provide( - OpenCodeRuntime.OpenCodeRuntimeLive.pipe( - Layer.provide(OpenCodeServerLedger.layerTest), - Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), - // Mock children have no OS process group; cleanup goes through the mock handle. - Layer.provide(Layer.succeed(HostProcessPlatform, "win32")), - Layer.provide(NodeServices.layer), ), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ); + const runtime = yield* OpenCodeRuntime.OpenCodeRuntime.pipe( + Effect.provide( + OpenCodeRuntime.OpenCodeRuntimeLive.pipe( + Layer.provide(OpenCodeServerLedger.layerTest), + Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), + // Mock children have no OS process group; cleanup goes through the mock handle. + Layer.provide(Layer.succeed(HostProcessPlatform, "win32")), + Layer.provide(NodeServices.layer), ), - ); - const loading = yield* runtime - .loadInventoryFromCli({ binaryPath: "opencode", cwd: process.cwd() }) - .pipe(Effect.forkChild); - for (let pass = 0; pass < (retry ? 2 : 1); pass += 1) { - if (pass > 0) yield* TestClock.adjust("1 second"); - for (const expected of [ - ["models", "--verbose"], - ["agent", "list"], - ["debug", "skill"], - ]) { - const command = yield* Queue.take(started); - NodeAssert.deepEqual(command.args, expected); - yield* Deferred.succeed(command.finish, retry && pass === 0 ? 1 : 0); - } + ), + ); + const loading = yield* runtime + .loadInventoryFromCli({ binaryPath: "opencode", cwd: process.cwd() }) + .pipe(Effect.forkChild); + for (let pass = 0; pass < (retry ? 2 : 1); pass += 1) { + if (pass > 0) yield* TestClock.adjust("1 second"); + for (const expected of [ + ["models", "--verbose"], + ["agent", "list"], + ["debug", "skill"], + ]) { + const command = yield* Queue.take(started); + NodeAssert.deepEqual(command.args, expected); + yield* Deferred.succeed(command.finish, retry && pass === 0 ? 1 : 0); } - const inventory = yield* Fiber.join(loading); - NodeAssert.equal(maximumActive, 1); - NodeAssert.equal(active, 0); - NodeAssert.deepEqual(inventory.providerList.connected, ["openai"]); - }), - ); -} + } + const inventory = yield* Fiber.join(loading); + NodeAssert.equal(maximumActive, 1); + NodeAssert.equal(active, 0); + NodeAssert.deepEqual(inventory.providerList.connected, ["openai"]); + }), +); diff --git a/apps/server/src/provider/opencodeRuntime.mcp.test.ts b/apps/server/src/provider/opencodeRuntime.mcp.test.ts index 3f8bf79681..f2a3c2339e 100644 --- a/apps/server/src/provider/opencodeRuntime.mcp.test.ts +++ b/apps/server/src/provider/opencodeRuntime.mcp.test.ts @@ -128,8 +128,9 @@ it.layer(testLayer)("OpenCodeRuntime Pylon MCP timeout", (it) => { }).pipe(Effect.scoped), ); - for (const timeout of [0, 90_000]) { - it.effect(`preserves provider per-server timeout ${timeout} ahead of the global setting`, () => + it.effect.each([0, 90_000])( + "preserves provider per-server timeout %s ahead of the global setting", + (timeout) => Effect.gen(function* () { const providerConfig = { mcp: { "t3-code": { ...remoteConfig, timeout } }, @@ -149,23 +150,20 @@ it.layer(testLayer)("OpenCodeRuntime Pylon MCP timeout", (it) => { "/provider/workspace", ); }).pipe(Effect.scoped), - ); - } + ); - for (const timeout of [0, 45_000]) { - it.effect(`preserves provider global MCP timeout ${timeout}`, () => - Effect.gen(function* () { - const { client, requests } = yield* makeHarness({ - config: () => ({ experimental: { mcp_timeout: timeout } }), - }); - yield* Effect.promise(() => client.mcp.add({ name: "t3-code", config: remoteConfig })); - expect(decodeBody(requests[1]!.body)).toEqual({ - name: "t3-code", - config: { ...remoteConfig, timeout }, - }); - }).pipe(Effect.scoped), - ); - } + it.effect.each([0, 45_000])("preserves provider global MCP timeout %s", (timeout) => + Effect.gen(function* () { + const { client, requests } = yield* makeHarness({ + config: () => ({ experimental: { mcp_timeout: timeout } }), + }); + yield* Effect.promise(() => client.mcp.add({ name: "t3-code", config: remoteConfig })); + expect(decodeBody(requests[1]!.body)).toEqual({ + name: "t3-code", + config: { ...remoteConfig, timeout }, + }); + }).pipe(Effect.scoped), + ); it.effect("leaves other names and local MCP servers unchanged", () => Effect.gen(function* () { diff --git a/apps/server/src/provider/prime/PrimeAgentDaemonAdapter.test.ts b/apps/server/src/provider/prime/PrimeAgentDaemonAdapter.test.ts index d6027a78a0..ad755a328c 100644 --- a/apps/server/src/provider/prime/PrimeAgentDaemonAdapter.test.ts +++ b/apps/server/src/provider/prime/PrimeAgentDaemonAdapter.test.ts @@ -1392,8 +1392,9 @@ describe("PrimeAgentDaemonAdapter", () => { ).pipe(Effect.provide(testLayer)), ); - for (const variant of ["recovered-final", "later-tool-use", "tool-only"] as const) { - it.effect(`preserves correlated completion message order: ${variant}`, () => + it.effect.each(["recovered-final", "later-tool-use", "tool-only"] as const)( + "preserves correlated completion message order: %s", + (variant) => Effect.scoped( Effect.gen(function* () { const captures = makeCaptures(); @@ -1465,8 +1466,7 @@ describe("PrimeAgentDaemonAdapter", () => { } }), ).pipe(Effect.provide(testLayer)), - ); - } + ); it.effect("rechecks correlated recovery before committing a new strict turn", () => Effect.scoped( @@ -2408,104 +2408,100 @@ describe("PrimeAgentDaemonAdapter", () => { ).pipe(Effect.provide(testLayer)), ); - for (const changedContent of [false, true]) { - it.effect( - `reconciles historical usage updates while rejecting content changes (${changedContent})`, - () => { - const logs: Array = []; - const logger = Logger.make(({ message }) => { - logs.push(message); - }); - return Effect.scoped( - Effect.gen(function* () { - const captures = makeCaptures(); - captures.correlatedPromptLifecycleAvailable = true; - captures.correlatedPromptObserved = yield* Queue.unbounded(); - const adapter = yield* makePrimeAgentDaemonAdapter(decodeSettings({}), manager, { - instanceId, - runtimeFactory: fakeRuntimeFactory(captures), - }); - const subscription = yield* subscribe(adapter); - yield* adapter.startSession({ - threadId, - cwd: process.cwd(), - runtimeMode: "full-access", - }); - const running = yield* adapter - .sendTurn({ threadId, input: "account for child usage" }) - .pipe(Effect.forkChild); - const correlationId = yield* Queue.take(captures.correlatedPromptObserved); - yield* awaitObservedType(subscription.observed, "turn.started"); - yield* offer(captures, { - _tag: "PromptLifecycleUpdated", - lifecycle: lifecycleSnapshot(correlationId, "delivered", 2), - }); - const original = assistantMessage("PRIVATE original answer"); - yield* offer(captures, { - _tag: "MessageCompleted", - message: original, - attribution: { scope: "prompt", correlationId }, - }); - const updated = { - ...original, - text: changedContent ? "PRIVATE changed answer" : original.text, - usage: { ...usage, totalTokens: 999, totalCostUsd: 5 }, - }; - yield* offer(captures, { - ...initialSnapshot(), - state: { ...initialSnapshot().state, messageCount: 1 }, - messages: [updated], - replayContinuity: "complete", - connectionGeneration: 0, - correlatedProofEpoch: 1, - promptLifecycles: { - records: [ - lifecycleSnapshot(correlationId, "completed", 3, { usage: updated.usage }), - ], - expired: [], - }, - }); - const result = yield* Fiber.join(running); - const completed = subscription.events.findLast( - (event) => event.turnId === result.turnId && event.type === "turn.completed", - ); + it.effect.each([false, true])( + "reconciles historical usage updates while rejecting content changes (%s)", + (changedContent) => { + const logs: Array = []; + const logger = Logger.make(({ message }) => { + logs.push(message); + }); + return Effect.scoped( + Effect.gen(function* () { + const captures = makeCaptures(); + captures.correlatedPromptLifecycleAvailable = true; + captures.correlatedPromptObserved = yield* Queue.unbounded(); + const adapter = yield* makePrimeAgentDaemonAdapter(decodeSettings({}), manager, { + instanceId, + runtimeFactory: fakeRuntimeFactory(captures), + }); + const subscription = yield* subscribe(adapter); + yield* adapter.startSession({ + threadId, + cwd: process.cwd(), + runtimeMode: "full-access", + }); + const running = yield* adapter + .sendTurn({ threadId, input: "account for child usage" }) + .pipe(Effect.forkChild); + const correlationId = yield* Queue.take(captures.correlatedPromptObserved); + yield* awaitObservedType(subscription.observed, "turn.started"); + yield* offer(captures, { + _tag: "PromptLifecycleUpdated", + lifecycle: lifecycleSnapshot(correlationId, "delivered", 2), + }); + const original = assistantMessage("PRIVATE original answer"); + yield* offer(captures, { + _tag: "MessageCompleted", + message: original, + attribution: { scope: "prompt", correlationId }, + }); + const updated = { + ...original, + text: changedContent ? "PRIVATE changed answer" : original.text, + usage: { ...usage, totalTokens: 999, totalCostUsd: 5 }, + }; + yield* offer(captures, { + ...initialSnapshot(), + state: { ...initialSnapshot().state, messageCount: 1 }, + messages: [updated], + replayContinuity: "complete", + connectionGeneration: 0, + correlatedProofEpoch: 1, + promptLifecycles: { + records: [lifecycleSnapshot(correlationId, "completed", 3, { usage: updated.usage })], + expired: [], + }, + }); + const result = yield* Fiber.join(running); + const completed = subscription.events.findLast( + (event) => event.turnId === result.turnId && event.type === "turn.completed", + ); + expect(completed).toMatchObject({ + payload: { state: changedContent ? "failed" : "completed" }, + }); + if (changedContent) { + expect(logs).toContainEqual([ + "Prime Agent transcript continuity verification failed.", + expect.objectContaining({ + threadId, + reason: "transcript-tail-mismatch", + mismatchIndex: 0, + changedFields: ["text"], + observedCount: 1, + snapshotCount: 1, + }), + ]); + expect(encodeUnknownJson(logs)).not.toContain("PRIVATE"); + } else { expect(completed).toMatchObject({ - payload: { state: changedContent ? "failed" : "completed" }, + payload: { usage: { totalTokens: 999 }, totalCostUsd: 5 }, }); - if (changedContent) { - expect(logs).toContainEqual([ - "Prime Agent transcript continuity verification failed.", - expect.objectContaining({ - threadId, - reason: "transcript-tail-mismatch", - mismatchIndex: 0, - changedFields: ["text"], - observedCount: 1, - snapshotCount: 1, - }), - ]); - expect(encodeUnknownJson(logs)).not.toContain("PRIVATE"); - } else { - expect(completed).toMatchObject({ - payload: { usage: { totalTokens: 999 }, totalCostUsd: 5 }, - }); - expect(subscription.events.filter((event) => event.type === "runtime.error")).toEqual( - [], - ); - expect(logs).not.toContainEqual( - expect.arrayContaining(["Prime Agent transcript continuity verification failed."]), - ); - } - yield* Fiber.interrupt(subscription.fiber); - }), - ).pipe( - Effect.provide( - Layer.merge(testLayer, Logger.layer([logger], { mergeWithExisting: false })), - ), - ); - }, - ); - } + expect(subscription.events.filter((event) => event.type === "runtime.error")).toEqual( + [], + ); + expect(logs).not.toContainEqual( + expect.arrayContaining(["Prime Agent transcript continuity verification failed."]), + ); + } + yield* Fiber.interrupt(subscription.fiber); + }), + ).pipe( + Effect.provide( + Layer.merge(testLayer, Logger.layer([logger], { mergeWithExisting: false })), + ), + ); + }, + ); it.effect("fails closed on a capable reconnect transcript delta", () => Effect.scoped( @@ -2625,384 +2621,379 @@ describe("PrimeAgentDaemonAdapter", () => { ).pipe(Effect.provide(testLayer)), ); - for (const { variant, orderedSnapshot } of ( - [ - "known", - "streaming", - "multiple", - "unknown id", - "wrong name", - "ambiguous name", - "duplicate", - "already observed", - "unattributed call", - "unattributed through snapshot", - "changed prefix", - "missing proof", - "missing lifecycle", - "undelivered", - "unavailable replay", - "extra assistant", - ] as const - ).flatMap((variant) => [false, true].map((orderedSnapshot) => ({ variant, orderedSnapshot })))) { - it.effect( - `reconciles only current correlated tool results: ${variant} (ordered: ${orderedSnapshot})`, - () => - Effect.scoped( - Effect.gen(function* () { - const captures = makeCaptures(); - captures.correlatedPromptLifecycleAvailable = true; - captures.correlatedRecoveryProofEpoch = 0; - captures.correlatedPromptObserved = yield* Queue.unbounded(); - const resolutions = - yield* Queue.unbounded(); - captures.reconnectSnapshotResolutionObserved = (resolution) => { - Queue.offerUnsafe(resolutions, resolution); - }; - const adapter = yield* makePrimeAgentDaemonAdapter(decodeSettings({}), manager, { - instanceId, - runtimeFactory: fakeRuntimeFactory(captures), - }); - const subscription = yield* subscribe(adapter); - yield* adapter.startSession({ - threadId, - cwd: process.cwd(), - runtimeMode: "full-access", - }); - const turnFiber = yield* adapter - .sendTurn({ threadId, input: "Read the fixture" }) - .pipe(Effect.forkChild); - const correlationId = yield* Queue.take(captures.correlatedPromptObserved); - const delivered = lifecycleSnapshot(correlationId, "delivered", 2); - yield* offer(captures, { _tag: "PromptLifecycleUpdated", lifecycle: delivered }); - const prompt = { - role: "user", - timestamp: 0, - text: "Read the fixture", - imageMimeTypes: [], - imageDigests: [], - } satisfies PrimeDaemonMessage; - const call = { - ...assistantMessage("", "toolUse"), - toolCalls: [ - { id: "tool-1", name: "mcp_list_tools_t3-code" }, - ...(variant === "multiple" ? [{ id: "tool-2", name: "read" }] : []), - ...(variant === "ambiguous name" ? [{ id: "tool-1", name: "different" }] : []), - ], - }; - const result = { - role: "toolResult", - timestamp: 2, - toolCallId: "tool-1", - toolName: "mcp_list_tools_t3-code", - text: "fixture tools", - imageMimeTypes: [], - isError: false, - } satisfies PrimeDaemonMessage; - yield* offer(captures, { - _tag: "MessageCompleted", - message: prompt, - attribution: { scope: "prompt", correlationId }, - }); - yield* offer(captures, { - _tag: "MessageCompleted", - message: call, - attribution: variant.startsWith("unattributed") - ? { scope: "session" } - : { scope: "prompt", correlationId }, - }); - if (variant === "unattributed through snapshot") { - yield* offer(captures, { - ...initialSnapshot(), - state: { ...initialSnapshot().state, isStreaming: true, messageCount: 2 }, - messages: [prompt, call], - replayContinuity: "complete", - connectionGeneration: 0, - correlatedProofEpoch: 0, - promptLifecycles: { records: [delivered], expired: [] }, - }); - expect((yield* Queue.take(resolutions)).reconciled).toBe(true); - } - if (variant === "already observed") - yield* offer(captures, { - _tag: "MessageCompleted", - message: result, - attribution: { scope: "prompt", correlationId }, - }); - const missing = - variant === "unknown id" - ? { ...result, toolCallId: "other" } - : variant === "wrong name" - ? { ...result, toolName: "other" } - : variant === "already observed" - ? { ...result, timestamp: 3 } - : result; - const messages = [ - prompt, - variant === "changed prefix" ? { ...call, text: "changed" } : call, - ...(variant === "already observed" ? [result] : []), - missing, - ...(variant === "duplicate" ? [{ ...result, timestamp: 3 }] : []), - ...(variant === "multiple" - ? [{ ...result, timestamp: 3, toolCallId: "tool-2", toolName: "read" }] - : []), - ...(variant === "extra assistant" ? [assistantMessage("unobserved response")] : []), - ]; + it.effect.each( + ( + [ + "known", + "streaming", + "multiple", + "unknown id", + "wrong name", + "ambiguous name", + "duplicate", + "already observed", + "unattributed call", + "unattributed through snapshot", + "changed prefix", + "missing proof", + "missing lifecycle", + "undelivered", + "unavailable replay", + "extra assistant", + ] as const + ).flatMap((variant) => [false, true].map((orderedSnapshot) => ({ variant, orderedSnapshot }))), + )( + "reconciles only current correlated tool results: $variant (ordered: $orderedSnapshot)", + ({ variant, orderedSnapshot }) => + Effect.scoped( + Effect.gen(function* () { + const captures = makeCaptures(); + captures.correlatedPromptLifecycleAvailable = true; + captures.correlatedRecoveryProofEpoch = 0; + captures.correlatedPromptObserved = yield* Queue.unbounded(); + const resolutions = + yield* Queue.unbounded(); + captures.reconnectSnapshotResolutionObserved = (resolution) => { + Queue.offerUnsafe(resolutions, resolution); + }; + const adapter = yield* makePrimeAgentDaemonAdapter(decodeSettings({}), manager, { + instanceId, + runtimeFactory: fakeRuntimeFactory(captures), + }); + const subscription = yield* subscribe(adapter); + yield* adapter.startSession({ + threadId, + cwd: process.cwd(), + runtimeMode: "full-access", + }); + const turnFiber = yield* adapter + .sendTurn({ threadId, input: "Read the fixture" }) + .pipe(Effect.forkChild); + const correlationId = yield* Queue.take(captures.correlatedPromptObserved); + const delivered = lifecycleSnapshot(correlationId, "delivered", 2); + yield* offer(captures, { _tag: "PromptLifecycleUpdated", lifecycle: delivered }); + const prompt = { + role: "user", + timestamp: 0, + text: "Read the fixture", + imageMimeTypes: [], + imageDigests: [], + } satisfies PrimeDaemonMessage; + const call = { + ...assistantMessage("", "toolUse"), + toolCalls: [ + { id: "tool-1", name: "mcp_list_tools_t3-code" }, + ...(variant === "multiple" ? [{ id: "tool-2", name: "read" }] : []), + ...(variant === "ambiguous name" ? [{ id: "tool-1", name: "different" }] : []), + ], + }; + const result = { + role: "toolResult", + timestamp: 2, + toolCallId: "tool-1", + toolName: "mcp_list_tools_t3-code", + text: "fixture tools", + imageMimeTypes: [], + isError: false, + } satisfies PrimeDaemonMessage; + yield* offer(captures, { + _tag: "MessageCompleted", + message: prompt, + attribution: { scope: "prompt", correlationId }, + }); + yield* offer(captures, { + _tag: "MessageCompleted", + message: call, + attribution: variant.startsWith("unattributed") + ? { scope: "session" } + : { scope: "prompt", correlationId }, + }); + if (variant === "unattributed through snapshot") { yield* offer(captures, { ...initialSnapshot(), - state: { - ...initialSnapshot().state, - isStreaming: true, - messageCount: messages.length, - }, - messages, - replayContinuity: - variant === "unavailable replay" - ? "unavailable" - : orderedSnapshot - ? "unknown" - : "complete", - ...(orderedSnapshot ? { orderedSnapshot: true } : {}), - ...(variant === "streaming" ? { streamingMessage: assistantMessage("") } : {}), - connectionGeneration: 0, - ...(variant === "missing proof" ? {} : { correlatedProofEpoch: 0 }), - promptLifecycles: { - records: - variant === "missing lifecycle" - ? [] - : [ - variant === "undelivered" - ? { ...delivered, deliveryCrossed: false } - : delivered, - ], - expired: [], - }, - }); - const resolution = yield* Queue.take(resolutions); - const accepted = - variant === "known" || variant === "multiple" || variant === "streaming"; - expect(resolution.reconciled).toBe(accepted); - if (accepted) { - expect(turnFiber.pollUnsafe()).toBeUndefined(); - const final = { ...assistantMessage("Fixture read completed."), timestamp: 4 }; - yield* offer(captures, { - _tag: "MessageCompleted", - message: final, - attribution: { scope: "prompt", correlationId }, - }); - yield* offer(captures, { - _tag: "PromptLifecycleUpdated", - lifecycle: lifecycleSnapshot(correlationId, "completed", 3, { usage }), - }); - } - const settled = yield* Fiber.join(turnFiber); - const events = subscription.events.filter((event) => event.turnId === settled.turnId); - expect(events.filter((event) => event.type === "turn.completed")).toHaveLength(1); - expect(events.findLast((event) => event.type === "turn.completed")).toMatchObject({ - payload: { state: accepted ? "completed" : "failed" }, - }); - if (accepted) { - expect(events.filter((event) => event.type === "runtime.error")).toEqual([]); - expect( - events.filter( - (event) => - event.type === "item.completed" && - event.payload.itemType === "dynamic_tool_call", - ), - ).toHaveLength(variant === "multiple" ? 2 : 1); - expect( - events.filter( - (event) => - event.type === "content.delta" && - event.payload.delta === "Fixture read completed.", - ), - ).toHaveLength(1); - } - }), - ).pipe(Effect.provide(testLayer)), - ); - } - - for (const { withImage, hidden, terminal } of [ - { withImage: false, hidden: "none" }, - { withImage: true, hidden: "none" }, - { withImage: false, hidden: "snapshot" }, - { withImage: false, hidden: "observed" }, - ].flatMap((variant) => [ - { ...variant, terminal: false }, - { ...variant, terminal: true }, - ])) { - it.effect( - `reconciles the delivered submitted user from the first complete resync (image: ${withImage}, hidden: ${hidden}, terminal: ${terminal})`, - () => - Effect.scoped( - Effect.gen(function* () { - const captures = makeCaptures(); - captures.correlatedPromptLifecycleAvailable = true; - captures.correlatedRecoveryProofEpoch = 0; - captures.correlatedPromptObserved = yield* Queue.unbounded(); - let reportResolution!: ( - resolution: FakeCaptures["reconnectResolutions"][number], - ) => void; - const resolutionObserved = new Promise( - (resolve) => { - reportResolution = resolve; - }, - ); - captures.reconnectSnapshotResolutionObserved = reportResolution; - const adapter = yield* makePrimeAgentDaemonAdapter(decodeSettings({}), manager, { - instanceId, - runtimeFactory: fakeRuntimeFactory(captures), - }); - const subscription = yield* subscribe(adapter); - yield* adapter.startSession({ - threadId, - cwd: process.cwd(), - runtimeMode: "full-access", - }); - const text = "List the files and summarize the README in two sentences."; - const attachment = withImage ? yield* createContinuityImage() : undefined; - const turnFiber = yield* adapter - .sendTurn({ - threadId, - input: text, - ...(attachment === undefined ? {} : { attachments: [attachment] }), - }) - .pipe(Effect.forkChild); - const correlationId = yield* Queue.take(captures.correlatedPromptObserved); - const delivered = lifecycleSnapshot(correlationId, "delivered", 2); - yield* offer(captures, { _tag: "PromptLifecycleUpdated", lifecycle: delivered }); - const prompt = { - role: "user", - timestamp: 1, - text, - imageMimeTypes: withImage ? ["image/png"] : [], - imageDigests: withImage ? [primeAgentDaemonImageDigest("AQID")] : [], - } satisfies PrimeDaemonMessage; - const digest = { - role: "harnessDigest", - timestamp: 0, - contentDigest: "a".repeat(64), - } satisfies PrimeDaemonMessage; - if (hidden === "observed") { - yield* offer(captures, { - _tag: "MessageCompleted", - message: digest, - attribution: { scope: "prompt", correlationId }, - }); - } - const answer = assistantMessage( - "The directory contains README.md. It describes the fixture.", - ); - const messages = [ - ...(hidden === "none" ? [] : [digest]), - prompt, - ...(terminal ? [answer] : []), - ]; - const resync = { - ...initialSnapshot(), - state: { - ...initialSnapshot().state, - messageCount: messages.length, - isStreaming: !terminal, - }, - messages, + state: { ...initialSnapshot().state, isStreaming: true, messageCount: 2 }, + messages: [prompt, call], replayContinuity: "complete", connectionGeneration: 0, correlatedProofEpoch: 0, - promptLifecycles: { - records: [ - terminal - ? lifecycleSnapshot(correlationId, "completed", 3, { usage }) - : delivered, - ], - expired: [], - }, - } satisfies PrimeDaemonEvent; - yield* offer(captures, { ...resync, connectionGeneration: -1 }); - yield* offer(captures, { ...resync, correlatedProofEpoch: -1 }); - yield* offer(captures, resync); - expect(yield* Effect.promise(() => resolutionObserved)).toMatchObject({ - reconciled: true, + promptLifecycles: { records: [delivered], expired: [] }, }); - if (!terminal) expect(turnFiber.pollUnsafe()).toBeUndefined(); - expect(captures.reconnectResolutions).toHaveLength(1); - yield* offer(captures, resync); + expect((yield* Queue.take(resolutions)).reconciled).toBe(true); + } + if (variant === "already observed") yield* offer(captures, { _tag: "MessageCompleted", - message: prompt, + message: result, attribution: { scope: "prompt", correlationId }, }); + const missing = + variant === "unknown id" + ? { ...result, toolCallId: "other" } + : variant === "wrong name" + ? { ...result, toolName: "other" } + : variant === "already observed" + ? { ...result, timestamp: 3 } + : result; + const messages = [ + prompt, + variant === "changed prefix" ? { ...call, text: "changed" } : call, + ...(variant === "already observed" ? [result] : []), + missing, + ...(variant === "duplicate" ? [{ ...result, timestamp: 3 }] : []), + ...(variant === "multiple" + ? [{ ...result, timestamp: 3, toolCallId: "tool-2", toolName: "read" }] + : []), + ...(variant === "extra assistant" ? [assistantMessage("unobserved response")] : []), + ]; + yield* offer(captures, { + ...initialSnapshot(), + state: { + ...initialSnapshot().state, + isStreaming: true, + messageCount: messages.length, + }, + messages, + replayContinuity: + variant === "unavailable replay" + ? "unavailable" + : orderedSnapshot + ? "unknown" + : "complete", + ...(orderedSnapshot ? { orderedSnapshot: true } : {}), + ...(variant === "streaming" ? { streamingMessage: assistantMessage("") } : {}), + connectionGeneration: 0, + ...(variant === "missing proof" ? {} : { correlatedProofEpoch: 0 }), + promptLifecycles: { + records: + variant === "missing lifecycle" + ? [] + : [ + variant === "undelivered" + ? { ...delivered, deliveryCrossed: false } + : delivered, + ], + expired: [], + }, + }); + const resolution = yield* Queue.take(resolutions); + const accepted = variant === "known" || variant === "multiple" || variant === "streaming"; + expect(resolution.reconciled).toBe(accepted); + if (accepted) { + expect(turnFiber.pollUnsafe()).toBeUndefined(); + const final = { ...assistantMessage("Fixture read completed."), timestamp: 4 }; yield* offer(captures, { _tag: "MessageCompleted", - message: answer, + message: final, attribution: { scope: "prompt", correlationId }, }); yield* offer(captures, { _tag: "PromptLifecycleUpdated", lifecycle: lifecycleSnapshot(correlationId, "completed", 3, { usage }), }); - const result = yield* Fiber.join(turnFiber); - const turnEvents = subscription.events.filter( - (event) => event.turnId === result.turnId, - ); - expect(turnEvents.filter((event) => event.type === "turn.completed")).toHaveLength(1); - expect(turnEvents.findLast((event) => event.type === "turn.completed")).toMatchObject({ - payload: { state: "completed" }, - }); - expect(turnEvents.filter((event) => event.type === "runtime.error")).toEqual([]); - expect(encodeUnknownJson(turnEvents)).not.toContain(digest.contentDigest); - expect(encodeUnknownJson(turnEvents)).not.toContain("harnessDigest"); + } + const settled = yield* Fiber.join(turnFiber); + const events = subscription.events.filter((event) => event.turnId === settled.turnId); + expect(events.filter((event) => event.type === "turn.completed")).toHaveLength(1); + expect(events.findLast((event) => event.type === "turn.completed")).toMatchObject({ + payload: { state: accepted ? "completed" : "failed" }, + }); + if (accepted) { + expect(events.filter((event) => event.type === "runtime.error")).toEqual([]); expect( - turnEvents.filter( - (event) => event.type === "content.delta" && event.payload.delta === answer.text, + events.filter( + (event) => + event.type === "item.completed" && event.payload.itemType === "dynamic_tool_call", + ), + ).toHaveLength(variant === "multiple" ? 2 : 1); + expect( + events.filter( + (event) => + event.type === "content.delta" && + event.payload.delta === "Fixture read completed.", ), ).toHaveLength(1); - }), - ).pipe(Effect.provide(testLayer)), - ); - } + } + }), + ).pipe(Effect.provide(testLayer)), + ); - for (const { rejection, terminal } of [ - "hidden unknown replay", - "hidden unavailable replay", - "hidden changed observed digest", - "hidden missing observed digest", - "hidden duplicate prefix", - "hidden suffix", - "hidden extra assistant", - "hidden count mismatch", - "different text", - "unexpected image MIME", - "unexpected image digest", - "different image MIME", - "different image bytes", - "missing proof epoch", - "unknown replay", - "replacement unknown replay", - "worker unknown replay", - "unavailable replay", - "missing lifecycle", - "different owner", - "undelivered owner", - "undelivered snapshot", - "changed lifecycle kind", - "stale lifecycle", - "extra assistant", - "repeated user boundary", - "changed observed transcript", - "missing observed transcript", - "unfinished tool answer", - "terminal still streaming", - ].flatMap((rejection) => - rejection === "unfinished tool answer" || rejection === "terminal still streaming" - ? [{ rejection, terminal: true }] - : [ - { rejection, terminal: false }, - { rejection, terminal: true }, - ], - )) { - it.effect(`rejects submitted-user resync with ${rejection} (terminal: ${terminal})`, () => + it.effect.each( + [ + { withImage: false, hidden: "none" }, + { withImage: true, hidden: "none" }, + { withImage: false, hidden: "snapshot" }, + { withImage: false, hidden: "observed" }, + ].flatMap((variant) => [ + { ...variant, terminal: false }, + { ...variant, terminal: true }, + ]), + )( + "reconciles the delivered submitted user from the first complete resync (image: $withImage, hidden: $hidden, terminal: $terminal)", + ({ withImage, hidden, terminal }) => + Effect.scoped( + Effect.gen(function* () { + const captures = makeCaptures(); + captures.correlatedPromptLifecycleAvailable = true; + captures.correlatedRecoveryProofEpoch = 0; + captures.correlatedPromptObserved = yield* Queue.unbounded(); + let reportResolution!: (resolution: FakeCaptures["reconnectResolutions"][number]) => void; + const resolutionObserved = new Promise( + (resolve) => { + reportResolution = resolve; + }, + ); + captures.reconnectSnapshotResolutionObserved = reportResolution; + const adapter = yield* makePrimeAgentDaemonAdapter(decodeSettings({}), manager, { + instanceId, + runtimeFactory: fakeRuntimeFactory(captures), + }); + const subscription = yield* subscribe(adapter); + yield* adapter.startSession({ + threadId, + cwd: process.cwd(), + runtimeMode: "full-access", + }); + const text = "List the files and summarize the README in two sentences."; + const attachment = withImage ? yield* createContinuityImage() : undefined; + const turnFiber = yield* adapter + .sendTurn({ + threadId, + input: text, + ...(attachment === undefined ? {} : { attachments: [attachment] }), + }) + .pipe(Effect.forkChild); + const correlationId = yield* Queue.take(captures.correlatedPromptObserved); + const delivered = lifecycleSnapshot(correlationId, "delivered", 2); + yield* offer(captures, { _tag: "PromptLifecycleUpdated", lifecycle: delivered }); + const prompt = { + role: "user", + timestamp: 1, + text, + imageMimeTypes: withImage ? ["image/png"] : [], + imageDigests: withImage ? [primeAgentDaemonImageDigest("AQID")] : [], + } satisfies PrimeDaemonMessage; + const digest = { + role: "harnessDigest", + timestamp: 0, + contentDigest: "a".repeat(64), + } satisfies PrimeDaemonMessage; + if (hidden === "observed") { + yield* offer(captures, { + _tag: "MessageCompleted", + message: digest, + attribution: { scope: "prompt", correlationId }, + }); + } + const answer = assistantMessage( + "The directory contains README.md. It describes the fixture.", + ); + const messages = [ + ...(hidden === "none" ? [] : [digest]), + prompt, + ...(terminal ? [answer] : []), + ]; + const resync = { + ...initialSnapshot(), + state: { + ...initialSnapshot().state, + messageCount: messages.length, + isStreaming: !terminal, + }, + messages, + replayContinuity: "complete", + connectionGeneration: 0, + correlatedProofEpoch: 0, + promptLifecycles: { + records: [ + terminal ? lifecycleSnapshot(correlationId, "completed", 3, { usage }) : delivered, + ], + expired: [], + }, + } satisfies PrimeDaemonEvent; + yield* offer(captures, { ...resync, connectionGeneration: -1 }); + yield* offer(captures, { ...resync, correlatedProofEpoch: -1 }); + yield* offer(captures, resync); + expect(yield* Effect.promise(() => resolutionObserved)).toMatchObject({ + reconciled: true, + }); + if (!terminal) expect(turnFiber.pollUnsafe()).toBeUndefined(); + expect(captures.reconnectResolutions).toHaveLength(1); + yield* offer(captures, resync); + yield* offer(captures, { + _tag: "MessageCompleted", + message: prompt, + attribution: { scope: "prompt", correlationId }, + }); + yield* offer(captures, { + _tag: "MessageCompleted", + message: answer, + attribution: { scope: "prompt", correlationId }, + }); + yield* offer(captures, { + _tag: "PromptLifecycleUpdated", + lifecycle: lifecycleSnapshot(correlationId, "completed", 3, { usage }), + }); + const result = yield* Fiber.join(turnFiber); + const turnEvents = subscription.events.filter((event) => event.turnId === result.turnId); + expect(turnEvents.filter((event) => event.type === "turn.completed")).toHaveLength(1); + expect(turnEvents.findLast((event) => event.type === "turn.completed")).toMatchObject({ + payload: { state: "completed" }, + }); + expect(turnEvents.filter((event) => event.type === "runtime.error")).toEqual([]); + expect(encodeUnknownJson(turnEvents)).not.toContain(digest.contentDigest); + expect(encodeUnknownJson(turnEvents)).not.toContain("harnessDigest"); + expect( + turnEvents.filter( + (event) => event.type === "content.delta" && event.payload.delta === answer.text, + ), + ).toHaveLength(1); + }), + ).pipe(Effect.provide(testLayer)), + ); + + it.effect.each( + [ + "hidden unknown replay", + "hidden unavailable replay", + "hidden changed observed digest", + "hidden missing observed digest", + "hidden duplicate prefix", + "hidden suffix", + "hidden extra assistant", + "hidden count mismatch", + "different text", + "unexpected image MIME", + "unexpected image digest", + "different image MIME", + "different image bytes", + "missing proof epoch", + "unknown replay", + "replacement unknown replay", + "worker unknown replay", + "unavailable replay", + "missing lifecycle", + "different owner", + "undelivered owner", + "undelivered snapshot", + "changed lifecycle kind", + "stale lifecycle", + "extra assistant", + "repeated user boundary", + "changed observed transcript", + "missing observed transcript", + "unfinished tool answer", + "terminal still streaming", + ].flatMap((rejection) => + rejection === "unfinished tool answer" || rejection === "terminal still streaming" + ? [{ rejection, terminal: true }] + : [ + { rejection, terminal: false }, + { rejection, terminal: true }, + ], + ), + )( + "rejects submitted-user resync with $rejection (terminal: $terminal)", + ({ rejection, terminal }) => Effect.scoped( Effect.gen(function* () { const recovering = @@ -3155,27 +3146,336 @@ describe("PrimeAgentDaemonAdapter", () => { reconciled: false, terminalResponseObserved: false, }); - expect(turnEvents.findLast((event) => event.type === "turn.completed")).toMatchObject({ - payload: { state: "failed" }, + expect(turnEvents.findLast((event) => event.type === "turn.completed")).toMatchObject({ + payload: { state: "failed" }, + }); + expect(turnEvents.filter((event) => event.type === "content.delta")).toEqual([]); + expect(captures.retryWorkerRecoverySnapshotCalls).toEqual([]); + }), + ).pipe(Effect.provide(testLayer)), + ); + + it.effect.each([ + "complete", + "missing result", + "result first", + "reused call", + "wrong name", + "duplicate call", + "duplicate result", + "foreign user", + ] as const)("reconciles a current correlated tool cycle: %s", (variant) => + Effect.scoped( + Effect.gen(function* () { + const captures = makeCaptures(); + captures.correlatedPromptLifecycleAvailable = true; + captures.correlatedRecoveryProofEpoch = 0; + captures.correlatedPromptObserved = yield* Queue.unbounded(); + const resolutions = yield* Queue.unbounded(); + captures.reconnectSnapshotResolutionObserved = (resolution) => { + Queue.offerUnsafe(resolutions, resolution); + }; + const adapter = yield* makePrimeAgentDaemonAdapter(decodeSettings({}), manager, { + instanceId, + runtimeFactory: fakeRuntimeFactory(captures), + }); + const subscription = yield* subscribe(adapter); + yield* adapter.startSession({ threadId, cwd: process.cwd(), runtimeMode: "full-access" }); + const turnFiber = yield* adapter + .sendTurn({ threadId, input: "Read both fixture files" }) + .pipe(Effect.forkChild); + const correlationId = yield* Queue.take(captures.correlatedPromptObserved); + const delivered = lifecycleSnapshot(correlationId, "delivered", 2); + yield* offer(captures, { _tag: "PromptLifecycleUpdated", lifecycle: delivered }); + const prompt = { + role: "user", + timestamp: 1, + text: "Read both fixture files", + imageMimeTypes: [], + imageDigests: [], + } satisfies PrimeDaemonMessage; + const firstCall = { + ...assistantMessage("", "toolUse"), + timestamp: 2, + toolCalls: [{ id: "first-read", name: "read" }], + }; + const firstResult = { + role: "toolResult", + timestamp: 3, + toolCallId: "first-read", + toolName: "read", + text: "First fixture", + imageMimeTypes: [], + isError: false, + } satisfies PrimeDaemonMessage; + for (const message of [prompt, firstCall, firstResult]) { + yield* offer(captures, { + _tag: "MessageCompleted", + message, + attribution: { scope: "prompt", correlationId }, + }); + } + const secondCall = { + ...assistantMessage("Reading the second fixture.", "toolUse"), + timestamp: 4, + toolCalls: [{ id: "second-read", name: "read" }], + }; + const secondResult = { + ...firstResult, + timestamp: 5, + toolCallId: "second-read", + text: "Second fixture", + }; + const tail = + variant === "missing result" + ? [secondCall] + : variant === "result first" + ? [secondResult, secondCall] + : variant === "reused call" + ? [ + { ...secondCall, toolCalls: firstCall.toolCalls }, + { ...secondResult, toolCallId: "first-read" }, + ] + : variant === "wrong name" + ? [secondCall, { ...secondResult, toolName: "other" }] + : variant === "duplicate call" + ? [ + { + ...secondCall, + toolCalls: [...secondCall.toolCalls, ...secondCall.toolCalls], + }, + secondResult, + ] + : variant === "duplicate result" + ? [secondCall, secondResult, { ...secondResult, timestamp: 6 }] + : variant === "foreign user" + ? [ + secondCall, + secondResult, + { ...prompt, timestamp: 6, text: "foreign prompt" }, + ] + : [secondCall, secondResult]; + const messages = [prompt, firstCall, firstResult, ...tail]; + yield* offer(captures, { + ...initialSnapshot(), + state: { ...initialSnapshot().state, isStreaming: true, messageCount: messages.length }, + messages, + replayContinuity: "complete", + connectionGeneration: 0, + correlatedProofEpoch: 0, + promptLifecycles: { records: [delivered], expired: [] }, + }); + const accepted = variant === "complete"; + expect((yield* Queue.take(resolutions)).reconciled).toBe(accepted); + if (accepted) { + const final = { ...assistantMessage("Both fixture files read."), timestamp: 6 }; + yield* offer(captures, { + _tag: "MessageCompleted", + message: final, + attribution: { scope: "prompt", correlationId }, + }); + yield* offer(captures, { + _tag: "PromptLifecycleUpdated", + lifecycle: lifecycleSnapshot(correlationId, "completed", 3, { usage }), + }); + } + const settled = yield* Fiber.join(turnFiber); + const events = subscription.events.filter((event) => event.turnId === settled.turnId); + expect(events.filter((event) => event.type === "runtime.error")).toHaveLength( + accepted ? 0 : 1, + ); + expect(events.filter((event) => event.type === "turn.completed")).toHaveLength(1); + expect(events.findLast((event) => event.type === "turn.completed")).toMatchObject({ + payload: { state: accepted ? "completed" : "failed" }, + }); + if (accepted) { + expect( + events.filter( + (event) => + event.type === "item.completed" && event.payload.itemType === "dynamic_tool_call", + ), + ).toHaveLength(2); + expect( + events.filter( + (event) => + event.type === "content.delta" && + event.payload.delta === "Both fixture files read.", + ), + ).toHaveLength(1); + } + }), + ).pipe(Effect.provide(testLayer)), + ); + + it.effect.each( + [false, true].flatMap((changed) => + [false, true].map((withNotice) => ({ changed, withNotice })), + ), + )( + "verifies a background refinement before a later tool snapshot: changed=$changed, notice=$withNotice", + ({ changed, withNotice }) => + Effect.scoped( + Effect.gen(function* () { + const captures = makeCaptures(); + captures.correlatedPromptLifecycleAvailable = true; + captures.correlatedRecoveryProofEpoch = 0; + captures.correlatedPromptObserved = yield* Queue.unbounded(); + const resolutions = + yield* Queue.unbounded(); + captures.reconnectSnapshotResolutionObserved = (resolution) => { + Queue.offerUnsafe(resolutions, resolution); + }; + const adapter = yield* makePrimeAgentDaemonAdapter(decodeSettings({}), manager, { + instanceId, + runtimeFactory: fakeRuntimeFactory(captures), + }); + const subscription = yield* subscribe(adapter); + yield* adapter.startSession({ + threadId, + cwd: process.cwd(), + runtimeMode: "full-access", + }); + const refinement = decodePrimeAgentDaemonEvent({ + type: "session_event", + event: { + type: "message_end", + message: { + role: "custom", + customType: "refinement_outcome", + display: true, + content: "private memory update", + details: { refinementId: "refine-1", edits: [] }, + timestamp: 1, + }, + }, + }); + if (refinement?._tag !== "MessageCompleted") throw new Error("missing refinement"); + yield* offer(captures, { ...refinement, attribution: { scope: "session" } }); + const notice = decodePrimeAgentDaemonEvent( + { + type: "session_event", + attribution: { scope: "session" }, + event: { + type: "message_end", + promptCorrelationId: null, + message: { + role: "custom", + customType: "refinement_notice", + display: false, + content: "private applied memory", + details: { refinementId: "refine-1", source: "auto" }, + timestamp: 1.5, + }, + }, + }, + { correlatedPromptLifecycle: true }, + ); + if (notice._tag !== "MessageCompleted") throw new Error("missing refinement notice"); + if (withNotice) yield* offer(captures, notice); + // Drain startup notifications, then wait for an ordered event behind the refinement. + yield* Queue.takeAll(subscription.observed); + yield* offer(captures, { _tag: "ConnectionStatus", status: "connected" }); + yield* awaitObservedType(subscription.observed, "session.state.changed"); + const turnFiber = yield* adapter + .sendTurn({ threadId, input: "Read fixture" }) + .pipe(Effect.forkChild); + const correlationId = yield* Queue.take(captures.correlatedPromptObserved); + const delivered = lifecycleSnapshot(correlationId, "delivered", 2); + yield* offer(captures, { _tag: "PromptLifecycleUpdated", lifecycle: delivered }); + const prompt = { + role: "user", + timestamp: 2, + text: "Read fixture", + imageMimeTypes: [], + imageDigests: [], + } satisfies PrimeDaemonMessage; + const call = { + ...assistantMessage("", "toolUse"), + timestamp: 3, + toolCalls: [{ id: "read-1", name: "read" }], + }; + for (const message of [prompt, call]) { + yield* offer(captures, { + _tag: "MessageCompleted", + message, + attribution: { scope: "prompt", correlationId }, + }); + } + const result = { + role: "toolResult", + timestamp: 4, + toolCallId: "read-1", + toolName: "read", + text: "fixture", + imageMimeTypes: [], + isError: false, + } satisfies PrimeDaemonMessage; + yield* offer(captures, { + ...initialSnapshot(), + state: { + ...initialSnapshot().state, + isStreaming: true, + messageCount: withNotice ? 5 : 4, + }, + messages: [ + changed ? { ...refinement.message, timestamp: 999 } : refinement.message, + ...(withNotice ? [notice.message] : []), + prompt, + call, + result, + ], + orderedSnapshot: true, + replayContinuity: "unknown", + connectionGeneration: 0, + correlatedProofEpoch: 0, + promptLifecycles: { records: [delivered], expired: [] }, + }); + expect((yield* Queue.take(resolutions)).reconciled).toBe(!changed); + if (!changed) { + yield* offer(captures, { + _tag: "MessageCompleted", + message: { ...assistantMessage("Read complete"), timestamp: 5 }, + attribution: { scope: "prompt", correlationId }, + }); + yield* offer(captures, { + _tag: "PromptLifecycleUpdated", + lifecycle: lifecycleSnapshot(correlationId, "completed", 3, { usage }), + }); + } + const settled = yield* Fiber.join(turnFiber); + const events = subscription.events.filter((event) => event.turnId === settled.turnId); + expect(events.findLast((event) => event.type === "turn.completed")).toMatchObject({ + payload: { state: changed ? "failed" : "completed" }, }); - expect(turnEvents.filter((event) => event.type === "content.delta")).toEqual([]); - expect(captures.retryWorkerRecoverySnapshotCalls).toEqual([]); + expect(events.filter((event) => event.type === "runtime.error")).toHaveLength( + changed ? 1 : 0, + ); + expect(encodeUnknownJson(subscription.events)).not.toContain("private memory"); + expect(encodeUnknownJson(subscription.events)).not.toContain("refinementOutcome"); + expect(encodeUnknownJson(subscription.events)).not.toContain("refinementNotice"); }), ).pipe(Effect.provide(testLayer)), - ); - } + ); - for (const variant of [ - "complete", - "missing result", - "result first", - "reused call", - "wrong name", - "duplicate call", - "duplicate result", - "foreign user", - ] as const) { - it.effect(`reconciles a current correlated tool cycle: ${variant}`, () => + it.effect.each( + [ + "compaction_outcome", + "ipython_state_restored", + "ipython_state", + "session_slash_command", + "session_slash_command_result", + "rlm_child_failure", + "rlm_child_terminal_notice", + "async_bash_completion", + "agent_message", + ].flatMap((customType) => + [false, true].flatMap((changed) => + [false].map((withNotice) => ({ customType, changed, withNotice })), + ), + ), + )( + "verifies a background $customType before a later tool snapshot: changed=$changed, notice=$withNotice", + ({ customType, changed, withNotice }) => Effect.scoped( Effect.gen(function* () { const captures = makeCaptures(); @@ -3192,98 +3492,111 @@ describe("PrimeAgentDaemonAdapter", () => { runtimeFactory: fakeRuntimeFactory(captures), }); const subscription = yield* subscribe(adapter); - yield* adapter.startSession({ threadId, cwd: process.cwd(), runtimeMode: "full-access" }); + yield* adapter.startSession({ + threadId, + cwd: process.cwd(), + runtimeMode: "full-access", + }); + const refinement = decodePrimeAgentDaemonEvent({ + type: "session_event", + event: { + type: "message_end", + message: { + role: "custom", + customType, + display: true, + content: "private memory update", + details: { refinementId: "refine-1", edits: [] }, + timestamp: 1, + }, + }, + }); + if (refinement?._tag !== "MessageCompleted") throw new Error("missing refinement"); + yield* offer(captures, { ...refinement, attribution: { scope: "session" } }); + const notice = decodePrimeAgentDaemonEvent( + { + type: "session_event", + attribution: { scope: "session" }, + event: { + type: "message_end", + promptCorrelationId: null, + message: { + role: "custom", + customType: "refinement_notice", + display: false, + content: "private applied memory", + details: { refinementId: "refine-1", source: "auto" }, + timestamp: 1.5, + }, + }, + }, + { correlatedPromptLifecycle: true }, + ); + if (notice._tag !== "MessageCompleted") throw new Error("missing refinement notice"); + if (withNotice) yield* offer(captures, notice); + // Drain startup notifications, then wait for an ordered event behind the refinement. + yield* Queue.takeAll(subscription.observed); + yield* offer(captures, { _tag: "ConnectionStatus", status: "connected" }); + yield* awaitObservedType(subscription.observed, "session.state.changed"); const turnFiber = yield* adapter - .sendTurn({ threadId, input: "Read both fixture files" }) + .sendTurn({ threadId, input: "Read fixture" }) .pipe(Effect.forkChild); const correlationId = yield* Queue.take(captures.correlatedPromptObserved); const delivered = lifecycleSnapshot(correlationId, "delivered", 2); yield* offer(captures, { _tag: "PromptLifecycleUpdated", lifecycle: delivered }); const prompt = { role: "user", - timestamp: 1, - text: "Read both fixture files", + timestamp: 2, + text: "Read fixture", imageMimeTypes: [], imageDigests: [], } satisfies PrimeDaemonMessage; - const firstCall = { + const call = { ...assistantMessage("", "toolUse"), - timestamp: 2, - toolCalls: [{ id: "first-read", name: "read" }], - }; - const firstResult = { - role: "toolResult", timestamp: 3, - toolCallId: "first-read", - toolName: "read", - text: "First fixture", - imageMimeTypes: [], - isError: false, - } satisfies PrimeDaemonMessage; - for (const message of [prompt, firstCall, firstResult]) { + toolCalls: [{ id: "read-1", name: "read" }], + }; + for (const message of [prompt, call]) { yield* offer(captures, { _tag: "MessageCompleted", message, attribution: { scope: "prompt", correlationId }, }); } - const secondCall = { - ...assistantMessage("Reading the second fixture.", "toolUse"), + const result = { + role: "toolResult", timestamp: 4, - toolCalls: [{ id: "second-read", name: "read" }], - }; - const secondResult = { - ...firstResult, - timestamp: 5, - toolCallId: "second-read", - text: "Second fixture", - }; - const tail = - variant === "missing result" - ? [secondCall] - : variant === "result first" - ? [secondResult, secondCall] - : variant === "reused call" - ? [ - { ...secondCall, toolCalls: firstCall.toolCalls }, - { ...secondResult, toolCallId: "first-read" }, - ] - : variant === "wrong name" - ? [secondCall, { ...secondResult, toolName: "other" }] - : variant === "duplicate call" - ? [ - { - ...secondCall, - toolCalls: [...secondCall.toolCalls, ...secondCall.toolCalls], - }, - secondResult, - ] - : variant === "duplicate result" - ? [secondCall, secondResult, { ...secondResult, timestamp: 6 }] - : variant === "foreign user" - ? [ - secondCall, - secondResult, - { ...prompt, timestamp: 6, text: "foreign prompt" }, - ] - : [secondCall, secondResult]; - const messages = [prompt, firstCall, firstResult, ...tail]; + toolCallId: "read-1", + toolName: "read", + text: "fixture", + imageMimeTypes: [], + isError: false, + } satisfies PrimeDaemonMessage; yield* offer(captures, { ...initialSnapshot(), - state: { ...initialSnapshot().state, isStreaming: true, messageCount: messages.length }, - messages, - replayContinuity: "complete", + state: { + ...initialSnapshot().state, + isStreaming: true, + messageCount: withNotice ? 5 : 4, + }, + messages: [ + changed ? { ...refinement.message, timestamp: 999 } : refinement.message, + ...(withNotice ? [notice.message] : []), + prompt, + call, + result, + ], + orderedSnapshot: true, + replayContinuity: "unknown", connectionGeneration: 0, correlatedProofEpoch: 0, promptLifecycles: { records: [delivered], expired: [] }, }); - const accepted = variant === "complete"; - expect((yield* Queue.take(resolutions)).reconciled).toBe(accepted); - if (accepted) { - const final = { ...assistantMessage("Both fixture files read."), timestamp: 6 }; + expect((yield* Queue.take(resolutions)).reconciled).toBe(!changed); + if (!changed) { yield* offer(captures, { _tag: "MessageCompleted", - message: final, + message: { ...assistantMessage("Read complete"), timestamp: 5 }, attribution: { scope: "prompt", correlationId }, }); yield* offer(captures, { @@ -3293,350 +3606,22 @@ describe("PrimeAgentDaemonAdapter", () => { } const settled = yield* Fiber.join(turnFiber); const events = subscription.events.filter((event) => event.turnId === settled.turnId); - expect(events.filter((event) => event.type === "runtime.error")).toHaveLength( - accepted ? 0 : 1, - ); - expect(events.filter((event) => event.type === "turn.completed")).toHaveLength(1); expect(events.findLast((event) => event.type === "turn.completed")).toMatchObject({ - payload: { state: accepted ? "completed" : "failed" }, + payload: { state: changed ? "failed" : "completed" }, }); - if (accepted) { - expect( - events.filter( - (event) => - event.type === "item.completed" && event.payload.itemType === "dynamic_tool_call", - ), - ).toHaveLength(2); - expect( - events.filter( - (event) => - event.type === "content.delta" && - event.payload.delta === "Both fixture files read.", - ), - ).toHaveLength(1); - } + expect(events.filter((event) => event.type === "runtime.error")).toHaveLength( + changed ? 1 : 0, + ); + expect(encodeUnknownJson(subscription.events)).not.toContain("private memory"); + expect(encodeUnknownJson(subscription.events)).not.toContain("refinementOutcome"); + expect(encodeUnknownJson(subscription.events)).not.toContain("refinementNotice"); }), ).pipe(Effect.provide(testLayer)), - ); - } - - for (const changed of [false, true]) { - for (const withNotice of [false, true]) { - it.effect( - `verifies a background refinement before a later tool snapshot: changed=${changed}, notice=${withNotice}`, - () => - Effect.scoped( - Effect.gen(function* () { - const captures = makeCaptures(); - captures.correlatedPromptLifecycleAvailable = true; - captures.correlatedRecoveryProofEpoch = 0; - captures.correlatedPromptObserved = yield* Queue.unbounded(); - const resolutions = - yield* Queue.unbounded(); - captures.reconnectSnapshotResolutionObserved = (resolution) => { - Queue.offerUnsafe(resolutions, resolution); - }; - const adapter = yield* makePrimeAgentDaemonAdapter(decodeSettings({}), manager, { - instanceId, - runtimeFactory: fakeRuntimeFactory(captures), - }); - const subscription = yield* subscribe(adapter); - yield* adapter.startSession({ - threadId, - cwd: process.cwd(), - runtimeMode: "full-access", - }); - const refinement = decodePrimeAgentDaemonEvent({ - type: "session_event", - event: { - type: "message_end", - message: { - role: "custom", - customType: "refinement_outcome", - display: true, - content: "private memory update", - details: { refinementId: "refine-1", edits: [] }, - timestamp: 1, - }, - }, - }); - if (refinement?._tag !== "MessageCompleted") throw new Error("missing refinement"); - yield* offer(captures, { ...refinement, attribution: { scope: "session" } }); - const notice = decodePrimeAgentDaemonEvent( - { - type: "session_event", - attribution: { scope: "session" }, - event: { - type: "message_end", - promptCorrelationId: null, - message: { - role: "custom", - customType: "refinement_notice", - display: false, - content: "private applied memory", - details: { refinementId: "refine-1", source: "auto" }, - timestamp: 1.5, - }, - }, - }, - { correlatedPromptLifecycle: true }, - ); - if (notice._tag !== "MessageCompleted") throw new Error("missing refinement notice"); - if (withNotice) yield* offer(captures, notice); - // Drain startup notifications, then wait for an ordered event behind the refinement. - yield* Queue.takeAll(subscription.observed); - yield* offer(captures, { _tag: "ConnectionStatus", status: "connected" }); - yield* awaitObservedType(subscription.observed, "session.state.changed"); - const turnFiber = yield* adapter - .sendTurn({ threadId, input: "Read fixture" }) - .pipe(Effect.forkChild); - const correlationId = yield* Queue.take(captures.correlatedPromptObserved); - const delivered = lifecycleSnapshot(correlationId, "delivered", 2); - yield* offer(captures, { _tag: "PromptLifecycleUpdated", lifecycle: delivered }); - const prompt = { - role: "user", - timestamp: 2, - text: "Read fixture", - imageMimeTypes: [], - imageDigests: [], - } satisfies PrimeDaemonMessage; - const call = { - ...assistantMessage("", "toolUse"), - timestamp: 3, - toolCalls: [{ id: "read-1", name: "read" }], - }; - for (const message of [prompt, call]) { - yield* offer(captures, { - _tag: "MessageCompleted", - message, - attribution: { scope: "prompt", correlationId }, - }); - } - const result = { - role: "toolResult", - timestamp: 4, - toolCallId: "read-1", - toolName: "read", - text: "fixture", - imageMimeTypes: [], - isError: false, - } satisfies PrimeDaemonMessage; - yield* offer(captures, { - ...initialSnapshot(), - state: { - ...initialSnapshot().state, - isStreaming: true, - messageCount: withNotice ? 5 : 4, - }, - messages: [ - changed ? { ...refinement.message, timestamp: 999 } : refinement.message, - ...(withNotice ? [notice.message] : []), - prompt, - call, - result, - ], - orderedSnapshot: true, - replayContinuity: "unknown", - connectionGeneration: 0, - correlatedProofEpoch: 0, - promptLifecycles: { records: [delivered], expired: [] }, - }); - expect((yield* Queue.take(resolutions)).reconciled).toBe(!changed); - if (!changed) { - yield* offer(captures, { - _tag: "MessageCompleted", - message: { ...assistantMessage("Read complete"), timestamp: 5 }, - attribution: { scope: "prompt", correlationId }, - }); - yield* offer(captures, { - _tag: "PromptLifecycleUpdated", - lifecycle: lifecycleSnapshot(correlationId, "completed", 3, { usage }), - }); - } - const settled = yield* Fiber.join(turnFiber); - const events = subscription.events.filter((event) => event.turnId === settled.turnId); - expect(events.findLast((event) => event.type === "turn.completed")).toMatchObject({ - payload: { state: changed ? "failed" : "completed" }, - }); - expect(events.filter((event) => event.type === "runtime.error")).toHaveLength( - changed ? 1 : 0, - ); - expect(encodeUnknownJson(subscription.events)).not.toContain("private memory"); - expect(encodeUnknownJson(subscription.events)).not.toContain("refinementOutcome"); - expect(encodeUnknownJson(subscription.events)).not.toContain("refinementNotice"); - }), - ).pipe(Effect.provide(testLayer)), - ); - } - } - - for (const customType of [ - "compaction_outcome", - "ipython_state_restored", - "ipython_state", - "session_slash_command", - "session_slash_command_result", - "rlm_child_failure", - "rlm_child_terminal_notice", - "async_bash_completion", - "agent_message", - ]) { - for (const changed of [false, true]) { - for (const withNotice of [false]) { - it.effect( - `verifies a background ${customType} before a later tool snapshot: changed=${changed}, notice=${withNotice}`, - () => - Effect.scoped( - Effect.gen(function* () { - const captures = makeCaptures(); - captures.correlatedPromptLifecycleAvailable = true; - captures.correlatedRecoveryProofEpoch = 0; - captures.correlatedPromptObserved = yield* Queue.unbounded(); - const resolutions = - yield* Queue.unbounded(); - captures.reconnectSnapshotResolutionObserved = (resolution) => { - Queue.offerUnsafe(resolutions, resolution); - }; - const adapter = yield* makePrimeAgentDaemonAdapter(decodeSettings({}), manager, { - instanceId, - runtimeFactory: fakeRuntimeFactory(captures), - }); - const subscription = yield* subscribe(adapter); - yield* adapter.startSession({ - threadId, - cwd: process.cwd(), - runtimeMode: "full-access", - }); - const refinement = decodePrimeAgentDaemonEvent({ - type: "session_event", - event: { - type: "message_end", - message: { - role: "custom", - customType, - display: true, - content: "private memory update", - details: { refinementId: "refine-1", edits: [] }, - timestamp: 1, - }, - }, - }); - if (refinement?._tag !== "MessageCompleted") throw new Error("missing refinement"); - yield* offer(captures, { ...refinement, attribution: { scope: "session" } }); - const notice = decodePrimeAgentDaemonEvent( - { - type: "session_event", - attribution: { scope: "session" }, - event: { - type: "message_end", - promptCorrelationId: null, - message: { - role: "custom", - customType: "refinement_notice", - display: false, - content: "private applied memory", - details: { refinementId: "refine-1", source: "auto" }, - timestamp: 1.5, - }, - }, - }, - { correlatedPromptLifecycle: true }, - ); - if (notice._tag !== "MessageCompleted") - throw new Error("missing refinement notice"); - if (withNotice) yield* offer(captures, notice); - // Drain startup notifications, then wait for an ordered event behind the refinement. - yield* Queue.takeAll(subscription.observed); - yield* offer(captures, { _tag: "ConnectionStatus", status: "connected" }); - yield* awaitObservedType(subscription.observed, "session.state.changed"); - const turnFiber = yield* adapter - .sendTurn({ threadId, input: "Read fixture" }) - .pipe(Effect.forkChild); - const correlationId = yield* Queue.take(captures.correlatedPromptObserved); - const delivered = lifecycleSnapshot(correlationId, "delivered", 2); - yield* offer(captures, { _tag: "PromptLifecycleUpdated", lifecycle: delivered }); - const prompt = { - role: "user", - timestamp: 2, - text: "Read fixture", - imageMimeTypes: [], - imageDigests: [], - } satisfies PrimeDaemonMessage; - const call = { - ...assistantMessage("", "toolUse"), - timestamp: 3, - toolCalls: [{ id: "read-1", name: "read" }], - }; - for (const message of [prompt, call]) { - yield* offer(captures, { - _tag: "MessageCompleted", - message, - attribution: { scope: "prompt", correlationId }, - }); - } - const result = { - role: "toolResult", - timestamp: 4, - toolCallId: "read-1", - toolName: "read", - text: "fixture", - imageMimeTypes: [], - isError: false, - } satisfies PrimeDaemonMessage; - yield* offer(captures, { - ...initialSnapshot(), - state: { - ...initialSnapshot().state, - isStreaming: true, - messageCount: withNotice ? 5 : 4, - }, - messages: [ - changed ? { ...refinement.message, timestamp: 999 } : refinement.message, - ...(withNotice ? [notice.message] : []), - prompt, - call, - result, - ], - orderedSnapshot: true, - replayContinuity: "unknown", - connectionGeneration: 0, - correlatedProofEpoch: 0, - promptLifecycles: { records: [delivered], expired: [] }, - }); - expect((yield* Queue.take(resolutions)).reconciled).toBe(!changed); - if (!changed) { - yield* offer(captures, { - _tag: "MessageCompleted", - message: { ...assistantMessage("Read complete"), timestamp: 5 }, - attribution: { scope: "prompt", correlationId }, - }); - yield* offer(captures, { - _tag: "PromptLifecycleUpdated", - lifecycle: lifecycleSnapshot(correlationId, "completed", 3, { usage }), - }); - } - const settled = yield* Fiber.join(turnFiber); - const events = subscription.events.filter( - (event) => event.turnId === settled.turnId, - ); - expect(events.findLast((event) => event.type === "turn.completed")).toMatchObject({ - payload: { state: changed ? "failed" : "completed" }, - }); - expect(events.filter((event) => event.type === "runtime.error")).toHaveLength( - changed ? 1 : 0, - ); - expect(encodeUnknownJson(subscription.events)).not.toContain("private memory"); - expect(encodeUnknownJson(subscription.events)).not.toContain("refinementOutcome"); - expect(encodeUnknownJson(subscription.events)).not.toContain("refinementNotice"); - }), - ).pipe(Effect.provide(testLayer)), - ); - } - } - } + ); - for (const variant of ["valid", "changed", "missing proof"] as const) { - it.effect(`proves context replacement after compaction: ${variant}`, () => + it.effect.each(["valid", "changed", "missing proof"] as const)( + "proves context replacement after compaction: %s", + (variant) => Effect.scoped( Effect.gen(function* () { const captures = makeCaptures(); @@ -3744,8 +3729,7 @@ describe("PrimeAgentDaemonAdapter", () => { expect(encodeUnknownJson(subscription.events)).not.toContain("private-summary"); }), ).pipe(Effect.provide(testLayer)), - ); - } + ); it.effect("accepts exact complete capable recovery with already observed output", () => Effect.scoped( @@ -7518,117 +7502,114 @@ describe("PrimeAgentDaemonAdapter", () => { ).pipe(Effect.provide(testLayer)), ); - for (const firstDecision of ["accept", "decline"] as const) { - it.effect( - `keeps native follow-up approvals live after the first ${firstDecision} worker completes`, - () => - Effect.scoped( - Effect.gen(function* () { - const captures = makeCaptures(); - captures.correlatedPromptLifecycleAvailable = true; - captures.correlatedPromptObserved = yield* Queue.unbounded(); - const adapter = yield* makePrimeAgentDaemonAdapter(decodeSettings({}), manager, { - instanceId, - runtimeFactory: fakeRuntimeFactory(captures), + it.effect.each(["accept", "decline"] as const)( + "keeps native follow-up approvals live after the first %s worker completes", + (firstDecision) => + Effect.scoped( + Effect.gen(function* () { + const captures = makeCaptures(); + captures.correlatedPromptLifecycleAvailable = true; + captures.correlatedPromptObserved = yield* Queue.unbounded(); + const adapter = yield* makePrimeAgentDaemonAdapter(decodeSettings({}), manager, { + instanceId, + runtimeFactory: fakeRuntimeFactory(captures), + }); + const subscription = yield* subscribe(adapter); + const firstWorker = yield* Effect.gen(function* () { + yield* adapter.startSession({ + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", }); - const subscription = yield* subscribe(adapter); - const firstWorker = yield* Effect.gen(function* () { - yield* adapter.startSession({ - threadId, - cwd: process.cwd(), - runtimeMode: "approval-required", - }); - return yield* adapter.sendTurn({ threadId, input: "first supervised turn" }); - }).pipe(Effect.forkChild); - const firstCorrelation = yield* Queue.take(captures.correlatedPromptObserved); - const firstStarted = yield* awaitObservedType(subscription.observed, "turn.started"); - const extensionSource = yield* Effect.promise(() => - NodeFSP.readFile(captures.runtimeInputs[0]!.extensions![0]!, "utf8"), - ); - const title = extensionSource.match(/const TITLE = "([^"]+)";/)?.[1]; - if (title === undefined) throw new Error("Managed extension title was not generated."); + return yield* adapter.sendTurn({ threadId, input: "first supervised turn" }); + }).pipe(Effect.forkChild); + const firstCorrelation = yield* Queue.take(captures.correlatedPromptObserved); + const firstStarted = yield* awaitObservedType(subscription.observed, "turn.started"); + const extensionSource = yield* Effect.promise(() => + NodeFSP.readFile(captures.runtimeInputs[0]!.extensions![0]!, "utf8"), + ); + const title = extensionSource.match(/const TITLE = "([^"]+)";/)?.[1]; + if (title === undefined) throw new Error("Managed extension title was not generated."); - const approve = Effect.fn("approveFollowupTest")(function* ( - correlationId: string, - nativeId: string, - decision: "accept" | "decline", - ) { - yield* offer(captures, { - _tag: "PromptLifecycleUpdated", - lifecycle: lifecycleSnapshot(correlationId, "delivered", 2), - }); - yield* offer(captures, { - _tag: "ExtensionRequest", - attribution: { scope: "prompt", correlationId }, - request: { - id: nativeId, - method: "confirm", - title, - message: - "pylon-permission-v1\ncommand_execution_approval\nipython\nwrite fixture", - }, - }); - const opened = yield* awaitObservedType(subscription.observed, "request.opened"); - yield* adapter.respondToRequest( - threadId, - ApprovalRequestId.make(String(opened.requestId)), - decision, - ); - yield* awaitObservedType(subscription.observed, "request.resolved"); - expect(captures.extensions.at(-1)).toEqual({ - id: nativeId, - response: { confirmed: decision === "accept" }, - }); - return opened; - }); - expect( - (yield* approve(firstCorrelation, "first-native-approval", firstDecision)).turnId, - ).toBe(firstStarted.turnId); + const approve = Effect.fn("approveFollowupTest")(function* ( + correlationId: string, + nativeId: string, + decision: "accept" | "decline", + ) { yield* offer(captures, { _tag: "PromptLifecycleUpdated", - lifecycle: lifecycleSnapshot(firstCorrelation, "completed", 3), - }); - yield* Fiber.join(firstWorker); - yield* awaitObservedType(subscription.observed, "turn.completed"); - expect(captures.eventStreamFinalizations).toBe(0); - - const secondWorker = yield* adapter - .sendTurn({ threadId, input: "second supervised turn" }) - .pipe(Effect.forkChild); - const secondCorrelation = yield* Queue.take(captures.correlatedPromptObserved); - const secondStarted = yield* awaitObservedType(subscription.observed, "turn.started"); - expect(secondStarted.turnId).not.toBe(firstStarted.turnId); - expect( - (yield* approve(secondCorrelation, "second-native-approval", "accept")).turnId, - ).toBe(secondStarted.turnId); - yield* offer(captures, { - _tag: "MessageCompleted", - attribution: { scope: "prompt", correlationId: secondCorrelation }, - message: assistantMessage("follow-up completed"), + lifecycle: lifecycleSnapshot(correlationId, "delivered", 2), }); yield* offer(captures, { - _tag: "PromptLifecycleUpdated", - lifecycle: lifecycleSnapshot(secondCorrelation, "completed", 3), + _tag: "ExtensionRequest", + attribution: { scope: "prompt", correlationId }, + request: { + id: nativeId, + method: "confirm", + title, + message: "pylon-permission-v1\ncommand_execution_approval\nipython\nwrite fixture", + }, }); - yield* Fiber.join(secondWorker); - yield* awaitObservedType(subscription.observed, "turn.completed"); - const secondEvents = subscription.events.filter( - (event) => event.turnId === secondStarted.turnId, + const opened = yield* awaitObservedType(subscription.observed, "request.opened"); + yield* adapter.respondToRequest( + threadId, + ApprovalRequestId.make(String(opened.requestId)), + decision, ); - expect(secondEvents.filter((event) => event.type === "request.opened")).toHaveLength(1); - expect(secondEvents.filter((event) => event.type === "content.delta")).toHaveLength(1); - expect(secondEvents.filter((event) => event.type === "turn.completed")).toHaveLength(1); - expect((yield* adapter.listSessions())[0]?.status).toBe("ready"); - expect(captures.runtimeInputs).toHaveLength(1); - expect(captures.eventStreamFinalizations).toBe(0); - expect(captures.correlatedPromptCancellations).toEqual([]); - expect(captures.order).not.toContain("abort-clear"); - yield* adapter.stopSession(threadId); - expect(captures.eventStreamFinalizations).toBe(1); - }), - ).pipe(Effect.provide(testLayer)), - ); - } + yield* awaitObservedType(subscription.observed, "request.resolved"); + expect(captures.extensions.at(-1)).toEqual({ + id: nativeId, + response: { confirmed: decision === "accept" }, + }); + return opened; + }); + expect( + (yield* approve(firstCorrelation, "first-native-approval", firstDecision)).turnId, + ).toBe(firstStarted.turnId); + yield* offer(captures, { + _tag: "PromptLifecycleUpdated", + lifecycle: lifecycleSnapshot(firstCorrelation, "completed", 3), + }); + yield* Fiber.join(firstWorker); + yield* awaitObservedType(subscription.observed, "turn.completed"); + expect(captures.eventStreamFinalizations).toBe(0); + + const secondWorker = yield* adapter + .sendTurn({ threadId, input: "second supervised turn" }) + .pipe(Effect.forkChild); + const secondCorrelation = yield* Queue.take(captures.correlatedPromptObserved); + const secondStarted = yield* awaitObservedType(subscription.observed, "turn.started"); + expect(secondStarted.turnId).not.toBe(firstStarted.turnId); + expect( + (yield* approve(secondCorrelation, "second-native-approval", "accept")).turnId, + ).toBe(secondStarted.turnId); + yield* offer(captures, { + _tag: "MessageCompleted", + attribution: { scope: "prompt", correlationId: secondCorrelation }, + message: assistantMessage("follow-up completed"), + }); + yield* offer(captures, { + _tag: "PromptLifecycleUpdated", + lifecycle: lifecycleSnapshot(secondCorrelation, "completed", 3), + }); + yield* Fiber.join(secondWorker); + yield* awaitObservedType(subscription.observed, "turn.completed"); + const secondEvents = subscription.events.filter( + (event) => event.turnId === secondStarted.turnId, + ); + expect(secondEvents.filter((event) => event.type === "request.opened")).toHaveLength(1); + expect(secondEvents.filter((event) => event.type === "content.delta")).toHaveLength(1); + expect(secondEvents.filter((event) => event.type === "turn.completed")).toHaveLength(1); + expect((yield* adapter.listSessions())[0]?.status).toBe("ready"); + expect(captures.runtimeInputs).toHaveLength(1); + expect(captures.eventStreamFinalizations).toBe(0); + expect(captures.correlatedPromptCancellations).toEqual([]); + expect(captures.order).not.toContain("abort-clear"); + yield* adapter.stopSession(threadId); + expect(captures.eventStreamFinalizations).toBe(1); + }), + ).pipe(Effect.provide(testLayer)), + ); it.effect("gates approval-required sessions through opaque canonical approvals", () => Effect.scoped( @@ -11356,78 +11337,76 @@ describe("PrimeAgentDaemonAdapter", () => { ).pipe(Effect.provide(testLayer)), ); - for (const cleanupProven of [false, true]) { - it.effect( - `preserves a failed managed start and retires authority only after cleanup (proven: ${cleanupProven})`, - () => - Effect.scoped( - Effect.gen(function* () { - class ExactDaemonConnection { - getState() {} - navigateTree() {} - } - const exactManager = { - bridge: { DaemonAgentConnection: ExactDaemonConnection }, - recoveryEnabled: true, - platform: "darwin", - architecture: "arm64", - } as unknown as PrimeAgentDaemonManager; - const captures = makeCaptures(); - const delegate = fakeRuntimeFactory(captures); - const failure = new PrimeAgentDaemonSessionRuntimeError({ - operation: "create-session", - reason: "request-failed", - detail: "Recoverable Prime Agent authority could not be durably recorded.", - }); - let managedAttempts = 0; - let discarded = false; - const recoveryLedger = { - get: () => Effect.succeedNone, - discardPrepared: (input: { threadId: string; ownerToken: string }) => - Effect.sync(() => { - expect(input.threadId).toBe(threadId); - expect(input.ownerToken).not.toHaveLength(0); - return (discarded = true); - }), - } as unknown as PrimeAgentRecoveryLedgerShape; - const adapter = yield* makePrimeAgentDaemonAdapter(decodeSettings({}), exactManager, { - instanceId, - recoveryManagedBuildId: "managed-prime-test-build", - recoveryLedger, - runtimeFactory: (input) => - input.recovery === undefined - ? delegate(input) - : Effect.gen(function* () { - managedAttempts++; - if (cleanupProven) - yield* Effect.promise(() => input.recovery!.onNativeCleanupProven!()); - return yield* failure; - }), - }); - const sessionIncarnationId = RuntimeSessionId.make("failed-managed-incarnation"); - yield* adapter.startSession({ - threadId, - cwd: process.cwd(), - runtimeMode: "full-access", - sessionIncarnationId, - }); - const result = yield* adapter.prepareTurnRecovery!({ - threadId, - input: "must not lose managed recovery", - sessionIncarnationId, - admissionRequestId: CommandId.make("failed-managed-admission"), - }).pipe(Effect.result); - expect(result._tag).toBe("Failure"); - if (result._tag === "Failure") expect(result.failure).toMatchObject({ cause: failure }); - expect(managedAttempts).toBe(1); - expect(captures.runtimeInputs).toHaveLength(1); - expect(captures.prompts).toHaveLength(0); - expect(discarded).toBe(cleanupProven); - expect(yield* adapter.listSessions()).toHaveLength(0); - }), - ).pipe(Effect.provide(testLayer)), - ); - } + it.effect.each([false, true])( + "preserves a failed managed start and retires authority only after cleanup (proven: %s)", + (cleanupProven) => + Effect.scoped( + Effect.gen(function* () { + class ExactDaemonConnection { + getState() {} + navigateTree() {} + } + const exactManager = { + bridge: { DaemonAgentConnection: ExactDaemonConnection }, + recoveryEnabled: true, + platform: "darwin", + architecture: "arm64", + } as unknown as PrimeAgentDaemonManager; + const captures = makeCaptures(); + const delegate = fakeRuntimeFactory(captures); + const failure = new PrimeAgentDaemonSessionRuntimeError({ + operation: "create-session", + reason: "request-failed", + detail: "Recoverable Prime Agent authority could not be durably recorded.", + }); + let managedAttempts = 0; + let discarded = false; + const recoveryLedger = { + get: () => Effect.succeedNone, + discardPrepared: (input: { threadId: string; ownerToken: string }) => + Effect.sync(() => { + expect(input.threadId).toBe(threadId); + expect(input.ownerToken).not.toHaveLength(0); + return (discarded = true); + }), + } as unknown as PrimeAgentRecoveryLedgerShape; + const adapter = yield* makePrimeAgentDaemonAdapter(decodeSettings({}), exactManager, { + instanceId, + recoveryManagedBuildId: "managed-prime-test-build", + recoveryLedger, + runtimeFactory: (input) => + input.recovery === undefined + ? delegate(input) + : Effect.gen(function* () { + managedAttempts++; + if (cleanupProven) + yield* Effect.promise(() => input.recovery!.onNativeCleanupProven!()); + return yield* failure; + }), + }); + const sessionIncarnationId = RuntimeSessionId.make("failed-managed-incarnation"); + yield* adapter.startSession({ + threadId, + cwd: process.cwd(), + runtimeMode: "full-access", + sessionIncarnationId, + }); + const result = yield* adapter.prepareTurnRecovery!({ + threadId, + input: "must not lose managed recovery", + sessionIncarnationId, + admissionRequestId: CommandId.make("failed-managed-admission"), + }).pipe(Effect.result); + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") expect(result.failure).toMatchObject({ cause: failure }); + expect(managedAttempts).toBe(1); + expect(captures.runtimeInputs).toHaveLength(1); + expect(captures.prompts).toHaveLength(0); + expect(discarded).toBe(cleanupProven); + expect(yield* adapter.listSessions()).toHaveLength(0); + }), + ).pipe(Effect.provide(testLayer)), + ); it.effect("retains a settled recoverable Prime session for exact rollback", () => Effect.scoped( diff --git a/apps/server/src/provider/prime/PrimeAgentDaemonEvents.test.ts b/apps/server/src/provider/prime/PrimeAgentDaemonEvents.test.ts index 7829ea5431..0622f2408d 100644 --- a/apps/server/src/provider/prime/PrimeAgentDaemonEvents.test.ts +++ b/apps/server/src/provider/prime/PrimeAgentDaemonEvents.test.ts @@ -95,6 +95,20 @@ function sessionEvent(event: Record) { return { type: "session_event", event }; } +const REFINEMENT_CUSTOM_TYPES = ["refinement_outcome", "refinement_notice"] as const; + +const PRIVATE_CUSTOM_TYPES = [ + "compaction_outcome", + "ipython_state_restored", + "ipython_state", + "session_slash_command", + "session_slash_command_result", + "rlm_child_failure", + "rlm_child_terminal_notice", + "async_bash_completion", + "agent_message", +] as const; + describe("PrimeAgentDaemonEvents", () => { it("retains exact hidden harness digest identity and native snapshot counts without content", () => { const hidden = { @@ -166,8 +180,9 @@ describe("PrimeAgentDaemonEvents", () => { expect(JSON.stringify(snapshot)).not.toContain("private compaction"); }); - for (const customType of ["refinement_outcome", "refinement_notice"] as const) { - it(`retains ${customType} identity in live events and snapshots without private content`, () => { + it.each(REFINEMENT_CUSTOM_TYPES)( + "retains %s identity in live events and snapshots without private content", + (customType: (typeof REFINEMENT_CUSTOM_TYPES)[number]) => { const refinement = { role: "custom", customType, @@ -241,21 +256,12 @@ describe("PrimeAgentDaemonEvents", () => { decodePrimeAgentDaemonEvent(sessionEvent({ type: "message_end", message: changed })), ).not.toEqual(completed); } - }); - } + }, + ); - for (const customType of [ - "compaction_outcome", - "ipython_state_restored", - "ipython_state", - "session_slash_command", - "session_slash_command_result", - "rlm_child_failure", - "rlm_child_terminal_notice", - "async_bash_completion", - "agent_message", - ] as const) { - it(`retains ${customType} identity in live events and snapshots without private content`, () => { + it.each(PRIVATE_CUSTOM_TYPES)( + "retains %s identity in live events and snapshots without private content", + (customType: (typeof PRIVATE_CUSTOM_TYPES)[number]) => { const refinement = { role: "custom", customType, @@ -329,8 +335,8 @@ describe("PrimeAgentDaemonEvents", () => { decodePrimeAgentDaemonEvent(sessionEvent({ type: "message_end", message: changed })), ).not.toEqual(completed); } - }); - } + }, + ); it("retains exact native branch and bash records without exposing their content", () => { for (const message of [ diff --git a/apps/server/src/provider/prime/PrimeAgentDaemonSessionRuntime.test.ts b/apps/server/src/provider/prime/PrimeAgentDaemonSessionRuntime.test.ts index cde6423aba..127df5e26e 100644 --- a/apps/server/src/provider/prime/PrimeAgentDaemonSessionRuntime.test.ts +++ b/apps/server/src/provider/prime/PrimeAgentDaemonSessionRuntime.test.ts @@ -1504,153 +1504,30 @@ describe("PrimeAgentDaemonSessionRuntime", () => { ), ); - for (const role of ["toolResult", "assistant"] as const) { - it.effect( - `suppresses only exact ${role} completions already published in a correlated resync`, - () => - Effect.scoped( - Effect.gen(function* () { - const test = fixture({ - correlatedPromptLifecycleCapability: true, - rawSnapshot: { ...snapshot(), promptLifecycles: { records: [], expired: [] } }, - }); - const runtime = yield* test.make(); - const received = yield* collectEvents(runtime, 7).pipe( - Effect.forkChild({ startImmediately: true }), - ); - const result = - role === "assistant" - ? terminalAssistantMessage("recovered result", 2) - : { - role: "toolResult", - toolCallId: "tool-recovered", - toolName: "read", - content: [{ type: "text", text: "recovered result" }], - isError: false, - timestamp: 2, - }; - test.setCorrelatedPromptLifecycleProof(false); - yield* Effect.promise(() => - test.emit({ type: "connection_status", status: "reconnecting" }), - ); - test.setCorrelatedPromptLifecycleProof(true); - yield* Effect.promise(() => - test.emit({ - type: "session_resynced", - snapshot: { - ...snapshot(9), - state: { ...snapshot(9).state, messageCount: 1 }, - messages: [result], - promptLifecycles: { records: [], expired: [] }, - replay: { - status: "complete", - toSequence: 9, - toCursor: { generation: "daemon-1", sequence: 9 }, - }, - }, - }), - ); - expect(runtime.resolveReconnectSnapshot(1, true)).toBe(true); - yield* Effect.promise(() => - test.emit({ type: "connection_status", status: "connected" }), - ); - yield* Effect.promise(() => - test.emit({ - type: "session_event", - attribution: { scope: "session" }, - meta: { cursor: { generation: "daemon-1", sequence: 10 } }, - event: { type: "message_end", promptCorrelationId: null, message: result }, - }), - ); - yield* Effect.promise(() => - test.emit({ - type: "session_event", - attribution: { scope: "session" }, - meta: { cursor: { generation: "daemon-1", sequence: 11 } }, - event: { - type: "message_end", - promptCorrelationId: null, - message: { - ...result, - timestamp: 2, - content: [{ type: "text", text: "different result" }], - }, - }, - }), - ); - yield* Effect.promise(() => - test.emit({ - type: "session_event", - attribution: { scope: "session" }, - event: { - type: "message_end", - promptCorrelationId: null, - message: { ...result, timestamp: 3 }, - }, - }), - ); - yield* Effect.promise(() => - test.emit({ - type: "session_event", - attribution: { scope: "session" }, - event: { - type: "message_end", - promptCorrelationId: null, - message: terminalAssistantMessage(), - }, - }), - ); - const events = yield* Fiber.join(received); - const completed = events.filter((event) => event._tag === "MessageCompleted"); - expect(completed).toHaveLength(3); - expect(completed[0]).toMatchObject({ - message: { role, text: "different result", timestamp: 2 }, - }); - expect(completed[1]).toMatchObject({ - message: { role, text: "recovered result", timestamp: 3 }, - }); - expect(completed[2]).toMatchObject({ message: { role: "assistant" } }); - }), - ), - ); - } - - for (const variant of [ - "replayed", - "usage updated", - "different completion", - "new prompt", - "long stream", - "overflow", - ] as const) { - it.effect(`reconciles a delayed assistant segment after a snapshot: ${variant}`, () => + it.effect.each(["toolResult", "assistant"] as const)( + "suppresses only exact %s completions already published in a correlated resync", + (role) => Effect.scoped( Effect.gen(function* () { - const correlationId = "recovered-prompt"; - const lifecycle = promptLifecycle(correlationId, "delivered", 2); - // A streamed reply arrives as one delta per token chunk; the old - // pubsub-sized cap (256) closed the session mid-reply. - const longStreamDeltas = PRIME_AGENT_EVENT_BUFFER_CAPACITY * 4; - const overflowLimit = 8; const test = fixture({ correlatedPromptLifecycleCapability: true, rawSnapshot: { ...snapshot(), promptLifecycles: { records: [], expired: [] } }, - ...(variant === "overflow" ? { provisionalSegmentEventLimit: overflowLimit } : {}), }); const runtime = yield* test.make(); - const isReplay = variant === "replayed" || variant === "usage updated"; - const expectedCount = - variant === "overflow" - ? 5 - : variant === "long stream" - ? 6 + longStreamDeltas - : isReplay - ? 7 - : 10; - const received = yield* collectEvents(runtime, expectedCount).pipe( + const received = yield* collectEvents(runtime, 7).pipe( Effect.forkChild({ startImmediately: true }), ); - const recovered = terminalAssistantMessage("recovered result", 2); + const result = + role === "assistant" + ? terminalAssistantMessage("recovered result", 2) + : { + role: "toolResult", + toolCallId: "tool-recovered", + toolName: "read", + content: [{ type: "text", text: "recovered result" }], + isError: false, + timestamp: 2, + }; test.setCorrelatedPromptLifecycleProof(false); yield* Effect.promise(() => test.emit({ type: "connection_status", status: "reconnecting" }), @@ -1662,19 +1539,8 @@ describe("PrimeAgentDaemonSessionRuntime", () => { snapshot: { ...snapshot(9), state: { ...snapshot(9).state, messageCount: 1 }, - messages: [ - variant === "usage updated" - ? { - ...recovered, - usage: { - ...recovered.usage, - totalTokens: 200, - cost: { ...recovered.usage.cost, total: 2 }, - }, - } - : recovered, - ], - promptLifecycles: { records: [lifecycle], expired: [] }, + messages: [result], + promptLifecycles: { records: [], expired: [] }, replay: { status: "complete", toSequence: 9, @@ -1687,115 +1553,242 @@ describe("PrimeAgentDaemonSessionRuntime", () => { yield* Effect.promise(() => test.emit({ type: "connection_status", status: "connected" }), ); - const attributedId = variant === "new prompt" ? "new-prompt" : correlationId; - const emit = (event: Record) => - Effect.promise(() => - test.emit({ - type: "session_event", - attribution: { scope: "prompt", correlationId: attributedId }, - event: { ...event, promptCorrelationId: attributedId }, - }), - ); - yield* emit({ type: "message_start", message: { ...recovered, content: [] } }); - if (variant === "long stream") { - for (let index = 0; index < longStreamDeltas; index += 1) { - yield* emit({ - type: "message_update", - message: recovered, - assistantMessageEvent: { type: "text_delta", contentIndex: 0, delta: "r" }, - }); - } - yield* emit({ type: "message_end", message: { ...recovered, timestamp: 3 } }); - const events = yield* Fiber.join(received); - expect(events.filter((event) => event._tag === "SessionClosed")).toHaveLength(0); - expect(events.filter((event) => event._tag === "AssistantStream")).toHaveLength( - longStreamDeltas, - ); - expect(events.at(-1)).toMatchObject({ - _tag: "MessageCompleted", - message: expect.objectContaining({ text: "recovered result" }), - }); - return; - } - if (variant === "overflow") { - for (let index = 0; index < overflowLimit; index += 1) { - yield* emit({ - type: "message_update", - message: recovered, - assistantMessageEvent: { type: "text_delta", contentIndex: 0, delta: "r" }, - }); - } - expect(yield* Fiber.join(received)).toEqual([ - expect.objectContaining({ _tag: "SessionResynced" }), - expect.objectContaining({ _tag: "ConnectionStatus" }), - expect.objectContaining({ _tag: "SessionResynced" }), - expect.objectContaining({ _tag: "ConnectionStatus" }), - { - _tag: "SessionClosed", - error: "Prime Agent event ingress exceeded its bounded capacity.", - diagnostic: expect.objectContaining({ reason: "ingress-capacity" }), + yield* Effect.promise(() => + test.emit({ + type: "session_event", + attribution: { scope: "session" }, + meta: { cursor: { generation: "daemon-1", sequence: 10 } }, + event: { type: "message_end", promptCorrelationId: null, message: result }, + }), + ); + yield* Effect.promise(() => + test.emit({ + type: "session_event", + attribution: { scope: "session" }, + meta: { cursor: { generation: "daemon-1", sequence: 11 } }, + event: { + type: "message_end", + promptCorrelationId: null, + message: { + ...result, + timestamp: 2, + content: [{ type: "text", text: "different result" }], + }, }, - ]); - return; - } - yield* emit({ - type: "message_update", - message: recovered, - assistantMessageEvent: { - type: "text_delta", - contentIndex: 0, - delta: "recovered result", - }, - }); - yield* emit({ - type: "message_end", - message: - variant === "different completion" ? { ...recovered, timestamp: 3 } : recovered, - }); - const next = terminalAssistantMessage("next genuine result", 4); - yield* emit({ type: "message_start", message: { ...next, content: [] } }); - yield* emit({ - type: "message_update", - message: next, - assistantMessageEvent: { - type: "text_delta", - contentIndex: 0, - delta: "next genuine result", - }, - }); - yield* emit({ type: "message_end", message: next }); - const events = yield* Fiber.join(received); - const output = events.filter( - (event) => - event._tag === "MessageStarted" || - event._tag === "AssistantStream" || - event._tag === "MessageCompleted", + }), ); - const expected = [ - expect.objectContaining({ _tag: "MessageStarted" }), - expect.objectContaining({ _tag: "AssistantStream", delta: "next genuine result" }), - expect.objectContaining({ - _tag: "MessageCompleted", - message: expect.objectContaining({ text: "next genuine result" }), + yield* Effect.promise(() => + test.emit({ + type: "session_event", + attribution: { scope: "session" }, + event: { + type: "message_end", + promptCorrelationId: null, + message: { ...result, timestamp: 3 }, + }, + }), + ); + yield* Effect.promise(() => + test.emit({ + type: "session_event", + attribution: { scope: "session" }, + event: { + type: "message_end", + promptCorrelationId: null, + message: terminalAssistantMessage(), + }, }), - ]; - expect(output).toEqual( - isReplay - ? expected - : [ - expect.objectContaining({ _tag: "MessageStarted" }), - expect.objectContaining({ _tag: "AssistantStream", delta: "recovered result" }), - expect.objectContaining({ - _tag: "MessageCompleted", - message: expect.objectContaining({ text: "recovered result" }), - }), - ...expected, - ], ); + const events = yield* Fiber.join(received); + const completed = events.filter((event) => event._tag === "MessageCompleted"); + expect(completed).toHaveLength(3); + expect(completed[0]).toMatchObject({ + message: { role, text: "different result", timestamp: 2 }, + }); + expect(completed[1]).toMatchObject({ + message: { role, text: "recovered result", timestamp: 3 }, + }); + expect(completed[2]).toMatchObject({ message: { role: "assistant" } }); }), ), - ); - } + ); + + it.effect.each([ + "replayed", + "usage updated", + "different completion", + "new prompt", + "long stream", + "overflow", + ] as const)("reconciles a delayed assistant segment after a snapshot: %s", (variant) => + Effect.scoped( + Effect.gen(function* () { + const correlationId = "recovered-prompt"; + const lifecycle = promptLifecycle(correlationId, "delivered", 2); + // A streamed reply arrives as one delta per token chunk; the old + // pubsub-sized cap (256) closed the session mid-reply. + const longStreamDeltas = PRIME_AGENT_EVENT_BUFFER_CAPACITY * 4; + const overflowLimit = 8; + const test = fixture({ + correlatedPromptLifecycleCapability: true, + rawSnapshot: { ...snapshot(), promptLifecycles: { records: [], expired: [] } }, + ...(variant === "overflow" ? { provisionalSegmentEventLimit: overflowLimit } : {}), + }); + const runtime = yield* test.make(); + const isReplay = variant === "replayed" || variant === "usage updated"; + const expectedCount = + variant === "overflow" + ? 5 + : variant === "long stream" + ? 6 + longStreamDeltas + : isReplay + ? 7 + : 10; + const received = yield* collectEvents(runtime, expectedCount).pipe( + Effect.forkChild({ startImmediately: true }), + ); + const recovered = terminalAssistantMessage("recovered result", 2); + test.setCorrelatedPromptLifecycleProof(false); + yield* Effect.promise(() => + test.emit({ type: "connection_status", status: "reconnecting" }), + ); + test.setCorrelatedPromptLifecycleProof(true); + yield* Effect.promise(() => + test.emit({ + type: "session_resynced", + snapshot: { + ...snapshot(9), + state: { ...snapshot(9).state, messageCount: 1 }, + messages: [ + variant === "usage updated" + ? { + ...recovered, + usage: { + ...recovered.usage, + totalTokens: 200, + cost: { ...recovered.usage.cost, total: 2 }, + }, + } + : recovered, + ], + promptLifecycles: { records: [lifecycle], expired: [] }, + replay: { + status: "complete", + toSequence: 9, + toCursor: { generation: "daemon-1", sequence: 9 }, + }, + }, + }), + ); + expect(runtime.resolveReconnectSnapshot(1, true)).toBe(true); + yield* Effect.promise(() => test.emit({ type: "connection_status", status: "connected" })); + const attributedId = variant === "new prompt" ? "new-prompt" : correlationId; + const emit = (event: Record) => + Effect.promise(() => + test.emit({ + type: "session_event", + attribution: { scope: "prompt", correlationId: attributedId }, + event: { ...event, promptCorrelationId: attributedId }, + }), + ); + yield* emit({ type: "message_start", message: { ...recovered, content: [] } }); + if (variant === "long stream") { + for (let index = 0; index < longStreamDeltas; index += 1) { + yield* emit({ + type: "message_update", + message: recovered, + assistantMessageEvent: { type: "text_delta", contentIndex: 0, delta: "r" }, + }); + } + yield* emit({ type: "message_end", message: { ...recovered, timestamp: 3 } }); + const events = yield* Fiber.join(received); + expect(events.filter((event) => event._tag === "SessionClosed")).toHaveLength(0); + expect(events.filter((event) => event._tag === "AssistantStream")).toHaveLength( + longStreamDeltas, + ); + expect(events.at(-1)).toMatchObject({ + _tag: "MessageCompleted", + message: expect.objectContaining({ text: "recovered result" }), + }); + return; + } + if (variant === "overflow") { + for (let index = 0; index < overflowLimit; index += 1) { + yield* emit({ + type: "message_update", + message: recovered, + assistantMessageEvent: { type: "text_delta", contentIndex: 0, delta: "r" }, + }); + } + expect(yield* Fiber.join(received)).toEqual([ + expect.objectContaining({ _tag: "SessionResynced" }), + expect.objectContaining({ _tag: "ConnectionStatus" }), + expect.objectContaining({ _tag: "SessionResynced" }), + expect.objectContaining({ _tag: "ConnectionStatus" }), + { + _tag: "SessionClosed", + error: "Prime Agent event ingress exceeded its bounded capacity.", + diagnostic: expect.objectContaining({ reason: "ingress-capacity" }), + }, + ]); + return; + } + yield* emit({ + type: "message_update", + message: recovered, + assistantMessageEvent: { + type: "text_delta", + contentIndex: 0, + delta: "recovered result", + }, + }); + yield* emit({ + type: "message_end", + message: variant === "different completion" ? { ...recovered, timestamp: 3 } : recovered, + }); + const next = terminalAssistantMessage("next genuine result", 4); + yield* emit({ type: "message_start", message: { ...next, content: [] } }); + yield* emit({ + type: "message_update", + message: next, + assistantMessageEvent: { + type: "text_delta", + contentIndex: 0, + delta: "next genuine result", + }, + }); + yield* emit({ type: "message_end", message: next }); + const events = yield* Fiber.join(received); + const output = events.filter( + (event) => + event._tag === "MessageStarted" || + event._tag === "AssistantStream" || + event._tag === "MessageCompleted", + ); + const expected = [ + expect.objectContaining({ _tag: "MessageStarted" }), + expect.objectContaining({ _tag: "AssistantStream", delta: "next genuine result" }), + expect.objectContaining({ + _tag: "MessageCompleted", + message: expect.objectContaining({ text: "next genuine result" }), + }), + ]; + expect(output).toEqual( + isReplay + ? expected + : [ + expect.objectContaining({ _tag: "MessageStarted" }), + expect.objectContaining({ _tag: "AssistantStream", delta: "recovered result" }), + expect.objectContaining({ + _tag: "MessageCompleted", + message: expect.objectContaining({ text: "recovered result" }), + }), + ...expected, + ], + ); + }), + ), + ); it.effect("blocks correlated commands until the reconnect snapshot is adapter-settled", () => Effect.scoped( @@ -2222,7 +2215,7 @@ describe("PrimeAgentDaemonSessionRuntime", () => { ), ); - for (const variant of [ + it.effect.each([ "delivered", "default-cancel", "completed", @@ -2233,125 +2226,123 @@ describe("PrimeAgentDaemonSessionRuntime", () => { "correlated-proof-lost", "abort-response-invalid", "abort-proof-lost", - ] as const) { - it.effect(`interrupts only the proved delivered owned session: ${variant}`, () => - Effect.scoped( - Effect.gen(function* () { - const correlationId = "5cfb76d6-9765-4fd0-883b-d60fbed2f7b8"; - const phase = - variant === "completed" - ? "completed" - : variant === "cancelled" - ? "cancelled" - : "delivered"; - const lifecycle = promptLifecycle(correlationId, phase, 2); - let ownedProofCurrent = true; - let retireCorrelatedProof = () => {}; - const test = fixture({ - correlatedPromptLifecycleCapability: true, - rawSnapshot: { ...snapshot(), promptLifecycles: { records: [lifecycle], expired: [] } }, - ownedSessionContractProofImpl: () => - ownedProofCurrent + ] as const)("interrupts only the proved delivered owned session: %s", (variant) => + Effect.scoped( + Effect.gen(function* () { + const correlationId = "5cfb76d6-9765-4fd0-883b-d60fbed2f7b8"; + const phase = + variant === "completed" + ? "completed" + : variant === "cancelled" + ? "cancelled" + : "delivered"; + const lifecycle = promptLifecycle(correlationId, phase, 2); + let ownedProofCurrent = true; + let retireCorrelatedProof = () => {}; + const test = fixture({ + correlatedPromptLifecycleCapability: true, + rawSnapshot: { ...snapshot(), promptLifecycles: { records: [lifecycle], expired: [] } }, + ownedSessionContractProofImpl: () => + ownedProofCurrent + ? { + feature: "caller_owned_session_environment_cleanup_v1", + status: "attached", + daemon: { + protocolName: "prime-agent.daemon", + protocolVersion: 7, + schemaRevision: 30, + appVersion: "0.7.1", + supervisorGeneration: "supervisor-1", + transportGeneration: 0, + }, + } + : undefined, + cancelPromptLifecycleImpl: () => { + if (variant === "owned-proof-lost") ownedProofCurrent = false; + if (variant === "correlated-proof-lost") retireCorrelatedProof(); + return Promise.resolve( + variant === "unknown" ? { - feature: "caller_owned_session_environment_cleanup_v1", - status: "attached", - daemon: { - protocolName: "prime-agent.daemon", - protocolVersion: 7, - schemaRevision: 30, - appVersion: "0.7.1", - supervisorGeneration: "supervisor-1", - transportGeneration: 0, - }, + status: "unknown", + ownershipCrossed: "unknown", + deliveryCrossed: "unknown", } - : undefined, - cancelPromptLifecycleImpl: () => { - if (variant === "owned-proof-lost") ownedProofCurrent = false; - if (variant === "correlated-proof-lost") retireCorrelatedProof(); - return Promise.resolve( - variant === "unknown" - ? { - status: "unknown", - ownershipCrossed: "unknown", - deliveryCrossed: "unknown", - } - : { - status: variant === "cancelled" ? "cancelled" : "too_late", - ownershipCrossed: true, - deliveryCrossed: lifecycle.deliveryCrossed, - lifecycle: - variant === "wrong-owner" - ? { ...lifecycle, correlationId: "another-owner" } - : lifecycle, - }, - ); - }, - interruptOwnedSessionImpl: () => { - if (variant === "abort-proof-lost") retireCorrelatedProof(); - return Promise.resolve( - variant === "abort-response-invalid" - ? { - type: "response", - command: "abort_and_clear_queue", - success: false, - } - : { - type: "response", - command: "abort_and_clear_queue", - success: true, - data: { steering: [], followUp: [] }, - }, - ); - }, + : { + status: variant === "cancelled" ? "cancelled" : "too_late", + ownershipCrossed: true, + deliveryCrossed: lifecycle.deliveryCrossed, + lifecycle: + variant === "wrong-owner" + ? { ...lifecycle, correlationId: "another-owner" } + : lifecycle, + }, + ); + }, + interruptOwnedSessionImpl: () => { + if (variant === "abort-proof-lost") retireCorrelatedProof(); + return Promise.resolve( + variant === "abort-response-invalid" + ? { + type: "response", + command: "abort_and_clear_queue", + success: false, + } + : { + type: "response", + command: "abort_and_clear_queue", + success: true, + data: { steering: [], followUp: [] }, + }, + ); + }, + }); + retireCorrelatedProof = () => test.setCorrelatedPromptLifecycleProof(false); + const runtime = yield* test.make(); + yield* Stream.runDrain(runtime.events).pipe(Effect.forkChild({ startImmediately: true })); + const interrupted = runtime.cancelPromptLifecycle( + correlationId, + variant === "default-cancel" ? undefined : { interruptDelivered: true }, + ); + const expectedError = [ + "wrong-owner", + "owned-proof-lost", + "correlated-proof-lost", + "abort-response-invalid", + "abort-proof-lost", + ].includes(variant); + if (expectedError) { + expect(yield* Effect.flip(interrupted)).toMatchObject({ operation: "abort" }); + } else { + expect(yield* interrupted).toMatchObject({ + status: + variant === "cancelled" + ? "cancelled" + : variant === "unknown" + ? "unknown" + : "too_late", }); - retireCorrelatedProof = () => test.setCorrelatedPromptLifecycleProof(false); - const runtime = yield* test.make(); - yield* Stream.runDrain(runtime.events).pipe(Effect.forkChild({ startImmediately: true })); - const interrupted = runtime.cancelPromptLifecycle( - correlationId, - variant === "default-cancel" ? undefined : { interruptDelivered: true }, - ); - const expectedError = [ - "wrong-owner", - "owned-proof-lost", - "correlated-proof-lost", - "abort-response-invalid", - "abort-proof-lost", - ].includes(variant); - if (expectedError) { - expect(yield* Effect.flip(interrupted)).toMatchObject({ operation: "abort" }); - } else { - expect(yield* interrupted).toMatchObject({ - status: - variant === "cancelled" - ? "cancelled" - : variant === "unknown" - ? "unknown" - : "too_late", - }); - } - const calls = test.captures.connectionCalls.filter( - (call) => call.method === "interruptOwnedSession", - ); - expect(calls).toHaveLength( - ["delivered", "abort-response-invalid", "abort-proof-lost"].includes(variant) ? 1 : 0, - ); - if (calls.length > 0) { - expect(calls[0]!.args).toEqual([ - { type: "abort_and_clear_queue", activeSessionId: "active-secret-1" }, - expect.any(Number), - { recoverable: false, recoverAcrossReconnect: false }, - ]); - } - expect( - test.captures.connectionCalls.filter( - (call) => call.method === "abortAndClearQueue" || call.method === "abort", - ), - ).toEqual([]); - }), - ), - ); - } + } + const calls = test.captures.connectionCalls.filter( + (call) => call.method === "interruptOwnedSession", + ); + expect(calls).toHaveLength( + ["delivered", "abort-response-invalid", "abort-proof-lost"].includes(variant) ? 1 : 0, + ); + if (calls.length > 0) { + expect(calls[0]!.args).toEqual([ + { type: "abort_and_clear_queue", activeSessionId: "active-secret-1" }, + expect.any(Number), + { recoverable: false, recoverAcrossReconnect: false }, + ]); + } + expect( + test.captures.connectionCalls.filter( + (call) => call.method === "abortAndClearQueue" || call.method === "abort", + ), + ).toEqual([]); + }), + ), + ); it.effect("rejects an awaited correlated cancellation when its proof fence changes", () => Effect.scoped( @@ -4084,8 +4075,9 @@ describe("PrimeAgentDaemonSessionRuntime", () => { }), ); - for (const reconnect of [false, true]) { - it.effect(`marks only live-transport snapshots as ordered (reconnect: ${reconnect})`, () => + it.effect.each([false, true])( + "marks only live-transport snapshots as ordered (reconnect: %s)", + (reconnect) => Effect.scoped( Effect.gen(function* () { const test = fixture({ @@ -4119,8 +4111,7 @@ describe("PrimeAgentDaemonSessionRuntime", () => { expect(runtime.resolveReconnectSnapshot(reconnect ? 1 : 0, true)).toBe(true); }), ), - ); - } + ); it.effect("does not replace live MCP ownership for an ordinary resync snapshot", () => Effect.scoped( @@ -12201,75 +12192,73 @@ describe("PrimeAgentDaemonSessionRuntime", () => { ), ); - for (const variant of [ + it.effect.each([ "valid", "wrong leaf", "wrong session", "stale generation", "retired during read", "unavailable", - ] as const) { - it.effect(`fences compaction tree reads: ${variant}`, () => - Effect.scoped( - Effect.gen(function* () { - const timestamp = "2026-09-14T12:00:00.000Z"; - const tree = { - leafId: variant === "wrong leaf" ? "foreign" : "c", - tree: [ - { - entry: { - id: "a", - parentId: null, - timestamp, - type: "message", - message: { role: "user", content: "before", timestamp: 1 }, - }, - children: [ - { - entry: { - id: "c", - parentId: "a", - timestamp, - type: "compaction", - summary: "private summary", - tokensBefore: 10, - firstKeptEntryId: "a", - }, - children: [], - }, - ], + ] as const)("fences compaction tree reads: %s", (variant) => + Effect.scoped( + Effect.gen(function* () { + const timestamp = "2026-09-14T12:00:00.000Z"; + const tree = { + leafId: variant === "wrong leaf" ? "foreign" : "c", + tree: [ + { + entry: { + id: "a", + parentId: null, + timestamp, + type: "message", + message: { role: "user", content: "before", timestamp: 1 }, }, - ], - }; - const test = fixture({ - getSessionTreeImpl: - variant === "unavailable" - ? undefined - : async () => { - if (variant === "retired during read") - await test.emit({ type: "connection_status", status: "reconnecting" }); - return tree; + children: [ + { + entry: { + id: "c", + parentId: "a", + timestamp, + type: "compaction", + summary: "private summary", + tokensBefore: 10, + firstKeptEntryId: "a", }, - }); - const runtime = yield* test.make(); - if (runtime.getCompactionHistory === undefined) throw new Error("missing history reader"); - const candidate = { - ...runtime.initialSnapshot, - state: { - ...runtime.initialSnapshot.state, - leafId: "c", - ...(variant === "wrong session" ? { sessionId: "foreign" } : {}), + children: [], + }, + ], }, - ...(variant === "stale generation" ? { connectionGeneration: 99 } : {}), - }; - const history = yield* runtime.getCompactionHistory(candidate); - expect(history !== undefined).toBe(variant === "valid"); - if (history !== undefined) - expect(encodePrivateHistory(history)).not.toContain("private summary"); - }), - ), - ); - } + ], + }; + const test = fixture({ + getSessionTreeImpl: + variant === "unavailable" + ? undefined + : async () => { + if (variant === "retired during read") + await test.emit({ type: "connection_status", status: "reconnecting" }); + return tree; + }, + }); + const runtime = yield* test.make(); + if (runtime.getCompactionHistory === undefined) throw new Error("missing history reader"); + const candidate = { + ...runtime.initialSnapshot, + state: { + ...runtime.initialSnapshot.state, + leafId: "c", + ...(variant === "wrong session" ? { sessionId: "foreign" } : {}), + }, + ...(variant === "stale generation" ? { connectionGeneration: 99 } : {}), + }; + const history = yield* runtime.getCompactionHistory(candidate); + expect(history !== undefined).toBe(variant === "valid"); + if (history !== undefined) + expect(encodePrivateHistory(history)).not.toContain("private summary"); + }), + ), + ); it.effect("uses argument-free compaction controls and projects only safe state", () => Effect.scoped( @@ -14525,72 +14514,68 @@ describe("Prime Agent live activity privacy boundary", () => { ), ); - for (const cleanupFails of [false, true]) { - it.effect( - `cleans failed creation only with exact owned proof (cleanup fails: ${cleanupFails})`, - () => - Effect.scoped( - Effect.gen(function* () { - const root = yield* Effect.acquireRelease( - Effect.promise(() => - NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "prime-failed-create-")), - ), - (directory) => - Effect.promise(() => NodeFSP.rm(directory, { recursive: true, force: true })), - ); - const ownershipStore = new PrimeAgentOwnershipReceiptStore(root, { - inspectProcessIdentity: async (pid) => `test:${pid}`, - }); - const side = fixture({ - ownershipStore, - recoveryMode: "create", - rawSnapshot: { - ...snapshot(), - lastEventCursor: { generation: "events-1", sequence: 4 }, + it.effect.each([false, true])( + "cleans failed creation only with exact owned proof (cleanup fails: %s)", + (cleanupFails) => + Effect.scoped( + Effect.gen(function* () { + const root = yield* Effect.acquireRelease( + Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "prime-failed-create-")), + ), + (directory) => + Effect.promise(() => NodeFSP.rm(directory, { recursive: true, force: true })), + ); + const ownershipStore = new PrimeAgentOwnershipReceiptStore(root, { + inspectProcessIdentity: async (pid) => `test:${pid}`, + }); + const side = fixture({ + ownershipStore, + recoveryMode: "create", + rawSnapshot: { + ...snapshot(), + lastEventCursor: { generation: "events-1", sequence: 4 }, + }, + ...(cleanupFails + ? { + disposeImpl: async () => { + throw new Error("transport lost"); + }, + } + : {}), + }); + const error = yield* side + .make(undefined, undefined, undefined, undefined, undefined, undefined, { + kind: "create", + requestId: "failed-create-request", + correlationId: "failed-create-correlation", + mcpOwnerId: "pylon:none:failed-create", + threadId: "failed-create-thread", + sessionIncarnationId: "failed-create-incarnation", + admissionRequestId: "failed-create-admission", + onAuthorityReady: async () => { + throw new Error("authority commit failed"); }, - ...(cleanupFails - ? { - disposeImpl: async () => { - throw new Error("transport lost"); - }, - } - : {}), - }); - const error = yield* side - .make(undefined, undefined, undefined, undefined, undefined, undefined, { - kind: "create", - requestId: "failed-create-request", - correlationId: "failed-create-correlation", - mcpOwnerId: "pylon:none:failed-create", - threadId: "failed-create-thread", - sessionIncarnationId: "failed-create-incarnation", - admissionRequestId: "failed-create-admission", - onAuthorityReady: async () => { - throw new Error("authority commit failed"); - }, - onNativeCleanupProven: async () => { - expect((await ownershipStore.scan()).receipts).toHaveLength(0); - side.captures.order.push("recovery-cleanup-durable"); - }, - }) - .pipe(Effect.flip); - expect(error).toMatchObject({ - operation: "create-session", - reason: "request-failed", - detail: "Recoverable Prime Agent authority could not be durably recorded.", - }); - expect(side.captures.order.filter((entry) => entry === "dispose-owned")).toHaveLength( - 1, - ); - const scan = yield* Effect.promise(() => ownershipStore.scan()); - expect(scan.corrupt).toBe(false); - expect(scan.receipts).toHaveLength(cleanupFails ? 1 : 0); - expect(side.captures.order.includes("release-daemon")).toBe(!cleanupFails); - expect(side.captures.order.includes("recovery-cleanup-durable")).toBe(!cleanupFails); - }), - ), - ); - } + onNativeCleanupProven: async () => { + expect((await ownershipStore.scan()).receipts).toHaveLength(0); + side.captures.order.push("recovery-cleanup-durable"); + }, + }) + .pipe(Effect.flip); + expect(error).toMatchObject({ + operation: "create-session", + reason: "request-failed", + detail: "Recoverable Prime Agent authority could not be durably recorded.", + }); + expect(side.captures.order.filter((entry) => entry === "dispose-owned")).toHaveLength(1); + const scan = yield* Effect.promise(() => ownershipStore.scan()); + expect(scan.corrupt).toBe(false); + expect(scan.receipts).toHaveLength(cleanupFails ? 1 : 0); + expect(side.captures.order.includes("release-daemon")).toBe(!cleanupFails); + expect(side.captures.order.includes("recovery-cleanup-durable")).toBe(!cleanupFails); + }), + ), + ); it.effect("commits adoption before replay and defers MCP replacement to the next prompt", () => Effect.scoped( diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts index 222e0d9c14..66217c0b24 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts @@ -274,82 +274,80 @@ layer("GitHubPullRequestCli.layer", (it) => { }), ); - for (const [label, response] of [ - ["missing alias", '{"data":{"s0":{"pullRequest":null}}}'], - [ - "unsupported stack field", - '{"data":null,"errors":[{"message":"Field stack does not exist"}]}', - ], - [ - "missing GraphQL scope", - '{"data":null,"errors":[{"message":"Resource not accessible by integration"}]}', - ], - ] as const) { - it.effect(`reads a pull request on its own after a ${label}`, () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output(response))).mockReturnValueOnce( - Effect.succeed( - output( - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify({ - number: 7, - title: "Reuse the summary", - url: "https://github.com/acme/web/pull/7", - author: { login: "octocat", name: "Octo Cat" }, - baseRefName: "main", - headRefName: "feat/summary", - state: "OPEN", - isDraft: false, - mergeable: "MERGEABLE", - reviewDecision: "APPROVED", - additions: 12, - deletions: 3, - changedFiles: 2, - createdAt: "2026-08-20T00:00:00.000Z", - updatedAt: "2026-08-24T12:34:56.000Z", - reviewRequests: [], - labels: [], - statusCheckRollup: [ - { - __typename: "CheckRun", - status: "COMPLETED", - conclusion: "SUCCESS", - name: "ci", - }, - ], - body: "", - }), - ), + it.effect.each([ + { label: "missing alias", response: '{"data":{"s0":{"pullRequest":null}}}' }, + { + label: "unsupported stack field", + response: '{"data":null,"errors":[{"message":"Field stack does not exist"}]}', + }, + { + label: "missing GraphQL scope", + response: '{"data":null,"errors":[{"message":"Resource not accessible by integration"}]}', + }, + ] as const)("reads a pull request on its own after a $label", ({ response }) => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(response))).mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + number: 7, + title: "Reuse the summary", + url: "https://github.com/acme/web/pull/7", + author: { login: "octocat", name: "Octo Cat" }, + baseRefName: "main", + headRefName: "feat/summary", + state: "OPEN", + isDraft: false, + mergeable: "MERGEABLE", + reviewDecision: "APPROVED", + additions: 12, + deletions: 3, + changedFiles: 2, + createdAt: "2026-08-20T00:00:00.000Z", + updatedAt: "2026-08-24T12:34:56.000Z", + reviewRequests: [], + labels: [], + statusCheckRollup: [ + { + __typename: "CheckRun", + status: "COMPLETED", + conclusion: "SUCCESS", + name: "ci", + }, + ], + body: "", + }), ), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - const read = yield* cli - .getPullRequestSummary({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - }) - .pipe(Effect.forkChild); - yield* TestClock.adjust("10 millis"); - const summary = yield* Fiber.join(read); - - assert.strictEqual(summary.headBranch, "feat/summary"); - assert.strictEqual(summary.checksState, "passing"); - assert.strictEqual(mockedExecute.mock.calls.length, 2); - expect(callAt(1).args).toEqual([ - "pr", - "view", - "7", - "--repo", - "github.com/acme/web", - "--json", - expect.stringContaining("statusCheckRollup"), - ]); - }), - ); - } + const read = yield* cli + .getPullRequestSummary({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }) + .pipe(Effect.forkChild); + yield* TestClock.adjust("10 millis"); + const summary = yield* Fiber.join(read); + + assert.strictEqual(summary.headBranch, "feat/summary"); + assert.strictEqual(summary.checksState, "passing"); + assert.strictEqual(mockedExecute.mock.calls.length, 2); + expect(callAt(1).args).toEqual([ + "pr", + "view", + "7", + "--repo", + "github.com/acme/web", + "--json", + expect.stringContaining("statusCheckRollup"), + ]); + }), + ); it.effect("does not fan out summary reads after a rejected GraphQL batch", () => Effect.gen(function* () { diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index ab3b6785b6..a3c5603e83 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -2136,7 +2136,7 @@ it.effect("routes Azure reads and writes through the requested organization's ch }), ); -for (const checkout of [ +it.effect.each([ { host: "ssh.dev.azure.com", repository: "v3/org-b/project/web", @@ -2152,57 +2152,55 @@ for (const checkout of [ repository: "DefaultCollection/project/_git/web", remoteUrl: "https://org-b.visualstudio.com/DefaultCollection/project/_git/web", }, -]) { - it.effect(`routes Azure URL reads and writes through a ${checkout.host} 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, - }), - ), - 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* () { @@ -4732,104 +4730,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/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 9a1ca9babc..01f9661334 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -1321,77 +1321,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* updateSettingsWithProviderInstances(serverSettings, { - providerInstances: { - [instanceId]: { - driver: ProviderDriverKind.make("codex"), - displayName: "Renamed provider", - environment: duplicate ? [variable, variable] : [variable], - config: {}, - }, + const next = yield* updateSettingsWithProviderInstances(serverSettings, { + 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 +1416,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { sensitiveLast ? "secret-last" : "", ); }).pipe(Effect.provide(makeServerSettingsLayer())), - ); - } + ); it.effect( "keeps Bitbucket tokens in the secret store and tells clients only that one is set", @@ -1679,8 +1679,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; @@ -1781,8 +1782,8 @@ it.layer(NodeServices.layer)("server settings", (it) => { "sk-kept", ); }).pipe(Effect.provide(settingsLayer)); - }); - } + }, + ); it.effect("stores hub keys outside settings.json and preserves them across redacted edits", () => Effect.gen(function* () { diff --git a/apps/server/src/serviceLauncher.test.ts b/apps/server/src/serviceLauncher.test.ts index d17429e48f..d29dd5f4ef 100644 --- a/apps/server/src/serviceLauncher.test.ts +++ b/apps/server/src/serviceLauncher.test.ts @@ -304,39 +304,39 @@ if (context.update?.status === "pending") { describe("directory durability", () => { afterEach(() => vi.restoreAllMocks()); - for (const stage of ["open", "sync"] as const) { - for (const platform of ["win32", "darwin", "linux"] as const) { - it(`handles ${stage} EPERM only on Windows (${platform})`, async () => { - const denied = Object.assign(new Error("directory handle denied"), { code: "EPERM" }); - const open = vi.mocked(NodeFSP.open); - if (stage === "open") { - open.mockRejectedValueOnce(denied); - } else { - const directory = await NodeFSP.mkdtemp( - NodePath.join(NodeOS.tmpdir(), "pylon-directory-sync-"), - ); - const fixture = NodePath.join(directory, "handle-fixture"); - await NodeFSP.writeFile(fixture, ""); - const handle = await NodeFSP.open(fixture, "r+"); - const close = vi.spyOn(handle, "close"); - vi.spyOn(handle, "sync").mockRejectedValueOnce(denied); - open.mockResolvedValueOnce(handle); - try { - const operation = syncDirectory(directory, platform); - if (platform === "win32") await expect(operation).resolves.toBeUndefined(); - else await expect(operation).rejects.toBe(denied); - expect(close).toHaveBeenCalledOnce(); - } finally { - await NodeFSP.rm(directory, { recursive: true }); - } - return; - } - const operation = syncDirectory("unused-directory", platform); + it.each( + (["open", "sync"] as const).flatMap((stage) => + (["win32", "darwin", "linux"] as const).map((platform) => ({ stage, platform })), + ), + )("handles $stage EPERM only on Windows ($platform)", async ({ stage, platform }) => { + const denied = Object.assign(new Error("directory handle denied"), { code: "EPERM" }); + const open = vi.mocked(NodeFSP.open); + if (stage === "open") { + open.mockRejectedValueOnce(denied); + } else { + const directory = await NodeFSP.mkdtemp( + NodePath.join(NodeOS.tmpdir(), "pylon-directory-sync-"), + ); + const fixture = NodePath.join(directory, "handle-fixture"); + await NodeFSP.writeFile(fixture, ""); + const handle = await NodeFSP.open(fixture, "r+"); + const close = vi.spyOn(handle, "close"); + vi.spyOn(handle, "sync").mockRejectedValueOnce(denied); + open.mockResolvedValueOnce(handle); + try { + const operation = syncDirectory(directory, platform); if (platform === "win32") await expect(operation).resolves.toBeUndefined(); else await expect(operation).rejects.toBe(denied); - }); + expect(close).toHaveBeenCalledOnce(); + } finally { + await NodeFSP.rm(directory, { recursive: true }); + } + return; } - } + const operation = syncDirectory("unused-directory", platform); + if (platform === "win32") await expect(operation).resolves.toBeUndefined(); + else await expect(operation).rejects.toBe(denied); + }); it("preserves other directory failures on Windows", async () => { const failure = Object.assign(new Error("storage failed"), { code: "EIO" }); diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 70cfc4ffdf..1a91383190 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -365,8 +365,9 @@ describe("GitHubCli.listPullRequestsByHead", () => { }).pipe(Effect.provide(layer)), ); - for (const failure of ["scope", "decode", "partial"] as const) { - it.effect(`falls back for unanswered heads after a ${failure} batch response`, () => + it.effect.each(["scope", "decode", "partial"] as const)( + "falls back for unanswered heads after a %s batch response", + (failure) => Effect.gen(function* () { const cliHeads: string[] = []; let documents = 0; @@ -430,8 +431,7 @@ describe("GitHubCli.listPullRequestsByHead", () => { failure === "partial" ? ["feature/b"] : ["feature/a", "feature/b"], ); }).pipe(Effect.provide(layer)), - ); - } + ); it.effect("asks gh pr list when gh could read another repository", () => Effect.gen(function* () { diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index dff2678080..b50a47b725 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -475,8 +475,9 @@ it("returns null when refining unknown remote with unauthenticated host", () => assert.strictEqual(provider, null); }); -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) => { @@ -516,11 +517,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", @@ -551,5 +552,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 deca59c48b..de0e6bad76 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/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index 030f4e90f4..09308da6d7 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -1019,74 +1019,76 @@ it.effect("rejects HTTP failures even when tea exits successfully", () => ), ); -for (const [body, expected] of [ - [ - '{"message":"Pull request is already closed"}', - "Forgejo rejected the request because it conflicts with the current state (HTTP 409).", - ], - [ - "Authorization: token private", - "Forgejo rejected the request because it conflicts with the current state (HTTP 409).", - ], - [ - '{"message":"Authorization: token private"}', - "Forgejo rejected the request because it conflicts with the current state (HTTP 409).", - ], - [ - '{"message":"test-token"}', - "Forgejo rejected the request because it conflicts with the current state (HTTP 409).", - ], - [ - '{"message":"secret is hunter2"}', - "Forgejo rejected the request because it conflicts with the current state (HTTP 409).", - ], -] as const) { - it.effect(`limits Forgejo HTTP error detail for ${body.slice(0, 24)}`, () => - Effect.gen(function* () { - const cli = yield* ForgejoCli.make; - const result = yield* cli - .api({ cwd: "/repo", host: "forgejo.test", path: "user" }) - .pipe(Effect.result); - assert.strictEqual(result._tag, "Failure"); - if (result._tag === "Failure") { - assert.strictEqual(result.failure.httpStatus, 409); - assert.strictEqual(result.failure.detail, expected); - } - }).pipe( - Effect.provideService( - FileSystem.FileSystem, - FileSystem.makeNoop({ - exists: () => Effect.succeed(true), - readFileString: () => - Effect.succeed( - encodeJson({ - hosts: { "forgejo.test": { type: "Application", token: "test-token" } }, - }), - ), - }), - ), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make((request) => - Effect.succeed(HttpClientResponse.fromWeb(request, new Response(body, { status: 409 }))), - ), - ), - Effect.provide( - Layer.mock(VcsProcess.VcsProcess)({ - run: () => - Effect.succeed(processOutput("", { exitCode: ChildProcessSpawner.ExitCode(2) })), - }), +it.effect.each( + ( + [ + [ + '{"message":"Pull request is already closed"}', + "Forgejo rejected the request because it conflicts with the current state (HTTP 409).", + ], + [ + "Authorization: token private", + "Forgejo rejected the request because it conflicts with the current state (HTTP 409).", + ], + [ + '{"message":"Authorization: token private"}', + "Forgejo rejected the request because it conflicts with the current state (HTTP 409).", + ], + [ + '{"message":"test-token"}', + "Forgejo rejected the request because it conflicts with the current state (HTTP 409).", + ], + [ + '{"message":"secret is hunter2"}', + "Forgejo rejected the request because it conflicts with the current state (HTTP 409).", + ], + ] as const + ).map(([body, expected]) => ({ label: body.slice(0, 24), body, expected })), +)("limits Forgejo HTTP error detail for $label", ({ body, expected }) => + Effect.gen(function* () { + const cli = yield* ForgejoCli.make; + const result = yield* cli + .api({ cwd: "/repo", host: "forgejo.test", path: "user" }) + .pipe(Effect.result); + assert.strictEqual(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.strictEqual(result.failure.httpStatus, 409); + assert.strictEqual(result.failure.detail, expected); + } + }).pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + exists: () => Effect.succeed(true), + readFileString: () => + Effect.succeed( + encodeJson({ + hosts: { "forgejo.test": { type: "Application", token: "test-token" } }, + }), + ), + }), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, new Response(body, { status: 409 }))), ), ), - ); -} + Effect.provide( + Layer.mock(VcsProcess.VcsProcess)({ + run: () => Effect.succeed(processOutput("", { exitCode: ChildProcessSpawner.ExitCode(2) })), + }), + ), + ), +); -for (const [status, reason] of [ - [401, "authentication"], - [403, "forbidden"], - [429, "rate-limit"], -] as const) { - it.effect(`preserves Forgejo HTTP ${status} when the error body stream fails`, () => +it.effect.each([ + { status: 401, reason: "authentication" }, + { status: 403, reason: "forbidden" }, + { status: 429, reason: "rate-limit" }, +] as const)( + "preserves Forgejo HTTP $status when the error body stream fails", + ({ status, reason }) => Effect.gen(function* () { const cli = yield* ForgejoCli.make; const result = yield* cli @@ -1136,8 +1138,7 @@ for (const [status, reason] of [ }), ), ), - ); -} +); it.effect("routes mounted Forgejo repositories without repeating the mount in API paths", () => Effect.gen(function* () { diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index 960e2459fb..ec7a10e403 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/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 070e16347f..2926e728a6 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -1583,8 +1583,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; @@ -1635,8 +1636,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 e3a947e451..5b61312d3d 100644 --- a/apps/server/src/terminal/NodePtyAdapter.test.ts +++ b/apps/server/src/terminal/NodePtyAdapter.test.ts @@ -109,8 +109,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; @@ -128,8 +129,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* () { @@ -159,8 +159,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({ @@ -188,8 +189,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* () { @@ -275,8 +275,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; @@ -296,8 +297,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* () { @@ -316,8 +316,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"); @@ -357,5 +358,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 76629cbf61..58fa9e3d74 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/textGeneration/ClaudeTextGeneration.test.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts index 8fe5152d34..fc51a7248a 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 6ae23c5e76..2b0f858260 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -162,8 +162,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" }), @@ -181,8 +182,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => { expect(result.title).toBe("Bedrock title"); }), ), - ); - } + ); it.effect("generates and sanitizes commit messages without branch by default", () => withFakeCodexEnv( { @@ -378,7 +378,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => { ), ); - for (const example of [ + it.effect.each([ { mode: "static", output: "Add Search", @@ -397,30 +397,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 fff76988e9..684d173463 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/cliproxyApi.test.ts b/apps/server/src/usage/cliproxyApi.test.ts index b0fee36382..10ce966ef1 100644 --- a/apps/server/src/usage/cliproxyApi.test.ts +++ b/apps/server/src/usage/cliproxyApi.test.ts @@ -234,22 +234,20 @@ describe("CLIProxyAPI built-in management API", () => { }), ); - 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/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index 9afefdea0b..e2ae77e32f 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -392,8 +392,9 @@ it.effect("checkpoint recovery refuses excessive candidates before probing", () }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), ); -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; @@ -472,8 +473,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* () { @@ -542,116 +542,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* () { @@ -693,206 +680,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* () { @@ -937,89 +904,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; @@ -1042,8 +1007,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/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 2cf634cebc..8cb89de577 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -254,50 +254,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 +820,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 +883,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", () => { @@ -1473,8 +1469,9 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); - for (const splitIndex of [false, true]) { - it.effect(`does not reread a clean preceding-second file (split: ${splitIndex})`, () => + it.effect.each([false, true])( + "does not reread a clean preceding-second file (split: %s)", + (splitIndex) => Effect.gen(function* () { const cwd = yield* makeTmpDir(); yield* initRepoWithCommit(cwd); @@ -1509,50 +1506,47 @@ 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 a same-size racy edit (${timestamp}, split: ${splitIndex})`, () => - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - yield* initRepoWithCommit(cwd); - const driver = yield* GitVcsDriver.GitVcsDriver; - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const filePath = path.join(cwd, "tracked.txt"); - const indexPath = path.join(cwd, ".git", "index"); - yield* git(cwd, ["config", "core.trustctime", "false"]); - yield* writeTextFile(cwd, "tracked.txt", "before\n"); - yield* fs.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* fs.utimes(indexPath, timestamp, timestamp); - const originalIndex = yield* fs.readFile(indexPath); - const originalMtime = (yield* fs.stat(indexPath)).mtime; - yield* writeTextFile(cwd, "tracked.txt", "after!\n"); - yield* fs.utimes(filePath, timestamp, timestamp); - yield* writeTextFile(cwd, "untracked.txt", "new\n"); + ] as const)("preserves a same-size racy edit (%s, split: %s)", ([timestamp, splitIndex]) => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const filePath = path.join(cwd, "tracked.txt"); + const indexPath = path.join(cwd, ".git", "index"); + yield* git(cwd, ["config", "core.trustctime", "false"]); + yield* writeTextFile(cwd, "tracked.txt", "before\n"); + yield* fs.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* fs.utimes(indexPath, timestamp, timestamp); + const originalIndex = yield* fs.readFile(indexPath); + const originalMtime = (yield* fs.stat(indexPath)).mtime; + yield* writeTextFile(cwd, "tracked.txt", "after!\n"); + yield* fs.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* fs.readFile(indexPath), originalIndex); - assert.deepStrictEqual((yield* fs.stat(indexPath)).mtime, originalMtime); - }), - ); - } + 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* fs.readFile(indexPath), originalIndex); + assert.deepStrictEqual((yield* fs.stat(indexPath)).mtime, originalMtime); + }), + ); it.effect("keeps complete stats for files beyond the combined patch limit", () => Effect.gen(function* () { @@ -2936,8 +2930,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(); yield* initRepoWithCommit(cwd); @@ -2994,8 +2989,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { ); } }), - ); - } + ); it.effect("explains a real fetch failure for a missing local remote", () => Effect.gen(function* () { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 7940669553..20de6ca9a0 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1288,10 +1288,9 @@ export function observeSidebarInboxReturns( return { workingKeys: working, returnedAt }; } -/** The timestamp a working thread's elapsed label counts from: the running - turn's start (request time until adoption), falling back to the session's - last transition when the turn projection lags behind. Malformed - timestamps fall through to the next candidate, not just missing ones. */ +/** The timestamp a working thread's elapsed label counts from: when its + current work started (request time until adoption). Background wakes do + not reset it. Malformed timestamps fall through to the next candidate. */ export function resolveWorkingStartedAt( thread: Pick, ): string | null { diff --git a/apps/web/src/components/chat/importComposerAttachment.test.ts b/apps/web/src/components/chat/importComposerAttachment.test.ts index 1af7933c25..2dbc79f7f5 100644 --- a/apps/web/src/components/chat/importComposerAttachment.test.ts +++ b/apps/web/src/components/chat/importComposerAttachment.test.ts @@ -18,13 +18,9 @@ const attachment = { name: "notes.txt", mimeType: "text/plain" }; afterEach(() => vi.unstubAllGlobals()); describe("pasted attachment authorization and download", () => { - for (const cancellation of [ - "remove chip", - "unmount", - "replace source epoch", - "deadline", - ] as const) { - it(`releases pending draft work when ${cancellation} interrupts unresolved authorization`, async () => { + it.each(["remove chip", "unmount", "replace source epoch", "deadline"] as const)( + "releases pending draft work when %s interrupts unresolved authorization", + async (cancellation) => { const target = scopeThreadRef( EnvironmentId.make("import-cancellation"), ThreadId.make(cancellation), @@ -90,8 +86,8 @@ describe("pasted attachment authorization and download", () => { await rejected; expect(pendingDraftWork.has(cancellation)).toBe(false); expect(fetch).not.toHaveBeenCalled(); - }); - } + }, + ); it("discards a capability from an old connection and authorizes again before downloading", async () => { let current = originalConnection; diff --git a/apps/web/src/components/chat/queuedMessageEdit.test.ts b/apps/web/src/components/chat/queuedMessageEdit.test.ts index 579b546fb6..50e9e469c1 100644 --- a/apps/web/src/components/chat/queuedMessageEdit.test.ts +++ b/apps/web/src/components/chat/queuedMessageEdit.test.ts @@ -42,24 +42,25 @@ describe("queued message file edits", () => { useComposerDraftStore.setState({ draftsByThreadKey: {}, draftThreadsByThreadKey: {} }); }); - for (const images of [[], [image]]) { - it(`preserves a generic file in a ${images.length === 0 ? "file-only" : "mixed"} save`, async () => { - const attachments = await prepareQueuedEditAttachments({ - existingAttachments: [], - images, - files: [file], - uploadFiles: async () => [uploadedFile], - readImage: async () => "data:image/png;base64,aW1hZ2U=", - }); - expect(attachments.at(-1)).toEqual(uploadedFile); - expect(attachments.length).toBe(images.length + 1); - if (images.length > 0) - expect(attachments[0]).toMatchObject({ - type: "image", - dataUrl: "data:image/png;base64,aW1hZ2U=", - }); + it.each([ + { images: [], label: "file-only" }, + { images: [image], label: "mixed" }, + ])("preserves a generic file in a $label save", async ({ images }) => { + const attachments = await prepareQueuedEditAttachments({ + existingAttachments: [], + images, + files: [file], + uploadFiles: async () => [uploadedFile], + readImage: async () => "data:image/png;base64,aW1hZ2U=", }); - } + expect(attachments.at(-1)).toEqual(uploadedFile); + expect(attachments.length).toBe(images.length + 1); + if (images.length > 0) + expect(attachments[0]).toMatchObject({ + type: "image", + dataUrl: "data:image/png;base64,aW1hZ2U=", + }); + }); it("retains saved attachments alongside newly uploaded files", async () => { const saved = { ...uploadedFile, id: "saved:earlier" }; diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 8b0acf6870..ba2897060b 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -1328,8 +1328,9 @@ describe("composer and pull request shortcuts", () => { ["k", "pullRequest.copyNumber"], ] as const; - for (const platform of ["MacIntel", "Win32", "Linux"]) { - it(`separates queued steering and background start on ${platform}`, () => { + it.each(["MacIntel", "Win32", "Linux"])( + "separates queued steering and background start on %s", + (platform) => { const modifier = { metaKey: platform === "MacIntel", ctrlKey: platform !== "MacIntel", @@ -1350,8 +1351,8 @@ describe("composer and pull request shortcuts", () => { }), "composer.sendBackground", ); - }); - } + }, + ); for (const platform of ["MacIntel", "Win32", "Linux"]) { it.each(shortcuts)( @@ -1380,8 +1381,9 @@ describe("composer and pull request shortcuts", () => { ); } - for (const platform of ["MacIntel", "Win32", "Linux"]) { - it(`edits the last queued message with Alt+ArrowUp from the composer on ${platform}`, () => { + it.each(["MacIntel", "Win32", "Linux"])( + "edits the last queued message with Alt+ArrowUp from the composer on %s", + (platform) => { const input = event({ key: "ArrowUp", altKey: true }); assert.strictEqual( resolveShortcutCommand(input, DEFAULT_RESOLVED_KEYBINDINGS, { @@ -1396,8 +1398,8 @@ describe("composer and pull request shortcuts", () => { context: { composerFocus: false }, }), ); - }); - } + }, + ); for (const platform of ["MacIntel", "Win32", "Linux"]) { it.each([ diff --git a/docs/internals/devices.md b/docs/internals/devices.md index 1729b9f489..4b1c4d4571 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/glossary.md b/docs/internals/glossary.md index 044fa40d03..ac951ebac0 100644 --- a/docs/internals/glossary.md +++ b/docs/internals/glossary.md @@ -13,7 +13,7 @@ Terms whose meaning matters across Pylon. Architecture and lifecycle constraints | 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. | | Turn window | A bounded page of a thread's turns that carry a user message, with subagent and fan-out turns kept beside their anchor. Requests opt in, gated on the `threadSnapshotPagination` capability. | | Activity | A non-message timeline item, such as a tool action, approval, or failure. | | Thread plan | The ordered steps an agent reports for a turn, projected as thread state from the provider's own plan or todo output. Each step is `pending`, `inProgress`, or `completed`. | @@ -22,17 +22,17 @@ Terms whose meaning matters across Pylon. Architecture and lifecycle constraints ## 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. | +| Reactor | A worker that performs follow-up work in response to recorded intent or runtime signals. | +| Runtime receipt | A test-only signal that an asynchronous milestone completed. | ## Providers and checkpoints @@ -58,7 +58,7 @@ Terms whose meaning matters across Pylon. Architecture and lifecycle constraints | 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 1d7cb046e6..b9bd7c6c94 100644 --- a/docs/internals/overview.md +++ b/docs/internals/overview.md @@ -123,8 +123,11 @@ landing; the [saga design](./rollback-recovery.md) remains historical reference. 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 Pylon, 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/providers.md b/docs/internals/providers.md index 0f58ea4002..2a064c1d76 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,17 +11,26 @@ session or catalog state. ## Process and account isolation -Pylon-managed OpenCode chat uses one server per thread. Its MCP registrations are directory-scoped, -while Pylon'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 30 seconds after its last borrower. External OpenCode servers remain externally owned, use -only their explicit provider password rather than the host's, 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 Pylon's MCP connection is thread-scoped, so threads in one +directory must not share one Pylon MCP entry. + +- **1.x** uses one Pylon-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 + 30 seconds after its last borrower. 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, use only their explicit provider password rather +than the host's, 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 Pylon'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/operations/development.md b/docs/operations/development.md index 1c8134769c..7c83b293fc 100644 --- a/docs/operations/development.md +++ b/docs/operations/development.md @@ -39,7 +39,7 @@ cases. Never run a development server against the live `~/.pylon-code/userdata`, Seed development state from a copy of Pylon's own database. See [test data](../../AGENTS.md#test-data) for a consistent `VACUUM INTO` snapshot. `vp run migrate-dev-db` seeds a worktree from a trimmed copy of -`~/.pylon-code/userdata/state.sqlite`, and `--source ` reads a different database (never +`~/.pylon-code/userdata/statev2.sqlite`, and `--source ` reads a different database (never `~/.t3`). `node apps/server/scripts/t3-sqlite-state.ts --base-dir ` inspects or seeds an isolated database after taking a private backup. Both refuse to write to either runtime home. diff --git a/infra/relay/src/agentActivity/ApnsClient.test.ts b/infra/relay/src/agentActivity/ApnsClient.test.ts index bcbf1f4f78..88918ba7b5 100644 --- a/infra/relay/src/agentActivity/ApnsClient.test.ts +++ b/infra/relay/src/agentActivity/ApnsClient.test.ts @@ -387,79 +387,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 e98c2b6395..934ebfdb44 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 dd72313fd4..390ff089b4 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.test.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.test.ts @@ -217,36 +217,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(); @@ -296,13 +299,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* () { @@ -318,8 +317,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(); @@ -335,8 +334,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])); @@ -351,8 +351,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 = { @@ -411,13 +411,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* () { @@ -435,8 +436,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( @@ -574,23 +575,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", @@ -811,8 +812,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 => ({ @@ -853,6 +855,6 @@ describe("FCM queue message isolation", () => { expect(h.sent).toHaveLength(1); expect(h.marked).toHaveLength(1); }).pipe(Effect.provide(h.layer)); - }); - } + }, + ); }); diff --git a/oxlint-plugin-t3code/index.ts b/oxlint-plugin-t3code/index.ts index a2d1997e43..ea360a0c6c 100644 --- a/oxlint-plugin-t3code/index.ts +++ b/oxlint-plugin-t3code/index.ts @@ -7,6 +7,7 @@ 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"; export default definePlugin({ meta: { @@ -20,5 +21,6 @@ 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, }, }); 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 ae90cb2f29..5a5d13b696 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 0000000000..39efb80f89 --- /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 0000000000..b963fdee62 --- /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/packages/client-runtime/src/operations/commands.test.ts b/packages/client-runtime/src/operations/commands.test.ts index 67de890f12..3df8870456 100644 --- a/packages/client-runtime/src/operations/commands.test.ts +++ b/packages/client-runtime/src/operations/commands.test.ts @@ -551,83 +551,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/rpc/client.test.ts b/packages/client-runtime/src/rpc/client.test.ts index e8514240f0..343d8238f4 100644 --- a/packages/client-runtime/src/rpc/client.test.ts +++ b/packages/client-runtime/src/rpc/client.test.ts @@ -347,61 +347,68 @@ describe("environment RPC", () => { }), ); - for (const [profileId, supported, allowed] of [ - [undefined, undefined, true], - ["default", undefined, true], - ["work", undefined, false], - ["incognito", false, false], - ["work", true, true], - ["incognito", true, true], - ] as const) { - it.effect( - `opens profile ${profileId ?? "omitted"} only when isolation is supported (${supported})`, - () => - Effect.gen(function* () { - const calls: string[] = []; - const snapshot: PreviewSessionSnapshot = { - threadId: ThreadId.make("thread-1"), - tabId: "tab-1", - navStatus: { _tag: "Idle" }, - canGoBack: false, - canGoForward: false, - updatedAt: "2026-09-08T00:00:00Z", - }; - const client = { - [WS_METHODS.previewOpen]: () => - Effect.sync(() => { - calls.push("open"); - return snapshot; - }), - } as unknown as WsRpcProtocolClient; - const { activeSession, supervisor } = yield* makeHarness(); - yield* SubscriptionRef.set( - activeSession, - Option.some({ - ...session(client), - initialConfig: - profileId === undefined || profileId === "default" - ? Effect.never - : Effect.succeed({ - environment: { capabilities: { browserProfiles: supported } }, - } as unknown as ServerConfig), + it.effect.each( + ( + [ + [undefined, undefined, true], + ["default", undefined, true], + ["work", undefined, false], + ["incognito", false, false], + ["work", true, true], + ["incognito", true, true], + ] as const + ).map(([profileId, supported, allowed]) => ({ + name: profileId ?? "omitted", + profileId, + supported, + allowed, + })), + )( + "opens profile $name only when isolation is supported ($supported)", + ({ profileId, supported, allowed }) => + Effect.gen(function* () { + const calls: string[] = []; + const snapshot: PreviewSessionSnapshot = { + threadId: ThreadId.make("thread-1"), + tabId: "tab-1", + navStatus: { _tag: "Idle" }, + canGoBack: false, + canGoForward: false, + updatedAt: "2026-09-08T00:00:00Z", + }; + const client = { + [WS_METHODS.previewOpen]: () => + Effect.sync(() => { + calls.push("open"); + return snapshot; }), - ); - const exit = yield* request(WS_METHODS.previewOpen, { - threadId: ThreadId.make("thread-1"), - ...(profileId === undefined ? {} : { profileId }), - }).pipe( - Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), - Effect.exit, - ); - expect(Exit.isSuccess(exit)).toBe(allowed); - expect(calls).toEqual(allowed ? ["open"] : []); - if (Exit.isFailure(exit)) - expect(Cause.pretty(exit.cause)).toContain("Update this environment"); - }), - ); - } + } as unknown as WsRpcProtocolClient; + const { activeSession, supervisor } = yield* makeHarness(); + yield* SubscriptionRef.set( + activeSession, + Option.some({ + ...session(client), + initialConfig: + profileId === undefined || profileId === "default" + ? Effect.never + : Effect.succeed({ + environment: { capabilities: { browserProfiles: supported } }, + } as unknown as ServerConfig), + }), + ); + const exit = yield* request(WS_METHODS.previewOpen, { + threadId: ThreadId.make("thread-1"), + ...(profileId === undefined ? {} : { profileId }), + }).pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.exit, + ); + expect(Exit.isSuccess(exit)).toBe(allowed); + expect(calls).toEqual(allowed ? ["open"] : []); + if (Exit.isFailure(exit)) + expect(Cause.pretty(exit.cause)).toContain("Update this environment"); + }), + ); it.effect("rejects a queued pasted-text command when its receiving session is older", () => Effect.gen(function* () { diff --git a/packages/client-runtime/src/rpc/session.test.ts b/packages/client-runtime/src/rpc/session.test.ts index d55af2c6ce..b5bdaa05d5 100644 --- a/packages/client-runtime/src/rpc/session.test.ts +++ b/packages/client-runtime/src/rpc/session.test.ts @@ -290,7 +290,7 @@ const publishConfigEvents = Effect.fn("TestRpcSessionFactory.publishConfigEvents }); describe("RpcSessionFactory", () => { - for (const target of [ + it.effect.each([ TARGET, new BearerConnectionTarget({ environmentId: TARGET.environmentId, @@ -303,67 +303,65 @@ describe("RpcSessionFactory", () => { label: TARGET.label, connectionId: "ssh-test", }), - ]) { - it.effect(`rejects a config from another environment for ${target._tag}`, () => - Effect.gen(function* () { - const { factory, sockets } = yield* makeFactory(); - yield* Effect.scoped( - Effect.gen(function* () { - const session = yield* factory.connect({ ...PREPARED, target }); - const readyFiber = yield* Effect.forkChild(Effect.exit(session.ready)); - const configFiber = yield* session - .subscribeServerConfig({}) - .pipe(Stream.runHead, Effect.exit, Effect.forkChild); - const customConfigFiber = yield* session - .subscribeServerConfig({ environmentThemes: true }) - .pipe(Stream.runHead, Effect.exit, Effect.forkChild); - const socket = yield* awaitSocket(sockets); - socket.open(); - yield* completeInitialConfig(socket, { - ...ENCODED_SERVER_CONFIG, - environment: { - ...ENCODED_SERVER_CONFIG.environment, - environmentId: "other-environment", - }, - }); - - const ready = yield* Fiber.join(readyFiber); - expect(Exit.isFailure(ready)).toBe(true); - if (Exit.isFailure(ready)) { - expect(Cause.findErrorOption(ready.cause)).toEqual( - Option.some( - new ConnectionBlockedError({ - reason: "configuration", - detail: `Connected environment other-environment does not match ${TARGET.environmentId}.`, - }), - ), - ); - } - for (const result of [ - yield* Fiber.join(configFiber), - yield* Fiber.join(customConfigFiber), - ]) { - expect(Exit.isFailure(result)).toBe(true); - if (Exit.isFailure(result)) { - expect(Option.getOrThrow(Cause.findErrorOption(result.cause))).toMatchObject({ - _tag: "RpcClientError", - }); - } + ])("rejects a config from another environment for $_tag", (target) => + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory(); + yield* Effect.scoped( + Effect.gen(function* () { + const session = yield* factory.connect({ ...PREPARED, target }); + const readyFiber = yield* Effect.forkChild(Effect.exit(session.ready)); + const configFiber = yield* session + .subscribeServerConfig({}) + .pipe(Stream.runHead, Effect.exit, Effect.forkChild); + const customConfigFiber = yield* session + .subscribeServerConfig({ environmentThemes: true }) + .pipe(Stream.runHead, Effect.exit, Effect.forkChild); + const socket = yield* awaitSocket(sockets); + socket.open(); + yield* completeInitialConfig(socket, { + ...ENCODED_SERVER_CONFIG, + environment: { + ...ENCODED_SERVER_CONFIG.environment, + environmentId: "other-environment", + }, + }); + + const ready = yield* Fiber.join(readyFiber); + expect(Exit.isFailure(ready)).toBe(true); + if (Exit.isFailure(ready)) { + expect(Cause.findErrorOption(ready.cause)).toEqual( + Option.some( + new ConnectionBlockedError({ + reason: "configuration", + detail: `Connected environment other-environment does not match ${TARGET.environmentId}.`, + }), + ), + ); + } + for (const result of [ + yield* Fiber.join(configFiber), + yield* Fiber.join(customConfigFiber), + ]) { + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) { + expect(Option.getOrThrow(Cause.findErrorOption(result.cause))).toMatchObject({ + _tag: "RpcClientError", + }); } - const initialConfig = yield* Effect.exit(session.initialConfig); - const probe = yield* Effect.exit(session.probe); - expect(Exit.isFailure(initialConfig)).toBe(true); - expect(Exit.isFailure(probe)).toBe(true); - // Custom subscriptions and probes must not send another request to the wrong host. - expect( - socket.sent.map((message) => decodeJson(message)).filter(isRpcRequest), - ).toHaveLength(1); - }), - ); - expect(sockets[0]?.readyState).toBe(TestWebSocket.CLOSED); - }), - ); - } + } + const initialConfig = yield* Effect.exit(session.initialConfig); + const probe = yield* Effect.exit(session.probe); + expect(Exit.isFailure(initialConfig)).toBe(true); + expect(Exit.isFailure(probe)).toBe(true); + // Custom subscriptions and probes must not send another request to the wrong host. + expect( + socket.sent.map((message) => decodeJson(message)).filter(isRpcRequest), + ).toHaveLength(1); + }), + ); + expect(sockets[0]?.readyState).toBe(TestWebSocket.CLOSED); + }), + ); it.effect("owns one scoped websocket attempt and exposes readiness and closure", () => Effect.gen(function* () { @@ -506,59 +504,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 }, @@ -1240,37 +1234,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 b243734467..30aff2e162 100644 --- a/packages/client-runtime/src/state/assets.test.ts +++ b/packages/client-runtime/src/state/assets.test.ts @@ -61,7 +61,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 }, @@ -77,113 +77,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/models.ts b/packages/client-runtime/src/state/models.ts index 7163bd79a8..37b7b5b8e3 100644 --- a/packages/client-runtime/src/state/models.ts +++ b/packages/client-runtime/src/state/models.ts @@ -296,7 +296,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/threadCommands.test.ts b/packages/client-runtime/src/state/threadCommands.test.ts index 0c834c08b5..a80573d0cc 100644 --- a/packages/client-runtime/src/state/threadCommands.test.ts +++ b/packages/client-runtime/src/state/threadCommands.test.ts @@ -138,8 +138,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); @@ -180,8 +181,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* () { @@ -221,35 +221,34 @@ describe("remote thread lifecycle commands", () => { }), ); - for (const acknowledgeBeforeDisconnect of [false, true]) { - it.effect( - `drops the preview on disconnect ${acknowledgeBeforeDisconnect ? "after" : "before"} acknowledgement`, - () => - Effect.gen(function* () { - const h = yield* makeHarness(); - const result = h.commands.settle.run(h.registry, { - environmentId: ENVIRONMENT_ID, - input: { threadId: THREAD_ID }, - }); - const request = yield* Queue.take(h.requests); - expect(h.registry.get(h.visibleAtom)?.threads[0]?.settledOverride).toBe("settled"); - if (acknowledgeBeforeDisconnect) { - yield* Deferred.succeed(request.reply, { sequence: 8 }); - expect((yield* Effect.promise(() => result))._tag).toBe("Success"); - } - h.registry.set(h.liveAtom(ENVIRONMENT_ID), false); - expect(h.registry.get(h.visibleAtom)).toBe(SNAPSHOT); - // A stale reply from the previous connection must not revive its preview. - h.registry.set(h.liveAtom(ENVIRONMENT_ID), true); - h.registry.set(h.snapshotAtom(ENVIRONMENT_ID), { ...SNAPSHOT, snapshotSequence: 0 }); - if (!acknowledgeBeforeDisconnect) { - yield* Deferred.succeed(request.reply, { sequence: 8 }); - expect((yield* Effect.promise(() => result))._tag).toBe("Success"); - } - expect(h.registry.get(h.visibleAtom)?.threads[0]?.settledOverride).toBeNull(); - }), - ); - } + it.effect.each([ + { acknowledgeBeforeDisconnect: false, name: "before" }, + { acknowledgeBeforeDisconnect: true, name: "after" }, + ])("drops the preview on disconnect $name acknowledgement", ({ acknowledgeBeforeDisconnect }) => + Effect.gen(function* () { + const h = yield* makeHarness(); + const result = h.commands.settle.run(h.registry, { + environmentId: ENVIRONMENT_ID, + input: { threadId: THREAD_ID }, + }); + const request = yield* Queue.take(h.requests); + expect(h.registry.get(h.visibleAtom)?.threads[0]?.settledOverride).toBe("settled"); + if (acknowledgeBeforeDisconnect) { + yield* Deferred.succeed(request.reply, { sequence: 8 }); + expect((yield* Effect.promise(() => result))._tag).toBe("Success"); + } + h.registry.set(h.liveAtom(ENVIRONMENT_ID), false); + expect(h.registry.get(h.visibleAtom)).toBe(SNAPSHOT); + // A stale reply from the previous connection must not revive its preview. + h.registry.set(h.liveAtom(ENVIRONMENT_ID), true); + h.registry.set(h.snapshotAtom(ENVIRONMENT_ID), { ...SNAPSHOT, snapshotSequence: 0 }); + if (!acknowledgeBeforeDisconnect) { + yield* Deferred.succeed(request.reply, { sequence: 8 }); + expect((yield* Effect.promise(() => result))._tag).toBe("Success"); + } + expect(h.registry.get(h.visibleAtom)?.threads[0]?.settledOverride).toBeNull(); + }), + ); it.effect( "shows a queued reverse action immediately and preserves it if the earlier action fails", @@ -321,44 +320,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.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(`preserves a newer approval when the ${action} reply arrives after the shell`, () => + 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, { @@ -377,9 +377,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 = { @@ -401,6 +403,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/threadExecution.test.ts b/packages/client-runtime/src/state/threadExecution.test.ts index 70ed818cad..7e52b8eabf 100644 --- a/packages/client-runtime/src/state/threadExecution.test.ts +++ b/packages/client-runtime/src/state/threadExecution.test.ts @@ -96,6 +96,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"), diff --git a/packages/client-runtime/src/state/threadExecution.ts b/packages/client-runtime/src/state/threadExecution.ts index 0893fadb62..155bd0c648 100644 --- a/packages/client-runtime/src/state/threadExecution.ts +++ b/packages/client-runtime/src/state/threadExecution.ts @@ -13,6 +13,7 @@ import { type ServerProviderModel, type OrchestrationV2ExecutionNode, type OrchestrationV2ThreadProjection, + orchestrationV2RunWorkStartedAt, type ThreadId, } from "@t3tools/contracts"; import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; @@ -212,6 +213,9 @@ 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; @@ -231,9 +235,9 @@ export function deriveThreadRuntime( : (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( diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index 0c1619a52b..61bd092a4b 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/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index cb220a5b4c..9d907c8178 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -528,6 +528,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 +551,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. @@ -1688,7 +1704,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 +1860,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; diff --git a/packages/contracts/src/providerInstance.test.ts b/packages/contracts/src/providerInstance.test.ts index b7fcfbfc6a..cbbad3c187 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/shared/src/gitPatchPath.test.ts b/packages/shared/src/gitPatchPath.test.ts index 20c5065729..c1592a92ac 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/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index 1c6a9c3cf3..3d17c544a6 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -453,8 +453,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, @@ -481,8 +482,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. @@ -531,27 +531,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 15ebc3f597..00e3e30594 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 9aede2b6b9..de7f8b638e 100644 --- a/scripts/mobile-showcase-environment.ts +++ b/scripts/mobile-showcase-environment.ts @@ -420,7 +420,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", @@ -428,10 +432,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 { @@ -484,11 +489,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/scripts/prime-signature-runtime.test.mjs b/scripts/prime-signature-runtime.test.mjs index 9528e20dc5..16e5813ad1 100644 --- a/scripts/prime-signature-runtime.test.mjs +++ b/scripts/prime-signature-runtime.test.mjs @@ -50,40 +50,38 @@ const keys = [ ]; describe("Prime signed publications on Electron", () => { - for (const key of keys) { - it(`verifies Sigstore ${key.name} and rejects modified data and signatures`, () => { - const data = Buffer.from("signed Prime publication"); - const signature = NodeCrypto.sign(key.algorithm, data, key.privateKey); - const modified = Buffer.from(signature); - modified[0] ^= 1; - requireExplicitDigest(); - for (const publicKey of [ - key.publicKey, - key.publicKey.export({ type: "spki", format: "pem" }), - ]) { - expect(sigstoreCrypto.verify(data, publicKey, signature)).toBe(true); - expect( - sigstoreCrypto.verify(Buffer.from("different publication"), publicKey, signature), - ).toBe(false); - expect(sigstoreCrypto.verify(data, publicKey, modified)).toBe(false); - } - }); - - it(`verifies TUF ${key.name} and rejects modified metadata`, () => { - const metadata = { version: 1, role: "root", expires: "2030-01-01T00:00:00Z" }; - const options = - key.name === "RSA" ? { padding: NodeCrypto.constants.RSA_PKCS1_PSS_PADDING } : {}; - const signature = NodeCrypto.sign(key.algorithm, Buffer.from(canonicalize(metadata)), { - key: key.privateKey, - ...options, - }).toString("hex"); - requireExplicitDigest(); - expect(verifySignature(metadata, { key: key.publicKey, ...options }, signature)).toBe(true); + it.each(keys)("verifies Sigstore $name and rejects modified data and signatures", (key) => { + const data = Buffer.from("signed Prime publication"); + const signature = NodeCrypto.sign(key.algorithm, data, key.privateKey); + const modified = Buffer.from(signature); + modified[0] ^= 1; + requireExplicitDigest(); + for (const publicKey of [ + key.publicKey, + key.publicKey.export({ type: "spki", format: "pem" }), + ]) { + expect(sigstoreCrypto.verify(data, publicKey, signature)).toBe(true); expect( - verifySignature({ ...metadata, version: 2 }, { key: key.publicKey, ...options }, signature), + sigstoreCrypto.verify(Buffer.from("different publication"), publicKey, signature), ).toBe(false); - }); - } + expect(sigstoreCrypto.verify(data, publicKey, modified)).toBe(false); + } + }); + + it.each(keys)("verifies TUF $name and rejects modified metadata", (key) => { + const metadata = { version: 1, role: "root", expires: "2030-01-01T00:00:00Z" }; + const options = + key.name === "RSA" ? { padding: NodeCrypto.constants.RSA_PKCS1_PSS_PADDING } : {}; + const signature = NodeCrypto.sign(key.algorithm, Buffer.from(canonicalize(metadata)), { + key: key.privateKey, + ...options, + }).toString("hex"); + requireExplicitDigest(); + expect(verifySignature(metadata, { key: key.publicKey, ...options }, signature)).toBe(true); + expect( + verifySignature({ ...metadata, version: 2 }, { key: key.publicKey, ...options }, signature), + ).toBe(false); + }); it("preserves an explicit non-default digest and rejects malformed keys", () => { const key = keys[0]; diff --git a/vite.config.ts b/vite.config.ts index 9f5bd284cd..28f4e45f68 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -159,6 +159,7 @@ 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/namespace-node-imports": "error", }, overrides: [ @@ -229,27 +230,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",