diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml.disabled similarity index 100% rename from .github/workflows/ci.yml rename to .github/workflows/ci.yml.disabled diff --git a/.github/workflows/deploy-relay.yml b/.github/workflows/deploy-relay.yml.disabled similarity index 100% rename from .github/workflows/deploy-relay.yml rename to .github/workflows/deploy-relay.yml.disabled diff --git a/.github/workflows/issue-labels.yml b/.github/workflows/issue-labels.yml.disabled similarity index 100% rename from .github/workflows/issue-labels.yml rename to .github/workflows/issue-labels.yml.disabled diff --git a/.github/workflows/mobile-eas-preview.yml b/.github/workflows/mobile-eas-preview.yml.disabled similarity index 100% rename from .github/workflows/mobile-eas-preview.yml rename to .github/workflows/mobile-eas-preview.yml.disabled diff --git a/.github/workflows/pr-size.yml b/.github/workflows/pr-size.yml.disabled similarity index 100% rename from .github/workflows/pr-size.yml rename to .github/workflows/pr-size.yml.disabled diff --git a/.github/workflows/pr-vouch.yml b/.github/workflows/pr-vouch.yml.disabled similarity index 100% rename from .github/workflows/pr-vouch.yml rename to .github/workflows/pr-vouch.yml.disabled diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml.disabled similarity index 100% rename from .github/workflows/release.yml rename to .github/workflows/release.yml.disabled diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index ebc4f984b86d..d4351f344521 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -5,6 +5,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { ApprovalRequestId, CodexSettings, + CursorSettings, ProviderDriverKind, type OrchestrationEvent, type OrchestrationThread, @@ -39,6 +40,7 @@ import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSes import { ServerSettingsService } from "../src/serverSettings.ts"; import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts"; import { makeCodexAdapter } from "../src/provider/Layers/CodexAdapter.ts"; +import { makeCursorAdapter } from "../src/provider/Layers/CursorAdapter.ts"; import { NoOpProviderEventLoggers, ProviderEventLoggers, @@ -80,6 +82,7 @@ import * as VcsProcess from "../src/vcs/VcsProcess.ts"; import * as AgentAwarenessRelay from "../src/relay/AgentAwarenessRelay.ts"; const decodeCodexSettings = Schema.decodeEffect(CodexSettings); +const decodeCursorSettings = Schema.decodeEffect(CursorSettings); function runGit(cwd: string, args: ReadonlyArray) { return NodeChildProcess.execFileSync("git", args, { @@ -223,6 +226,7 @@ export interface OrchestrationIntegrationHarness { interface MakeOrchestrationIntegrationHarnessOptions { readonly provider?: ProviderDriverKind; readonly realCodex?: boolean; + readonly realCursor?: boolean; } export const makeOrchestrationIntegrationHarness = ( @@ -234,7 +238,14 @@ export const makeOrchestrationIntegrationHarness = ( const provider = options?.provider ?? ProviderDriverKind.make("codex"); const useRealCodex = options?.realCodex === true; - const adapterHarness = useRealCodex + const useRealCursor = options?.realCursor === true; + if (useRealCodex && useRealCursor) { + return yield* Effect.die( + "makeOrchestrationIntegrationHarness: realCodex and realCursor are mutually exclusive", + ); + } + const useRealAdapter = useRealCodex || useRealCursor; + const adapterHarness = useRealAdapter ? null : yield* makeTestProviderAdapterHarness({ provider, @@ -265,25 +276,41 @@ export const makeOrchestrationIntegrationHarness = ( const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe( Layer.provide(ProviderSessionRuntimeRepositoryLive), ); - const realCodexRegistry = Layer.effect( - ProviderAdapterRegistry, - Effect.gen(function* () { - const codexSettings = yield* decodeCodexSettings({}); - const codexAdapter = yield* makeCodexAdapter(codexSettings); - return makeAdapterRegistryMock({ - [ProviderDriverKind.make("codex")]: codexAdapter, - }); - }), - ).pipe( + const realAdapterRegistryLayer = useRealCodex + ? Layer.effect( + ProviderAdapterRegistry, + Effect.gen(function* () { + const codexSettings = yield* decodeCodexSettings({}); + const codexAdapter = yield* makeCodexAdapter(codexSettings); + return makeAdapterRegistryMock({ + [ProviderDriverKind.make("codex")]: codexAdapter, + }); + }), + ) + : useRealCursor + ? Layer.effect( + ProviderAdapterRegistry, + Effect.gen(function* () { + const cursorSettings = yield* decodeCursorSettings({ + binaryPath: process.env.CURSOR_AGENT_BIN ?? "agent", + }); + const cursorAdapter = yield* makeCursorAdapter(cursorSettings); + return makeAdapterRegistryMock({ + [ProviderDriverKind.make("cursor")]: cursorAdapter, + }); + }), + ) + : null; + const realAdapterRegistry = realAdapterRegistryLayer?.pipe( Layer.provideMerge(ServerConfig.layerTest(workspaceDir, rootDir)), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(providerSessionDirectoryLayer), ); const providerEventLoggersLayer = Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers); - const providerLayer = useRealCodex + const providerLayer = useRealAdapter ? makeProviderServiceLive().pipe( Layer.provide(providerSessionDirectoryLayer), - Layer.provide(realCodexRegistry), + Layer.provide(realAdapterRegistry!), Layer.provide(AnalyticsService.layerTest), Layer.provide(providerEventLoggersLayer), ) diff --git a/apps/server/integration/cursorResume.integration.test.ts b/apps/server/integration/cursorResume.integration.test.ts new file mode 100644 index 000000000000..2b917b993ee4 --- /dev/null +++ b/apps/server/integration/cursorResume.integration.test.ts @@ -0,0 +1,218 @@ +/** + * End-to-end Cursor resume tests against a real `agent acp` install. + * + * Enable with: + * T3_CURSOR_RESUME_E2E=1 vp test cursorResume.integration + * + * Uses composer-2.5 with fast mode disabled. The second turn intentionally + * asks the agent to run a shell command so we exercise tool calls after + * session/load resume, not just plain text replies. + */ +import { + CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, + MessageId, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import { createModelSelection } from "@t3tools/shared/model"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { + makeOrchestrationIntegrationHarness, + type OrchestrationIntegrationHarness, +} from "./OrchestrationEngineHarness.integration.ts"; + +const PROJECT_ID = ProjectId.make("project-cursor-resume"); +const THREAD_ID = ThreadId.make("thread-cursor-resume"); +const CURSOR_PROVIDER = ProviderDriverKind.make("cursor"); +const CURSOR_INSTANCE = ProviderInstanceId.make("cursor"); +const CURSOR_MODEL = createModelSelection(CURSOR_INSTANCE, "composer-2.5", [ + { id: "fastMode", value: false }, +]); + +const MARKER_ONE = "CURSOR_RESUME_MARKER_ONE"; +const MARKER_TWO = "CURSOR_RESUME_MARKER_TWO"; +const TURN_TIMEOUT_MS = 300_000; + +const asMessageId = (value: string): MessageId => MessageId.make(value); + +function completedAssistantMessages(thread: { + readonly messages: ReadonlyArray<{ + readonly role: string; + readonly text: string; + readonly streaming: boolean; + }>; +}) { + return thread.messages.filter( + (message) => + message.role === "assistant" && !message.streaming && message.text.trim().length > 0, + ); +} + +function lastCompletedAssistantText(thread: { + readonly messages: ReadonlyArray<{ + readonly role: string; + readonly text: string; + readonly streaming: boolean; + }>; +}): string | undefined { + for (let index = thread.messages.length - 1; index >= 0; index -= 1) { + const message = thread.messages[index]; + if (message?.role === "assistant" && !message.streaming && message.text.trim().length > 0) { + return message.text; + } + } + return undefined; +} + +function hasToolActivity(thread: { + readonly activities: ReadonlyArray<{ readonly kind: string }>; +}): boolean { + return thread.activities.some( + (activity) => activity.kind === "tool.started" || activity.kind === "tool.completed", + ); +} + +const seedCursorProjectAndThread = (harness: OrchestrationIntegrationHarness) => + Effect.gen(function* () { + const createdAt = "2026-06-29T12:00:00.000Z"; + + yield* harness.engine.dispatch({ + type: "project.create", + commandId: CommandId.make("cmd-project-create-cursor-resume"), + projectId: PROJECT_ID, + title: "Cursor Resume Project", + workspaceRoot: harness.workspaceDir, + defaultModelSelection: CURSOR_MODEL, + createdAt, + }); + + yield* harness.engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-thread-create-cursor-resume"), + threadId: THREAD_ID, + projectId: PROJECT_ID, + title: "Cursor Resume Thread", + modelSelection: CURSOR_MODEL, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + branch: null, + worktreePath: harness.workspaceDir, + createdAt, + }); + }); + +const startCursorTurn = (input: { + readonly harness: OrchestrationIntegrationHarness; + readonly commandId: string; + readonly messageId: string; + readonly text: string; +}) => + input.harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make(input.commandId), + threadId: THREAD_ID, + message: { + messageId: asMessageId(input.messageId), + role: "user", + text: input.text, + attachments: [], + }, + modelSelection: CURSOR_MODEL, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + createdAt: "2026-06-29T12:00:01.000Z", + }); + +function withRealCursorHarness( + use: (harness: OrchestrationIntegrationHarness) => Effect.Effect, +) { + return Effect.acquireUseRelease( + makeOrchestrationIntegrationHarness({ + provider: CURSOR_PROVIDER, + realCursor: true, + }), + use, + (harness) => harness.dispose, + ).pipe(Effect.provide(NodeServices.layer)); +} + +it.live.skipIf(process.env.T3_CURSOR_RESUME_E2E !== "1")( + "resumes a cursor session after stopSession and completes a tool-using turn with a final assistant message", + () => + withRealCursorHarness((harness) => + Effect.gen(function* () { + yield* seedCursorProjectAndThread(harness); + + yield* startCursorTurn({ + harness, + commandId: "cmd-cursor-resume-turn-1", + messageId: "msg-cursor-resume-1", + text: `Run the shell command \`ls\` in the workspace, then reply with one line that contains exactly ${MARKER_ONE}.`, + }); + + const firstThread = yield* harness.waitForThread( + THREAD_ID, + (entry) => + entry.session?.status === "ready" && + entry.session.providerName === "cursor" && + lastCompletedAssistantText(entry)?.includes(MARKER_ONE) === true, + TURN_TIMEOUT_MS, + ); + assert.equal(firstThread.session?.providerName, "cursor"); + const firstAssistants = completedAssistantMessages(firstThread); + assert.equal( + firstAssistants.some((message) => message.text.includes(MARKER_ONE)), + true, + ); + + yield* harness.providerService.stopSession({ threadId: THREAD_ID }); + + yield* startCursorTurn({ + harness, + commandId: "cmd-cursor-resume-turn-2", + messageId: "msg-cursor-resume-2", + text: `Run the shell command \`pwd\` in the workspace, then reply with one line that contains exactly ${MARKER_TWO}.`, + }); + + const resumedThread = yield* harness.waitForThread( + THREAD_ID, + (entry) => { + if (entry.session?.status !== "ready" || entry.session.providerName !== "cursor") { + return false; + } + const assistantText = lastCompletedAssistantText(entry); + return ( + assistantText?.includes(MARKER_TWO) === true && + !entry.activities.some((activity) => activity.kind === "provider.turn.start.failed") + ); + }, + TURN_TIMEOUT_MS, + ); + + const finalAssistantText = lastCompletedAssistantText(resumedThread); + const markerOneMessage = completedAssistantMessages(resumedThread).find((message) => + message.text.includes(MARKER_ONE), + ); + const lastAssistant = completedAssistantMessages(resumedThread).at(-1); + assert.equal(finalAssistantText?.includes(MARKER_TWO), true); + assert.equal(markerOneMessage?.text.includes(MARKER_TWO), false); + assert.equal(lastAssistant?.text.includes(MARKER_TWO), true); + assert.equal(lastAssistant?.text.includes(MARKER_ONE), false); + assert.equal( + resumedThread.activities.some( + (activity) => activity.kind === "provider.turn.start.failed", + ), + false, + ); + // The regression we are guarding: tool calls happen but the final assistant + // segment never lands in the projection after session/load resume. + assert.equal(hasToolActivity(resumedThread), true); + }), + ), +); diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index bc7828dd8547..9ecdf7d58bac 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -20,6 +20,8 @@ const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHO const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1"; const emitXAiAskUserQuestion = process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION === "1"; const emitXAiPromptCompleteThenHang = process.env.T3_ACP_EMIT_XAI_PROMPT_COMPLETE_THEN_HANG === "1"; +const emitXAiPromptCompleteWithoutPromptIdThenHang = + process.env.T3_ACP_EMIT_XAI_PROMPT_COMPLETE_WITHOUT_PROMPT_ID_THEN_HANG === "1"; const emitForeignSessionUpdates = process.env.T3_ACP_EMIT_FOREIGN_SESSION_UPDATES === "1"; const hangPromptForever = process.env.T3_ACP_HANG_PROMPT_FOREVER === "1"; const hangFirstPromptForever = process.env.T3_ACP_HANG_FIRST_PROMPT_FOREVER === "1"; @@ -30,6 +32,7 @@ const failLoadSession = process.env.T3_ACP_FAIL_LOAD_SESSION === "1"; const emitLoadReplay = process.env.T3_ACP_EMIT_LOAD_REPLAY === "1"; const hangLoadSessionAfterReplay = process.env.T3_ACP_HANG_LOAD_SESSION_AFTER_REPLAY === "1"; const delayLoadSessionAfterReplay = process.env.T3_ACP_DELAY_LOAD_SESSION_AFTER_REPLAY === "1"; +const fastLoadWithDelayedReplayTail = process.env.T3_ACP_FAST_LOAD_WITH_DELAYED_REPLAY_TAIL === "1"; const loadSessionDelayMs = Number(process.env.T3_ACP_LOAD_SESSION_DELAY_MS ?? "5000"); const emitStaleXAiPromptCompleteBeforeSecondHang = process.env.T3_ACP_EMIT_STALE_XAI_PROMPT_COMPLETE_BEFORE_SECOND_HANG === "1"; @@ -362,6 +365,41 @@ const program = Effect.gen(function* () { configOptions: configOptions(), }; } + if (fastLoadWithDelayedReplayTail) { + emitLoadReplayNotifications(requestedSessionId); + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "user_message_chunk", + content: { type: "text", text: "replay-head" }, + }, + }); + const response = { + modes: modeState(), + models: modelState(), + configOptions: configOptions(), + }; + void Effect.runFork( + Effect.gen(function* () { + yield* Effect.sleep("30 millis"); + writeJsonRpcNotification("session/update", { + _meta: { isReplay: true }, + sessionId: requestedSessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "delayed replay tail" }, + }, + }); + writeJsonRpcNotification("_x.ai/session/prompt_complete", { + sessionId: requestedSessionId, + promptId: "replay-stale-prompt-id", + stopReason: "end_turn", + agentResult: null, + }); + }).pipe(Effect.provide(NodeServices.layer)), + ); + return response; + } if (emitLoadReplay) { emitLoadReplayNotifications(requestedSessionId); } @@ -522,6 +560,15 @@ const program = Effect.gen(function* () { return yield* Effect.never; } + if (emitXAiPromptCompleteWithoutPromptIdThenHang) { + writeJsonRpcNotification("_x.ai/session/prompt_complete", { + sessionId: requestedSessionId, + stopReason: "end_turn", + agentResult: null, + }); + return yield* Effect.never; + } + if (emitXAiPromptCompleteThenHang) { writeJsonRpcNotification("session/update", { sessionId: requestedSessionId, diff --git a/apps/server/src/auth/EnvironmentAuthPolicy.test.ts b/apps/server/src/auth/EnvironmentAuthPolicy.test.ts index 95269fb6c37a..8205f32a63a4 100644 --- a/apps/server/src/auth/EnvironmentAuthPolicy.test.ts +++ b/apps/server/src/auth/EnvironmentAuthPolicy.test.ts @@ -97,6 +97,24 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => { ), ); + it.effect("uses remote-reachable policy when Tailscale Serve exposes a loopback host", () => + Effect.gen(function* () { + const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; + const descriptor = yield* policy.getDescriptor(); + + expect(descriptor.policy).toBe("remote-reachable"); + expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]); + }).pipe( + Effect.provide( + makeEnvironmentAuthPolicyLayer({ + mode: "web", + host: "127.0.0.1", + tailscaleServeEnabled: true, + }), + ), + ), + ); + it.effect("uses remote-reachable policy for non-loopback web hosts", () => Effect.gen(function* () { const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; diff --git a/apps/server/src/auth/EnvironmentAuthPolicy.ts b/apps/server/src/auth/EnvironmentAuthPolicy.ts index 7ffef0ff0a5f..0c62d54877a6 100644 --- a/apps/server/src/auth/EnvironmentAuthPolicy.ts +++ b/apps/server/src/auth/EnvironmentAuthPolicy.ts @@ -16,7 +16,10 @@ export class EnvironmentAuthPolicy extends Context.Service< export const make = Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; - const isRemoteReachable = isWildcardHost(config.host) || !isLoopbackHost(config.host); + // Tailscale Serve proxies the loopback-bound backend onto the tailnet, so the + // server is remotely reachable even though config.host stays on 127.0.0.1. + const isRemoteReachable = + config.tailscaleServeEnabled || isWildcardHost(config.host) || !isLoopbackHost(config.host); const policy = config.mode === "desktop" diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index f12df850941f..20876c99630e 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -819,6 +819,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti const nextText = Option.match(existingMessage, { onNone: () => event.payload.text, onSome: (message) => { + if (event.payload.reset === true) { + return event.payload.text; + } if (event.payload.streaming) { return `${message.text}${event.payload.text}`; } diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index ce464565dc5f..3427f3afbf6b 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -1,4 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off +/* oxlint-disable t3code/no-manual-effect-runtime-in-tests */ import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; @@ -1195,6 +1196,74 @@ describe("ProviderCommandReactor", () => { }); }); + it("discards grok resume cursors when the thread workspace changes", async () => { + const harness = await createHarness({ + threadModelSelection: { + instanceId: ProviderInstanceId.make("grok"), + model: "grok-build", + }, + }); + const now = "2026-01-01T00:00:00.000Z"; + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-grok-workspace-1"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-grok-workspace-1"), + role: "user", + text: "first in project root", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-grok-worktree-change"), + threadId: ThreadId.make("thread-1"), + worktreePath: "/tmp/provider-project-grok-worktree", + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-grok-workspace-2"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-grok-workspace-2"), + role: "user", + text: "second in worktree", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 2); + expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ + threadId: ThreadId.make("thread-1"), + cwd: "/tmp/provider-project-grok-worktree", + resumeCursor: null, + modelSelection: { + instanceId: ProviderInstanceId.make("grok"), + model: "grok-build", + }, + runtimeMode: "approval-required", + }); + }); + it("restarts claude sessions when claude effort changes", async () => { const harness = await createHarness({ threadModelSelection: { @@ -1634,7 +1703,93 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.interruptTurn.mock.calls.length === 1); expect(harness.interruptTurn.mock.calls[0]?.[0]).toEqual({ threadId: "thread-1", + turnId: "turn-1", + }); + + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.session?.status).toBe("ready"); + expect(thread?.session?.activeTurnId).toBeNull(); + }); + + it("settles a stuck running session when stop is pressed after the turn is already interrupted", async () => { + const harness = await createHarness(); + const firstInterruptAt = "2026-01-01T00:00:00.000Z"; + const repeatInterruptAt = "2026-01-01T00:00:01.000Z"; + const threadId = ThreadId.make("thread-1"); + const turnId = asTurnId("turn-1"); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set-stuck"), + threadId, + session: { + threadId, + status: "running", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: turnId, + lastError: null, + updatedAt: firstInterruptAt, + }, + createdAt: firstInterruptAt, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.interrupt", + commandId: CommandId.make("cmd-turn-interrupt-stuck"), + threadId, + turnId, + createdAt: firstInterruptAt, + }), + ); + + await waitFor(() => harness.interruptTurn.mock.calls.length === 1); + + harness.interruptTurn.mockClear(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set-stuck-again"), + threadId, + session: { + threadId, + status: "running", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: turnId, + lastError: null, + updatedAt: repeatInterruptAt, + }, + createdAt: repeatInterruptAt, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.interrupt", + commandId: CommandId.make("cmd-turn-interrupt-stuck-repeat"), + threadId, + turnId, + createdAt: repeatInterruptAt, + }), + ); + + await waitFor(async () => { + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === threadId); + return thread?.session?.status === "ready" && thread?.session?.activeTurnId === null; }); + + expect(harness.interruptTurn.mock.calls.length).toBe(0); + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === threadId); + expect(thread?.session?.status).toBe("ready"); + expect(thread?.session?.activeTurnId).toBeNull(); }); it("starts a fresh session when only projected session state exists", async () => { diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 9c7a7c94bb10..3c6ca52af7b7 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -472,7 +472,7 @@ const make = Effect.gen(function* () { }); const startProviderSession = (input?: { - readonly resumeCursor?: unknown; + readonly resumeCursor?: unknown | null; readonly provider?: ProviderDriverKind; }) => providerService.startSession(threadId, { @@ -541,9 +541,11 @@ const make = Effect.gen(function* () { return existingSessionThreadId; } - const resumeCursor = shouldRestartForModelChange - ? undefined - : (activeSession?.resumeCursor ?? undefined); + const shouldDiscardResumeCursorForCwdChange = cwdChanged && preferredProvider === "grok"; + const resumeCursor = + shouldRestartForModelChange || shouldDiscardResumeCursorForCwdChange + ? null + : (activeSession?.resumeCursor ?? undefined); yield* Effect.logInfo("provider command reactor restarting provider session", { threadId, existingSessionThreadId, @@ -561,6 +563,7 @@ const make = Effect.gen(function* () { instanceChanged, shouldRestartForModelChange, shouldRestartForModelSelectionChange, + shouldDiscardResumeCursorForCwdChange, hasResumeCursor: resumeCursor !== undefined, }); const restartedSession = yield* startProviderSession( @@ -867,8 +870,8 @@ const make = Effect.gen(function* () { if (!thread) { return; } - const hasSession = thread.session && thread.session.status !== "stopped"; - if (!hasSession) { + const session = thread.session; + if (!session || session.status === "stopped") { return yield* appendProviderFailureActivity({ threadId: event.payload.threadId, kind: "provider.turn.interrupt.failed", @@ -879,8 +882,63 @@ const make = Effect.gen(function* () { }); } - // Orchestration turn ids are not provider turn ids, so interrupt by session. - yield* providerService.interruptTurn({ threadId: event.payload.threadId }); + const now = event.payload.createdAt; + const turnId = event.payload.turnId; + const latestTurn = thread.latestTurn; + const sessionStillShowsActiveTurn = + session.status === "running" || + session.status === "starting" || + session.activeTurnId !== null; + const turnWasSettledBeforeThisInterrupt = + turnId !== undefined && + latestTurn?.turnId === turnId && + latestTurn.state !== "running" && + latestTurn.completedAt !== null && + latestTurn.completedAt < now; + const shouldSignalProvider = + !turnWasSettledBeforeThisInterrupt && + (session.status === "running" || + session.status === "starting" || + session.activeTurnId !== null); + + if (shouldSignalProvider) { + // Orchestration turn ids are not provider turn ids, so interrupt by session. + yield* providerService + .interruptTurn({ + threadId: event.payload.threadId, + ...(turnId !== undefined ? { turnId } : {}), + }) + .pipe( + Effect.catchCause((cause) => + appendProviderFailureActivity({ + threadId: event.payload.threadId, + kind: "provider.turn.interrupt.failed", + summary: "Provider turn interrupt failed", + detail: Cause.pretty(cause), + turnId: turnId ?? null, + createdAt: now, + }), + ), + ); + } + + // Projection marks the turn interrupted, but only provider runtime events + // (or an explicit session set) clear session.activeTurnId. Without this, + // stop leaves the thread "working" forever — especially when the provider + // process is already gone and interrupt could not reach it. + if (sessionStillShowsActiveTurn) { + yield* setThreadSession({ + threadId: thread.id, + session: { + ...session, + status: "ready", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + createdAt: now, + }); + } }); const processApprovalResponseRequested = Effect.fn("processApprovalResponseRequested")(function* ( diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 001ba3889496..377a030ca205 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -62,6 +62,14 @@ const asMessageId = (value: string): MessageId => MessageId.make(value); const asThreadId = (value: string): ThreadId => ThreadId.make(value); const asTurnId = (value: string): TurnId => TurnId.make(value); +/** Mirrors ProviderRuntimeIngestion.assistantSegmentMessageId for test expectations. */ +function expectedAssistantMessageId(itemId: string, turnId?: string, segmentIndex = 0): string { + const turnScope = turnId ? `:turn:${turnId}` : ""; + return segmentIndex === 0 + ? `assistant:${itemId}${turnScope}` + : `assistant:${itemId}${turnScope}:segment:${segmentIndex}`; +} + type LegacyProviderRuntimeEvent = { readonly type: string; readonly eventId: EventId; @@ -706,14 +714,15 @@ describe("ProviderRuntimeIngestion", () => { }, }); + const expectedMessageId = expectedAssistantMessageId("item-1", "turn-2"); const thread = await waitForThread(harness.readModel, (entry) => entry.messages.some( (message: ProviderRuntimeTestMessage) => - message.id === "assistant:item-1" && !message.streaming, + message.id === expectedMessageId && !message.streaming, ), ); const message = thread.messages.find( - (entry: ProviderRuntimeTestMessage) => entry.id === "assistant:item-1", + (entry: ProviderRuntimeTestMessage) => entry.id === expectedMessageId, ); expect(message?.text).toBe("hello world"); expect(message?.streaming).toBe(false); @@ -738,14 +747,15 @@ describe("ProviderRuntimeIngestion", () => { }, }); + const expectedMessageId = expectedAssistantMessageId("item-no-delta", "turn-no-delta"); const thread = await waitForThread(harness.readModel, (entry) => entry.messages.some( (message: ProviderRuntimeTestMessage) => - message.id === "assistant:item-no-delta" && !message.streaming, + message.id === expectedMessageId && !message.streaming, ), ); const message = thread.messages.find( - (entry: ProviderRuntimeTestMessage) => entry.id === "assistant:item-no-delta", + (entry: ProviderRuntimeTestMessage) => entry.id === expectedMessageId, ); expect(message?.text).toBe("assistant-only final text"); expect(message?.streaming).toBe(false); @@ -1613,9 +1623,10 @@ describe("ProviderRuntimeIngestion", () => { await harness.drain(); const midReadModel = await harness.readModel(); const midThread = midReadModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + const bufferedMessageId = expectedAssistantMessageId("item-buffered", "turn-buffered"); expect( midThread?.messages.some( - (message: ProviderRuntimeTestMessage) => message.id === "assistant:item-buffered", + (message: ProviderRuntimeTestMessage) => message.id === bufferedMessageId, ), ).toBe(false); @@ -1636,11 +1647,11 @@ describe("ProviderRuntimeIngestion", () => { const thread = await waitForThread(harness.readModel, (entry) => entry.messages.some( (message: ProviderRuntimeTestMessage) => - message.id === "assistant:item-buffered" && !message.streaming, + message.id === bufferedMessageId && !message.streaming, ), ); const message = thread.messages.find( - (entry: ProviderRuntimeTestMessage) => entry.id === "assistant:item-buffered", + (entry: ProviderRuntimeTestMessage) => entry.id === bufferedMessageId, ); expect(message?.text).toBe("buffer me"); expect(message?.streaming).toBe(false); @@ -1692,16 +1703,20 @@ describe("ProviderRuntimeIngestion", () => { }, }); + const requestFlushMessageId = expectedAssistantMessageId( + "item-buffered-request-flush", + "turn-buffered-request-flush", + ); const thread = await waitForThread(harness.readModel, (entry) => entry.messages.some( (message: ProviderRuntimeTestMessage) => - message.id === "assistant:item-buffered-request-flush" && + message.id === requestFlushMessageId && !message.streaming && message.text === "visible before approval", ), ); const message = thread.messages.find( - (entry: ProviderRuntimeTestMessage) => entry.id === "assistant:item-buffered-request-flush", + (entry: ProviderRuntimeTestMessage) => entry.id === requestFlushMessageId, ); expect(message?.streaming).toBe(false); }); @@ -1758,17 +1773,20 @@ describe("ProviderRuntimeIngestion", () => { }, }); + const userInputFlushMessageId = expectedAssistantMessageId( + "item-buffered-user-input-flush", + "turn-buffered-user-input-flush", + ); const thread = await waitForThread(harness.readModel, (entry) => entry.messages.some( (message: ProviderRuntimeTestMessage) => - message.id === "assistant:item-buffered-user-input-flush" && + message.id === userInputFlushMessageId && !message.streaming && message.text === "visible before user input", ), ); const message = thread.messages.find( - (entry: ProviderRuntimeTestMessage) => - entry.id === "assistant:item-buffered-user-input-flush", + (entry: ProviderRuntimeTestMessage) => entry.id === userInputFlushMessageId, ); expect(message?.streaming).toBe(false); }); @@ -1840,13 +1858,24 @@ describe("ProviderRuntimeIngestion", () => { const resumedAt = "2026-03-28T06:07:02.000Z"; const completedAt = "2026-03-28T06:07:03.000Z"; + const appendTurnId = "turn-buffered-request-append"; + const appendFirstMessageId = expectedAssistantMessageId( + "item-buffered-request-append", + appendTurnId, + ); + const appendSegmentMessageId = expectedAssistantMessageId( + "item-buffered-request-append", + appendTurnId, + 1, + ); + harness.emit({ type: "turn.started", eventId: asEventId("evt-turn-started-buffered-request-append"), provider: ProviderDriverKind.make("codex"), createdAt: startedAt, threadId: asThreadId("thread-1"), - turnId: asTurnId("turn-buffered-request-append"), + turnId: asTurnId(appendTurnId), }); await waitForThread( harness.readModel, @@ -1885,7 +1914,7 @@ describe("ProviderRuntimeIngestion", () => { await waitForThread(harness.readModel, (entry) => entry.messages.some( (message: ProviderRuntimeTestMessage) => - message.id === "assistant:item-buffered-request-append" && + message.id === appendFirstMessageId && !message.streaming && message.text === "first half", ), @@ -1921,17 +1950,16 @@ describe("ProviderRuntimeIngestion", () => { const thread = await waitForThread(harness.readModel, (entry) => entry.messages.some( (message: ProviderRuntimeTestMessage) => - message.id === "assistant:item-buffered-request-append:segment:1" && + message.id === appendSegmentMessageId && !message.streaming && message.text === " second half", ), ); const firstMessage = thread.messages.find( - (entry: ProviderRuntimeTestMessage) => entry.id === "assistant:item-buffered-request-append", + (entry: ProviderRuntimeTestMessage) => entry.id === appendFirstMessageId, ); const resumedMessage = thread.messages.find( - (entry: ProviderRuntimeTestMessage) => - entry.id === "assistant:item-buffered-request-append:segment:1", + (entry: ProviderRuntimeTestMessage) => entry.id === appendSegmentMessageId, ); expect(firstMessage?.text).toBe("first half"); expect(firstMessage?.streaming).toBe(false); @@ -1946,21 +1974,17 @@ describe("ProviderRuntimeIngestion", () => { const assistantEvents = events.filter( (event): event is Extract<(typeof events)[number], { type: "thread.message-sent" }> => event.type === "thread.message-sent" && - event.payload.messageId.startsWith("assistant:item-buffered-request-append"), + event.payload.messageId.startsWith(appendFirstMessageId), ); expect(assistantEvents).toHaveLength(4); expect(assistantEvents[0]?.payload.streaming).toBe(true); expect(assistantEvents[0]?.payload.text).toBe("first half"); expect(assistantEvents[1]?.payload.streaming).toBe(false); expect(assistantEvents[1]?.payload.text).toBe(""); - expect(assistantEvents[2]?.payload.messageId).toBe( - "assistant:item-buffered-request-append:segment:1", - ); + expect(assistantEvents[2]?.payload.messageId).toBe(appendSegmentMessageId); expect(assistantEvents[2]?.payload.streaming).toBe(true); expect(assistantEvents[2]?.payload.text).toBe(" second half"); - expect(assistantEvents[3]?.payload.messageId).toBe( - "assistant:item-buffered-request-append:segment:1", - ); + expect(assistantEvents[3]?.payload.messageId).toBe(appendSegmentMessageId); expect(assistantEvents[3]?.payload.streaming).toBe(false); expect(assistantEvents[3]?.payload.text).toBe(""); }); @@ -1972,13 +1996,24 @@ describe("ProviderRuntimeIngestion", () => { const resumedAt = "2026-03-28T07:00:02.000Z"; const completedAt = "2026-03-28T07:00:03.000Z"; + const streamingSegmentTurnId = "turn-streaming-request-segment"; + const streamingFirstMessageId = expectedAssistantMessageId( + "item-streaming-request-segment", + streamingSegmentTurnId, + ); + const streamingSegmentMessageId = expectedAssistantMessageId( + "item-streaming-request-segment", + streamingSegmentTurnId, + 1, + ); + harness.emit({ type: "turn.started", eventId: asEventId("evt-turn-started-streaming-request-segment"), provider: ProviderDriverKind.make("codex"), createdAt: startedAt, threadId: asThreadId("thread-1"), - turnId: asTurnId("turn-streaming-request-segment"), + turnId: asTurnId(streamingSegmentTurnId), }); await waitForThread( harness.readModel, @@ -2017,7 +2052,7 @@ describe("ProviderRuntimeIngestion", () => { await waitForThread(harness.readModel, (entry) => entry.messages.some( (message: ProviderRuntimeTestMessage) => - message.id === "assistant:item-streaming-request-segment" && + message.id === streamingFirstMessageId && !message.streaming && message.text === "before approval", ), @@ -2053,25 +2088,283 @@ describe("ProviderRuntimeIngestion", () => { const thread = await waitForThread(harness.readModel, (entry) => entry.messages.some( (message: ProviderRuntimeTestMessage) => - message.id === "assistant:item-streaming-request-segment:segment:1" && + message.id === streamingSegmentMessageId && !message.streaming && message.text === " after approval", ), ); expect( thread.messages.find( - (message: ProviderRuntimeTestMessage) => - message.id === "assistant:item-streaming-request-segment", + (message: ProviderRuntimeTestMessage) => message.id === streamingFirstMessageId, )?.text, ).toBe("before approval"); expect( thread.messages.find( - (message: ProviderRuntimeTestMessage) => - message.id === "assistant:item-streaming-request-segment:segment:1", + (message: ProviderRuntimeTestMessage) => message.id === streamingSegmentMessageId, )?.text, ).toBe(" after approval"); }); + it("resets a completed assistant segment when item.started arrives again after resume", async () => { + const harness = await createHarness({ serverSettings: { enableAssistantStreaming: true } }); + const startedAt = "2026-03-28T08:00:00.000Z"; + const resumedAt = "2026-03-28T08:00:10.000Z"; + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-turn-started-resume-assistant-segment"), + provider: ProviderDriverKind.make("cursor"), + createdAt: startedAt, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-resume-assistant-segment"), + }); + await waitForThread( + harness.readModel, + (thread) => + thread.session?.status === "running" && + thread.session?.activeTurnId === "turn-resume-assistant-segment", + ); + + const resumedItemId = asItemId("assistant:session-1:segment:3"); + const resumeTurnId = "turn-resume-assistant-segment"; + const resumedMessageId = expectedAssistantMessageId(String(resumedItemId), resumeTurnId); + + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-message-delta-resume-assistant-segment-initial"), + provider: ProviderDriverKind.make("cursor"), + createdAt: startedAt, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-resume-assistant-segment"), + itemId: resumedItemId, + payload: { + streamKind: "assistant_text", + delta: "stale summary before restart", + }, + }); + harness.emit({ + type: "item.completed", + eventId: asEventId("evt-message-completed-resume-assistant-segment-initial"), + provider: ProviderDriverKind.make("cursor"), + createdAt: startedAt, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-resume-assistant-segment"), + itemId: resumedItemId, + payload: { + itemType: "assistant_message", + status: "completed", + }, + }); + + await waitForThread(harness.readModel, (entry) => + entry.messages.some( + (message: ProviderRuntimeTestMessage) => + message.id === resumedMessageId && + !message.streaming && + message.text === "stale summary before restart", + ), + ); + + harness.emit({ + type: "item.started", + eventId: asEventId("evt-message-started-resume-assistant-segment"), + provider: ProviderDriverKind.make("cursor"), + createdAt: resumedAt, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-resume-assistant-segment"), + itemId: resumedItemId, + payload: { + itemType: "assistant_message", + status: "inProgress", + }, + }); + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-message-delta-resume-assistant-segment-after-resume"), + provider: ProviderDriverKind.make("cursor"), + createdAt: resumedAt, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-resume-assistant-segment"), + itemId: resumedItemId, + payload: { + streamKind: "assistant_text", + delta: "fresh answer after resume", + }, + }); + harness.emit({ + type: "item.completed", + eventId: asEventId("evt-message-completed-resume-assistant-segment-after-resume"), + provider: ProviderDriverKind.make("cursor"), + createdAt: resumedAt, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-resume-assistant-segment"), + itemId: resumedItemId, + payload: { + itemType: "assistant_message", + status: "completed", + }, + }); + + const thread = await waitForThread(harness.readModel, (entry) => + entry.messages.some( + (message: ProviderRuntimeTestMessage) => + message.id === resumedMessageId && + !message.streaming && + message.text === "fresh answer after resume", + ), + ); + expect( + thread.messages.find((message: ProviderRuntimeTestMessage) => message.id === resumedMessageId) + ?.text, + ).toBe("fresh answer after resume"); + }); + + it("creates a new assistant message when a later turn reuses the same provider item id", async () => { + const harness = await createHarness({ serverSettings: { enableAssistantStreaming: true } }); + const now = "2026-06-29T00:00:00.000Z"; + const sharedItemId = asItemId("assistant:session-1:segment:0"); + const turnOneId = "turn-one"; + const turnTwoId = "turn-two"; + const turnOneMessageId = expectedAssistantMessageId(String(sharedItemId), turnOneId); + const turnTwoMessageId = expectedAssistantMessageId(String(sharedItemId), turnTwoId); + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-turn-started-cross-turn-item-reuse-one"), + provider: ProviderDriverKind.make("cursor"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId(turnOneId), + }); + await waitForThread( + harness.readModel, + (thread) => + thread.session?.status === "running" && thread.session?.activeTurnId === turnOneId, + ); + + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-message-delta-cross-turn-item-reuse-one"), + provider: ProviderDriverKind.make("cursor"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId(turnOneId), + itemId: sharedItemId, + payload: { + streamKind: "assistant_text", + delta: "turn one answer", + }, + }); + harness.emit({ + type: "item.completed", + eventId: asEventId("evt-message-completed-cross-turn-item-reuse-one"), + provider: ProviderDriverKind.make("cursor"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId(turnOneId), + itemId: sharedItemId, + payload: { + itemType: "assistant_message", + status: "completed", + }, + }); + harness.emit({ + type: "turn.completed", + eventId: asEventId("evt-turn-completed-cross-turn-item-reuse-one"), + provider: ProviderDriverKind.make("cursor"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId(turnOneId), + status: "completed", + }); + + await waitForThread(harness.readModel, (entry) => + entry.messages.some( + (message: ProviderRuntimeTestMessage) => + message.id === turnOneMessageId && + !message.streaming && + message.text === "turn one answer", + ), + ); + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-turn-started-cross-turn-item-reuse-two"), + provider: ProviderDriverKind.make("cursor"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId(turnTwoId), + }); + await waitForThread( + harness.readModel, + (thread) => + thread.session?.status === "running" && thread.session?.activeTurnId === turnTwoId, + ); + + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-message-delta-cross-turn-item-reuse-two"), + provider: ProviderDriverKind.make("cursor"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId(turnTwoId), + itemId: sharedItemId, + payload: { + streamKind: "assistant_text", + delta: "turn two answer", + }, + }); + harness.emit({ + type: "item.completed", + eventId: asEventId("evt-message-completed-cross-turn-item-reuse-two"), + provider: ProviderDriverKind.make("cursor"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId(turnTwoId), + itemId: sharedItemId, + payload: { + itemType: "assistant_message", + status: "completed", + }, + }); + harness.emit({ + type: "turn.completed", + eventId: asEventId("evt-turn-completed-cross-turn-item-reuse-two"), + provider: ProviderDriverKind.make("cursor"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId(turnTwoId), + status: "completed", + }); + + const thread = await waitForThread(harness.readModel, (entry) => + entry.messages.some( + (message: ProviderRuntimeTestMessage) => + message.id === turnTwoMessageId && + !message.streaming && + message.text === "turn two answer", + ), + ); + + const turnOneMessage = thread.messages.find( + (message: ProviderRuntimeTestMessage) => message.id === turnOneMessageId, + ); + const turnTwoMessage = thread.messages.find( + (message: ProviderRuntimeTestMessage) => message.id === turnTwoMessageId, + ); + expect(turnOneMessage?.text).toBe("turn one answer"); + expect(turnTwoMessage?.text).toBe("turn two answer"); + expect(turnOneMessage?.text).not.toContain("turn two answer"); + + const turnOneIndex = thread.messages.findIndex( + (message: ProviderRuntimeTestMessage) => message.id === turnOneMessageId, + ); + const turnTwoIndex = thread.messages.findIndex( + (message: ProviderRuntimeTestMessage) => message.id === turnTwoMessageId, + ); + expect(turnTwoIndex).toBeGreaterThan(turnOneIndex); + }); + it("streams assistant deltas when thread.turn.start requests streaming mode", async () => { const harness = await createHarness({ serverSettings: { enableAssistantStreaming: true } }); const now = "2026-01-01T00:00:00.000Z"; @@ -2109,6 +2402,11 @@ describe("ProviderRuntimeIngestion", () => { thread.session?.activeTurnId === "turn-streaming-mode", ); + const streamingModeMessageId = expectedAssistantMessageId( + "item-streaming-mode", + "turn-streaming-mode", + ); + harness.emit({ type: "content.delta", eventId: asEventId("evt-message-delta-streaming-mode"), @@ -2126,13 +2424,13 @@ describe("ProviderRuntimeIngestion", () => { const liveThread = await waitForThread(harness.readModel, (entry) => entry.messages.some( (message: ProviderRuntimeTestMessage) => - message.id === "assistant:item-streaming-mode" && + message.id === streamingModeMessageId && message.streaming && message.text === "hello live", ), ); const liveMessage = liveThread.messages.find( - (entry: ProviderRuntimeTestMessage) => entry.id === "assistant:item-streaming-mode", + (entry: ProviderRuntimeTestMessage) => entry.id === streamingModeMessageId, ); expect(liveMessage?.streaming).toBe(true); @@ -2154,11 +2452,11 @@ describe("ProviderRuntimeIngestion", () => { const finalThread = await waitForThread(harness.readModel, (entry) => entry.messages.some( (message: ProviderRuntimeTestMessage) => - message.id === "assistant:item-streaming-mode" && !message.streaming, + message.id === streamingModeMessageId && !message.streaming, ), ); const finalMessage = finalThread.messages.find( - (entry: ProviderRuntimeTestMessage) => entry.id === "assistant:item-streaming-mode", + (entry: ProviderRuntimeTestMessage) => entry.id === streamingModeMessageId, ); expect(finalMessage?.text).toBe("hello live"); expect(finalMessage?.streaming).toBe(false); @@ -2184,6 +2482,8 @@ describe("ProviderRuntimeIngestion", () => { thread.session?.activeTurnId === "turn-buffer-spill", ); + const spillMessageId = expectedAssistantMessageId("item-buffer-spill", "turn-buffer-spill"); + harness.emit({ type: "content.delta", eventId: asEventId("evt-message-delta-buffer-spill"), @@ -2214,11 +2514,11 @@ describe("ProviderRuntimeIngestion", () => { const thread = await waitForThread(harness.readModel, (entry) => entry.messages.some( (message: ProviderRuntimeTestMessage) => - message.id === "assistant:item-buffer-spill" && !message.streaming, + message.id === spillMessageId && !message.streaming, ), ); const message = thread.messages.find( - (entry: ProviderRuntimeTestMessage) => entry.id === "assistant:item-buffer-spill", + (entry: ProviderRuntimeTestMessage) => entry.id === spillMessageId, ); expect(message?.text.length).toBe(oversizedText.length); expect(message?.text).toBe(oversizedText); @@ -2245,6 +2545,8 @@ describe("ProviderRuntimeIngestion", () => { thread.session?.activeTurnId === "turn-complete-dedup", ); + const dedupMessageId = expectedAssistantMessageId("item-complete-dedup", "turn-complete-dedup"); + harness.emit({ type: "content.delta", eventId: asEventId("evt-message-delta-for-complete-dedup"), @@ -2290,7 +2592,7 @@ describe("ProviderRuntimeIngestion", () => { thread.session?.activeTurnId === null && thread.messages.some( (message: ProviderRuntimeTestMessage) => - message.id === "assistant:item-complete-dedup" && !message.streaming, + message.id === dedupMessageId && !message.streaming, ), ); @@ -2303,10 +2605,7 @@ describe("ProviderRuntimeIngestion", () => { if (event.type !== "thread.message-sent") { return false; } - return ( - event.payload.messageId === "assistant:item-complete-dedup" && - event.payload.streaming === false - ); + return event.payload.messageId === dedupMessageId && event.payload.streaming === false; }); expect(completionEvents).toHaveLength(1); }); @@ -2661,7 +2960,9 @@ describe("ProviderRuntimeIngestion", () => { (entry: ProviderRuntimeTestCheckpoint) => entry.turnId === "turn-p1", ); expect(checkpoint?.status).toBe("missing"); - expect(checkpoint?.assistantMessageId).toBe("assistant:item-p1-assistant"); + expect(checkpoint?.assistantMessageId).toBe( + expectedAssistantMessageId("item-p1-assistant", "turn-p1"), + ); expect(checkpoint?.checkpointRef).toBe("provider-diff:evt-turn-diff-updated"); }); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 3e5978f4846d..3038d60f8a29 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -197,11 +197,35 @@ function assistantSegmentBaseKeyFromEvent(event: ProviderRuntimeEvent): string { return String(event.itemId ?? event.turnId ?? event.eventId); } -function assistantSegmentMessageId(baseKey: string, segmentIndex: number): MessageId { +function assistantSegmentMessageId( + baseKey: string, + segmentIndex: number, + turnId?: string | null, +): MessageId { + const turnScope = turnId ? `:turn:${turnId}` : ""; return MessageId.make( - segmentIndex === 0 ? `assistant:${baseKey}` : `assistant:${baseKey}:segment:${segmentIndex}`, + segmentIndex === 0 + ? `assistant:${baseKey}${turnScope}` + : `assistant:${baseKey}${turnScope}:segment:${segmentIndex}`, ); } + +function assistantMessageIdFromEvent(event: ProviderRuntimeEvent): MessageId { + return assistantSegmentMessageId( + assistantSegmentBaseKeyFromEvent(event), + 0, + event.turnId ?? null, + ); +} + +function nextAssistantSegmentIndexFromItemId(itemId: string | undefined): number { + if (!itemId) { + return 1; + } + const match = itemId.match(/:segment:(\d+)$/); + return match ? Number(match[1]) + 1 : 1; +} + function buildContextWindowActivityPayload( event: ProviderRuntimeEvent, ): ThreadTokenUsageSnapshot | undefined { @@ -756,11 +780,15 @@ const make = Effect.gen(function* () { onNone: () => ({ baseKey: input.baseKey, nextSegmentIndex: 1, - activeMessageId: assistantSegmentMessageId(input.baseKey, 0), + activeMessageId: assistantSegmentMessageId(input.baseKey, 0, input.turnId), }), onSome: (state) => { const segmentIndex = state.baseKey === input.baseKey ? state.nextSegmentIndex : 0; - const messageId = assistantSegmentMessageId(input.baseKey, segmentIndex); + const messageId = assistantSegmentMessageId( + input.baseKey, + segmentIndex, + input.turnId, + ); return { baseKey: input.baseKey, nextSegmentIndex: state.baseKey === input.baseKey ? state.nextSegmentIndex + 1 : 1, @@ -781,7 +809,7 @@ const make = Effect.gen(function* () { }) => Effect.gen(function* () { if (!input.turnId) { - return assistantSegmentMessageId(assistantSegmentBaseKeyFromEvent(input.event), 0); + return assistantSegmentMessageId(assistantSegmentBaseKeyFromEvent(input.event), 0, null); } const activeMessageId = yield* getActiveAssistantMessageIdForTurn( @@ -1365,6 +1393,36 @@ const make = Effect.gen(function* () { const proposedPlanDelta = event.type === "turn.proposed.delta" ? event.payload.delta : undefined; + if (event.type === "item.started" && event.payload.itemType === "assistant_message") { + const turnId = toTurnId(event.turnId); + if (turnId) { + const messageId = assistantMessageIdFromEvent(event); + const detailedThread = yield* getLoadedThreadDetail(); + const existingMessage = detailedThread + ? findMessageById(detailedThread.messages, messageId) + : undefined; + if (existingMessage && !existingMessage.streaming) { + yield* orchestrationEngine.dispatch({ + type: "thread.message.assistant.reset", + commandId: yield* providerCommandId(event, "assistant-reset-on-item-started"), + threadId: thread.id, + messageId, + turnId, + createdAt: now, + }); + } + yield* clearBufferedAssistantText(messageId); + yield* rememberAssistantMessageId(thread.id, turnId, messageId); + yield* setAssistantSegmentStateForTurn(thread.id, turnId, { + baseKey: assistantSegmentBaseKeyFromEvent(event), + nextSegmentIndex: nextAssistantSegmentIndexFromItemId( + event.itemId !== undefined ? String(event.itemId) : undefined, + ), + activeMessageId: messageId, + }); + } + } + if (assistantDelta && assistantDelta.length > 0) { const turnId = toTurnId(event.turnId); const assistantMessageId = yield* getOrCreateAssistantMessageId({ @@ -1459,9 +1517,7 @@ const make = Effect.gen(function* () { const assistantCompletion = event.type === "item.completed" && event.payload.itemType === "assistant_message" ? { - messageId: MessageId.make( - `assistant:${event.itemId ?? event.turnId ?? event.eventId}`, - ), + messageId: assistantMessageIdFromEvent(event), fallbackText: event.payload.detail, } : undefined; @@ -1634,9 +1690,7 @@ const make = Effect.gen(function* () { if (hasCheckpointForTurn(checkpointContext.checkpoints, turnId)) { // Already tracked; no-op. } else { - const assistantMessageId = MessageId.make( - `assistant:${event.itemId ?? event.turnId ?? event.eventId}`, - ); + const assistantMessageId = assistantMessageIdFromEvent(event); yield* orchestrationEngine.dispatch({ type: "thread.turn.diff.complete", commandId: yield* providerCommandId(event, "thread-turn-diff-complete"), diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 0d4af771ca8a..e27ea53f25ce 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -654,6 +654,34 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.message.assistant.reset": { + yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.message-sent", + payload: { + threadId: command.threadId, + messageId: command.messageId, + role: "assistant", + text: "", + turnId: command.turnId ?? null, + streaming: true, + reset: true, + createdAt: command.createdAt, + updatedAt: command.createdAt, + }, + }; + } + case "thread.proposed-plan.upsert": { yield* requireThread({ readModel, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index fc6ab8f6fcf8..fe56f2bed930 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -415,11 +415,14 @@ export function projectEvent( entry.id === message.id ? { ...entry, - text: message.streaming - ? `${entry.text}${message.text}` - : message.text.length > 0 + text: + payload.reset === true ? message.text - : entry.text, + : message.streaming + ? `${entry.text}${message.text}` + : message.text.length > 0 + ? message.text + : entry.text, streaming: message.streaming, updatedAt: message.updatedAt, turnId: message.turnId, diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index c22b21801835..d1033da724cc 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -570,32 +570,45 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte }); const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); - const acp = yield* makeGrokAcpRuntime({ - grokSettings, - ...(options?.environment ? { environment: options.environment } : {}), - childProcessSpawner, - cwd, - ...(resumeSessionId ? { resumeSessionId } : {}), - clientInfo: { name: "t3-code", version: "0.0.0" }, - ...(mcpSession - ? { - mcpServers: [ - { - type: "http" as const, - name: "t3-code", - url: mcpSession.endpoint, - headers: [ - { - name: "Authorization", - value: mcpSession.authorizationHeader, - }, - ], - }, - ], - } - : {}), - ...acpNativeLoggers, - }).pipe( + const allocateXAiPromptFallbackId = randomUUIDv4.pipe( + Effect.map((uuid) => `t3-xai-prompt-${uuid}`), + Effect.mapError( + (cause) => + new EffectAcpErrors.AcpTransportError({ + detail: "Failed to allocate xAI prompt identifier.", + cause, + }), + ), + ); + const acp = yield* makeGrokAcpRuntime( + { + grokSettings, + ...(options?.environment ? { environment: options.environment } : {}), + childProcessSpawner, + cwd, + ...(resumeSessionId ? { resumeSessionId } : {}), + clientInfo: { name: "t3-code", version: "0.0.0" }, + ...(mcpSession + ? { + mcpServers: [ + { + type: "http" as const, + name: "t3-code", + url: mcpSession.endpoint, + headers: [ + { + name: "Authorization", + value: mcpSession.authorizationHeader, + }, + ], + }, + ], + } + : {}), + ...acpNativeLoggers, + }, + allocateXAiPromptFallbackId, + ).pipe( Effect.provideService(Scope.Scope, sessionScope), Effect.mapError( (cause) => diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index ccbbce1759f0..93d91e560e46 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -1020,6 +1020,43 @@ routing.layer("ProviderServiceLive routing", (it) => { }), ); + it.effect("does not reuse a persisted resume cursor when an explicit cwd changes", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + + const initial = yield* provider.startSession(asThreadId("thread-cwd-changed"), { + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + threadId: asThreadId("thread-cwd-changed"), + cwd: "/tmp/project-before-cwd-change", + runtimeMode: "full-access", + }); + + yield* provider.stopSession({ threadId: initial.threadId }); + routing.codex.startSession.mockClear(); + + yield* provider.startSession(initial.threadId, { + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + threadId: initial.threadId, + cwd: "/tmp/project-after-cwd-change", + runtimeMode: "full-access", + }); + + assert.equal(routing.codex.startSession.mock.calls.length, 1); + const startInput = routing.codex.startSession.mock.calls[0]?.[0]; + assert.equal(typeof startInput === "object" && startInput !== null, true); + if (startInput && typeof startInput === "object") { + const startPayload = startInput as { + cwd?: string; + resumeCursor?: unknown; + }; + assert.equal(startPayload.cwd, "/tmp/project-after-cwd-change"); + assert.equal("resumeCursor" in startPayload, false); + } + }), + ); + it.effect("routes explicit claudeAgent provider session starts to the claude adapter", () => Effect.gen(function* () { const provider = yield* ProviderService.ProviderService; @@ -1256,6 +1293,19 @@ routing.layer("ProviderServiceLive routing", (it) => { attachments: [], }); + yield* Stream.runForEach(provider.streamEvents, () => Effect.void).pipe(Effect.forkChild); + yield* advanceTestClock(50); + + routing.codex.emit({ + type: "turn.started", + eventId: asEventId("evt-runtime-status-start"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + threadId: session.threadId, + turnId: asTurnId(`turn-${String(session.threadId)}`), + }); + yield* advanceTestClock(50); + const runningRuntime = yield* runtimeRepository.getByThreadId({ threadId: session.threadId, }); @@ -1277,7 +1327,7 @@ routing.layer("ProviderServiceLive routing", (it) => { assert.equal(runtimePayload.model, null); assert.equal(runtimePayload.activeTurnId, `turn-${String(session.threadId)}`); assert.equal(runtimePayload.lastError, null); - assert.equal(runtimePayload.lastRuntimeEvent, "provider.sendTurn"); + assert.equal(runtimePayload.lastRuntimeEvent, "provider.turn.started"); } } }), @@ -1536,6 +1586,137 @@ fanout.layer("ProviderServiceLive fanout", (it) => { }), ); + it.effect("clears persisted activeTurnId when adapter emits turn.completed", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const session = yield* provider.startSession(asThreadId("thread-runtime-turn-complete"), { + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + threadId: asThreadId("thread-runtime-turn-complete"), + runtimeMode: "full-access", + }); + yield* provider.sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }); + + yield* Stream.runForEach(provider.streamEvents, () => Effect.void).pipe(Effect.forkChild); + yield* advanceTestClock(50); + + fanout.codex.emit({ + type: "turn.started", + eventId: asEventId("evt-runtime-turn-start"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + threadId: session.threadId, + turnId: asTurnId(`turn-${String(session.threadId)}`), + }); + yield* advanceTestClock(50); + + const runningRuntime = yield* runtimeRepository.getByThreadId({ + threadId: session.threadId, + }); + assert.equal(Option.isSome(runningRuntime), true); + if (Option.isSome(runningRuntime)) { + const payload = runningRuntime.value.runtimePayload; + assert.equal(payload !== null && typeof payload === "object", true); + if (payload !== null && typeof payload === "object" && !Array.isArray(payload)) { + assert.equal( + (payload as { activeTurnId: string | null }).activeTurnId, + `turn-${String(session.threadId)}`, + ); + } + } + + fanout.codex.emit({ + type: "turn.completed", + eventId: asEventId("evt-runtime-turn-complete"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + threadId: session.threadId, + turnId: asTurnId(`turn-${String(session.threadId)}`), + status: "completed", + }); + yield* advanceTestClock(50); + + const settledRuntime = yield* runtimeRepository.getByThreadId({ + threadId: session.threadId, + }); + assert.equal(Option.isSome(settledRuntime), true); + if (Option.isSome(settledRuntime)) { + const payload = settledRuntime.value.runtimePayload; + assert.equal(payload !== null && typeof payload === "object", true); + if (payload !== null && typeof payload === "object" && !Array.isArray(payload)) { + const runtimePayload = payload as { + activeTurnId: string | null; + lastRuntimeEvent: string | null; + }; + assert.equal(runtimePayload.activeTurnId, null); + assert.equal(runtimePayload.lastRuntimeEvent, "provider.turn.completed"); + assert.equal(settledRuntime.value.status, "running"); + } + } + }), + ); + + it.effect("does not resurrect activeTurnId after sendTurn returns following turn.completed", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const session = yield* provider.startSession(asThreadId("thread-runtime-sendturn-settle"), { + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + threadId: asThreadId("thread-runtime-sendturn-settle"), + runtimeMode: "full-access", + }); + const turnId = asTurnId(`turn-${String(session.threadId)}`); + + yield* Stream.runForEach(provider.streamEvents, () => Effect.void).pipe(Effect.forkChild); + yield* advanceTestClock(50); + + fanout.codex.emit({ + type: "turn.started", + eventId: asEventId("evt-runtime-sendturn-start"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + threadId: session.threadId, + turnId, + }); + fanout.codex.emit({ + type: "turn.completed", + eventId: asEventId("evt-runtime-sendturn-complete"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + threadId: session.threadId, + turnId, + status: "completed", + }); + yield* advanceTestClock(50); + + yield* provider.sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }); + yield* advanceTestClock(50); + + const settledRuntime = yield* runtimeRepository.getByThreadId({ + threadId: session.threadId, + }); + assert.equal(Option.isSome(settledRuntime), true); + if (Option.isSome(settledRuntime)) { + const payload = settledRuntime.value.runtimePayload; + assert.equal(payload !== null && typeof payload === "object", true); + if (payload !== null && typeof payload === "object" && !Array.isArray(payload)) { + assert.equal((payload as { activeTurnId: string | null }).activeTurnId, null); + assert.equal(settledRuntime.value.status, "running"); + } + } + }), + ); + it.effect("fans out canonical runtime events in emission order", () => Effect.gen(function* () { const provider = yield* ProviderService.ProviderService; diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 2eaaeb8ce3c0..b104fbec851d 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -25,6 +25,7 @@ import { type ProviderSession, } from "@t3tools/contracts"; import { causeErrorTag } from "@t3tools/shared/observability"; +import * as Cause from "effect/Cause"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -281,6 +282,80 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( }); }); + const syncPersistedRuntimeFromLifecycleEvent = ( + event: ProviderRuntimeEvent, + ): Effect.Effect => + Effect.gen(function* () { + const threadId = event.threadId; + if (threadId === undefined) { + return; + } + + const bindingOption = yield* directory.getBinding(threadId); + const binding = Option.getOrUndefined(bindingOption); + if (!binding || binding.providerInstanceId === undefined) { + return; + } + + const providerInstanceId = binding.providerInstanceId; + + const now = yield* nowIso; + switch (event.type) { + case "turn.started": + if (event.turnId === undefined) { + return; + } + yield* directory.upsert({ + threadId, + provider: binding.provider, + providerInstanceId, + status: "running", + runtimePayload: { + activeTurnId: event.turnId, + lastRuntimeEvent: "provider.turn.started", + lastRuntimeEventAt: now, + }, + }); + return; + case "turn.completed": + yield* directory.upsert({ + threadId, + provider: binding.provider, + providerInstanceId, + status: binding.status === "starting" ? "starting" : "running", + runtimePayload: { + activeTurnId: null, + lastRuntimeEvent: "provider.turn.completed", + lastRuntimeEventAt: now, + }, + }); + return; + case "session.exited": + yield* directory.upsert({ + threadId, + provider: binding.provider, + providerInstanceId, + status: "stopped", + runtimePayload: { + activeTurnId: null, + lastRuntimeEvent: "provider.session.exited", + lastRuntimeEventAt: now, + }, + }); + return; + default: + return; + } + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("provider runtime binding sync failed", { + eventType: event.type, + threadId: event.threadId, + cause: Cause.pretty(cause), + }), + ), + ); + const processRuntimeEvent = ( source: { readonly instanceId: ProviderInstanceId; @@ -290,10 +365,15 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ): Effect.Effect => Effect.sync(() => correlateRuntimeEventWithInstance(source, event)).pipe( Effect.flatMap((canonicalEvent) => - increment(providerRuntimeEventsTotal, { - provider: canonicalEvent.provider, - eventType: canonicalEvent.type, - }).pipe(Effect.andThen(publishRuntimeEvent(canonicalEvent))), + syncPersistedRuntimeFromLifecycleEvent(canonicalEvent).pipe( + Effect.andThen( + increment(providerRuntimeEventsTotal, { + provider: canonicalEvent.provider, + eventType: canonicalEvent.type, + }), + ), + Effect.andThen(publishRuntimeEvent(canonicalEvent)), + ), ), ); @@ -560,16 +640,19 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ); } const persistedBinding = Option.getOrUndefined(yield* directory.getBinding(threadId)); - const effectiveResumeCursor = - input.resumeCursor ?? - (persistedBinding?.providerInstanceId === resolvedInstanceId - ? persistedBinding.resumeCursor - : undefined); - const effectiveCwd = - input.cwd ?? - (persistedBinding?.providerInstanceId === resolvedInstanceId + const persistedCwd = + persistedBinding?.providerInstanceId === resolvedInstanceId ? readPersistedCwd(persistedBinding.runtimePayload) - : undefined); + : undefined; + const canUsePersistedResumeCursor = + persistedBinding?.providerInstanceId === resolvedInstanceId && + (input.cwd === undefined || persistedCwd === undefined || input.cwd === persistedCwd); + const effectiveResumeCursor = + input.resumeCursor === null + ? undefined + : (input.resumeCursor ?? + (canUsePersistedResumeCursor ? persistedBinding.resumeCursor : undefined)); + const effectiveCwd = input.cwd ?? persistedCwd; yield* Effect.annotateCurrentSpan({ "provider.kind": resolvedProvider, "provider.resume_cursor.source": @@ -680,15 +763,16 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ...(input.modelSelection?.model ? { "provider.model": input.modelSelection.model } : {}), }); const turn = yield* routed.adapter.sendTurn(input); + // Persist resume cursor and bookkeeping only. Turn/session status and + // activeTurnId are owned by lifecycle events (turn.started/completed). yield* directory.upsert({ threadId: input.threadId, provider: routed.adapter.provider, providerInstanceId: routed.instanceId, - status: "running", ...(turn.resumeCursor !== undefined ? { resumeCursor: turn.resumeCursor } : {}), runtimePayload: { ...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}), - activeTurnId: turn.turnId, + activeTurnId: null, lastRuntimeEvent: "provider.sendTurn", lastRuntimeEventAt: yield* nowIso, }, @@ -729,7 +813,9 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const routed = yield* resolveRoutableSession({ threadId: input.threadId, operation: "ProviderService.interruptTurn", - allowRecovery: true, + // Stop must not resurrect a dead provider process; session settle + // happens in ProviderCommandReactor when interrupt is a no-op. + allowRecovery: false, }); metricProvider = routed.adapter.provider; yield* Effect.annotateCurrentSpan({ @@ -738,7 +824,9 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( "provider.thread_id": input.threadId, "provider.turn_id": input.turnId, }); - yield* routed.adapter.interruptTurn(routed.threadId, input.turnId); + if (routed.isActive) { + yield* routed.adapter.interruptTurn(routed.threadId, input.turnId); + } yield* analytics.record("provider.turn.interrupted", { provider: routed.adapter.provider, }); diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index 23075bd9a06e..69c4750636fb 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -125,6 +125,10 @@ const makeProviderSessionDirectory = Effect.gen(function* () { issue: "providerInstanceId is required for provider session runtime bindings.", }); } + const freshExisting = yield* repository + .getByThreadId({ threadId: resolvedThreadId }) + .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:getByThreadId"))); + const freshRuntime = Option.getOrUndefined(freshExisting); yield* repository .upsert({ threadId: resolvedThreadId, @@ -133,15 +137,19 @@ const makeProviderSessionDirectory = Effect.gen(function* () { adapterKey: binding.adapterKey ?? (providerChanged ? binding.provider : (existingRuntime?.adapterKey ?? binding.provider)), - runtimeMode: binding.runtimeMode ?? existingRuntime?.runtimeMode ?? "full-access", - status: binding.status ?? existingRuntime?.status ?? "running", + runtimeMode: + binding.runtimeMode ?? + freshRuntime?.runtimeMode ?? + existingRuntime?.runtimeMode ?? + "full-access", + status: binding.status ?? freshRuntime?.status ?? existingRuntime?.status ?? "running", lastSeenAt: now, resumeCursor: binding.resumeCursor !== undefined ? binding.resumeCursor - : (existingRuntime?.resumeCursor ?? null), + : (freshRuntime?.resumeCursor ?? existingRuntime?.resumeCursor ?? null), runtimePayload: mergeRuntimePayload( - existingRuntime?.runtimePayload ?? null, + freshRuntime?.runtimePayload ?? existingRuntime?.runtimePayload ?? null, binding.runtimePayload, ), }) diff --git a/apps/server/src/provider/Layers/ProviderSessionRecovery.test.ts b/apps/server/src/provider/Layers/ProviderSessionRecovery.test.ts new file mode 100644 index 000000000000..8e129babc078 --- /dev/null +++ b/apps/server/src/provider/Layers/ProviderSessionRecovery.test.ts @@ -0,0 +1,135 @@ +import { + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + type OrchestrationCommand, +} from "@t3tools/contracts"; +import { describe, it, assert } from "@effect/vitest"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; + +import { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import * as ProviderSessionRuntime from "../../persistence/ProviderSessionRuntime.ts"; +import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; +import { recoverOrphanedProviderSessions } from "./ProviderSessionRecovery.ts"; + +const threadId = ThreadId.make("thread-recovery-1"); +const grokInstanceId = ProviderInstanceId.make("grok"); +const resumeCursor = { schemaVersion: 1, sessionId: "session-recovery-1" }; + +const makeTestLayer = (dispatchedRef: Ref.Ref>) => { + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( + Layer.provide(SqlitePersistenceMemory), + ); + const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); + const engineLayer = Layer.succeed(OrchestrationEngineService, { + dispatch: (command) => + Ref.update(dispatchedRef, (current) => [...current, command]).pipe( + Effect.as({ sequence: 1 }), + ), + readEvents: () => { + throw new Error("not implemented in test"); + }, + streamDomainEvents: Effect.die("not implemented in test") as never, + }); + const cryptoLayer = Layer.succeed( + Crypto.Crypto, + Crypto.make({ + randomBytes: (size) => new Uint8Array(size), + digest: () => Effect.succeed(new Uint8Array(32)), + }), + ); + + return Layer.mergeAll(directoryLayer, runtimeRepositoryLayer, engineLayer, cryptoLayer); +}; + +describe("ProviderSessionRecovery", () => { + it.effect("settles orphaned runtime bindings and marks them stopped on startup", () => + Effect.gen(function* () { + const dispatchedRef = yield* Ref.make>([]); + const testLayer = makeTestLayer(dispatchedRef); + + const dispatched = yield* Effect.gen(function* () { + const repository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + yield* repository.upsert({ + threadId, + providerName: ProviderDriverKind.make("grok"), + providerInstanceId: grokInstanceId, + adapterKey: "grok", + runtimeMode: "full-access", + status: "running", + lastSeenAt: "2026-06-29T17:54:19.390Z", + resumeCursor, + runtimePayload: { + activeTurnId: "turn-orphaned", + lastRuntimeEvent: "provider.sendTurn", + }, + }); + + yield* recoverOrphanedProviderSessions; + + const persisted = yield* repository.getByThreadId({ threadId }); + assert.equal(Option.isSome(persisted), true); + if (Option.isSome(persisted)) { + assert.equal(persisted.value.status, "stopped"); + assert.deepEqual(persisted.value.resumeCursor, resumeCursor); + const payload = persisted.value.runtimePayload; + assert.equal( + payload !== null && typeof payload === "object" && !Array.isArray(payload), + true, + ); + if (payload !== null && typeof payload === "object" && !Array.isArray(payload)) { + assert.equal((payload as { activeTurnId: string | null }).activeTurnId, null); + assert.equal( + (payload as { lastRuntimeEvent: string }).lastRuntimeEvent, + "provider.session.recovered", + ); + } + } + + return yield* Ref.get(dispatchedRef); + }).pipe(Effect.provide(testLayer)); + + assert.equal(dispatched.length, 1); + assert.equal(dispatched[0]?.type, "thread.session.set"); + if (dispatched[0]?.type === "thread.session.set") { + assert.equal(dispatched[0].session.status, "ready"); + assert.equal(dispatched[0].session.activeTurnId, null); + assert.equal(dispatched[0].session.providerName, "grok"); + } + }), + ); + + it.effect("skips already-terminal runtime bindings", () => + Effect.gen(function* () { + const dispatchedRef = yield* Ref.make>([]); + const testLayer = makeTestLayer(dispatchedRef); + + const dispatched = yield* Effect.gen(function* () { + const repository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + yield* repository.upsert({ + threadId, + providerName: ProviderDriverKind.make("cursor"), + providerInstanceId: ProviderInstanceId.make("cursor"), + adapterKey: "cursor", + runtimeMode: "full-access", + status: "stopped", + lastSeenAt: "2026-06-29T17:56:45.038Z", + resumeCursor, + runtimePayload: { + activeTurnId: null, + }, + }); + + yield* recoverOrphanedProviderSessions; + return yield* Ref.get(dispatchedRef); + }).pipe(Effect.provide(testLayer)); + + assert.equal(dispatched.length, 0); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/ProviderSessionRecovery.ts b/apps/server/src/provider/Layers/ProviderSessionRecovery.ts new file mode 100644 index 000000000000..d2e1dff78cf2 --- /dev/null +++ b/apps/server/src/provider/Layers/ProviderSessionRecovery.ts @@ -0,0 +1,161 @@ +import { CommandId, type OrchestrationSession } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts"; +import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; + +/** + * ProviderSessionRecovery — settle provider sessions orphaned by a restart. + * + * Provider sessions are backed by child processes (`grok agent stdio`, + * `claude`, `codex app-server`, …) that do not survive a server restart, and + * can also be killed out from under a still-running server. The persisted + * `provider_session_runtime` row, however, keeps the last known status — so a + * session that was mid-turn when its provider went away stays marked + * `running`/`starting` with an unsettled turn. + * + * On the projection side a turn only settles when its session leaves the + * `running` status (see `settledTurnStateForSessionStatus` in + * `ProjectionPipeline`). With no live provider left to emit that transition, + * the thread is stuck showing a phantom "running" turn forever and the UI will + * not let the user continue talking. + * + * This recovery runs once on startup. For every persisted session still in a + * non-terminal status it: + * 1. dispatches a `thread.session.set` with status `ready` and no active + * turn, which settles the orphaned running turn (projected as + * `interrupted`) and unsticks the thread; and + * 2. marks the persisted runtime row `stopped` while preserving the resume + * cursor, so the next prompt resumes the existing provider session + * (continues the conversation) instead of resuming a dead one. + * + * It is best-effort and idempotent: terminal rows are skipped, re-running is a + * no-op, and a failure for one thread is logged without blocking the others or + * server startup. + * + * @module provider/Layers/ProviderSessionRecovery + */ + +/** + * Persisted runtime statuses that imply a live provider process. After a + * restart there is none, so these are the rows that need settling. `stopped` + * and `error` are already terminal and left untouched. + */ +const NON_TERMINAL_RUNTIME_STATUSES = new Set(["starting", "running"]); + +function readPersistedActiveTurnId(runtimePayload: unknown): string | null { + if ( + runtimePayload === null || + typeof runtimePayload !== "object" || + Array.isArray(runtimePayload) + ) { + return null; + } + const activeTurnId = (runtimePayload as { activeTurnId?: unknown }).activeTurnId; + return typeof activeTurnId === "string" && activeTurnId.length > 0 ? activeTurnId : null; +} + +export const recoverOrphanedProviderSessions = Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + const engine = yield* OrchestrationEngineService; + const crypto = yield* Crypto.Crypto; + + const bindings = yield* directory.listBindings().pipe( + Effect.catchCause((cause) => + Effect.logWarning("provider session recovery failed to list bindings", { + cause: Cause.pretty(cause), + }).pipe(Effect.as([])), + ), + ); + + const orphaned = bindings.filter((binding) => { + if (binding.status !== undefined && NON_TERMINAL_RUNTIME_STATUSES.has(binding.status)) { + return true; + } + // Split-brain after restart: projection may already be ready while the + // persisted runtime row still carries a stale active turn id. + return readPersistedActiveTurnId(binding.runtimePayload) !== null; + }); + + if (orphaned.length === 0) { + return; + } + + yield* Effect.logInfo("recovering orphaned provider sessions on startup", { + count: orphaned.length, + }); + + yield* Effect.forEach( + orphaned, + (binding) => + Effect.gen(function* () { + const now = DateTime.formatIso(yield* DateTime.now); + const commandId = CommandId.make( + `server:provider-session-recovery:${yield* crypto.randomUUIDv4}`, + ); + const session: OrchestrationSession = { + threadId: binding.threadId, + status: "ready", + providerName: binding.provider, + ...(binding.providerInstanceId !== undefined + ? { providerInstanceId: binding.providerInstanceId } + : {}), + runtimeMode: binding.runtimeMode ?? "full-access", + // Provider turn ids are not orchestration turn ids; clearing the + // active turn is what settles the orphaned running turn. + activeTurnId: null, + lastError: null, + updatedAt: now, + }; + + // Settle the orphaned running turn (UI unstick). + yield* engine.dispatch({ + type: "thread.session.set", + commandId, + threadId: binding.threadId, + session, + createdAt: now, + }); + + // Mark the runtime row stopped, preserving the resume cursor so the + // next prompt resumes this session instead of starting a dead one. + yield* directory.upsert({ + threadId: binding.threadId, + provider: binding.provider, + ...(binding.providerInstanceId !== undefined + ? { providerInstanceId: binding.providerInstanceId } + : {}), + status: "stopped", + runtimePayload: { + activeTurnId: null, + lastRuntimeEvent: "provider.session.recovered", + lastRuntimeEventAt: now, + }, + }); + + yield* Effect.logInfo("recovered orphaned provider session", { + threadId: binding.threadId, + provider: binding.provider, + }); + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("provider session recovery failed for thread", { + threadId: binding.threadId, + cause: Cause.pretty(cause), + }), + ), + ), + { concurrency: 1, discard: true }, + ); +}); + +/** + * Startup layer that runs {@link recoverOrphanedProviderSessions} once when + * built. Requires `ProviderSessionDirectory` and `OrchestrationEngineService` + * (both provided by the server runtime services layer). + */ +export const ProviderSessionRecoveryLive = Layer.effectDiscard(recoverOrphanedProviderSessions); diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index 4e9700dab7d6..7422395fc839 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -526,6 +526,7 @@ describe("AcpSessionRuntime", () => { ), Effect.scoped, Effect.provide(NodeServices.layer), + TestClock.withLive, ), ); @@ -568,6 +569,44 @@ describe("AcpSessionRuntime", () => { ), ); + it.effect("waits for delayed session/load replay tail after the load RPC returns", () => + Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; + const started = yield* runtime.start().pipe(Effect.timeout("2 seconds")); + + expect(started.sessionId).toBe("mock-session-1"); + expect(started.sessionSetupResult._meta).not.toMatchObject({ + t3SessionLoadReady: "replay_idle", + }); + + const unexpectedReplayEvent = yield* Stream.runHead(runtime.getEvents()).pipe( + Effect.timeoutOption("100 millis"), + ); + expect(Option.isNone(unexpectedReplayEvent)).toBe(true); + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + authMethodId: "test", + spawn: { + command: mockAgentCommand, + args: mockAgentArgs, + env: { + T3_ACP_FAST_LOAD_WITH_DELAYED_REPLAY_TAIL: "1", + }, + }, + cwd: process.cwd(), + resumeSessionId: "mock-session-1", + sessionLoadReplayIdleGap: "50 millis", + sessionLoadTimeout: "2 seconds", + clientInfo: { name: "t3-test", version: "0.0.0" }, + }), + ), + Effect.scoped, + Effect.provide(NodeServices.layer), + TestClock.withLive, + ), + ); + it.effect("rejects invalid config option values before sending session/set_config_option", () => { const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "acp-runtime-")); const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.test.ts b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts index 7682c5f5f9cb..14b36a435016 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.test.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts @@ -8,6 +8,7 @@ import { parsePermissionRequest, parseSessionModeState, parseSessionUpdateEvent, + sessionLoadReplaySettleActivityMillis, sessionUpdateIsReplay, syntheticLoadSessionResponseFromInitialize, } from "./AcpRuntimeModel.ts"; @@ -336,6 +337,16 @@ describe("AcpRuntimeModel", () => { ]); }); + it("requires replay idle after load RPC completion even when replay stopped earlier", () => { + const idleGapMillis = 2_000; + const replayAt = 0; + const rpcAt = 2_000; + const settleFrom = sessionLoadReplaySettleActivityMillis(replayAt, rpcAt); + + expect(rpcAt - settleFrom >= idleGapMillis).toBe(false); + expect(rpcAt + idleGapMillis - settleFrom >= idleGapMillis).toBe(true); + }); + it("keeps permission request parsing compatible with loose extension payloads", () => { const request = parsePermissionRequest({ sessionId: "session-1", diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.ts b/apps/server/src/provider/acp/AcpRuntimeModel.ts index e6bfc127e6e9..df6b07563fcd 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.ts @@ -465,6 +465,62 @@ export interface SessionLoadGate { readonly initializeResult: EffectAcpSchema.InitializeResponse; } +export function sessionLoadReplaySettleActivityMillis( + lastActivityAtMillis: number | undefined, + baselineMillis: number, +): number { + return Math.max(lastActivityAtMillis ?? 0, baselineMillis); +} + +/** + * Waits until session/load replay notifications have been quiet for `idleGap`. + * `baselineMillis` is recorded when the load RPC completes. The idle window must + * start after both the last replay activity and RPC completion, so a slow RPC + * cannot inherit replay quiet time that elapsed while the request was in flight. + */ +export const waitForSessionLoadReplayToSettle = (input: { + readonly gateRef: Ref.Ref>; + readonly baselineMillis: number; +}): Effect.Effect => + Effect.gen(function* () { + const pollInterval = Duration.millis(25); + while (true) { + const gate = yield* Ref.get(input.gateRef); + if (Option.isNone(gate) || !gate.value.active) { + return; + } + const idleGapMillis = Duration.toMillis(gate.value.idleGap); + const nowMillis = yield* Clock.currentTimeMillis; + const lastActivity = sessionLoadReplaySettleActivityMillis( + gate.value.lastActivityAtMillis, + input.baselineMillis, + ); + if (nowMillis - lastActivity >= idleGapMillis) { + return; + } + yield* Effect.sleep(pollInterval); + } + }); + +/** Records inbound protocol activity while the session/load replay gate is active. */ +export const touchSessionLoadReplayActivity = (input: { + readonly gateRef: Ref.Ref>; +}): Effect.Effect => + Effect.gen(function* () { + const gate = yield* Ref.get(input.gateRef); + if (Option.isNone(gate) || !gate.value.active) { + return; + } + const lastActivityAtMillis = yield* Clock.currentTimeMillis; + yield* Ref.set( + input.gateRef, + Option.some({ + ...gate.value, + lastActivityAtMillis, + }), + ); + }); + export const waitForSessionLoadReplayIdle = (input: { readonly gateRef: Ref.Ref>; }): Effect.Effect => diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index bc2df3aa8d40..120f156944b0 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -4,6 +4,7 @@ import * as Context from "effect/Context"; import * as Deferred from "effect/Deferred"; 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 Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -29,6 +30,8 @@ import { parseSessionUpdateEvent, sessionUpdateIsReplay, waitForSessionLoadReplayIdle, + touchSessionLoadReplayActivity, + waitForSessionLoadReplayToSettle, type SessionLoadGate, type AcpParsedSessionEvent, type AcpSessionModeState, @@ -242,6 +245,10 @@ export class AcpSessionRuntime extends Context.Service< method: string, payload: unknown, ) => Effect.Effect; + /** True while session/load replay is still settling and should suppress side effects. */ + readonly isSessionLoadReplayActive: Effect.Effect; + /** Extends the session/load replay idle window when non-session/update traffic arrives. */ + readonly touchSessionLoadReplayActivity: Effect.Effect; } >()("t3/provider/acp/AcpSessionRuntime") {} @@ -360,14 +367,7 @@ export const make = ( Effect.gen(function* () { const gate = yield* Ref.get(sessionLoadGateRef); if (Option.isSome(gate) && gate.value.active) { - const lastActivityAtMillis = yield* Clock.currentTimeMillis; - yield* Ref.set( - sessionLoadGateRef, - Option.some({ - ...gate.value, - lastActivityAtMillis, - }), - ); + yield* touchSessionLoadReplayActivity({ gateRef: sessionLoadGateRef }); return; } if (sessionUpdateIsReplay(notification)) { @@ -578,11 +578,35 @@ export const make = ( const idleFiber = yield* waitForSessionLoadReplayIdle({ gateRef: sessionLoadGateRef, }).pipe(Effect.forkIn(runtimeScope)); - const loaded = yield* Effect.raceFirst( - acp.agent.loadSession(loadPayload), - Fiber.join(idleFiber), - ).pipe( - Effect.ensuring(Fiber.interrupt(idleFiber).pipe(Effect.ignore)), + const rpcFiber = yield* acp.agent + .loadSession(loadPayload) + .pipe(Effect.forkIn(runtimeScope)); + const loaded = yield* Effect.gen(function* () { + const winner = yield* Effect.race( + Fiber.await(rpcFiber).pipe(Effect.map((exit) => ({ _tag: "Rpc" as const, exit }))), + Fiber.join(idleFiber).pipe( + Effect.map((synthetic) => ({ _tag: "Idle" as const, synthetic })), + ), + ); + + if (winner._tag === "Idle") { + yield* Fiber.interrupt(rpcFiber).pipe(Effect.ignore); + return winner.synthetic; + } + + if (Exit.isFailure(winner.exit)) { + yield* Fiber.interrupt(idleFiber).pipe(Effect.ignore); + return yield* Effect.failCause(winner.exit.cause); + } + + const rpcCompletedAtMillis = yield* Clock.currentTimeMillis; + yield* waitForSessionLoadReplayToSettle({ + gateRef: sessionLoadGateRef, + baselineMillis: rpcCompletedAtMillis, + }); + yield* Fiber.interrupt(idleFiber).pipe(Effect.ignore); + return winner.exit.value; + }).pipe( Effect.timeoutOption(sessionLoadTimeout), Effect.flatMap((result) => Option.match(result, { @@ -794,6 +818,12 @@ export const make = ( request: (method, payload) => runLoggedRequest(method, payload, acp.raw.request(method, payload)), notify: acp.raw.notify, + isSessionLoadReplayActive: Ref.get(sessionLoadGateRef).pipe( + Effect.map((gate) => Option.isSome(gate) && gate.value.active), + ), + touchSessionLoadReplayActivity: touchSessionLoadReplayActivity({ + gateRef: sessionLoadGateRef, + }), } satisfies AcpSessionRuntime["Service"]; }); diff --git a/apps/server/src/provider/acp/GrokAcpSupport.ts b/apps/server/src/provider/acp/GrokAcpSupport.ts index 3e6d63a53935..da5ad9807540 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.ts @@ -50,8 +50,20 @@ function resolveGrokAuthMethodId(environment: NodeJS.ProcessEnv | undefined): st : GROK_AUTH_METHOD_CACHED_TOKEN; } +export const sequentialXAiPromptFallbackIdAllocator = (): Effect.Effect => { + let next = 0; + return Effect.sync(() => { + next += 1; + return `t3-xai-prompt-${next}`; + }); +}; + export const makeGrokAcpRuntime = ( input: GrokAcpRuntimeInput, + allocatePromptFallbackId: Effect.Effect< + string, + EffectAcpErrors.AcpError + > = sequentialXAiPromptFallbackIdAllocator(), ): Effect.Effect< AcpSessionRuntime.AcpSessionRuntime["Service"], EffectAcpErrors.AcpError, @@ -72,7 +84,7 @@ export const makeGrokAcpRuntime = ( const runtime = yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( Effect.provide(acpContext), ); - return yield* makeXAiPromptCompletionRuntime(runtime); + return yield* makeXAiPromptCompletionRuntime(runtime, allocatePromptFallbackId); }); export function resolveGrokAcpBaseModelId(model: string | null | undefined): string { diff --git a/apps/server/src/provider/acp/XAiAcpExtension.test.ts b/apps/server/src/provider/acp/XAiAcpExtension.test.ts index c435269fd76d..e2bf8a56bbe0 100644 --- a/apps/server/src/provider/acp/XAiAcpExtension.test.ts +++ b/apps/server/src/provider/acp/XAiAcpExtension.test.ts @@ -4,8 +4,12 @@ import * as NodeURL from "node:url"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; +import * as Crypto from "effect/Crypto"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; import { describe, expect } from "vite-plus/test"; import { @@ -13,15 +17,18 @@ import { makeXAiAskUserQuestionCancelledResponse, makeXAiAskUserQuestionResponse, makeXAiPromptCompletionRuntime, + resolveXAiPromptCompletionFallback, XAiAskUserQuestionRequest, } from "./XAiAcpExtension.ts"; import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; +import * as EffectAcpErrors from "effect-acp/errors"; const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts"); -const makePromptCompletionRuntime = (env: NodeJS.ProcessEnv) => +const makePromptCompletionRuntime = (env: NodeJS.ProcessEnv, resumeSessionId?: string) => Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; const runtime = yield* AcpSessionRuntime.make({ spawn: { command: process.execPath, @@ -31,8 +38,21 @@ const makePromptCompletionRuntime = (env: NodeJS.ProcessEnv) => cwd: process.cwd(), clientInfo: { name: "t3-test", version: "0.0.0" }, authMethodId: "test", + ...(resumeSessionId ? { resumeSessionId } : {}), + sessionLoadReplayIdleGap: "50 millis", + sessionLoadTimeout: "2 seconds", }); - return yield* makeXAiPromptCompletionRuntime(runtime); + const allocatePromptFallbackId = crypto.randomUUIDv4.pipe( + Effect.map((uuid) => `t3-xai-prompt-${uuid}`), + Effect.mapError( + (cause) => + new EffectAcpErrors.AcpTransportError({ + detail: "Failed to allocate xAI prompt identifier.", + cause, + }), + ), + ); + return yield* makeXAiPromptCompletionRuntime(runtime, allocatePromptFallbackId); }); const decodeXAiAskUserQuestionRequest = Schema.decodeUnknownSync(XAiAskUserQuestionRequest); @@ -296,7 +316,175 @@ describe("XAiAcpExtension", () => { requestId: promptId, }, }); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer), TestClock.withLive), + ); + + it.effect("ignores xAI prompt completion notifications during session/load replay", () => + Effect.gen(function* () { + const pendingRef = yield* Ref.make< + ReadonlyArray<{ + readonly sessionId: string; + readonly promptId: string; + readonly deferred: Deferred.Deferred; + }> + >([ + { + sessionId: "session-1", + promptId: "t3-xai-prompt-1", + deferred: yield* Deferred.make(), + }, + ]); + const completedPromptIdsRef = yield* Ref.make>([]); + + yield* resolveXAiPromptCompletionFallback({ + pendingRef, + completedPromptIdsRef, + notification: { + sessionId: "session-1", + promptId: "t3-xai-prompt-1", + stopReason: "end_turn", + agentResult: null, + }, + isSessionLoadReplayActive: Effect.succeed(true), + }); + + const pending = yield* Ref.get(pendingRef); + expect(pending).toHaveLength(1); + expect(pending[0]?.promptId).toBe("t3-xai-prompt-1"); + }), + ); + + it.effect("completes a prompt after session/load with a delayed replay tail", () => + Effect.gen(function* () { + const runtime = yield* makePromptCompletionRuntime( + { + T3_ACP_FAST_LOAD_WITH_DELAYED_REPLAY_TAIL: "1", + T3_ACP_EMIT_XAI_PROMPT_COMPLETE_THEN_HANG: "1", + }, + "mock-session-1", + ); + yield* runtime.start(); + + const promptResult = yield* runtime.prompt({ + prompt: [{ type: "text", text: "after resume" }], + }); + const promptId = promptResult._meta?.promptId; + + expect(typeof promptId).toBe("string"); + expect(promptResult).toMatchObject({ + stopReason: "end_turn", + _meta: { + sessionId: "mock-session-1", + promptId, + requestId: promptId, + }, + }); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer), TestClock.withLive), + ); + + it.effect("ignores xAI prompt completion notifications without a prompt id", () => + Effect.gen(function* () { + const pendingRef = yield* Ref.make< + ReadonlyArray<{ + readonly sessionId: string; + readonly promptId: string; + readonly deferred: Deferred.Deferred; + }> + >([ + { + sessionId: "session-1", + promptId: "t3-xai-prompt-1", + deferred: yield* Deferred.make(), + }, + ]); + const completedPromptIdsRef = yield* Ref.make>([]); + + yield* resolveXAiPromptCompletionFallback({ + pendingRef, + completedPromptIdsRef, + notification: { + sessionId: "session-1", + stopReason: "end_turn", + agentResult: null, + }, + }); + + const pending = yield* Ref.get(pendingRef); + expect(pending).toHaveLength(1); + expect(pending[0]?.promptId).toBe("t3-xai-prompt-1"); + }), + ); + + it.effect("settles deferred after grace without blocking the notification handler", () => + Effect.gen(function* () { + const deferred = yield* Deferred.make(); + const pendingRef = yield* Ref.make< + ReadonlyArray<{ + readonly sessionId: string; + readonly promptId: string; + readonly deferred: Deferred.Deferred; + }> + >([ + { + sessionId: "session-1", + promptId: "t3-xai-prompt-1", + deferred, + }, + ]); + const completedPromptIdsRef = yield* Ref.make>([]); + + yield* resolveXAiPromptCompletionFallback({ + pendingRef, + completedPromptIdsRef, + notification: { + sessionId: "session-1", + promptId: "t3-xai-prompt-1", + stopReason: "end_turn", + agentResult: null, + }, + }); + + expect(yield* Deferred.isDone(deferred)).toBe(false); + yield* Effect.sleep("600 millis"); + expect(yield* Deferred.isDone(deferred)).toBe(true); + }).pipe(TestClock.withLive), + ); + + it.effect( + "ignores replayed prompt completion when prompt id does not match the in-flight prompt", + () => + Effect.gen(function* () { + const deferred = yield* Deferred.make(); + const pendingRef = yield* Ref.make< + ReadonlyArray<{ + readonly sessionId: string; + readonly promptId: string; + readonly deferred: Deferred.Deferred; + }> + >([ + { + sessionId: "session-1", + promptId: "t3-xai-prompt-fresh-uuid", + deferred, + }, + ]); + const completedPromptIdsRef = yield* Ref.make>([]); + + yield* resolveXAiPromptCompletionFallback({ + pendingRef, + completedPromptIdsRef, + notification: { + sessionId: "session-1", + promptId: "t3-xai-prompt-1", + stopReason: "end_turn", + agentResult: null, + }, + }); + + const pending = yield* Ref.get(pendingRef); + expect(pending).toHaveLength(1); + expect(yield* Deferred.isDone(deferred)).toBe(false); + }), ); it.effect("ignores stale xAI completion from an already settled prompt", () => @@ -327,6 +515,6 @@ describe("XAiAcpExtension", () => { requestId: secondPromptId, }, }); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer), TestClock.withLive), ); }); diff --git a/apps/server/src/provider/acp/XAiAcpExtension.ts b/apps/server/src/provider/acp/XAiAcpExtension.ts index d36a5fcfc895..b99e64a65c46 100644 --- a/apps/server/src/provider/acp/XAiAcpExtension.ts +++ b/apps/server/src/provider/acp/XAiAcpExtension.ts @@ -1,9 +1,11 @@ import type { ProviderUserInputAnswers, UserInputQuestion } from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import type * as EffectAcpSchema from "effect-acp/schema"; +import * as EffectAcpErrors from "effect-acp/errors"; import type * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; @@ -24,6 +26,7 @@ interface PendingXAiPromptCompletion { const completedXAiPromptIdLimit = 128; const xAiStopReasonMissingMetaKey = "xAiStopReasonMissing"; +export const xAiPromptCompletionSettleDelay = Duration.millis(500); const XAiAskUserQuestionOption = Schema.Struct({ label: Schema.String, @@ -201,15 +204,13 @@ export function makeXAiAskUserQuestionCancelledResponse(): XAiAskUserQuestionCan * The underlying runtime remains unaware of xAI methods and metadata. */ export const makeXAiPromptCompletionRuntime = Effect.fn("makeXAiPromptCompletionRuntime")( - function* (runtime: AcpSessionRuntime.AcpSessionRuntime["Service"]) { + function* ( + runtime: AcpSessionRuntime.AcpSessionRuntime["Service"], + allocatePromptFallbackId: Effect.Effect, + ) { const activeSessionIdRef = yield* Ref.make(undefined); const pendingRef = yield* Ref.make>([]); const completedPromptIdsRef = yield* Ref.make>([]); - let nextPromptFallbackId = 0; - const allocatePromptFallbackId = Effect.sync(() => { - nextPromptFallbackId += 1; - return `t3-xai-prompt-${nextPromptFallbackId}`; - }); yield* runtime.handleExtNotification( "_x.ai/session/prompt_complete", @@ -219,6 +220,8 @@ export const makeXAiPromptCompletionRuntime = Effect.fn("makeXAiPromptCompletion pendingRef, completedPromptIdsRef, notification, + isSessionLoadReplayActive: runtime.isSessionLoadReplayActive, + touchSessionLoadReplayActivity: runtime.touchSessionLoadReplayActivity, }), ); @@ -324,45 +327,74 @@ const abortPendingPromptCompletions = ( ] as const; }).pipe(Effect.flatten); -const resolveXAiPromptCompletionFallback = ({ +export const resolveXAiPromptCompletionFallback = ({ pendingRef, completedPromptIdsRef, notification, + isSessionLoadReplayActive, + touchSessionLoadReplayActivity, }: { readonly pendingRef: Ref.Ref>; readonly completedPromptIdsRef: Ref.Ref>; readonly notification: XAiPromptCompleteNotification; + readonly isSessionLoadReplayActive?: Effect.Effect; + readonly touchSessionLoadReplayActivity?: Effect.Effect; }) => - Ref.get(completedPromptIdsRef).pipe( - Effect.flatMap((completedPromptIds) => { - if ( - notification.promptId !== undefined && - completedPromptIds.includes(notification.promptId) - ) { - return Effect.void; - } - return Ref.modify(pendingRef, (pending) => { - const index = - notification.promptId !== undefined - ? pending.findIndex( - (entry) => - entry.sessionId === notification.sessionId && - entry.promptId === notification.promptId, - ) - : pending.findIndex((entry) => entry.sessionId === notification.sessionId); - if (index < 0) { - return [Effect.void, pending] as const; - } - const entry = pending[index]; - if (!entry) { - return [Effect.void, pending] as const; - } - return [ - Deferred.succeed(entry.deferred, promptResponseFromXAi(notification)).pipe(Effect.asVoid), - [...pending.slice(0, index), ...pending.slice(index + 1)], - ] as const; - }).pipe(Effect.flatten); - }), + (isSessionLoadReplayActive ?? Effect.succeed(false)).pipe( + Effect.flatMap((loadReplayActive) => + loadReplayActive + ? (touchSessionLoadReplayActivity ?? Effect.void) + : Ref.get(completedPromptIdsRef).pipe( + Effect.flatMap((completedPromptIds) => { + if ( + notification.promptId !== undefined && + completedPromptIds.includes(notification.promptId) + ) { + return Effect.void; + } + // Require an explicit prompt id. Session-id-only matching would let a + // replayed or stale prompt_complete from session/load settle the wrong + // in-flight prompt and interrupt the real session/prompt RPC. + if (notification.promptId === undefined) { + return Effect.void; + } + return Ref.modify(pendingRef, (pending) => { + const index = pending.findIndex( + (entry) => + entry.sessionId === notification.sessionId && + entry.promptId === notification.promptId, + ); + if (index < 0) { + return [Effect.void, pending] as const; + } + const entry = pending[index]; + if (!entry) { + return [Effect.void, pending] as const; + } + return [ + Effect.gen(function* () { + yield* Effect.sleep(xAiPromptCompletionSettleDelay); + yield* Ref.modify(pendingRef, (current) => { + const stillIndex = current.findIndex( + (candidate) => candidate.deferred === entry.deferred, + ); + if (stillIndex < 0) { + return [Effect.void, current] as const; + } + return [ + Deferred.succeed(entry.deferred, promptResponseFromXAi(notification)).pipe( + Effect.asVoid, + ), + [...current.slice(0, stillIndex), ...current.slice(stillIndex + 1)], + ] as const; + }).pipe(Effect.flatten); + }).pipe(Effect.forkDetach, Effect.asVoid), + pending, + ] as const; + }).pipe(Effect.flatten); + }), + ), + ), ); const rememberCompletedXAiPromptId = ( diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index b52b577c5b5a..c9eeebf233f5 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -33,6 +33,7 @@ import * as ServerSettings from "./serverSettings.ts"; import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; +import { recoverOrphanedProviderSessions } from "./provider/Layers/ProviderSessionRecovery.ts"; import * as ProviderSessionReaper from "./provider/Services/ProviderSessionReaper.ts"; import { formatHeadlessServeOutput, @@ -342,6 +343,13 @@ export const make = Effect.gen(function* () { "reactors.start", Effect.gen(function* () { yield* orchestrationReactor.start().pipe(Scope.provide(reactorScope)); + yield* recoverOrphanedProviderSessions.pipe( + Effect.catchCause((cause) => + Effect.logWarning("provider session recovery failed on startup", { + cause, + }), + ), + ); yield* providerSessionReaper.start().pipe(Scope.provide(reactorScope)); }), ); diff --git a/apps/server/src/textGeneration/GrokTextGeneration.ts b/apps/server/src/textGeneration/GrokTextGeneration.ts index 1bb582163056..b2a81a3e4026 100644 --- a/apps/server/src/textGeneration/GrokTextGeneration.ts +++ b/apps/server/src/textGeneration/GrokTextGeneration.ts @@ -1,9 +1,10 @@ import * as Effect from "effect/Effect"; +import * as Crypto from "effect/Crypto"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import { ChildProcessSpawner } from "effect/unstable/process"; -import type * as EffectAcpErrors from "effect-acp/errors"; +import * as EffectAcpErrors from "effect-acp/errors"; import { type GrokSettings, type ModelSelection } from "@t3tools/contracts"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; @@ -38,6 +39,17 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi environment: NodeJS.ProcessEnv = process.env, ) { const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const crypto = yield* Crypto.Crypto; + const allocateXAiPromptFallbackId = crypto.randomUUIDv4.pipe( + Effect.map((uuid) => `t3-xai-prompt-${uuid}`), + Effect.mapError( + (cause) => + new EffectAcpErrors.AcpTransportError({ + detail: "Failed to allocate xAI prompt identifier.", + cause, + }), + ), + ); const runGrokJson = ({ operation, @@ -59,13 +71,16 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi Effect.gen(function* () { const resolvedModel = resolveGrokAcpBaseModelId(modelSelection.model); const outputRef = yield* Ref.make(""); - const runtime = yield* makeGrokAcpRuntime({ - grokSettings, - environment, - childProcessSpawner: commandSpawner, - cwd, - clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, - }); + const runtime = yield* makeGrokAcpRuntime( + { + grokSettings, + environment, + childProcessSpawner: commandSpawner, + cwd, + clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, + }, + allocateXAiPromptFallbackId, + ); yield* runtime.handleSessionUpdate((notification) => { const update = notification.update; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 623fed0917bf..266b86763253 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -725,6 +725,15 @@ const ThreadMessageAssistantCompleteCommand = Schema.Struct({ createdAt: IsoDateTime, }); +const ThreadMessageAssistantResetCommand = Schema.Struct({ + type: Schema.Literal("thread.message.assistant.reset"), + commandId: CommandId, + threadId: ThreadId, + messageId: MessageId, + turnId: Schema.optional(TurnId), + createdAt: IsoDateTime, +}); + const ThreadProposedPlanUpsertCommand = Schema.Struct({ type: Schema.Literal("thread.proposed-plan.upsert"), commandId: CommandId, @@ -767,6 +776,7 @@ const InternalOrchestrationCommand = Schema.Union([ ThreadSessionSetCommand, ThreadMessageAssistantDeltaCommand, ThreadMessageAssistantCompleteCommand, + ThreadMessageAssistantResetCommand, ThreadProposedPlanUpsertCommand, ThreadTurnDiffCompleteCommand, ThreadActivityAppendCommand, @@ -898,6 +908,7 @@ export const ThreadMessageSentPayload = Schema.Struct({ attachments: Schema.optional(Schema.Array(ChatAttachment)), turnId: Schema.NullOr(TurnId), streaming: Schema.Boolean, + reset: Schema.optional(Schema.Boolean), createdAt: IsoDateTime, updatedAt: IsoDateTime, }); diff --git a/packages/effect-acp/src/grok-skills-reload.test.ts b/packages/effect-acp/src/grok-skills-reload.test.ts new file mode 100644 index 000000000000..aa261b8ea630 --- /dev/null +++ b/packages/effect-acp/src/grok-skills-reload.test.ts @@ -0,0 +1,97 @@ +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Queue from "effect/Queue"; +import { it, assert } from "@effect/vitest"; + +import * as AcpClient from "./client.ts"; +import { makeInMemoryStdio } from "./_internal/stdio.ts"; + +// Regression test for a production hang against the grok agent. After the +// `initialize` response, grok emits unsolicited JSON-RPC responses with the +// symbolic id `"skills-reload"`. Those ids never match a request we issued (the +// RpcClient only mints numeric ids), and forwarding them to the RpcClient made +// it tear down its response loop, so the subsequent `authenticate` and +// `session/new` responses were never delivered and the handshake stalled until +// the caller timed out. The protocol layer must drop such untracked, +// non-numeric responses instead. + +const encoder = new TextEncoder(); + +const skillsReloadResponse = `${JSON.stringify({ + jsonrpc: "2.0", + id: "skills-reload", + result: { result: { reloaded: 0 } }, +})}\n`; + +const extNotification = `${JSON.stringify({ + jsonrpc: "2.0", + method: "_x.ai/mcp/servers_updated", + params: { mcpServers: [] }, +})}\n`; + +const responseFor = (id: unknown, result: unknown) => + `${JSON.stringify({ jsonrpc: "2.0", id, result })}\n`; + +it.effect( + "completes initialize/authenticate/session despite untracked skills-reload responses", + () => + Effect.gen(function* () { + const { stdio, input, output } = yield* makeInMemoryStdio(); + const push = (line: string) => Queue.offer(input, encoder.encode(line)); + + // Fake grok peer: answer each request and, right after `initialize`, + // flood the stream with untracked `skills-reload` responses like grok. + const peer = yield* Effect.forkScoped( + Effect.forever( + Queue.take(output).pipe( + Effect.flatMap((chunk) => + Effect.forEach( + chunk.split("\n").filter((line) => line.trim().length > 0), + (line) => { + const message = JSON.parse(line) as { id?: unknown; method?: string }; + if (message.method === "initialize") { + return push(responseFor(message.id, { protocolVersion: 1 })).pipe( + Effect.andThen( + Effect.forEach([1, 2, 3, 4, 5], () => push(skillsReloadResponse), { + discard: true, + }), + ), + Effect.andThen(push(extNotification)), + ); + } + if (message.method === "authenticate") { + return push(responseFor(message.id, {})); + } + if (message.method === "session/new") { + return push(responseFor(message.id, { sessionId: "mock-session-1" })); + } + return Effect.void; + }, + { discard: true }, + ), + ), + ), + ), + ); + + const result = yield* Effect.gen(function* () { + const acp = yield* AcpClient.AcpClient; + const init = yield* acp.agent.initialize({ + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + }, + clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, + }); + assert.equal(init.protocolVersion, 1); + yield* acp.agent.authenticate({ methodId: "cached_token" }); + const session = yield* acp.agent.createSession({ cwd: "/tmp", mcpServers: [] }); + return session.sessionId; + }).pipe(Effect.provide(AcpClient.layer(stdio)), Effect.timeout("5 seconds")); + + assert.equal(result, "mock-session-1"); + yield* Fiber.interrupt(peer); + }).pipe(Effect.scoped), + { timeout: 15_000 }, +); diff --git a/packages/effect-acp/src/protocol.ts b/packages/effect-acp/src/protocol.ts index 27c619296c0a..ace9fc11b5d5 100644 --- a/packages/effect-acp/src/protocol.ts +++ b/packages/effect-acp/src/protocol.ts @@ -337,12 +337,29 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi return Queue.offer(serverQueue, message).pipe(Effect.asVoid); }; + // The RpcClient and raw ext requests only ever issue numeric request ids, so + // a response whose id is not a base-10 number cannot match anything we sent. + // Some agents (e.g. grok) emit unsolicited responses with symbolic ids such + // as `"skills-reload"`; forwarding those to the RpcClient makes it fail while + // resolving the id, which tears down the response loop and stalls every + // in-flight request. Drop untracked non-numeric responses instead. + const forwardUntrackedResponse = (requestId: string, forward: () => Effect.Effect) => { + if (/^[0-9]+$/.test(requestId)) { + return forward(); + } + return Effect.logDebug("Dropping untracked ACP response with non-numeric request id").pipe( + Effect.annotateLogs({ requestId }), + ); + }; + const handleExitEncoded = (message: RpcMessage.ResponseExitEncoded) => Ref.get(extPending).pipe( Effect.flatMap((pending) => { const pendingRequest = pending.get(message.requestId); if (!pendingRequest) { - return Queue.offer(clientQueue, message).pipe(Effect.asVoid); + return forwardUntrackedResponse(message.requestId, () => + Queue.offer(clientQueue, message).pipe(Effect.asVoid), + ); } if (message.exit._tag === "Success") { return completeExtPendingSuccess(message.requestId, message.exit.value); @@ -389,7 +406,9 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi message.requestId, ), ) - : Queue.offer(clientQueue, message).pipe(Effect.asVoid); + : forwardUntrackedResponse(message.requestId, () => + Queue.offer(clientQueue, message).pipe(Effect.asVoid), + ); }), ); case "Defect": diff --git a/patches/effect@4.0.0-beta.78.patch b/patches/effect@4.0.0-beta.78.patch index dd4f0035af6f..432f2db0cd08 100644 --- a/patches/effect@4.0.0-beta.78.patch +++ b/patches/effect@4.0.0-beta.78.patch @@ -1,5 +1,5 @@ diff --git a/dist/unstable/ai/McpServer.js b/dist/unstable/ai/McpServer.js -index 4819ff0..f5e0f60 100644 +index 38eb099d99026120a437701adc4b0fc5b1d0c714..fe29f3368f6d5e52034728ce789c73a318ccc453 100644 --- a/dist/unstable/ai/McpServer.js +++ b/dist/unstable/ai/McpServer.js @@ -235,6 +235,26 @@ export const run = /*#__PURE__*/Effect.fnUntraced(function* (options) { @@ -30,7 +30,7 @@ index 4819ff0..f5e0f60 100644 clientSessions })); diff --git a/dist/unstable/rpc/RpcClient.d.ts b/dist/unstable/rpc/RpcClient.d.ts -index b0f61df..ead663e 100644 +index b0f61df34613dee99a6f6f1b446a282b2319f5ab..ead663e54cee98fd799a3f3f0761f3f00c6e8420 100644 --- a/dist/unstable/rpc/RpcClient.d.ts +++ b/dist/unstable/rpc/RpcClient.d.ts @@ -2,6 +2,7 @@ import * as Cause from "../../Cause.ts"; @@ -82,15 +82,8 @@ index b0f61df..ead663e 100644 }>; /** * Represents optional client protocol hooks that run when a transport connects -@@ -279,4 +311,4 @@ declare const ConnectionHooks_base: Context.ServiceClass Effect.fail(new Socket.SocketError({ reason: new Socket.SocketCloseError({ code: 1000 -@@ -664,20 +693,20 @@ export const makeProtocolSocket = options => Protocol.make(Effect.fnUntraced(fun +@@ -664,21 +693,27 @@ export const makeProtocolSocket = options => Protocol.make(Effect.fnUntraced(fun }; })); const defaultRetryPolicy = /*#__PURE__*/Schedule.exponential(500, 1.5).pipe(/*#__PURE__*/Schedule.either(/*#__PURE__*/Schedule.spaced(5000))); -const makePinger = /*#__PURE__*/Effect.fnUntraced(function* (writePing) { +- let recievedPong = true; +const makePinger = /*#__PURE__*/Effect.fnUntraced(function* (writePing, hooks) { - let recievedPong = true; ++ // t3code patch: tolerate brief stalls on lossy/reordering links (e.g. WireGuard). ++ // Ping less often and only fail after several consecutive missed pongs, instead ++ // of effect's default 5s/single-missed-pong which churns the UI WebSocket. See ++ // pingdotgg/t3code#3054. ++ const PING_INTERVAL = "15 seconds"; ++ const MAX_MISSED_PONGS = 3; ++ let missedPongs = 0; const latch = Latch.makeUnsafe(); const reset = () => { - recievedPong = true; +- recievedPong = true; ++ missedPongs = 0; latch.closeUnsafe(); }; - const onPong = () => { -+ const onPong = Effect.sync(() => { - recievedPong = true; +- recievedPong = true; - }; ++ const onPong = Effect.sync(() => { ++ missedPongs = 0; + }).pipe(Effect.andThen(hooks?.onPong ?? Effect.void)); yield* Effect.suspend(() => { - if (!recievedPong) return latch.open; - recievedPong = false; +- if (!recievedPong) return latch.open; +- recievedPong = false; - return writePing; +- }).pipe(Effect.delay("5 seconds"), Effect.ignore, Effect.forever, Effect.interruptible, Effect.forkScoped); ++ if (missedPongs >= MAX_MISSED_PONGS) return latch.open; ++ missedPongs++; + return (hooks?.onPing ?? Effect.void).pipe(Effect.andThen(writePing)); - }).pipe(Effect.delay("5 seconds"), Effect.ignore, Effect.forever, Effect.interruptible, Effect.forkScoped); ++ }).pipe(Effect.delay(PING_INTERVAL), Effect.ignore, Effect.forever, Effect.interruptible, Effect.forkScoped); return { timeout: latch.await, -@@ -820,6 +849,11 @@ export const makeProtocolWorker = options => Protocol.make(Effect.fnUntraced(fun + reset, +@@ -820,6 +855,11 @@ export const makeProtocolWorker = options => Protocol.make(Effect.fnUntraced(fun * @since 4.0.0 */ export const layerProtocolWorker = /*#__PURE__*/flow(makeProtocolWorker, /*#__PURE__*/Layer.effect(Protocol)); @@ -313,10 +319,3 @@ index a3161eb..0cb81f3 100644 /** * Represents optional client protocol hooks that run when a transport connects * and disconnects. -@@ -835,4 +869,4 @@ export const layerProtocolWorker = /*#__PURE__*/flow(makeProtocolWorker, /*#__PU - export class ConnectionHooks extends /*#__PURE__*/Context.Service()("effect/rpc/RpcClient/ConnectionHooks") {} - // internal - const decodeDefect = /*#__PURE__*/Schema.decodeSync(/*#__PURE__*/Schema.Defect()); --//# sourceMappingURL=RpcClient.js.map -\ No newline at end of file -+//# sourceMappingURL=RpcClient.js.map diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee959513d055..abf8c0ce8642 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -79,7 +79,7 @@ patchedDependencies: hash: 7cb6da88544119adda056b2f46f43956f99326227732da0b345081e285a6c53a path: patches/@pierre%2Fdiffs@1.3.0-beta.5.patch effect@4.0.0-beta.78: - hash: c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5 + hash: 6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b path: patches/effect@4.0.0-beta.78.patch react-native-nitro-modules@0.35.9: hash: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675 @@ -118,7 +118,7 @@ importers: version: 0.0.2-snapshot.v20260622234151 '@effect/platform-node': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@t3tools/client-runtime': specifier: workspace:* version: link:../../packages/client-runtime @@ -136,7 +136,7 @@ importers: version: link:../../packages/tailscale effect: specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + version: 4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b) electron: specifier: 41.5.0 version: 41.5.0 @@ -155,7 +155,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -198,7 +198,7 @@ importers: version: 3.5.3-snapshot.v20260622234151(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@effect/atom-react': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(react@19.2.3)(scheduler@0.27.0) + version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b))(react@19.2.3)(scheduler@0.27.0) '@expo-google-fonts/dm-sans': specifier: ^0.4.2 version: 0.4.2 @@ -258,7 +258,7 @@ importers: version: 8.0.3 effect: specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + version: 4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b) expo: specifier: ^56.0.0 version: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) @@ -394,7 +394,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b)) '@pierre/trees': specifier: 1.0.0-beta.4 version: 1.0.0-beta.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -418,16 +418,16 @@ importers: version: 0.3.170(@anthropic-ai/sdk@0.93.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3) '@effect/platform-bun': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(utf-8-validate@6.0.6) + version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b))(utf-8-validate@6.0.6) '@effect/platform-node': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/platform-node-shared': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(utf-8-validate@6.0.6) + version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b))(utf-8-validate@6.0.6) '@effect/sql-sqlite-bun': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b)) '@ff-labs/fff-node': specifier: 0.9.4 version: 0.9.4(patch_hash=2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8) @@ -439,14 +439,14 @@ importers: version: 1.3.0-beta.5(patch_hash=7cb6da88544119adda056b2f46f43956f99326227732da0b345081e285a6c53a)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) effect: specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + version: 4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b) node-pty: specifier: ^1.1.0 version: 1.1.0 devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b)) '@t3tools/contracts': specifier: workspace:* version: link:../../packages/contracts @@ -500,7 +500,7 @@ importers: version: 3.2.2(react@19.2.6) '@effect/atom-react': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(react@19.2.6)(scheduler@0.27.0) + version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b))(react@19.2.6)(scheduler@0.27.0) '@fontsource-variable/dm-sans': specifier: ^5.2.8 version: 5.2.8 @@ -548,7 +548,7 @@ importers: version: 0.7.1 effect: specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + version: 4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b) jose: specifier: 'catalog:' version: 6.2.2 @@ -588,10 +588,10 @@ importers: devDependencies: '@effect/platform-node': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b)) '@rolldown/plugin-babel': specifier: ^0.2.0 version: 0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@voidzero-dev/vite-plus-core@0.2.1(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.3) @@ -639,7 +639,7 @@ importers: version: 3.8.3-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@effect/sql-pg': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b)) '@t3tools/client-runtime': specifier: workspace:* version: link:../../packages/client-runtime @@ -654,20 +654,20 @@ importers: version: https://pkg.ing/alchemy/078ff00(9ca451f9fa94c3cde9e1ba8985172661) drizzle-orm: specifier: 1.0.0-rc.3 - version: 1.0.0-rc.3(@cloudflare/workers-types@4.20260604.1)(@effect/sql-pg@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3) + version: 1.0.0-rc.3(@cloudflare/workers-types@4.20260604.1)(@effect/sql-pg@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3) effect: specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + version: 4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b) devDependencies: '@cloudflare/workers-types': specifier: ^4.20260601.1 version: 4.20260604.1 '@effect/platform-node': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -685,17 +685,17 @@ importers: dependencies: '@effect/platform-node': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@oxlint/plugins': specifier: ^1.63.0 version: 1.68.0 effect: specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + version: 4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b) devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b)) vite-plus: specifier: 'catalog:' version: 0.2.1(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -710,11 +710,11 @@ importers: version: link:../shared effect: specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + version: 4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b) devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b)) vite-plus: specifier: 'catalog:' version: 0.2.1(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -723,11 +723,11 @@ importers: dependencies: effect: specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + version: 4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b) devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b)) vite-plus: specifier: 'catalog:' version: 0.2.1(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -736,17 +736,17 @@ importers: dependencies: effect: specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + version: 4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b) devDependencies: '@effect/openapi-generator': specifier: 'catalog:' - version: 4.0.0-beta.78(@effect/platform-node@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(@effect/platform-node@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b)) '@effect/platform-node': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -758,17 +758,17 @@ importers: dependencies: effect: specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + version: 4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b) devDependencies: '@effect/openapi-generator': specifier: 'catalog:' - version: 4.0.0-beta.78(@effect/platform-node@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(@effect/platform-node@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b)) '@effect/platform-node': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -789,7 +789,7 @@ importers: version: link:../contracts effect: specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + version: 4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b) jose: specifier: 'catalog:' version: 6.2.2 @@ -799,10 +799,10 @@ importers: devDependencies: '@effect/platform-node': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -820,14 +820,14 @@ importers: version: link:../shared effect: specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + version: 4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b) devDependencies: '@effect/platform-node': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -839,17 +839,17 @@ importers: dependencies: '@effect/platform-node': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@t3tools/shared': specifier: workspace:* version: link:../shared effect: specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + version: 4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b) devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -861,7 +861,7 @@ importers: dependencies: '@effect/platform-node': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@t3tools/contracts': specifier: workspace:* version: link:../packages/contracts @@ -870,14 +870,14 @@ importers: version: link:../packages/shared effect: specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + version: 4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b) yaml: specifier: ^2.9.0 version: 2.9.0 devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b)) vite-plus: specifier: 'catalog:' version: 0.2.1(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -11404,24 +11404,24 @@ snapshots: ajv: 6.15.0 ajv-keywords: 3.5.2(ajv@6.15.0) - '@distilled.cloud/aws@0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))': + '@distilled.cloud/aws@0.23.1(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b))': dependencies: '@aws-crypto/crc32': 5.2.0 '@aws-crypto/util': 5.2.0 '@aws-sdk/credential-providers': 3.1062.0 '@aws-sdk/types': 3.973.10 - '@distilled.cloud/core': 0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + '@distilled.cloud/core': 0.23.1(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b)) '@smithy/shared-ini-file-loader': 4.5.6 '@smithy/types': 4.14.3 '@smithy/util-base64': 4.4.6 aws4fetch: 1.0.20 - effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + effect: 4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b) fast-xml-parser: 5.8.0 - '@distilled.cloud/axiom@0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))': + '@distilled.cloud/axiom@0.23.1(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b))': dependencies: - '@distilled.cloud/core': 0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) - effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + '@distilled.cloud/core': 0.23.1(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b)) + effect: 4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b) '@distilled.cloud/cloudflare-rolldown-plugin@0.10.5(rolldown@1.0.1)(workerd@1.20260526.1)': dependencies: @@ -11433,51 +11433,51 @@ snapshots: transitivePeerDependencies: - workerd - '@distilled.cloud/cloudflare-runtime@0.10.5(@distilled.cloud/cloudflare@0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)))(@effect/platform-bun@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))': + '@distilled.cloud/cloudflare-runtime@0.10.5(@distilled.cloud/cloudflare@0.23.1(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b)))(@effect/platform-bun@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b))': dependencies: '@alchemy.run/node-utils': 0.0.4 - '@distilled.cloud/cloudflare': 0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + '@distilled.cloud/cloudflare': 0.23.1(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b)) capnweb: 0.7.0 chokidar: 4.0.3 - effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + effect: 4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b) workerd: 1.20260526.1 xdg-app-paths: 8.3.0 optionalDependencies: - '@effect/platform-bun': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(utf-8-validate@6.0.6) - '@effect/platform-node': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) + '@effect/platform-bun': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b))(utf-8-validate@6.0.6) + '@effect/platform-node': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@distilled.cloud/cloudflare-vite-plugin@0.10.5(b718c520067d628926c627c09697d025)': dependencies: - '@distilled.cloud/cloudflare': 0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + '@distilled.cloud/cloudflare': 0.23.1(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b)) '@distilled.cloud/cloudflare-rolldown-plugin': 0.10.5(rolldown@1.0.1)(workerd@1.20260526.1) - '@distilled.cloud/cloudflare-runtime': 0.10.5(@distilled.cloud/cloudflare@0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)))(@effect/platform-bun@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) - effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + '@distilled.cloud/cloudflare-runtime': 0.10.5(@distilled.cloud/cloudflare@0.23.1(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b)))(@effect/platform-bun@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b)) + effect: 4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b) vite: '@voidzero-dev/vite-plus-core@0.2.1(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' optionalDependencies: - '@effect/platform-bun': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(utf-8-validate@6.0.6) - '@effect/platform-node': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) + '@effect/platform-bun': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b))(utf-8-validate@6.0.6) + '@effect/platform-node': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b))(ioredis@5.11.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - rolldown - workerd - '@distilled.cloud/cloudflare@0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))': + '@distilled.cloud/cloudflare@0.23.1(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b))': dependencies: - '@distilled.cloud/core': 0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) - effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + '@distilled.cloud/core': 0.23.1(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b)) + effect: 4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b) - '@distilled.cloud/core@0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))': + '@distilled.cloud/core@0.23.1(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b))': dependencies: - effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + effect: 4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b) - '@distilled.cloud/neon@0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))': + '@distilled.cloud/neon@0.23.1(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b))': dependencies: - '@distilled.cloud/core': 0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) - effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + '@distilled.cloud/core': 0.23.1(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b)) + effect: 4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b) - '@distilled.cloud/planetscale@0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))': + '@distilled.cloud/planetscale@0.23.1(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b))': dependencies: - '@distilled.cloud/core': 0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) - effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + '@distilled.cloud/core': 0.23.1(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b)) + effect: 4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b) '@dnd-kit/accessibility@3.1.1(react@19.2.6)': dependencies: @@ -11513,44 +11513,44 @@ snapshots: '@drizzle-team/brocli@0.11.0': {} - '@effect/atom-react@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(react@19.2.3)(scheduler@0.27.0)': + '@effect/atom-react@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b))(react@19.2.3)(scheduler@0.27.0)': dependencies: - effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + effect: 4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b) react: 19.2.3 scheduler: 0.27.0 - '@effect/atom-react@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(react@19.2.6)(scheduler@0.27.0)': + '@effect/atom-react@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b))(react@19.2.6)(scheduler@0.27.0)': dependencies: - effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + effect: 4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b) react: 19.2.6 scheduler: 0.27.0 - '@effect/openapi-generator@4.0.0-beta.78(@effect/platform-node@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))': + '@effect/openapi-generator@4.0.0-beta.78(@effect/platform-node@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b))': dependencies: - '@effect/platform-node': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) - effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + '@effect/platform-node': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b))(ioredis@5.11.0)(utf-8-validate@6.0.6) + effect: 4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b) - '@effect/platform-bun@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(utf-8-validate@6.0.6)': + '@effect/platform-bun@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b))(utf-8-validate@6.0.6)': dependencies: - '@effect/platform-node-shared': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(utf-8-validate@6.0.6) - effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + '@effect/platform-node-shared': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b))(utf-8-validate@6.0.6) + effect: 4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b) transitivePeerDependencies: - bufferutil - utf-8-validate - '@effect/platform-node-shared@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(utf-8-validate@6.0.6)': + '@effect/platform-node-shared@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b))(utf-8-validate@6.0.6)': dependencies: '@types/ws': 8.18.1 - effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + effect: 4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b) ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - bufferutil - utf-8-validate - '@effect/platform-node@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6)': + '@effect/platform-node@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b))(ioredis@5.11.0)(utf-8-validate@6.0.6)': dependencies: - '@effect/platform-node-shared': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(utf-8-validate@6.0.6) - effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + '@effect/platform-node-shared': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b))(utf-8-validate@6.0.6) + effect: 4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b) ioredis: 5.11.0 mime: 4.1.0 undici: 8.3.0 @@ -11558,9 +11558,9 @@ snapshots: - bufferutil - utf-8-validate - '@effect/sql-pg@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))': + '@effect/sql-pg@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b))': dependencies: - effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + effect: 4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b) pg: 8.21.0 pg-connection-string: 2.12.0 pg-cursor: 2.20.0(pg@8.21.0) @@ -11569,9 +11569,9 @@ snapshots: transitivePeerDependencies: - pg-native - '@effect/sql-sqlite-bun@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))': + '@effect/sql-sqlite-bun@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b))': dependencies: - effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + effect: 4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b) '@effect/tsgo-darwin-arm64@0.13.2': optional: true @@ -11604,9 +11604,9 @@ snapshots: '@effect/tsgo-win32-arm64': 0.13.2 '@effect/tsgo-win32-x64': 0.13.2 - '@effect/vitest@4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))': + '@effect/vitest@4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b))': dependencies: - effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + effect: 4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b) '@egjs/hammerjs@2.0.17': dependencies: @@ -14646,16 +14646,16 @@ snapshots: '@alchemy.run/node-utils': 0.0.4 '@aws-sdk/credential-providers': 3.1062.0 '@clack/prompts': 0.11.0 - '@distilled.cloud/aws': 0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) - '@distilled.cloud/axiom': 0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) - '@distilled.cloud/cloudflare': 0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + '@distilled.cloud/aws': 0.23.1(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b)) + '@distilled.cloud/axiom': 0.23.1(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b)) + '@distilled.cloud/cloudflare': 0.23.1(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b)) '@distilled.cloud/cloudflare-rolldown-plugin': 0.10.5(rolldown@1.0.1)(workerd@1.20260526.1) - '@distilled.cloud/cloudflare-runtime': 0.10.5(@distilled.cloud/cloudflare@0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)))(@effect/platform-bun@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + '@distilled.cloud/cloudflare-runtime': 0.10.5(@distilled.cloud/cloudflare@0.23.1(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b)))(@effect/platform-bun@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b)) '@distilled.cloud/cloudflare-vite-plugin': 0.10.5(b718c520067d628926c627c09697d025) - '@distilled.cloud/core': 0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) - '@distilled.cloud/neon': 0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) - '@distilled.cloud/planetscale': 0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) - '@effect/vitest': 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + '@distilled.cloud/core': 0.23.1(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b)) + '@distilled.cloud/neon': 0.23.1(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b)) + '@distilled.cloud/planetscale': 0.23.1(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b)) + '@effect/vitest': 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b)) '@libsql/client': 0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) '@octokit/rest': 22.0.1 '@smithy/node-config-provider': 4.4.6 @@ -14664,7 +14664,7 @@ snapshots: '@types/aws-lambda': 8.10.161 aws4fetch: 1.0.20 capnweb: 0.6.1 - effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + effect: 4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b) fast-glob: 3.3.3 fast-xml-parser: 5.8.0 ink: 6.8.0(@types/react@19.2.16)(bufferutil@4.1.0)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react@19.2.6)(utf-8-validate@6.0.6) @@ -14680,11 +14680,11 @@ snapshots: undici: 7.27.1 yaml: 2.9.0 optionalDependencies: - '@effect/platform-bun': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(utf-8-validate@6.0.6) - '@effect/platform-node': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) - '@effect/sql-pg': 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + '@effect/platform-bun': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b))(utf-8-validate@6.0.6) + '@effect/platform-node': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b))(ioredis@5.11.0)(utf-8-validate@6.0.6) + '@effect/sql-pg': 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b)) drizzle-kit: 1.0.0-rc.3 - drizzle-orm: 1.0.0-rc.3(@cloudflare/workers-types@4.20260604.1)(@effect/sql-pg@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3) + drizzle-orm: 1.0.0-rc.3(@cloudflare/workers-types@4.20260604.1)(@effect/sql-pg@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3) vite: '@voidzero-dev/vite-plus-core@0.2.1(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: @@ -15658,13 +15658,13 @@ snapshots: get-tsconfig: 4.14.0 jiti: 2.7.0 - drizzle-orm@1.0.0-rc.3(@cloudflare/workers-types@4.20260604.1)(@effect/sql-pg@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3): + drizzle-orm@1.0.0-rc.3(@cloudflare/workers-types@4.20260604.1)(@effect/sql-pg@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3): optionalDependencies: '@cloudflare/workers-types': 4.20260604.1 - '@effect/sql-pg': 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + '@effect/sql-pg': 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b)) '@libsql/client': 0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) bun-types: 1.3.14 - effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + effect: 4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b) mysql2: 3.22.4(@types/node@24.12.4) pg: 8.21.0 zod: 4.4.3 @@ -15679,7 +15679,7 @@ snapshots: ee-first@1.1.1: {} - effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5): + effect@4.0.0-beta.78(patch_hash=6923666d9587cd3740f84903d0369c086e5fbfd8655b50e12c5f3f7afb50178b): dependencies: '@standard-schema/spec': 1.1.0 fast-check: 4.8.0