From 6ee1385e97e48597943a01ffc60e642bd5bedf9e Mon Sep 17 00:00:00 2001 From: lukemaj Date: Thu, 8 Oct 2026 17:56:02 +0200 Subject: [PATCH 01/10] wip: port shared Prism routing settings and persisted recovery --- .../OrchestratorMcpService.activity.test.ts | 9 +- .../src/mcp/OrchestratorMcpService.test.ts | 105 ++- apps/server/src/mcp/OrchestratorMcpService.ts | 36 +- ...OrchestratorMcpToolkit.integration.test.ts | 2 + apps/server/src/mcp/toolInstructions.ts | 6 + apps/server/src/mcp/toolkits/core.test.ts | 4 + .../src/mcp/toolkits/orchestrator/tools.ts | 2 +- .../src/mcp/toolkits/project/handlers.test.ts | 97 ++- .../src/mcp/toolkits/project/handlers.ts | 23 +- apps/server/src/mcp/toolkits/project/tools.ts | 9 +- .../src/orchestration-v2/Orchestrator.ts | 5 +- apps/server/src/prism/PrismService.test.ts | 206 +++++ apps/server/src/prism/PrismService.ts | 144 ++++ .../src/prism/RecoveryCoordinator.test.ts | 104 +++ apps/server/src/prism/RecoveryCoordinator.ts | 69 ++ apps/server/src/prism/RecoveryHooks.ts | 67 ++ .../server/src/prism/RecoverySettings.test.ts | 35 + apps/server/src/prism/RecoveryStore.test.ts | 239 ++++++ apps/server/src/prism/RecoveryStore.ts | 70 ++ apps/server/src/prism/recovery.testkit.ts | 114 +++ .../src/prism/recoveryAdmission.test.ts | 133 ++++ apps/server/src/prism/recoveryAdmission.ts | 82 ++ apps/server/src/prism/recoveryPolicy.ts | 125 +++ .../src/provider/RuntimeInstructions.ts | 3 +- .../provider/T3OrchestrationInstructions.ts | 4 +- .../settings/PrismSettings.logic.test.ts | 296 +++++++ .../settings/PrismSettings.logic.ts | 167 ++++ .../settings/PrismSettings.state.test.ts | 81 ++ .../settings/PrismSettings.state.ts | 60 ++ .../src/components/settings/PrismSettings.tsx | 722 ++++++++++++++++++ .../settings/SettingsSidebarNav.tsx | 2 + .../src/components/settings/settingsSearch.ts | 19 + apps/web/src/routeTree.gen.ts | 21 + apps/web/src/routes/settings.prism.tsx | 4 + packages/contracts/src/index.ts | 1 + packages/contracts/src/orchestrationV2.ts | 3 + packages/contracts/src/orchestratorMcp.ts | 3 + packages/contracts/src/prism.test.ts | 174 +++++ packages/contracts/src/prism.ts | 214 ++++++ packages/contracts/src/settings.ts | 7 +- 40 files changed, 3407 insertions(+), 60 deletions(-) create mode 100644 apps/server/src/mcp/toolInstructions.ts create mode 100644 apps/server/src/prism/PrismService.test.ts create mode 100644 apps/server/src/prism/PrismService.ts create mode 100644 apps/server/src/prism/RecoveryCoordinator.test.ts create mode 100644 apps/server/src/prism/RecoveryCoordinator.ts create mode 100644 apps/server/src/prism/RecoveryHooks.ts create mode 100644 apps/server/src/prism/RecoverySettings.test.ts create mode 100644 apps/server/src/prism/RecoveryStore.test.ts create mode 100644 apps/server/src/prism/RecoveryStore.ts create mode 100644 apps/server/src/prism/recovery.testkit.ts create mode 100644 apps/server/src/prism/recoveryAdmission.test.ts create mode 100644 apps/server/src/prism/recoveryAdmission.ts create mode 100644 apps/server/src/prism/recoveryPolicy.ts create mode 100644 apps/web/src/components/settings/PrismSettings.logic.test.ts create mode 100644 apps/web/src/components/settings/PrismSettings.logic.ts create mode 100644 apps/web/src/components/settings/PrismSettings.state.test.ts create mode 100644 apps/web/src/components/settings/PrismSettings.state.ts create mode 100644 apps/web/src/components/settings/PrismSettings.tsx create mode 100644 apps/web/src/routes/settings.prism.tsx create mode 100644 packages/contracts/src/prism.test.ts create mode 100644 packages/contracts/src/prism.ts diff --git a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts index c8a6f5f9d5..761854050d 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts @@ -1,6 +1,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as ThreadLaunch from "../orchestration-v2/ThreadLaunchService.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as ServerSettings from "../serverSettings.ts"; import { EnvironmentId, NodeId, @@ -129,7 +130,7 @@ it("readThread prefers activity-run status over a newer cancelled queued run", a updatedAt: now, } as unknown as OrchestrationV2ThreadProjection; - const layer = OrchestratorMcpService.layer.pipe( + const layer = OrchestratorMcpService.layer.pipe(Layer.provide(ServerSettings.layerTest())).pipe( Layer.provide( Layer.mergeAll( NodeServices.layer, @@ -197,7 +198,7 @@ it("readThread prefers waiting activity status over a newer cancelled queued run updatedAt: now, } as unknown as OrchestrationV2ThreadProjection; - const layer = OrchestratorMcpService.layer.pipe( + const layer = OrchestratorMcpService.layer.pipe(Layer.provide(ServerSettings.layerTest())).pipe( Layer.provide( Layer.mergeAll( NodeServices.layer, @@ -322,7 +323,7 @@ it("taskStatus returns task.providerInstanceId rather than the driver kind", asy updatedAt: now, } as unknown as OrchestrationV2ThreadProjection; - const layer = OrchestratorMcpService.layer.pipe( + const layer = OrchestratorMcpService.layer.pipe(Layer.provide(ServerSettings.layerTest())).pipe( Layer.provide( Layer.mergeAll( NodeServices.layer, @@ -423,7 +424,7 @@ it("readThread and sendToThread reach threads in other projects", async () => { updatedAt: now, }) as unknown as OrchestrationV2ThreadProjection; - const layer = OrchestratorMcpService.layer.pipe( + const layer = OrchestratorMcpService.layer.pipe(Layer.provide(ServerSettings.layerTest())).pipe( Layer.provide( Layer.mergeAll( NodeServices.layer, diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 7a9ef4d600..e5d534b666 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -1,5 +1,6 @@ import * as ThreadLaunch from "../orchestration-v2/ThreadLaunchService.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import { @@ -172,7 +173,13 @@ describe("OrchestratorMcpService", () => { const commandIds = yield* Ref.get(acknowledgementCommandIds); assert.equal(commandIds.length, 2); assert.notEqual(commandIds[0], commandIds[1]); - }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies)))); + }).pipe( + Effect.provide( + OrchestratorMcpService.layer + .pipe(Layer.provide(ServerSettings.layerTest())) + .pipe(Layer.provide(layerDependencies)), + ), + ); }), ); @@ -261,7 +268,13 @@ describe("OrchestratorMcpService", () => { const settled = yield* service.taskStatus(scope, taskId); assert.equal(settled.status, "cancelled"); assert.equal(yield* Ref.get(dispatched), 1); - }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies)))); + }).pipe( + Effect.provide( + OrchestratorMcpService.layer + .pipe(Layer.provide(ServerSettings.layerTest())) + .pipe(Layer.provide(layerDependencies)), + ), + ); }), ); @@ -338,7 +351,13 @@ describe("OrchestratorMcpService", () => { .pipe(Effect.flip); assert.equal(error.code, "task_not_cancellable"); assert.deepEqual(yield* Ref.get(dispatched), []); - }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies)))); + }).pipe( + Effect.provide( + OrchestratorMcpService.layer + .pipe(Layer.provide(ServerSettings.layerTest())) + .pipe(Layer.provide(layerDependencies)), + ), + ); }), ); @@ -419,7 +438,13 @@ describe("OrchestratorMcpService", () => { (yield* Ref.get(dispatched)).map((command) => (command as { type: string }).type), ["thread.stop"], ); - }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies)))); + }).pipe( + Effect.provide( + OrchestratorMcpService.layer + .pipe(Layer.provide(ServerSettings.layerTest())) + .pipe(Layer.provide(layerDependencies)), + ), + ); }), ); @@ -506,7 +531,13 @@ describe("OrchestratorMcpService", () => { (yield* Ref.get(dispatched)).map((command) => (command as { type: string }).type), ["thread.stop", "delegated_task.completion-delivery.dispose"], ); - }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies)))); + }).pipe( + Effect.provide( + OrchestratorMcpService.layer + .pipe(Layer.provide(ServerSettings.layerTest())) + .pipe(Layer.provide(layerDependencies)), + ), + ); }), ); @@ -590,7 +621,13 @@ describe("OrchestratorMcpService", () => { .pipe(Effect.flip); assert.equal(error.code, "runtime_mode_escalation_denied"); assert.deepEqual(yield* Ref.get(dispatched), []); - }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies)))); + }).pipe( + Effect.provide( + OrchestratorMcpService.layer + .pipe(Layer.provide(ServerSettings.layerTest())) + .pipe(Layer.provide(layerDependencies)), + ), + ); }), ); @@ -735,7 +772,13 @@ describe("OrchestratorMcpService", () => { dispatched: yield* Ref.get(dispatched), stoppedBelow: yield* Ref.get(stoppedBelow), }; - }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies)))); + }).pipe( + Effect.provide( + OrchestratorMcpService.layer + .pipe(Layer.provide(ServerSettings.layerTest())) + .pipe(Layer.provide(layerDependencies)), + ), + ); }); it.effect("refuses to cancel a task when a task under it now runs above the parent's modes", () => @@ -1018,7 +1061,11 @@ describe("OrchestratorMcpService provider resolution", () => { fork!.constraints.includes("Driver 'forkOnly' is not registered in this build."), ); }).pipe( - Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies))), + Effect.provide( + OrchestratorMcpService.layer + .pipe(Layer.provide(ServerSettings.layerTest())) + .pipe(Layer.provide(layerDependencies)), + ), ); }), ); @@ -1119,7 +1166,11 @@ describe("OrchestratorMcpService provider resolution", () => { assert.equal(request.modelSelection.instanceId, antigravityInstanceId); assert.equal(request.modelSelection.model, "ant-model"); }).pipe( - Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies))), + Effect.provide( + OrchestratorMcpService.layer + .pipe(Layer.provide(ServerSettings.layerTest())) + .pipe(Layer.provide(layerDependencies)), + ), ); }), ); @@ -1214,7 +1265,13 @@ describe("OrchestratorMcpService provider resolution", () => { }; assert.equal(request.modelSelection.instanceId, antigravityInstanceId); assert.equal(request.modelSelection.model, "ant-model"); - }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies)))); + }).pipe( + Effect.provide( + OrchestratorMcpService.layer + .pipe(Layer.provide(ServerSettings.layerTest())) + .pipe(Layer.provide(layerDependencies)), + ), + ); }), ); @@ -1273,7 +1330,13 @@ describe("OrchestratorMcpService provider resolution", () => { assert.isTrue( byDriver.message.includes("No V2 provider adapter is registered for driver forkOnly."), ); - }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies)))); + }).pipe( + Effect.provide( + OrchestratorMcpService.layer + .pipe(Layer.provide(ServerSettings.layerTest())) + .pipe(Layer.provide(layerDependencies)), + ), + ); }), ); @@ -1377,7 +1440,13 @@ describe("OrchestratorMcpService provider resolution", () => { assert.equal(result.providerInstanceId, claudeInstanceId); assert.equal(yield* Ref.get(probes), 2); assert.equal(yield* Ref.get(dispatched), 1); - }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies)))); + }).pipe( + Effect.provide( + OrchestratorMcpService.layer + .pipe(Layer.provide(ServerSettings.layerTest())) + .pipe(Layer.provide(layerDependencies)), + ), + ); }), ); @@ -1587,7 +1656,11 @@ describe("OrchestratorMcpService provider resolution", () => { assert.equal(request.modelSelection.model, "codex-alt-model", testCase.name); } }).pipe( - Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies))), + Effect.provide( + OrchestratorMcpService.layer + .pipe(Layer.provide(ServerSettings.layerTest())) + .pipe(Layer.provide(layerDependencies)), + ), ); } }), @@ -1641,7 +1714,7 @@ describe("OrchestratorMcpService provider resolution", () => { boundThread: OrchestrationV2ThreadShell | null, upserted: Ref.Ref, ) => - OrchestratorMcpService.layer.pipe( + OrchestratorMcpService.layer.pipe(Layer.provide(ServerSettings.layerTest())).pipe( Layer.provide( Layer.mergeAll( NodeServices.layer, @@ -1710,7 +1783,7 @@ describe("OrchestratorMcpService provider resolution", () => { ), ), Effect.provide( - OrchestratorMcpService.layer.pipe( + OrchestratorMcpService.layer.pipe(Layer.provide(ServerSettings.layerTest())).pipe( Layer.provide( Layer.mergeAll( NodeServices.layer, @@ -1796,7 +1869,7 @@ describe("OrchestratorMcpService provider resolution", () => { // The edit's own check reads the thread; the read after the save fails. const mcp = yield* OrchestratorMcpService.OrchestratorMcpService.pipe( Effect.provide( - OrchestratorMcpService.layer.pipe( + OrchestratorMcpService.layer.pipe(Layer.provide(ServerSettings.layerTest())).pipe( Layer.provide( Layer.mergeAll( NodeServices.layer, diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index fcd208f27a..3a42a72a05 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -1,3 +1,4 @@ +import * as Prism from "../prism/PrismService.ts"; import { CommandId, type RunId, @@ -1059,6 +1060,7 @@ const make = Effect.gen(function* () { return { parent, target, shell } as const; }); + const prism = yield* Prism.PrismService; const loadProviders = providerRegistry.getProviders; /** @@ -1849,10 +1851,31 @@ const make = Effect.gen(function* () { } } const providers = yield* loadProviders; - const target = yield* resolveTargetRechecking({ - parent, - target: input.target, - providers, + // Explicit targets retain upstream validation and precedence. + const explicit = + input.target === undefined + ? undefined + : (yield* resolveTargetRechecking({ parent, target: input.target, providers })) + .modelSelection; + const target = yield* prism.resolve({ + projectId: parent.thread.projectId, + role: input.prismRole ?? Prism.delegatedPrismRole(input.role), + lane: input.lane, + explicit, + inherited: parent.thread.modelSelection, + validate: + explicit === undefined + ? (selection) => + resolveTargetRechecking({ + parent, + providers, + target: { + providerInstanceId: selection.instanceId, + model: selection.model, + options: selection.options, + }, + }).pipe(Effect.map((target) => target.modelSelection)) + : undefined, }); const runtimeMode = yield* resolveRuntimeMode(parent.thread.runtimeMode, input.runtimeMode); const interactionMode = yield* resolveInteractionMode( @@ -1878,7 +1901,7 @@ const make = Effect.gen(function* () { ...(input.workspaceStrategy === undefined ? {} : { workspaceStrategy: input.workspaceStrategy }), - task: taskPrompt(input), + task: [target.kitText, taskPrompt(input)].filter(Boolean).join("\n\n"), ...(input.title === undefined ? {} : { title: input.title }), modelSelection: target.modelSelection, runtimeMode, @@ -2544,10 +2567,11 @@ export const layer: Layer.Layer< | FileSystem.FileSystem | GitVcsDriver.GitVcsDriver | ThreadLaunch.ThreadLaunchService + | import("../serverSettings.ts").ServerSettingsService | ThreadManagementService.ThreadManagementService | ProviderRegistry.ProviderRegistry | ProviderAdapterRegistry.ProviderAdapterRegistryV2 | ScheduledTaskService.ScheduledTaskService | ProjectService.ProjectService | SecretRequests.SecretRequests -> = Layer.effect(OrchestratorMcpService, make); +> = Layer.effect(OrchestratorMcpService, make).pipe(Layer.provide(Prism.layer)); diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index c3835293a4..87573d3610 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -1,3 +1,4 @@ +import * as PrismServerSettings from "../serverSettings.ts"; import * as ThreadLaunch from "../orchestration-v2/ThreadLaunchService.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -3822,6 +3823,7 @@ describe("orchestrator MCP toolkit", () => { }), ]); const layerTest = McpHttpServer.layerOrchestratorToolkit.pipe( + Layer.provide(PrismServerSettings.layerTest()), Layer.provideMerge(McpServer.McpServer.layer), Layer.provideMerge(layerOrchestration), Layer.provide( diff --git a/apps/server/src/mcp/toolInstructions.ts b/apps/server/src/mcp/toolInstructions.ts new file mode 100644 index 0000000000..502aae43b4 --- /dev/null +++ b/apps/server/src/mcp/toolInstructions.ts @@ -0,0 +1,6 @@ +/** Prism guidance shared by every provider runtime. */ +export const T3_CODE_TOOL_USE_INSTRUCTIONS = ` +Use the t3-code MCP tools for visible agent work. Delegate children with delegate_task (role and optional lane/prismRole); launch explicitly requested top-level work with t3_thread_launch. Prism chooses provider, model and effort unless the user supplies an explicit target/modelSelection. Never substitute shell agent CLIs. +Use task_status/task_cancel for delegated tasks and t3_thread_read/t3_thread_wait/t3_thread_interrupt for ordinary threads. Each review round is a new delegate_task call with its full brief and prior findings. Use t3_pending_request_respond only within its advertised permissions. +Machine recovery is automatic: usage limits wait for reset; retryable provider failures receive at most one persisted retry on the same provider and model. Stop and retirement cancel recovery. Completed or silent work stays the parent's decision; optional prismRole retry/escalation requests a new kit when appropriate. +`; diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index 4eeb2f0beb..eb2983b756 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -1,3 +1,4 @@ +import * as PrismServerSettings from "../../serverSettings.ts"; import * as ThreadLaunch from "../../orchestration-v2/ThreadLaunchService.ts"; import * as GitVcsDriver from "../../vcs/GitVcsDriver.ts"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; @@ -565,6 +566,7 @@ it.effect("refuses act-as-caller tools to a client caller", () => }).pipe( Effect.provide( McpHttpServer.layerOrchestratorToolkit.pipe( + Layer.provide(PrismServerSettings.layerTest()), Layer.provide( Layer.mergeAll( NodeServices.layer, @@ -610,6 +612,7 @@ it.effect("a caller cannot rewrite a scheduled task that runs above its own mode }).pipe( Effect.provide( McpHttpServer.layerOrchestratorToolkit.pipe( + Layer.provide(PrismServerSettings.layerTest()), Layer.provide( Layer.mergeAll( NodeServices.layer, @@ -683,6 +686,7 @@ it.effect("a caller cannot interrupt a thread that runs above its own modes", () }).pipe( Effect.provide( McpHttpServer.layerOrchestratorToolkit.pipe( + Layer.provide(PrismServerSettings.layerTest()), Layer.provide( Layer.mergeAll( NodeServices.layer, diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index ac0ebb63b7..476b412161 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -63,7 +63,7 @@ const OrchestratorCapabilitiesTool = Tool.make("orchestrator_capabilities", { export const DelegateTaskTool = Tool.make("delegate_task", { description: - "Needs an agent running inside a T3 thread. Delegate one task to a T3-owned child agent/subagent of THIS thread and run it with only the supplied task prompt, without copying parent conversation history. Choose providers and models from orchestrator_capabilities, which uses the same live catalog as the composer. Prefer native subagent tools for same-provider work only when they support the chosen model. Use this for any model missing from the native tool, including same-provider work, for cross-provider work, or for explicitly T3-owned child tasks. For every T3 delegated review round, call delegate_task again with the original brief, prior findings, responses, and unresolved objections in the task prompt. Track each round by its own taskId and use a distinct clientRequestId per round, stable across retries of that round. The childThreadId is backing storage, not the target for starting another delegated review round through t3_thread_send. Provider, model, model options (see orchestrator_capabilities), runtime mode, and interaction mode inherit unless target overrides them. Prefer mode='async' for long work; mode='wait' blocks until completion or timeout. timeoutMs on mode=wait is only the parent's wait budget and does not cancel the child. waitTimedOut on that wait call means the timeout fired; keep that taskId and read status on later task_status. An async child's completion wakes this thread through a notification, steered into active turns where supported or queued otherwise, so end the turn instead of polling or spawning watchers; use task_status only when the result is needed mid-turn.", + "Needs an agent running inside a T3 thread. Delegate one task to a T3-owned child agent/subagent of THIS thread and run it with only the supplied task prompt, without copying parent conversation history. Prism chooses provider, model and effort from the configured role kit and usage headroom. Omit target unless the user chose a provider/model explicitly; an explicit target wins. Optional lane chooses worker difficulty; prismRole chooses an explicit kit. Use visible T3 delegation instead of native or shell agent runners. For every T3 delegated review round, call delegate_task again with the original brief, prior findings, responses, and unresolved objections in the task prompt. Track each round by its own taskId and use a distinct clientRequestId per round, stable across retries of that round. The childThreadId is backing storage, not the target for starting another delegated review round through t3_thread_send. Unconfigured roles inherit the parent model. Runtime mode and interaction mode retain the upstream ceiling. Prefer mode='async' for long work; mode='wait' blocks until completion or timeout. timeoutMs on mode=wait is only the parent's wait budget and does not cancel the child. waitTimedOut on that wait call means the timeout fired; keep that taskId and read status on later task_status. An async child's completion wakes this thread through a notification, steered into active turns where supported or queued otherwise, so end the turn instead of polling or spawning watchers; use task_status only when the result is needed mid-turn.", parameters: OrchestratorMcpDelegateTaskInput, success: OrchestratorMcpDelegateTaskResult, failure: OrchestratorMcpFailure, diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index a83ad61a60..9415ba5904 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -1,3 +1,6 @@ +import { ProviderDriverKind } from "@t3tools/contracts"; +import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts"; +import * as ServerSettings from "../../../serverSettings.ts"; import { expect, it } from "@effect/vitest"; import { EnvironmentId, @@ -28,6 +31,40 @@ import * as McpToolAccess from "../../McpToolAccess.ts"; import * as ProjectHandlers from "./handlers.ts"; import { ProjectToolkit } from "./tools.ts"; +const prismDependencies = Layer.mergeAll( + ServerSettings.layerTest(), + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed([ + { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "test", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-10-08T00:00:00Z", + models: [{ slug: "gpt-5", name: "test", isCustom: false, capabilities: null }], + skills: [], + slashCommands: [], + }, + { + instanceId: ProviderInstanceId.make("claude"), + driver: ProviderDriverKind.make("claude"), + enabled: true, + installed: true, + version: "test", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-10-08T00:00:00Z", + models: [{ slug: "claude-opus", name: "test", isCustom: false, capabilities: null }], + skills: [], + slashCommands: [], + }, + ]), + }), +); + it.effect("attributes a launched thread's first message to the calling thread", () => Effect.gen(function* () { const sourceThreadId = ThreadId.make("source-thread"); @@ -89,14 +126,18 @@ it.effect("attributes a launched thread's first message to the calling thread", ); const toolkit = yield* ProjectToolkit.pipe( Effect.provide( - McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe( - Layer.provide(layerDependencies), - ), + McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer) + .pipe(Layer.provide(prismDependencies)) + .pipe(Layer.provide(layerDependencies)), ), ); const result = yield* toolkit .handle("t3_thread_launch", { title: "Audit", message: "Review the change" }) - .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(layerDependencies)); + .pipe( + Stream.unwrap, + Stream.runCollect, + Effect.provide(Layer.mergeAll(layerDependencies, prismDependencies)), + ); expect(result.at(-1)?.result).toMatchObject({ projectId, modelSelection }); expect(launchedSender).toBe(sourceThreadId); // Fork: a launched thread belongs to the caller's owner (toolboxmd/chromeria#170). @@ -165,15 +206,19 @@ it.effect("launches a scratch thread into the Scratch project", () => ); const toolkit = yield* ProjectToolkit.pipe( Effect.provide( - McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe( - Layer.provide(layerDependencies), - ), + McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer) + .pipe(Layer.provide(prismDependencies)) + .pipe(Layer.provide(layerDependencies)), ), ); const handle = (params: Parameters>[1]) => toolkit .handle("t3_thread_launch", params) - .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(layerDependencies)); + .pipe( + Stream.unwrap, + Stream.runCollect, + Effect.provide(Layer.mergeAll(layerDependencies, prismDependencies)), + ); const result = yield* handle({ title: "Notes", scratch: true, message: "Draft a list" }); expect(result.at(-1)?.result).toMatchObject({ projectId: scratchProjectId }); @@ -268,15 +313,19 @@ it.effect("starts a project from just a title when workspaceRoot is omitted", () ); const toolkit = yield* ProjectToolkit.pipe( Effect.provide( - McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe( - Layer.provide(layerDependencies), - ), + McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer) + .pipe(Layer.provide(prismDependencies)) + .pipe(Layer.provide(layerDependencies)), ), ); const handle = (params: Parameters>[1]) => toolkit .handle("t3_project_create", params) - .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(layerDependencies)); + .pipe( + Stream.unwrap, + Stream.runCollect, + Effect.provide(Layer.mergeAll(layerDependencies, prismDependencies)), + ); const result = yield* handle({ title: "Pinball Stats" }); expect(result.at(-1)?.result).toMatchObject({ @@ -377,13 +426,19 @@ it.effect("a client launches at its ceiling with the project's default model", ( }); const toolkit = yield* ProjectToolkit.pipe( Effect.provide( - McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(Layer.provide(dependencies)), + McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer) + .pipe(Layer.provide(prismDependencies)) + .pipe(Layer.provide(dependencies)), ), ); const handle = (params: Parameters>[1]) => toolkit .handle("t3_thread_launch", params) - .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies)); + .pipe( + Stream.unwrap, + Stream.runCollect, + Effect.provide(Layer.mergeAll(dependencies, prismDependencies)), + ); const result = yield* handle({ title: "Fix", projectId, message: "Fix the bug" }); expect(result.at(-1)?.result).toMatchObject({ projectId, modelSelection }); @@ -416,7 +471,9 @@ it.effect("a launch binds only an existing checkout that is one of the project's launched, workspaceRoot: repo, }); - const git = yield* GitVcsDriver.GitVcsDriver.pipe(Effect.provide(dependencies)); + const git = yield* GitVcsDriver.GitVcsDriver.pipe( + Effect.provide(Layer.mergeAll(dependencies, prismDependencies)), + ); for (const args of [ ["init", "-b", "main"], ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "--allow-empty", "-m", "init"], @@ -426,7 +483,9 @@ it.effect("a launch binds only an existing checkout that is one of the project's } const toolkit = yield* ProjectToolkit.pipe( Effect.provide( - McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(Layer.provide(dependencies)), + McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer) + .pipe(Layer.provide(prismDependencies)) + .pipe(Layer.provide(dependencies)), ), ); const launchInto = (worktreePath: string) => @@ -436,7 +495,11 @@ it.effect("a launch binds only an existing checkout that is one of the project's projectId, workspaceStrategy: { type: "existing_worktree", worktreePath }, }) - .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies)); + .pipe( + Stream.unwrap, + Stream.runCollect, + Effect.provide(Layer.mergeAll(dependencies, prismDependencies)), + ); expect((yield* launchInto(worktree)).at(-1)?.result).toMatchObject({ projectId }); expect((yield* launchInto(repo)).at(-1)?.result).toMatchObject({ projectId }); diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index e3385c2825..772fec808e 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -1,10 +1,5 @@ -import { - MessageId, - ThreadId, - OrchestratorMcpFailure, - ProjectId, - threadOwner, -} from "@t3tools/contracts"; +import * as Prism from "../../../prism/PrismService.ts"; +import { MessageId, ThreadId, OrchestratorMcpFailure, ProjectId, threadOwner } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; @@ -109,11 +104,19 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { }); yield* assertProjectWorktree(project.workspaceRoot, input.workspaceStrategy.worktreePath); } - const modelSelection = + const inherited = input.modelSelection ?? caller?.modelSelection ?? (yield* readProject)?.defaultModelSelection ?? undefined; + const prism = yield* Prism.PrismService; + const { modelSelection, kitText } = yield* prism.resolve({ + projectId, + role: input.prismRole ?? "planner", + lane: input.lane, + explicit: input.modelSelection, + inherited, + }); if (modelSelection === undefined) return yield* new OrchestratorMcpFailure({ code: "invalid_request", @@ -135,7 +138,7 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { initialMessage: { messageId, ...(caller === undefined ? {} : { senderThreadId: caller.id }), - text: input.message ?? "", + text: [kitText, input.message ?? ""].filter(Boolean).join("\n\n"), attachments, }, }), @@ -159,7 +162,7 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { runId: run?.id ?? null, status: run?.status ?? null, }; - }), + }).pipe(Effect.provide(Prism.layer)), ), t3_project_list: McpToolAccess.reads((input) => Effect.gen(function* () { diff --git a/apps/server/src/mcp/toolkits/project/tools.ts b/apps/server/src/mcp/toolkits/project/tools.ts index ee489d4e10..6d4a2c30a9 100644 --- a/apps/server/src/mcp/toolkits/project/tools.ts +++ b/apps/server/src/mcp/toolkits/project/tools.ts @@ -1,3 +1,6 @@ +import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts"; +import * as ServerSettings from "../../../serverSettings.ts"; +import { PrismLane, PrismRole } from "@t3tools/contracts"; import { McpAttachmentInput } from "../attachment/input.ts"; import { NonNegativeInt, @@ -103,7 +106,7 @@ const ProjectCloneTool = Tool.make("t3_project_clone", { const ThreadLaunchTool = Tool.make("t3_thread_launch", { ...shared, description: - 'Create an ordinary TOP-LEVEL thread with an explicit workspace binding before its agent starts. Use this when the user requests independent work, a new thread, or a PR stack in its own worktree; use delegate_task for child subagents. Set workspaceStrategy to {type:"worktree",baseRef:"parent-branch",branch:"new-branch",startFromOrigin:false} for a new worktree based on local commits, or {type:"existing_worktree",worktreePath:"/absolute/path",branch:"existing-branch"} to use an existing checkout. For upstream commits, set startFromOrigin:true. Omitted workspaceStrategy means the project root, NOT the caller\'s worktree. Omit projectId/modelSelection/modes to inherit those settings from the calling thread; a caller outside a T3 thread must pass projectId and gets the project\'s default model. Set scratch:true instead of projectId for a thread without a project: it runs in a fresh folder of its own, outside any repository. Put the task in message. Do not ask the agent to create its own worktree via shell: that does not update the thread binding. Each call creates a new launch with no retry key; retain threadId and use t3_thread_read/t3_thread_wait to follow preparation. To link a thread for the user, write `[title](t3-thread://v1/)` with the threadId exactly as returned, not URL-encoded; T3 Code shows the thread\'s current title. After errors or lost responses, inspect t3_thread_list before retrying. Attachments must be pending uploads. The new thread may not run with broader runtime or interaction modes than the caller: the calling T3 thread\'s own modes, or the permission mode an outside agent was approved with.', + 'Create an ordinary TOP-LEVEL thread with an explicit workspace binding before its agent starts. Use this when the user requests independent work, a new thread, or a PR stack in its own worktree; use delegate_task for child subagents. Set workspaceStrategy to {type:"worktree",baseRef:"parent-branch",branch:"new-branch",startFromOrigin:false} for a new worktree based on local commits, or {type:"existing_worktree",worktreePath:"/absolute/path",branch:"existing-branch"} to use an existing checkout. For upstream commits, set startFromOrigin:true. Omitted workspaceStrategy means the project root, NOT the caller\'s worktree. Omit modelSelection for Prism routing; prismRole selects the kit (planner by default, including dispatcher/promachos at launch) and lane selects worker difficulty. Explicit modelSelection wins. Unconfigured roles inherit the caller or project default model. Omit projectId/modes to inherit those settings from the calling thread; a caller outside a T3 thread must pass projectId and gets the project\'s default model. Set scratch:true instead of projectId for a thread without a project: it runs in a fresh folder of its own, outside any repository. Put the task in message. Do not ask the agent to create its own worktree via shell: that does not update the thread binding. Each call creates a new launch with no retry key; retain threadId and use t3_thread_read/t3_thread_wait to follow preparation. To link a thread for the user, write `[title](t3-thread://v1/)` with the threadId exactly as returned, not URL-encoded; T3 Code shows the thread\'s current title. After errors or lost responses, inspect t3_thread_list before retrying. Attachments must be pending uploads. The new thread may not run with broader runtime or interaction modes than the caller: the calling T3 thread\'s own modes, or the permission mode an outside agent was approved with.', parameters: Schema.Struct({ projectId: Schema.optional(ProjectId), scratch: Schema.optional( @@ -114,6 +117,8 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", { ), title: TrimmedNonEmptyString, modelSelection: Schema.optional(ModelSelection), + lane: Schema.optional(PrismLane), + prismRole: Schema.optional(PrismRole), runtimeMode: Schema.optional(RuntimeMode), interactionMode: Schema.optional(ProviderInteractionMode), workspaceStrategy: Schema.optional( @@ -140,6 +145,8 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", { dependencies: [ ...shared.dependencies, ThreadLaunchService.ThreadLaunchService, + ProviderRegistry.ProviderRegistry, + ServerSettings.ServerSettingsService, ManagedProjectFolders.ManagedProjectFolders, GitVcsDriver.GitVcsDriver, FileSystem.FileSystem, diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index faf97aba09..a5c61e84f9 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -2,6 +2,7 @@ import { reconcileChildRequests, reconcilePendingChildRequests, } from "../childThreads/requestWake.ts"; +import * as PrismRecovery from "../prism/RecoveryHooks.ts"; import type { OrchestrationV2SearchThreadInput, OrchestrationV2SearchThreadResult, @@ -820,6 +821,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const idAllocator = yield* IdAllocatorV2; const projects = yield* ProjectStore.ProjectStoreV2; const projectionStore = yield* ProjectionStoreV2; + const prismRecovery = yield* PrismRecovery.RecoveryHooks; const effectOutbox = yield* EffectOutbox.EffectOutboxV2; const retirement = (threadId: ThreadId) => readRetirementState(threadId).pipe(Effect.provideService(ProjectionStoreV2, projectionStore)); @@ -9808,6 +9810,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio if (existingResultTransfer !== undefined) { return; } + if (yield* prismRecovery.holdsFinalization(childThreadId, childRun.id)) return; const now = yield* DateTime.now; const result = subagentResultForRun(childProjection, childRun); @@ -10891,7 +10894,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio acceptedAt, events: plan.events, effects: plan.effects, - forkPlans: plan.forkPlans, + forkPlans: [...(plan.forkPlans ?? []), ...prismRecovery.commitPlans(command)], ...(plan.cancelUnsettledEffects === undefined ? {} : { cancelUnsettledEffects: plan.cancelUnsettledEffects }), diff --git a/apps/server/src/prism/PrismService.test.ts b/apps/server/src/prism/PrismService.test.ts new file mode 100644 index 0000000000..4a1711530c --- /dev/null +++ b/apps/server/src/prism/PrismService.test.ts @@ -0,0 +1,206 @@ +import { assert, describe, it } from "@effect/vitest"; +import { + OrchestratorMcpFailure, + ProjectId, + ProviderInstanceId, + type ServerProvider, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; +import { buildUnavailableProviderSnapshot } from "../provider/unavailableProviderSnapshot.ts"; +import * as Settings from "../serverSettings.ts"; +import * as Prism from "./PrismService.ts"; + +const instanceId = ProviderInstanceId.make("codex"); +const projectId = ProjectId.make("project:prism"); +const selection = { instanceId, model: "configured" }; +const registry = (providers: readonly ServerProvider[]) => + Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed(providers) }); +const provider = Effect.map( + buildUnavailableProviderSnapshot({ + driverKind: "codex", + instanceId, + reason: "test", + checkedAt: "2026-10-08T00:00:00Z", + }), + (p) => ({ + ...p, + enabled: true, + availability: "available" as const, + models: [{ slug: "configured", name: "Configured", isCustom: false, capabilities: null }], + }), +); + +describe("PrismService", () => { + it.effect("explicit model wins even when configured capacity is exhausted, and kit remains", () => + Effect.gen(function* () { + const service = yield* Prism.PrismService; + const result = yield* service.resolve({ + projectId, + role: "worker", + explicit: { instanceId, model: "chosen" }, + }); + assert.strictEqual(result.modelSelection.model, "chosen"); + assert.strictEqual(result.kitText, "Follow the kit."); + }).pipe( + Effect.provide( + Prism.layer.pipe( + Layer.provide( + Layer.mergeAll( + registry([]), + Settings.layerTest({ + prismRoles: { + worker: { instructions: "Follow the kit.", lanes: { medium: [selection] } }, + }, + }), + ), + ), + ), + ), + ), + ); + it.effect("walks role preferences after provider/model unavailable and preserves effort", () => + Effect.gen(function* () { + const p = yield* provider; + const seen: string[] = []; + const result = yield* Effect.gen(function* () { + const service = yield* Prism.PrismService; + return yield* service.resolve({ + projectId, + role: "worker", + lane: "hard", + validate: (pick) => { + seen.push(pick.model); + return pick.model === "first" + ? Effect.fail( + new OrchestratorMcpFailure({ code: "model_unavailable", message: "gone" }), + ) + : Effect.succeed(pick); + }, + }); + }).pipe( + Effect.provide( + Prism.layer.pipe( + Layer.provide( + Layer.mergeAll( + registry([ + { + ...p, + models: [ + ...p.models, + { slug: "first", name: "First", isCustom: false, capabilities: null }, + ], + }, + ]), + Settings.layerTest({ + prismRoles: { + worker: { + lanes: { + hard: [ + { instanceId, model: "first" }, + { ...selection, effort: "high" }, + ], + }, + }, + }, + }), + ), + ), + ), + ), + ); + assert.deepStrictEqual(seen, ["first", "configured"]); + assert.deepStrictEqual(result.modelSelection.options, [ + { id: "reasoningEffort", value: "high" }, + ]); + }), + ); + it.effect("skips exhausted capacity and honors project role overrides", () => + Effect.gen(function* () { + const p = yield* provider; + const peerId = ProviderInstanceId.make("codex-work"); + const result = yield* Effect.gen(function* () { + const service = yield* Prism.PrismService; + return yield* service.resolve({ projectId, role: "reviewer" }); + }).pipe( + Effect.provide( + Prism.layer.pipe( + Layer.provide( + Layer.mergeAll( + registry([ + { + ...p, + usageLimits: { + checkedAt: "2026-10-08T00:00:00Z", + windows: [ + { id: "weekly", kind: "weekly", label: "Weekly", usedPercent: 100 }, + ], + }, + }, + { ...p, instanceId: peerId }, + ]), + Settings.layerTest({ + prismRoles: { + reviewer: { instructions: "Environment kit", models: [selection] }, + }, + projectSettingsOverrides: { + [projectId]: { + prismRoles: { + reviewer: { + instructions: "Project kit", + models: [selection, { instanceId: peerId, model: "configured" }], + }, + }, + }, + }, + }), + ), + ), + ), + ), + ); + assert.strictEqual(result.modelSelection.instanceId, peerId); + assert.strictEqual(result.kitText, "Project kit"); + }), + ); + it.effect("does not hide invalid options behind capacity fallback", () => + Effect.gen(function* () { + const p = yield* provider; + const error = yield* Effect.gen(function* () { + const service = yield* Prism.PrismService; + return yield* service.resolve({ + projectId, + role: "worker", + validate: () => + Effect.fail( + new OrchestratorMcpFailure({ code: "invalid_request", message: "Invalid option" }), + ), + }); + }).pipe( + Effect.flip, + Effect.provide( + Prism.layer.pipe( + Layer.provide( + Layer.mergeAll( + registry([p]), + Settings.layerTest({ + prismRoles: { worker: { lanes: { medium: [selection, selection] } } }, + }), + ), + ), + ), + ), + ); + assert.strictEqual(error.code, "invalid_request"); + }), + ); + it("maps every upstream role to the fixed kit", () => { + assert.strictEqual(Prism.delegatedPrismRole("implementation"), "worker"); + assert.strictEqual(Prism.delegatedPrismRole("test"), "worker"); + assert.strictEqual(Prism.delegatedPrismRole("general"), "worker"); + assert.strictEqual(Prism.delegatedPrismRole("review"), "reviewer"); + assert.strictEqual(Prism.delegatedPrismRole("research"), "planner"); + assert.strictEqual(Prism.delegatedPrismRole("design"), "planner"); + }); +}); diff --git a/apps/server/src/prism/PrismService.ts b/apps/server/src/prism/PrismService.ts new file mode 100644 index 0000000000..fd4b7729ab --- /dev/null +++ b/apps/server/src/prism/PrismService.ts @@ -0,0 +1,144 @@ +import { + DEFAULT_PRISM_LANE, + OrchestratorMcpFailure, + prismRoleModels, + type ModelSelection, + type OrchestratorMcpTaskRole, + type PrismLane, + type PrismRole, + type ProjectId, + type ServerProvider, +} from "@t3tools/contracts"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; +import * as ServerSettings from "../serverSettings.ts"; + +export function delegatedPrismRole(role?: OrchestratorMcpTaskRole): PrismRole { + switch (role) { + case "review": + return "reviewer"; + case "research": + case "design": + return "planner"; + default: + return "worker"; + } +} + +function blocked(provider: ServerProvider, now: number) { + return (provider.usageLimits?.windows ?? []).some( + (window) => + window.usedPercent >= 100 && + (window.resetsAt === undefined || Date.parse(window.resetsAt) > now), + ); +} + +function effortOption(driver: string) { + switch (driver) { + case "codex": + case "grok": + return "reasoningEffort"; + case "opencode": + return "variant"; + default: + return "effort"; + } +} + +export class PrismService extends Context.Service< + PrismService, + { + readonly resolve: (input: { + readonly projectId: ProjectId; + readonly role: PrismRole; + readonly lane?: PrismLane | undefined; + readonly explicit?: ModelSelection | undefined; + readonly inherited?: ModelSelection | undefined; + readonly validate?: + | ((selection: ModelSelection) => Effect.Effect) + | undefined; + }) => Effect.Effect< + { readonly modelSelection: ModelSelection; readonly kitText: string }, + OrchestratorMcpFailure + >; + } +>()("t3/prism/PrismService") {} + +const make = Effect.gen(function* () { + const registry = yield* ProviderRegistry.ProviderRegistry; + const settings = yield* ServerSettings.ServerSettingsService; + const resolve: PrismService["Service"]["resolve"] = Effect.fn("PrismService.resolve")( + function* (input) { + const kits = resolveProjectSettings( + yield* settings.getSettings.pipe( + Effect.mapError( + () => + new OrchestratorMcpFailure({ + code: "orchestration_error", + message: "Unable to read Prism settings.", + }), + ), + ), + input.projectId, + ).settings.prismRoles; + const kit = kits[input.role]; + const kitText = [ + kit.instructions, + kit.skills.length > 0 ? `Use these skills: ${kit.skills.join(", ")}.` : "", + ] + .filter(Boolean) + .join("\n\n"); + if (input.explicit !== undefined) { + const modelSelection = input.validate + ? yield* input.validate(input.explicit) + : input.explicit; + return { modelSelection, kitText }; + } + const providers = yield* registry.getProviders; + const now = DateTime.toEpochMillis(yield* DateTime.now); + const preferences = prismRoleModels(kits, input.role, input.lane ?? DEFAULT_PRISM_LANE); + const candidates = + preferences.length > 0 ? preferences : input.inherited ? [input.inherited] : []; + for (const candidate of candidates) { + const provider = providers.find((p) => p.instanceId === candidate.instanceId); + if (!provider?.enabled || blocked(provider, now)) continue; + if (provider.availability === "unavailable" && (preferences.length > 0 || !input.validate)) + continue; + const model = provider.models.find( + (m) => m.slug === candidate.model || m.aliases?.includes(candidate.model), + ); + if (!model && (preferences.length > 0 || provider.models.length > 0)) continue; + const effort = "effort" in candidate ? candidate.effort : undefined; + const selection: ModelSelection = { + instanceId: candidate.instanceId, + model: model?.slug ?? candidate.model, + ...(effort === undefined + ? "options" in candidate + ? { options: candidate.options } + : {} + : { options: [{ id: effortOption(provider.driver), value: effort }] }), + }; + const validated = yield* ( + input.validate ? input.validate(selection) : Effect.succeed(selection) + ).pipe( + Effect.catch((error) => + error.code === "provider_unavailable" || error.code === "model_unavailable" + ? Effect.succeed(null) + : Effect.fail(error), + ), + ); + if (validated !== null) return { modelSelection: validated, kitText }; + } + return yield* new OrchestratorMcpFailure({ + code: "provider_unavailable", + message: "No eligible model with usage headroom for this Prism role and lane.", + }); + }, + ); + return PrismService.of({ resolve }); +}); +export const layer = Layer.effect(PrismService, make); diff --git a/apps/server/src/prism/RecoveryCoordinator.test.ts b/apps/server/src/prism/RecoveryCoordinator.test.ts new file mode 100644 index 0000000000..4d89526b27 --- /dev/null +++ b/apps/server/src/prism/RecoveryCoordinator.test.ts @@ -0,0 +1,104 @@ +import { assert, describe, it } from "@effect/vitest"; +import { MessageId, RunId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Settings from "../serverSettings.ts"; +import * as Persistence from "../persistence/Sqlite.ts"; +import * as Coordinator from "./RecoveryCoordinator.ts"; +import { recoveryRun as run, threadFor, errorFor } from "./recovery.testkit.ts"; +const layer = Coordinator.layer.pipe( + Layer.provide(Layer.mergeAll(Persistence.layerMemory, Settings.layerTest())), +); +const projection = { + thread: threadFor(run), + runs: [run], + turnItems: [errorFor(run)], + runtimeRequests: [], +}; + +describe("Prism recovery finalization coordination", () => { + it.effect("holds the first failed result and releases only the retry's eventual result", () => + Effect.gen(function* () { + const coordinator = yield* Coordinator.RecoveryCoordinator; + const armed = yield* coordinator.observe(projection, false); + assert.strictEqual(armed?.state, "retry_pending"); + assert.strictEqual(yield* coordinator.holdsResult(run.threadId), true); + const retry = { + ...run, + id: RunId.make("run:retry"), + ordinal: 2, + userMessageId: armed!.retryMessageId, + status: "running" as const, + completedAt: null, + }; + yield* coordinator.observe({ ...projection, runs: [run, retry] }, false); + assert.strictEqual(yield* coordinator.holdsResult(run.threadId), true); + yield* coordinator.observe( + { + ...projection, + runs: [run, { ...retry, status: "completed", completedAt: run.completedAt }], + }, + false, + ); + assert.strictEqual(yield* coordinator.holdsResult(run.threadId), false); + // A delayed observer must not replace the eventual result with the first failure. + yield* coordinator.observe(projection, false); + assert.strictEqual(yield* coordinator.holdsResult(run.threadId), false); + }).pipe(Effect.provide(layer)), + ); + it.effect( + "reset wait survives an active reset continuation and releases its completed result", + () => + Effect.gen(function* () { + const coordinator = yield* Coordinator.RecoveryCoordinator; + const item = errorFor(run); + if (item.type !== "error") throw new Error("fixture"); + const limited = { + ...projection, + turnItems: [ + { + ...item, + failure: { + ...item.failure, + class: "usage_limit" as const, + resetAt: "2026-10-08T11:00:00Z", + }, + }, + ], + }; + const waiting = yield* coordinator.observe(limited, false); + assert.strictEqual(waiting?.state, "reset_wait"); + assert.strictEqual(yield* coordinator.holdsResult(run.threadId), true); + const resumed = { + ...run, + id: RunId.make("run:reset"), + ordinal: 2, + userMessageId: MessageId.make(`limit-resume:${run.threadId}:${run.id}:reset`), + status: "running" as const, + completedAt: null, + }; + yield* coordinator.observe({ ...limited, runs: [run, resumed] }, false); + assert.strictEqual(yield* coordinator.holdsResult(run.threadId), true); + yield* coordinator.observe( + { + ...limited, + runs: [run, { ...resumed, status: "completed", completedAt: run.completedAt }], + }, + false, + ); + assert.strictEqual(yield* coordinator.holdsResult(run.threadId), false); + }).pipe(Effect.provide(layer)), + ); + it.effect( + "retirement or incomplete ancestry releases a held failure without creating another retry", + () => + Effect.gen(function* () { + const coordinator = yield* Coordinator.RecoveryCoordinator; + yield* coordinator.observe(projection, false); + assert.strictEqual(yield* coordinator.holdsResult(run.threadId), true); + const closed = yield* coordinator.observe(projection, true); + assert.strictEqual(closed?.state, "closed"); + assert.strictEqual(yield* coordinator.holdsResult(run.threadId), false); + }).pipe(Effect.provide(layer)), + ); +}); diff --git a/apps/server/src/prism/RecoveryCoordinator.ts b/apps/server/src/prism/RecoveryCoordinator.ts new file mode 100644 index 0000000000..accfd67119 --- /dev/null +++ b/apps/server/src/prism/RecoveryCoordinator.ts @@ -0,0 +1,69 @@ +import { + type OrchestrationV2ThreadProjection, + type ServerSettingsError, + type ThreadId, +} from "@t3tools/contracts"; +import { + latestExecutedRun, + latestRootProviderFailure, +} from "@t3tools/shared/orchestrationV2ThreadError"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type { SqlError } from "effect/sql/SqlError"; +import * as Settings from "../serverSettings.ts"; +import * as Store from "./RecoveryStore.ts"; +import { sameModelSelection, type RecoveryRecord } from "./recoveryPolicy.ts"; + +export type RecoveryProjection = Pick< + OrchestrationV2ThreadProjection, + "thread" | "runs" | "turnItems" | "runtimeRequests" +>; + +/** Retirement is observed by the caller and admitted atomically by #168's commit guard. */ +export class RecoveryCoordinator extends Context.Service< + RecoveryCoordinator, + { + readonly observe: ( + projection: RecoveryProjection, + retiredOrIncomplete: boolean, + ) => Effect.Effect; + readonly holdsResult: (threadId: ThreadId) => Effect.Effect; + } +>()("t3/prism/RecoveryCoordinator") {} + +const make = Effect.gen(function* () { + const store = yield* Store.RecoveryStore; + const settings = yield* Settings.ServerSettingsService; + const observe: RecoveryCoordinator["Service"]["observe"] = Effect.fn( + "RecoveryCoordinator.observe", + )(function* (projection, retiredOrIncomplete) { + const run = latestExecutedRun(projection.runs); + if (!run || projection.thread.creationSource !== "mcp") return null; + const failure = latestRootProviderFailure(run, projection.turnItems); + const stopped = projection.turnItems.some( + (item) => item.type === "run_interrupt_request" && item.runId === run.id, + ); + const blocked = + retiredOrIncomplete || + stopped || + projection.thread.archivedAt !== null || + projection.thread.deletedAt !== null || + projection.thread.settledOverride === "settled" || + projection.runtimeRequests.some((request) => request.status === "pending") || + !sameModelSelection(projection.thread.modelSelection, run.modelSelection); + return yield* store.reconcile({ + previous: null, + run, + failure, + stoppedOrRetired: blocked, + autoResume: (yield* settings.getSettings).autoResumeLimitedThreads, + }); + }); + const holdsResult = Effect.fn("RecoveryCoordinator.holdsResult")(function* (threadId: ThreadId) { + const record = yield* store.get(threadId); + return record !== null && record.state !== "closed"; + }); + return RecoveryCoordinator.of({ observe, holdsResult }); +}); +export const layer = Layer.effect(RecoveryCoordinator, make).pipe(Layer.provide(Store.layer)); diff --git a/apps/server/src/prism/RecoveryHooks.ts b/apps/server/src/prism/RecoveryHooks.ts new file mode 100644 index 0000000000..bc601e761a --- /dev/null +++ b/apps/server/src/prism/RecoveryHooks.ts @@ -0,0 +1,67 @@ +import type { + OrchestrationV2ServerCommand, + RunId, + ServerSettingsError, + ThreadId, +} from "@t3tools/contracts"; +import { latestExecutedRun } from "@t3tools/shared/orchestrationV2ThreadError"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type { SqlError } from "effect/sql/SqlError"; +import type { ForkCommitPlan } from "../childThreads/ForkCommitPlan.ts"; +import * as Projection from "../orchestration-v2/ProjectionStore.ts"; +import * as Coordinator from "./RecoveryCoordinator.ts"; +import { retryAdmission } from "./recoveryAdmission.ts"; + +/** Optional fork behavior; upstream orchestration retains its ordinary finalization. */ +export class RecoveryHooks extends Context.Reference<{ + readonly holdsFinalization: ( + threadId: ThreadId, + expectedRunId: RunId, + ) => Effect.Effect; + readonly commitPlans: (command: OrchestrationV2ServerCommand) => ReadonlyArray; +}>("t3/prism/RecoveryHooks", { + defaultValue: () => ({ holdsFinalization: () => Effect.succeed(false), commitPlans: () => [] }), +}) {} + +/** The retirement implementation stays owned by child-threads; this layer holds no ancestry state. */ +export const layer = ( + readRetirementState: (threadId: ThreadId) => Effect.Effect< + { + readonly retired: boolean; + readonly complete: boolean; + }, + Projection.ProjectionStoreV2Error + >, +) => + Layer.effect( + RecoveryHooks, + Effect.gen(function* () { + const coordinator = yield* Coordinator.RecoveryCoordinator; + const projections = yield* Projection.ProjectionStoreV2; + return { + holdsFinalization: (threadId: ThreadId, expectedRunId: RunId) => + Effect.gen(function* () { + // The first terminal observer must arm recovery before transferring a failed task result. + const projection = yield* projections.getThreadRecords( + threadId, + ["runs", "turnItems", "runtimeRequests"], + { + turnItemTypes: ["error", "run_interrupt_request"], + }, + ); + const retirement = yield* readRetirementState(threadId); + yield* coordinator.observe(projection, retirement.retired || !retirement.complete); + return ( + latestExecutedRun(projection.runs)?.id !== expectedRunId || + (yield* coordinator.holdsResult(threadId)) + ); + }), + commitPlans: (command: OrchestrationV2ServerCommand) => { + const plan = command.type === "message.dispatch" ? retryAdmission(command) : null; + return plan === null ? [] : [plan]; + }, + }; + }), + ); diff --git a/apps/server/src/prism/RecoverySettings.test.ts b/apps/server/src/prism/RecoverySettings.test.ts new file mode 100644 index 0000000000..e59d2543c4 --- /dev/null +++ b/apps/server/src/prism/RecoverySettings.test.ts @@ -0,0 +1,35 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { ServerSettings } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Secrets from "../auth/ServerSecretStore.ts"; +import * as Config from "../config.ts"; +import * as Persistence from "../persistence/Sqlite.ts"; +import * as Settings from "../serverSettings.ts"; + +const settingsLayer = Settings.layer.pipe( + Layer.provide(Secrets.layer), + Layer.provideMerge(Persistence.layerMemory), + Layer.provideMerge(Config.layerTest(process.cwd(), { prefix: "prism-settings-" })), + Layer.provideMerge(NodeServices.layer), +); +it.effect("Prism settings writes preserve a saved reset-recovery opt-out", () => + Effect.gen(function* () { + const config = yield* Config.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const settings = yield* Settings.ServerSettingsService; + yield* fs.writeFileString(config.settingsPath, '{"autoResumeLimitedThreads":false}'); + assert.strictEqual((yield* settings.getSettings).autoResumeLimitedThreads, false); + yield* settings.updateSettings({ + prismRoles: { planner: { instructions: "Use the project plan." } }, + }); + const persisted = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(ServerSettings))( + yield* fs.readFileString(config.settingsPath), + ); + assert.strictEqual(persisted.autoResumeLimitedThreads, false); + assert.strictEqual(persisted.prismRoles.planner.instructions, "Use the project plan."); + }).pipe(Effect.provide(settingsLayer)), +); diff --git a/apps/server/src/prism/RecoveryStore.test.ts b/apps/server/src/prism/RecoveryStore.test.ts new file mode 100644 index 0000000000..7d703591d9 --- /dev/null +++ b/apps/server/src/prism/RecoveryStore.test.ts @@ -0,0 +1,239 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { MessageId, RunId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Persistence from "../persistence/Sqlite.ts"; +import * as Store from "./RecoveryStore.ts"; +import { decideRecovery, retryCommand } from "./recoveryPolicy.ts"; + +import { recoveryRun as run } from "./recovery.testkit.ts"; +const threadId = run.threadId; +const input = { + previous: null, + run, + failure: { class: "unknown" as const, message: "Provider failed", code: null, retryable: true }, + stoppedOrRetired: false, + autoResume: true, +}; +const storeLayer = Store.layer.pipe(Layer.provide(Persistence.layerMemory)); + +describe("Prism persisted recovery", () => { + it.effect("concurrent duplicate failure observers produce one stable retry identity", () => + Effect.gen(function* () { + const store = yield* Store.RecoveryStore; + const records = yield* Effect.all( + Array.from({ length: 8 }, () => store.reconcile(input)), + { concurrency: "unbounded" }, + ); + const ids = records.map((record) => String(retryCommand(record)?.commandId)); + assert.deepStrictEqual(new Set(ids), new Set(["prism-retry:thread:recovery:run:original"])); + assert.strictEqual((yield* store.pending).length, 1); + assert.strictEqual(records[0]?.originalRunId, run.id); + }).pipe(Effect.provide(storeLayer)), + ); + it.effect( + "record-before-dispatch recovery repeats the exact command and message without a new budget", + () => + Effect.gen(function* () { + const store = yield* Store.RecoveryStore; + const armed = yield* store.reconcile(input); + // The persisted row is all a fresh reactor needs after a lost dispatch response. + const reloaded = yield* store.get(threadId); + assert.deepStrictEqual(retryCommand(reloaded!), retryCommand(armed)); + const repeated = yield* store.reconcile(input); + assert.deepStrictEqual(retryCommand(repeated), retryCommand(armed)); + const command = retryCommand(armed); + assert.strictEqual(command?.type, "message.dispatch"); + if (command?.type === "message.dispatch") { + assert.deepStrictEqual(command.modelSelection, run.modelSelection); + assert.strictEqual(command.creationSource, "server"); + assert.strictEqual(command.createdBy, "system"); + } + }).pipe(Effect.provide(storeLayer)), + ); + it.effect( + "a database reopen preserves both record-before-dispatch and committed-retry windows", + () => { + const dir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "prism-restart-")); + const dbPath = NodePath.join(dir, "state.sqlite"); + // Separate scopes close each connection before the next simulated process starts. + const inProcess = (work: Effect.Effect) => + work.pipe( + Effect.provide( + Store.layer.pipe( + Layer.provide( + Persistence.layerFromPath(dbPath).pipe(Layer.provide(NodeServices.layer)), + ), + ), + ), + ); + return Effect.gen(function* () { + const armed = yield* inProcess( + Effect.gen(function* () { + return yield* (yield* Store.RecoveryStore).reconcile(input); + }), + ); + const reloaded = yield* inProcess( + Effect.gen(function* () { + const store = yield* Store.RecoveryStore; + const record = yield* store.get(threadId); + assert.deepStrictEqual(retryCommand(record!), retryCommand(armed)); + yield* store.started(record!); + return record; + }), + ); + yield* inProcess( + Effect.gen(function* () { + const store = yield* Store.RecoveryStore; + const record = yield* store.reconcile(input); + assert.strictEqual(record.state, "retry_started"); + assert.strictEqual(record.originalRunId, reloaded?.originalRunId); + assert.strictEqual(retryCommand(record), null); + const completed = yield* store.reconcile({ + ...input, + run: { + ...run, + id: RunId.make("run:after-restart"), + ordinal: 2, + userMessageId: armed.retryMessageId, + status: "completed", + }, + }); + assert.strictEqual(completed.state, "closed"); + }), + ); + }).pipe( + Effect.ensuring(Effect.sync(() => NodeFS.rmSync(dir, { recursive: true, force: true }))), + ); + }, + ); + it("stale failure events cannot reopen an exhausted budget, and Stop closes a newer active continuation", () => { + const armed = decideRecovery(input); + const active = decideRecovery({ + ...input, + previous: armed, + run: { + ...run, + id: RunId.make("run:retry"), + ordinal: 2, + userMessageId: armed.retryMessageId, + status: "running", + }, + }); + assert.strictEqual( + decideRecovery({ ...input, previous: active }).sourceRunId, + active.sourceRunId, + ); + assert.strictEqual( + decideRecovery({ ...input, previous: active, stoppedOrRetired: true }).state, + "closed", + ); + const exhausted = decideRecovery({ + ...input, + previous: active, + run: { + ...run, + id: active.sourceRunId, + ordinal: 2, + userMessageId: armed.retryMessageId, + }, + }); + assert.strictEqual(exhausted.state, "closed"); + assert.strictEqual(decideRecovery({ ...input, previous: exhausted }).state, "closed"); + }); + it.effect( + "exhaustion closes the retry and stale reactor writes cannot re-block finalization", + () => + Effect.gen(function* () { + const store = yield* Store.RecoveryStore; + const armed = yield* store.reconcile(input); + const retryRun = { + ...run, + id: RunId.make("run:retry"), + ordinal: 2, + userMessageId: armed.retryMessageId, + }; + const exhausted = yield* store.reconcile({ ...input, run: retryRun }); + assert.strictEqual(exhausted.originalRunId, run.id); + assert.strictEqual(exhausted.state, "closed"); + assert.strictEqual(retryCommand(exhausted), null); + yield* store.started(armed); + assert.strictEqual((yield* store.get(threadId))?.state, "closed"); + assert.strictEqual((yield* store.pending).length, 0); + }).pipe(Effect.provide(storeLayer)), + ); + it("Stop and retirement close both retry and reset wait", () => { + const armed = decideRecovery(input); + assert.strictEqual( + decideRecovery({ ...input, previous: armed, stoppedOrRetired: true }).state, + "closed", + ); + const waiting = decideRecovery({ + ...input, + failure: { ...input.failure, class: "usage_limit", resetAt: "2026-10-08T11:00:00Z" }, + }); + assert.strictEqual(waiting.state, "reset_wait"); + assert.strictEqual( + decideRecovery({ ...input, previous: waiting, stoppedOrRetired: true }).state, + "closed", + ); + }); + it("reset continuation preserves retry budget and closes on the eventual result", () => { + const waiting = decideRecovery({ + ...input, + failure: { ...input.failure, class: "usage_limit", resetAt: "2026-10-08T11:00:00Z" }, + }); + const resumed = { + ...run, + id: RunId.make("run:reset"), + ordinal: 2, + userMessageId: MessageId.make(`limit-resume:${threadId}:${run.id}:reset`), + }; + const retry = decideRecovery({ ...input, previous: waiting, run: resumed }); + assert.strictEqual(retry.originalRunId, run.id); + assert.strictEqual(retry.state, "retry_pending"); + const completed = decideRecovery({ + ...input, + previous: retry, + run: { + ...resumed, + id: RunId.make("run:retried"), + ordinal: 3, + userMessageId: retry.retryMessageId, + status: "completed", + }, + }); + assert.strictEqual(completed.state, "closed"); + }); + it("nonretryable failure, absent or expired reset, and model change cannot recover", () => { + assert.strictEqual( + decideRecovery({ ...input, failure: { ...input.failure, retryable: false } }).state, + "closed", + ); + assert.strictEqual( + decideRecovery({ ...input, failure: { ...input.failure, class: "usage_limit" } }).state, + "closed", + ); + assert.strictEqual( + decideRecovery({ + ...input, + failure: { ...input.failure, class: "usage_limit", resetAt: "2026-10-08T09:00:00Z" }, + }).state, + "closed", + ); + const armed = decideRecovery(input); + assert.strictEqual( + decideRecovery({ + ...input, + previous: armed, + run: { ...run, modelSelection: { ...run.modelSelection, model: "different" } }, + }).state, + "closed", + ); + }); +}); diff --git a/apps/server/src/prism/RecoveryStore.ts b/apps/server/src/prism/RecoveryStore.ts new file mode 100644 index 0000000000..940124ccfe --- /dev/null +++ b/apps/server/src/prism/RecoveryStore.ts @@ -0,0 +1,70 @@ +import { type ThreadId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/sql/SqlClient"; +import type { SqlError } from "effect/sql/SqlError"; +import { decideRecovery, RecoveryRecord } from "./recoveryPolicy.ts"; + +const RecordJson = Schema.fromJsonString(RecoveryRecord); +const encode = Schema.encodeSync(RecordJson); +const decode = Schema.decodeUnknownSync(RecordJson); + +export class RecoveryStore extends Context.Service< + RecoveryStore, + { + readonly get: (threadId: ThreadId) => Effect.Effect; + readonly reconcile: ( + input: Parameters[0], + ) => Effect.Effect; + readonly pending: Effect.Effect, SqlError>; + readonly close: (record: RecoveryRecord) => Effect.Effect; + readonly started: (record: RecoveryRecord) => Effect.Effect; + } +>()("t3/prism/RecoveryStore") {} + +const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`CREATE TABLE IF NOT EXISTS fork_prism_recovery ( + thread_id TEXT PRIMARY KEY, original_run_id TEXT NOT NULL, source_run_id TEXT NOT NULL, + state TEXT NOT NULL, payload_json TEXT NOT NULL + )`; + const get = Effect.fn("RecoveryStore.get")(function* (threadId: ThreadId) { + const rows = yield* sql<{ + payload_json: string; + }>`SELECT payload_json FROM fork_prism_recovery WHERE thread_id = ${threadId}`; + return rows[0] ? decode(rows[0].payload_json) : null; + }); + const reconcile = Effect.fn("RecoveryStore.reconcile")(function* ( + input: Parameters[0], + ) { + return yield* sql.withTransaction( + Effect.gen(function* () { + // Read in the transaction: duplicate failure observers share one retry budget. + const previous = yield* get(input.run.threadId); + const record = decideRecovery({ ...input, previous }); + yield* sql`INSERT INTO fork_prism_recovery (thread_id, original_run_id, source_run_id, state, payload_json) + VALUES (${record.threadId}, ${record.originalRunId}, ${record.sourceRunId}, ${record.state}, ${encode(record)}) + ON CONFLICT(thread_id) DO UPDATE SET original_run_id=excluded.original_run_id, + source_run_id=excluded.source_run_id, state=excluded.state, payload_json=excluded.payload_json`; + return record; + }), + ); + }); + const transition = (record: RecoveryRecord, state: "closed" | "retry_started") => + sql`UPDATE fork_prism_recovery SET state=${state}, payload_json=${encode({ ...record, state })} + WHERE thread_id=${record.threadId} AND original_run_id=${record.originalRunId} + AND source_run_id=${record.sourceRunId} AND state=${record.state}`.pipe(Effect.asVoid); + return RecoveryStore.of({ + get, + reconcile, + pending: sql<{ payload_json: string }>`SELECT payload_json FROM fork_prism_recovery + WHERE state IN ('retry_pending','retry_started','reset_wait')`.pipe( + Effect.map((rows) => rows.map((row) => decode(row.payload_json))), + ), + close: (record) => transition(record, "closed"), + started: (record) => transition(record, "retry_started"), + }); +}); +export const layer = Layer.effect(RecoveryStore, make); diff --git a/apps/server/src/prism/recovery.testkit.ts b/apps/server/src/prism/recovery.testkit.ts new file mode 100644 index 0000000000..67e383893f --- /dev/null +++ b/apps/server/src/prism/recovery.testkit.ts @@ -0,0 +1,114 @@ +import { + EventId, + ProjectId, + TurnItemId, + MessageId, + ProviderInstanceId, + RunId, + ThreadId, + type OrchestrationV2AppThread, + type OrchestrationV2DomainEvent, + type OrchestrationV2Run, + type OrchestrationV2TurnItem, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Layer from "effect/Layer"; +import * as Persistence from "../persistence/Sqlite.ts"; +import * as EventStore from "../orchestration-v2/EventStore.ts"; +import * as Projection from "../orchestration-v2/ProjectionStore.ts"; +import * as EventSink from "../orchestration-v2/EventSink.ts"; +import * as Store from "./RecoveryStore.ts"; + +const threadId = ThreadId.make("thread:recovery"); +export const recoveryRun: OrchestrationV2Run = { + id: RunId.make("run:original"), + threadId, + ordinal: 1, + providerInstanceId: ProviderInstanceId.make("codex"), + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "fixed" }, + providerThreadId: null, + userMessageId: MessageId.make("message:original"), + rootNodeId: null, + activeAttemptId: null, + status: "failed", + requestedAt: DateTime.makeUnsafe("2026-10-08T10:00:00Z"), + startedAt: DateTime.makeUnsafe("2026-10-08T10:00:00Z"), + completedAt: DateTime.makeUnsafe("2026-10-08T10:01:00Z"), + checkpointId: null, + contextHandoffId: null, +}; + +export const database = Persistence.layerMemory; +const stores = Layer.mergeAll( + database, + EventStore.layer.pipe(Layer.provide(database)), + Projection.layer.pipe(Layer.provide(database)), +); +export const recoveryTestLayer = Layer.mergeAll( + stores, + EventSink.layer.pipe(Layer.provide(stores)), + Store.layer.pipe(Layer.provide(database)), +); +export function threadFor(run: OrchestrationV2Run): OrchestrationV2AppThread { + return { + id: run.threadId, + projectId: ProjectId.make("project:recovery"), + title: "Recovery", + createdBy: "agent", + creationSource: "mcp", + providerInstanceId: run.providerInstanceId, + modelSelection: run.modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: run.threadId }, + forkedFrom: null, + createdAt: run.requestedAt, + updatedAt: run.requestedAt, + archivedAt: null, + settledAt: null, + settledOverride: null, + lastVisitedAt: null, + deletedAt: null, + }; +} +export function errorFor(run: OrchestrationV2Run): OrchestrationV2TurnItem { + return { + id: TurnItemId.make(`error:${run.id}`), + type: "error", + threadId: run.threadId, + runId: run.id, + nodeId: run.rootNodeId, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + status: "failed", + title: null, + startedAt: run.requestedAt, + completedAt: run.completedAt, + updatedAt: run.completedAt ?? run.requestedAt, + failure: { class: "unknown", message: "Provider failed", code: null, retryable: true }, + }; +} +export function recoveryEvents(run: OrchestrationV2Run): ReadonlyArray { + const base = { threadId: run.threadId, occurredAt: run.requestedAt }; + return [ + { + ...base, + id: EventId.make("event:recovery:thread"), + type: "thread.created", + payload: threadFor(run), + }, + { ...base, id: EventId.make("event:recovery:run"), type: "run.created", payload: run }, + { + ...base, + id: EventId.make("event:recovery:failure"), + type: "turn-item.updated", + payload: errorFor(run), + }, + ]; +} diff --git a/apps/server/src/prism/recoveryAdmission.test.ts b/apps/server/src/prism/recoveryAdmission.test.ts new file mode 100644 index 0000000000..707706e618 --- /dev/null +++ b/apps/server/src/prism/recoveryAdmission.test.ts @@ -0,0 +1,133 @@ +import { assert, describe, it } from "@effect/vitest"; +import { CommandId, EventId, RunId } from "@t3tools/contracts"; +import * as SqlClient from "effect/sql/SqlClient"; +import * as Effect from "effect/Effect"; +import * as EventSink from "../orchestration-v2/EventSink.ts"; +import * as Store from "./RecoveryStore.ts"; +import { + recoveryTestLayer, + recoveryRun as run, + recoveryEvents, + errorFor, +} from "./recovery.testkit.ts"; +import { retryAdmission } from "./recoveryAdmission.ts"; +import { retryCommand } from "./recoveryPolicy.ts"; + +const setup = Effect.gen(function* () { + const sink = yield* EventSink.EventSinkV2; + const store = yield* Store.RecoveryStore; + yield* sink.commitCommand({ + commandId: CommandId.make("seed"), + threadId: run.threadId, + commandType: "seed", + acceptedAt: run.requestedAt, + events: recoveryEvents(run), + effects: [], + }); + const failure = errorFor(run); + if (failure.type !== "error") throw new Error("fixture"); + const armed = yield* store.reconcile({ + previous: null, + run, + failure: failure.failure, + stoppedOrRetired: false, + autoResume: true, + }); + const command = retryCommand(armed); + if (command?.type !== "message.dispatch") throw new Error("fixture"); + const plan = retryAdmission(command)!; + return { sink, store, armed, command, plan }; +}); +describe("Prism atomic retry admission", () => { + it.effect( + "duplicate dispatch commits one retry and receipt replay bypasses consumed eligibility", + () => + Effect.gen(function* () { + const { sink, store, command, plan } = yield* setup; + const retryRun = { + ...run, + id: RunId.make("run:committed-retry"), + ordinal: 2, + userMessageId: command.messageId, + status: "running" as const, + completedAt: null, + }; + const input = { + commandId: command.commandId, + threadId: command.threadId, + commandType: command.type, + acceptedAt: run.requestedAt, + events: [ + { + id: EventId.make("event:retry"), + type: "run.created" as const, + threadId: command.threadId, + occurredAt: run.requestedAt, + payload: retryRun, + }, + ], + effects: [ + { + id: "effect:retry", + commandId: command.commandId, + threadId: command.threadId, + request: { type: "provider-turn.start" as const, runId: retryRun.id }, + }, + ], + forkPlans: [plan], + }; + const receipts = yield* Effect.all([sink.commitCommand(input), sink.commitCommand(input)], { + concurrency: "unbounded", + }); + assert.deepStrictEqual(receipts[0]?.receipt, receipts[1]?.receipt); + assert.strictEqual((yield* store.get(run.threadId))?.state, "retry_started"); + const replay = yield* sink.commitCommand(input); + assert.deepStrictEqual(replay.receipt, receipts[0]?.receipt); + const sql = yield* SqlClient.SqlClient; + const effects = yield* sql<{ + count: number; + }>`SELECT COUNT(*) AS count FROM orchestration_v2_effect_outbox WHERE command_id=${command.commandId}`; + assert.strictEqual(effects[0]?.count, 1); + }).pipe(Effect.provide(recoveryTestLayer)), + ); + it.effect( + "closing recovery after plan construction rejects dispatch and leaves finalization released", + () => + Effect.gen(function* () { + const { sink, store, armed, command, plan } = yield* setup; + yield* store.close(armed); + const rejected = yield* sink + .commitCommand({ + commandId: command.commandId, + threadId: command.threadId, + commandType: command.type, + acceptedAt: run.requestedAt, + events: [], + effects: [], + forkPlans: [plan], + }) + .pipe(Effect.flip); + assert.strictEqual(rejected._tag, "ForkCommitGuardRejected"); + assert.strictEqual((yield* store.get(run.threadId))?.state, "closed"); + }).pipe(Effect.provide(recoveryTestLayer)), + ); + it.effect("a changed provider/model is refused without consuming the retry", () => + Effect.gen(function* () { + const { sink, store, command } = yield* setup; + const altered = { ...command, modelSelection: { ...run.modelSelection, model: "different" } }; + const rejected = yield* sink + .commitCommand({ + commandId: command.commandId, + threadId: command.threadId, + commandType: command.type, + acceptedAt: run.requestedAt, + events: [], + effects: [], + forkPlans: [retryAdmission(altered)!], + }) + .pipe(Effect.flip); + assert.strictEqual(rejected._tag, "ForkCommitGuardRejected"); + assert.strictEqual((yield* store.get(run.threadId))?.state, "retry_pending"); + }).pipe(Effect.provide(recoveryTestLayer)), + ); +}); diff --git a/apps/server/src/prism/recoveryAdmission.ts b/apps/server/src/prism/recoveryAdmission.ts new file mode 100644 index 0000000000..a1ccd8d0bc --- /dev/null +++ b/apps/server/src/prism/recoveryAdmission.ts @@ -0,0 +1,82 @@ +import { type OrchestrationV2Command } from "@t3tools/contracts"; +import { + latestExecutedRun, + latestRootProviderFailure, +} from "@t3tools/shared/orchestrationV2ThreadError"; +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; +import { ForkCommitGuardRejected, type ForkCommitPlan } from "../childThreads/ForkCommitPlan.ts"; +import { sameModelSelection } from "./recoveryPolicy.ts"; +import * as Projection from "../orchestration-v2/ProjectionStore.ts"; + +/** Caller composes ancestry retirement admission in the same commit plan list. */ +export function retryAdmission( + command: Extract, +): ForkCommitPlan | null { + if (command.forkPrismRetryOfRunId === undefined) return null; + const reject = () => + new ForkCommitGuardRejected({ threadId: command.threadId, kind: "state_conflict" }); + const guards = [ + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const projections = yield* Projection.ProjectionStoreV2; + const projection = yield* projections.getThreadRecords( + command.threadId, + ["runs", "turnItems", "runtimeRequests"], + { + turnItemTypes: ["error", "run_interrupt_request"], + }, + ); + const latest = latestExecutedRun(projection.runs); + const failure = latestRootProviderFailure(latest, projection.turnItems); + const row = yield* sql<{ source_run_id: string; original_run_id: string; state: string }>` + SELECT source_run_id, original_run_id, state FROM fork_prism_recovery WHERE thread_id=${command.threadId}`; + const record = row[0]; + if (!record || !latest) return yield* reject(); + if ( + command.creationSource !== "server" || + command.createdBy !== "system" || + command.messageId !== `prism-retry:${command.threadId}:${command.forkPrismOriginalRunId}` || + record.source_run_id !== command.forkPrismRetryOfRunId || + record.original_run_id !== command.forkPrismOriginalRunId || + record.state !== "retry_pending" || + latest.id !== command.forkPrismRetryOfRunId || + latest.status !== "failed" || + failure?.retryable !== true || + failure.class === "usage_limit" || + projection.thread.archivedAt !== null || + projection.thread.deletedAt !== null || + projection.thread.settledOverride === "settled" || + projection.runtimeRequests.some((request) => request.status === "pending") || + projection.turnItems.some( + (item) => item.type === "run_interrupt_request" && item.runId === latest.id, + ) || + command.modelSelection === undefined || + !sameModelSelection(latest.modelSelection, command.modelSelection) || + !sameModelSelection(projection.thread.modelSelection, latest.modelSelection) + ) + return yield* reject(); + }).pipe( + Effect.mapError((error) => + error._tag === "ForkCommitGuardRejected" + ? error + : new ForkCommitGuardRejected({ threadId: command.threadId, kind: "storage_failure" }), + ), + ), + ]; + const mutations = [ + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + // Payload and indexed state move together; replay never executes this mutation again. + yield* sql`UPDATE fork_prism_recovery SET state='retry_started', + payload_json=json_set(payload_json,'$.state','retry_started') + WHERE thread_id=${command.threadId} AND original_run_id=${command.forkPrismOriginalRunId} + AND source_run_id=${command.forkPrismRetryOfRunId} AND state='retry_pending'`; + }).pipe( + Effect.mapError( + () => new ForkCommitGuardRejected({ threadId: command.threadId, kind: "storage_failure" }), + ), + ), + ]; + return { guards, mutations }; +} diff --git a/apps/server/src/prism/recoveryPolicy.ts b/apps/server/src/prism/recoveryPolicy.ts new file mode 100644 index 0000000000..120e103a0b --- /dev/null +++ b/apps/server/src/prism/recoveryPolicy.ts @@ -0,0 +1,125 @@ +import { + CommandId, + MessageId, + ModelSelection, + RunId, + ThreadId, + type OrchestrationV2Command, + type OrchestrationV2ProviderFailure, + type OrchestrationV2Run, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Schema from "effect/Schema"; + +/** A retry keeps its original identity across reset waits and server restarts. */ +export const RecoveryRecord = Schema.Struct({ + threadId: ThreadId, + originalRunId: RunId, + sourceRunId: RunId, + sourceOrdinal: Schema.Number, + sourceEndedAt: Schema.NullOr(Schema.Number), + modelSelection: ModelSelection, + retryMessageId: MessageId, + retryUsed: Schema.Boolean, + state: Schema.Literals(["retry_pending", "retry_started", "reset_wait", "closed"]), + resetAt: Schema.NullOr(Schema.String), +}); +export type RecoveryRecord = typeof RecoveryRecord.Type; + +export const sameModelSelection = Schema.toEquivalence(ModelSelection); + +/** Only server continuations belong to the same recovery budget. */ +function continues(record: RecoveryRecord, run: OrchestrationV2Run) { + return ( + run.id === record.sourceRunId || + run.userMessageId === record.retryMessageId || + String(run.userMessageId).startsWith(`limit-resume:${record.threadId}:${record.sourceRunId}:`) + ); +} + +export function decideRecovery(input: { + readonly previous: RecoveryRecord | null; + readonly run: OrchestrationV2Run; + readonly failure: OrchestrationV2ProviderFailure | null; + readonly stoppedOrRetired: boolean; + readonly autoResume: boolean; +}): RecoveryRecord { + const { run, failure, previous } = input; + // Execution order can differ from submission order when a held queue resumes. + const sourceEndedAt = run.completedAt === null ? null : DateTime.toEpochMillis(run.completedAt); + const end = sourceEndedAt ?? Infinity; + const previousEnd = previous?.sourceEndedAt ?? Infinity; + // Terminal observers can arrive after a newer continuation already committed. + if ( + previous !== null && + run.id !== previous.sourceRunId && + (end < previousEnd || (end === previousEnd && run.ordinal < previous.sourceOrdinal)) + ) + return input.stoppedOrRetired ? { ...previous, state: "closed", resetAt: null } : previous; + const related = previous !== null && continues(previous, run); + const originalRunId = related ? previous.originalRunId : run.id; + const record: RecoveryRecord = { + threadId: run.threadId, + originalRunId, + sourceRunId: run.id, + sourceOrdinal: run.ordinal, + sourceEndedAt, + modelSelection: related ? previous.modelSelection : run.modelSelection, + retryMessageId: MessageId.make(`prism-retry:${run.threadId}:${originalRunId}`), + retryUsed: related && previous.retryUsed, + state: "closed", + resetAt: null, + }; + if (input.stoppedOrRetired) return record; + if ( + related && + !["completed", "failed", "interrupted", "cancelled", "rolled_back"].includes(run.status) + ) + return { + ...record, + state: run.userMessageId === previous.retryMessageId ? "retry_started" : previous.state, + resetAt: previous.resetAt, + }; + if (run.status !== "failed" || !failure) return record; + if ( + related && + previous.state === "retry_started" && + run.userMessageId !== previous.retryMessageId && + run.id === previous.sourceRunId + ) + return previous; + // A changed selection is a new human decision, never an automatic handoff. + if (!sameModelSelection(record.modelSelection, run.modelSelection)) return record; + if (failure.class === "usage_limit") { + const resetMs = failure.resetAt == null ? NaN : Date.parse(failure.resetAt); + const failedMs = DateTime.toEpochMillis(run.completedAt ?? run.requestedAt); + return input.autoResume && Number.isFinite(resetMs) && resetMs > failedMs + ? { ...record, state: "reset_wait", resetAt: failure.resetAt! } + : record; + } + if (related && previous.state === "closed") return record; + if (related && run.id === previous.sourceRunId && previous.state === "retry_pending") { + return { ...previous }; + } + if (record.retryUsed || failure.retryable !== true) return record; + return { ...record, state: "retry_pending", retryUsed: true }; +} + +/** Eligibility is rechecked under serialized dispatch, not trusted from this record. */ +export function retryCommand(record: RecoveryRecord): OrchestrationV2Command | null { + if (record.state !== "retry_pending") return null; + return { + type: "message.dispatch", + commandId: CommandId.make(String(record.retryMessageId)), + messageId: record.retryMessageId, + threadId: record.threadId, + forkPrismRetryOfRunId: record.sourceRunId, + forkPrismOriginalRunId: record.originalRunId, + modelSelection: record.modelSelection, + text: "Continue where you left off. The provider failure permits one retry on the same provider and model.", + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "system", + creationSource: "server", + }; +} diff --git a/apps/server/src/provider/RuntimeInstructions.ts b/apps/server/src/provider/RuntimeInstructions.ts index a7b1c5e4c2..7616c97184 100644 --- a/apps/server/src/provider/RuntimeInstructions.ts +++ b/apps/server/src/provider/RuntimeInstructions.ts @@ -1,3 +1,4 @@ +import { T3_CODE_TOOL_USE_INSTRUCTIONS } from "../mcp/toolInstructions.ts"; const PULL_REQUEST_LINKING_INSTRUCTIONS = ` When the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox. `; @@ -20,7 +21,7 @@ export function buildRuntimeInstructions(runtime: { modelName && modelName !== model ? `${modelName} (model slug: ${model})` : model; const modelInfo = model && model !== "auto" && model !== "default" ? `, as ${modelLabel}` : ""; const effortInfo = effort ? ` with ${effort} reasoning effort` : ""; - return `In case you're asked: you are running in T3 Code through the ${harness} harness${modelInfo}${effortInfo}. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n${PULL_REQUEST_LINKING_INSTRUCTIONS}`; + return `In case you're asked: you are running in T3 Code through the ${harness} harness${modelInfo}${effortInfo}. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n${PULL_REQUEST_LINKING_INSTRUCTIONS}\n\n${T3_CODE_TOOL_USE_INSTRUCTIONS}`; } function toSingleLine(value: string): string { diff --git a/apps/server/src/provider/T3OrchestrationInstructions.ts b/apps/server/src/provider/T3OrchestrationInstructions.ts index 2280d4d256..af71b98d77 100644 --- a/apps/server/src/provider/T3OrchestrationInstructions.ts +++ b/apps/server/src/provider/T3OrchestrationInstructions.ts @@ -6,8 +6,8 @@ export const T3_CODE_ORCHESTRATION_INSTRUCTIONS = ` The \`t3-code\` MCP server provides app-owned orchestration. Treat these concepts distinctly: -- A delegated task/subagent is child work owned by the current thread. Use \`orchestrator_capabilities\` to discover the current provider/model IDs from the same live catalog as the composer, including configured custom models. Do not treat a native tool's model list as the full list of available subagent models. Prefer native subagent tools for same-provider work only when they support the chosen model. Use \`delegate_task\` with that provider instance and model when native tools cannot, including for same-provider work. Also use \`delegate_task\` for cross-provider or explicitly T3-owned child tasks. Retain each returned \`taskId\`, and use \`task_status\` or \`task_cancel\` to manage it. The returned \`childThreadId\` is backing storage for the subagent, not the target for starting another delegated review round. -- \`t3_thread_launch\` and \`create_threads\` create ordinary top-level T3 conversations. Use them only when the user explicitly asks for separate/new/top-level threads or conversations. Never use them merely because the user said "subagent" or requested parallel delegated work. +- A delegated task/subagent is child work owned by the current thread. Use \`delegate_task\` so Prism selects the provider, model and effort from the configured role kit and capacity. Omit \`target\` unless the user explicitly chose a provider/model; an explicit target wins over Prism. Upstream role labels map to kits: implementation/test/general to worker, review to reviewer, research/design to planner. Use optional \`lane\` for worker difficulty and \`prismRole\` for an explicit kit. Retain each returned \`taskId\`, and use \`task_status\` or \`task_cancel\` to manage it. The returned \`childThreadId\` is backing storage, not the target for starting another delegated review round. Do not start shell agent CLIs or native subagents for work that belongs in visible T3 threads. +- \`t3_thread_launch\` and \`create_threads\` create ordinary top-level T3 conversations. \`t3_thread_launch\` uses Prism and accepts optional \`prismRole\` (including dispatcher or promachos) and \`lane\`; an explicit \`modelSelection\` wins. Use them only when the user explicitly asks for separate/new/top-level threads or conversations. Never use them merely because the user said "subagent" or requested parallel delegated work. - For every T3 delegated review round, call \`delegate_task\` again. Include the original brief, prior findings, responses, and unresolved objections in each new task prompt. Track each round by its own \`taskId\`. Use a distinct \`clientRequestId\` per round, stable across retries of that round. Do not use \`t3_thread_send\` on \`childThreadId\` to continue a delegated review. - \`schedule_task\` creates persistent recurring work in the app scheduler. Pass \`schedule\` as a structured object, never as JSON text: \`{"type":"interval","everyMs":3600000}\` for an interval, or \`{"type":"fixed_time","timeOfDay":"09:00","weekdays":[1,2,3,4,5]}\` for a wall-clock schedule, or \`{"type":"webhook"}\` to run on each request to the returned \`webhookUrl\` (the run sees the request only through \`{{body.path}}\`-style placeholders in the prompt). By default runs return to the current thread, which suits orchestrating: each trigger arrives here and you delegate or dedupe; set \`bindToCurrentThread=false\` only when the user wants a fresh thread for every run. After scheduling a timer, report the returned cadence and next run time; for a webhook, report its \`webhookUrl\`, or say T3 Connect remote access is needed if it is missing. - When you need a secret from the user (a token, API key, or webhook signing secret), call \`request_secret\` so they enter it privately, then pass the returned \`secretRef\` to the tool that needs it, e.g. \`signature.secretRef\` on a webhook task for a sender that signs requests such as GitHub. A \`secretRef\` works once. Never ask for a secret in chat, never invent one, and never repeat one. diff --git a/apps/web/src/components/settings/PrismSettings.logic.test.ts b/apps/web/src/components/settings/PrismSettings.logic.test.ts new file mode 100644 index 0000000000..f524641ad2 --- /dev/null +++ b/apps/web/src/components/settings/PrismSettings.logic.test.ts @@ -0,0 +1,296 @@ +import { + DEFAULT_SERVER_SETTINGS, + EnvironmentId, + ProjectId, + ProviderInstanceId, +} from "@t3tools/contracts"; +import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; +import { describe, expect, it } from "vite-plus/test"; +import { + movePrismPreference, + planPrismRolePatch, + prismEffortOptions, + prismModelChoices, + prismWriteObserved, +} from "./PrismSettings.logic"; +import { resolveSettingsScope } from "./settingsScope"; +import { persistScopedSettingsPatch } from "./scopedSettings"; + +const first = { instanceId: ProviderInstanceId.make("codex"), model: "first", effort: "high" }; +const second = { instanceId: ProviderInstanceId.make("opencode"), model: "second" }; +const rolesLanes = { + ...DEFAULT_SERVER_SETTINGS.prismRoles.worker.lanes, + easy: [second], + hard: [{ ...first, effort: "low" }], +}; +const roles = { + ...DEFAULT_SERVER_SETTINGS.prismRoles, + worker: { + ...DEFAULT_SERVER_SETTINGS.prismRoles.worker, + instructions: "Keep this kit", + skills: ["test"], + lanes: { ...rolesLanes, medium: [first] }, + }, +}; +const environment = (id: string, connected = true) => ({ + environmentId: EnvironmentId.make(id), + label: id, + connection: { phase: connected ? ("connected" as const) : ("offline" as const) }, + serverConfig: { + settings: { ...DEFAULT_SERVER_SETTINGS, prismRoles: roles }, + environment: { capabilities: { projectSettingsOverrides: true } }, + }, +}); + +describe("Prism role preferences", () => { + it("moves preferences with their effort and does not mutate or wrap the list", () => { + const original = [first, second]; + expect(movePrismPreference(original, 0, 1)).toEqual([second, first]); + expect(original).toEqual([first, second]); + expect(movePrismPreference(original, 0, -1)).toBe(original); + expect(movePrismPreference(original, 1, 1)).toBe(original); + expect(movePrismPreference([], 0, 1)).toEqual([]); + }); + + it("uses provider effort choices and excludes prompt-only controls", () => { + expect( + prismEffortOptions({ + optionDescriptors: [ + { + id: "agent", + label: "Agent", + type: "select", + options: [{ id: "build", label: "Build" }], + }, + { + id: "variant", + label: "Reasoning", + type: "select", + options: [ + { id: "deep", label: "Deep" }, + { id: "prompt", label: "Prompt" }, + ], + promptInjectedValues: ["prompt"], + }, + ], + }), + ).toEqual([{ id: "deep", label: "Deep" }]); + expect(prismEffortOptions(null)).toEqual([]); + }); + + it("replaces model arrays, including clearing them, without changing role kits or offline environments", () => { + const envs = [environment("one"), environment("two", false)]; + const scope = resolveSettingsScope({}, [], envs); + const plan = planPrismRolePatch(scope, envs, { + kind: "lane", + role: "worker", + lane: "medium", + models: [second], + }); + expect(plan.serverWrites.map((write) => write.environmentId)).toEqual(["one"]); + const next = applyServerSettingsPatch( + envs[0]!.serverConfig.settings, + plan.serverWrites[0]!.patch, + ); + expect(next.prismRoles.worker).toEqual({ + ...roles.worker, + lanes: { ...rolesLanes, medium: [second] }, + }); + expect(next.prismRoles.planner).toEqual(roles.planner); + const clear = planPrismRolePatch(scope, envs, { + kind: "lane", + role: "worker", + lane: "medium", + models: [], + }); + expect( + applyServerSettingsPatch(next, clear.serverWrites[0]!.patch).prismRoles.worker.lanes.medium, + ).toEqual([]); + }); + + it("preserves each project's own kit when writing a project override", () => { + const env = environment("one"); + const projectId = ProjectId.make("project"); + const member = { + id: projectId, + environmentId: env.environmentId, + title: "Project", + workspaceRoot: "/repo", + physicalProjectKey: "one:/repo", + environmentLabel: "one", + defaultModelSelection: null, + scripts: [], + createdAt: "2026-09-25T00:00:00.000Z", + updatedAt: "2026-09-25T00:00:00.000Z", + }; + const scopedEnv = { + ...env, + serverConfig: { + ...env.serverConfig, + settings: { + ...env.serverConfig.settings, + projectSettingsOverrides: { + [projectId]: { + prismRoles: { + ...roles, + worker: { ...roles.worker, instructions: "Project instructions" }, + }, + }, + }, + }, + }, + }; + const group = { + ...member, + projectKey: "group", + displayName: "Project", + memberProjects: [member], + memberProjectRefs: [{ environmentId: env.environmentId, projectId }], + groupedProjectCount: 1, + environmentPresence: "remote-only" as const, + allRemoteMembersAreDesktopLocal: false, + allRemoteMembersAreWsl: false, + remoteEnvironmentLabels: ["one"], + }; + const scope = resolveSettingsScope({ project: "group" }, [group], [scopedEnv]); + const plan = planPrismRolePatch(scope, [scopedEnv], { + kind: "lane", + role: "worker", + lane: "medium", + models: [second, first], + }); + const next = applyServerSettingsPatch( + scopedEnv.serverConfig.settings, + plan.serverWrites[0]!.patch, + ); + expect(next.prismRoles).toEqual(roles); + expect(next.projectSettingsOverrides[projectId]?.prismRoles?.worker).toEqual({ + ...roles.worker, + instructions: "Project instructions", + lanes: { ...rolesLanes, medium: [second, first] }, + }); + expect(next.projectSettingsOverrides[projectId]?.prismRoles?.reviewer).toEqual(roles.reviewer); + }); + + it("reports disconnected scopes and partial save failures", async () => { + const offline = environment("offline", false); + const scope = resolveSettingsScope({ machine: offline.environmentId }, [], [offline]); + expect( + planPrismRolePatch(scope, [offline], { + kind: "lane", + role: "worker", + lane: "medium", + models: [], + }).unavailableReason, + ).toBeTruthy(); + const envs = [environment("one"), environment("two")]; + const plan = planPrismRolePatch(resolveSettingsScope({}, [], envs), envs, { + kind: "lane", + role: "worker", + lane: "medium", + models: [first], + }); + const result = await persistScopedSettingsPatch( + plan, + async ({ environmentId }) => ({ _tag: environmentId === "one" ? "Success" : "Failure" }), + () => {}, + ); + expect(result.savedEnvironmentCount).toBe(1); + expect(result.failedEnvironments.map((env) => env.label)).toEqual(["two"]); + }); +}); + +it("offers only enabled models available across the selected scope", () => { + const entry = { + instanceId: first.instanceId, + displayName: "Codex", + enabled: true, + isAvailable: true, + models: [], + }; + const options = new Map([ + [ + first.instanceId, + [ + { slug: "enabled", name: "Enabled" }, + { slug: "unavailable", name: "Unavailable", isUnavailable: true }, + { slug: "other-scope", name: "Other scope" }, + ], + ], + ]); + expect( + prismModelChoices([entry], options, (_, model) => + model === "other-scope" ? "Unavailable elsewhere" : null, + ).map((choice) => choice.preference.model), + ).toEqual(["enabled"]); + expect(prismModelChoices([{ ...entry, enabled: false }], options, () => null)).toEqual([]); + expect(prismModelChoices([{ ...entry, isAvailable: false }], options, () => null)).toEqual([]); +}); + +it("keeps the same model at distinct efforts as separate ordered entries", () => { + const envs = [environment("one")]; + const models = [first, { ...first, effort: "low" }]; + const plan = planPrismRolePatch(resolveSettingsScope({}, [], envs), envs, { + kind: "models", + role: "reviewer", + models, + }); + const next = applyServerSettingsPatch( + envs[0]!.serverConfig.settings, + plan.serverWrites[0]!.patch, + ); + expect(next.prismRoles.reviewer.models).toEqual(models); + expect(next.prismRoles.worker).toEqual(roles.worker); +}); + +it("switches Retry off and back on without touching its model list", () => { + const envs = [environment("one")]; + const scope = resolveSettingsScope({}, [], envs); + const off = planPrismRolePatch(scope, envs, { + kind: "enabled", + role: "retry", + enabled: false, + }); + const next = applyServerSettingsPatch(envs[0]!.serverConfig.settings, off.serverWrites[0]!.patch); + expect(next.prismRoles.retry).toEqual({ ...roles.retry, enabled: false }); + expect(next.prismRoles.escalation.enabled).toBe(true); + const on = planPrismRolePatch(scope, envs, { + kind: "enabled", + role: "retry", + enabled: true, + }); + expect(applyServerSettingsPatch(next, on.serverWrites[0]!.patch).prismRoles.retry).toEqual( + roles.retry, + ); +}); + +it("keeps the save barrier until the updated settings snapshot arrives", () => { + const envs = [environment("one")]; + const plan = planPrismRolePatch(resolveSettingsScope({}, [], envs), envs, { + kind: "lane", + role: "worker", + lane: "easy", + models: [first], + }); + const expectation = { + kind: "lane" as const, + role: "worker" as const, + lane: "easy" as const, + models: [first], + }; + expect(prismWriteObserved(plan, envs, expectation)).toBe(false); + const updated = { + ...envs[0]!, + serverConfig: { + ...envs[0]!.serverConfig, + settings: applyServerSettingsPatch( + envs[0]!.serverConfig.settings, + plan.serverWrites[0]!.patch, + ), + }, + }; + expect(prismWriteObserved(plan, [updated], expectation)).toBe(true); + expect(prismWriteObserved(plan, [], expectation)).toBe(false); + // Failed writes do not wait forever for a snapshot that will never arrive. + expect(prismWriteObserved({ ...plan, serverWrites: [] }, envs, expectation)).toBe(true); +}); diff --git a/apps/web/src/components/settings/PrismSettings.logic.ts b/apps/web/src/components/settings/PrismSettings.logic.ts new file mode 100644 index 0000000000..09e8815981 --- /dev/null +++ b/apps/web/src/components/settings/PrismSettings.logic.ts @@ -0,0 +1,167 @@ +import { + ProjectId, + type PrismModelPreference, + type PrismRole, + type PrismRoleKits, + type PrismRoleKitsPatch, + type PrismLane, + type PrismSwitchableRole, + type ModelCapabilities, + type ProviderInstanceId, +} from "@t3tools/contracts"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; +import type { ProviderInstanceEntry } from "../../providerInstances"; +import type { ModelEsque } from "../chat/providerIconUtils"; +import { planScopedSettingsPatch } from "./scopedSettings"; + +export function prismModelKey(entry: Pick) { + return JSON.stringify([entry.instanceId, entry.model]); +} + +export function movePrismPreference( + models: readonly T[], + index: number, + direction: -1 | 1, +) { + const destination = index + direction; + if (index < 0 || index >= models.length || destination < 0 || destination >= models.length) + return models; + const next = [...models]; + [next[index], next[destination]] = [next[destination]!, next[index]!]; + return next; +} + +export function prismEffortOptions(capabilities: ModelCapabilities | null | undefined) { + const descriptor = capabilities?.optionDescriptors?.find( + (option) => + option.type === "select" && ["effort", "reasoningEffort", "variant"].includes(option.id), + ); + return descriptor?.type === "select" + ? descriptor.options.filter((option) => !descriptor.promptInjectedValues?.includes(option.id)) + : []; +} + +/** One autosaved edit on the Prism page. */ +export type PrismRoleUpdate = + | { kind: "lane"; role: "worker"; lane: PrismLane; models: readonly PrismModelPreference[] } + | { + kind: "models"; + role: Exclude; + models: readonly PrismModelPreference[]; + } + | { kind: "enabled"; role: PrismSwitchableRole; enabled: boolean }; + +function prismRolePatch(update: PrismRoleUpdate): PrismRoleKitsPatch { + switch (update.kind) { + case "lane": + return { worker: { lanes: { [update.lane]: update.models } } }; + case "models": + return { [update.role]: { models: update.models } }; + case "enabled": + return { [update.role]: { enabled: update.enabled } }; + } +} + +function applyPrismRoleUpdate(kits: PrismRoleKits, update: PrismRoleUpdate): PrismRoleKits { + switch (update.kind) { + case "lane": + return { + ...kits, + worker: { ...kits.worker, lanes: { ...kits.worker.lanes, [update.lane]: update.models } }, + }; + case "models": + return { ...kits, [update.role]: { ...kits[update.role], models: update.models } }; + case "enabled": + return { ...kits, [update.role]: { ...kits[update.role], enabled: update.enabled } }; + } +} + +/** Project overrides store complete kits, while environment updates accept deep patches. */ +export function planPrismRolePatch( + scope: Parameters[0], + environments: Parameters[1], + update: PrismRoleUpdate, +): ReturnType { + const plan = planScopedSettingsPatch(scope, environments, { + prismRoles: prismRolePatch(update), + }); + return { + ...plan, + serverWrites: plan.serverWrites.map((write) => { + const overrides = write.patch.projectSettingsOverrides; + const settings = environments.find((env) => env.environmentId === write.environmentId) + ?.serverConfig?.settings; + if (!overrides || !settings) return write; + return { + ...write, + patch: { + ...write.patch, + projectSettingsOverrides: Object.fromEntries( + Object.entries(overrides).map(([id, override]) => [ + id, + { + ...override, + prismRoles: applyPrismRoleUpdate( + resolveProjectSettings(settings, ProjectId.make(id)).settings.prismRoles, + update, + ), + }, + ]), + ), + }, + }; + }), + }; +} + +export function prismModelChoices( + entries: readonly Pick< + ProviderInstanceEntry, + "instanceId" | "displayName" | "enabled" | "isAvailable" | "models" + >[], + options: ReadonlyMap, + disabledReason: (instanceId: ProviderInstanceId, model: string) => string | null, +) { + return entries + .filter((entry) => entry.enabled && entry.isAvailable) + .flatMap((entry) => + (options.get(entry.instanceId) ?? []) + .filter((model) => !model.isUnavailable && !disabledReason(entry.instanceId, model.slug)) + .map((model) => ({ + preference: { instanceId: entry.instanceId, model: model.slug }, + label: `${entry.displayName} / ${model.name}`, + efforts: prismEffortOptions( + entry.models.find((candidate) => candidate.slug === model.slug)?.capabilities, + ), + })), + ); +} + +export type PrismWriteExpectation = PrismRoleUpdate | { kind: "inherit" }; + +/** A successful RPC is not enough: wait for the streamed settings before building another full override. */ +export function prismWriteObserved( + plan: ReturnType, + environments: Parameters[1], + expectation: PrismWriteExpectation, +) { + return plan.serverWrites.every((write) => { + const settings = environments.find((env) => env.environmentId === write.environmentId) + ?.serverConfig?.settings; + if (!settings) return false; + const projectIds = Object.keys(write.patch.projectSettingsOverrides ?? {}); + if (expectation.kind === "inherit") { + return projectIds.every( + (id) => settings.projectSettingsOverrides[ProjectId.make(id)]?.prismRoles === undefined, + ); + } + const effective = projectIds.length + ? projectIds.map((id) => resolveProjectSettings(settings, ProjectId.make(id)).settings) + : [settings]; + return effective.every( + (target) => + JSON.stringify(target.prismRoles[expectation.role]) === + JSON.stringify(applyPrismRoleUpdate(target.prismRoles, expectation)[expectation.role]), + ); + }); +} diff --git a/apps/web/src/components/settings/PrismSettings.state.test.ts b/apps/web/src/components/settings/PrismSettings.state.test.ts new file mode 100644 index 0000000000..0390e86905 --- /dev/null +++ b/apps/web/src/components/settings/PrismSettings.state.test.ts @@ -0,0 +1,81 @@ +import { DEFAULT_SERVER_SETTINGS, EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; +import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; +import { describe, expect, it } from "vite-plus/test"; +import { planPrismRolePatch } from "./PrismSettings.logic"; +import { createPrismSaveStore } from "./PrismSettings.state"; +import { resolveSettingsScope } from "./settingsScope"; + +const environment = { + environmentId: EnvironmentId.make("one"), + label: "one", + connection: { phase: "connected" as const }, + serverConfig: { settings: DEFAULT_SERVER_SETTINGS }, +}; +const models = [{ instanceId: ProviderInstanceId.make("codex"), model: "model", effort: "high" }]; +const expectation = { + kind: "lane" as const, + role: "worker" as const, + lane: "easy" as const, + models, +}; +const scope = resolveSettingsScope({}, [], [environment]); +const plan = planPrismRolePatch(scope, [environment], { + kind: "lane", + role: "worker", + lane: "easy", + models: models, +}); +const updatedEnvironment = { + ...environment, + serverConfig: { + settings: applyServerSettingsPatch( + environment.serverConfig.settings, + plan.serverWrites[0]!.patch, + ), + }, +}; + +describe("Prism save lifecycle", () => { + it("retains the guard without page subscribers until acknowledgement and fresh settings arrive", () => { + const store = createPrismSaveStore(); + const unsubscribe = store.subscribe(() => {}); + expect(store.getState().begin(plan, expectation)).toBe(true); + unsubscribe(); // Scope navigation unmounts the editor while its RPC is outstanding. + expect(store.getState().begin(plan, expectation)).toBe(false); + store.getState().observe([updatedEnvironment]); + expect(store.getState().pendingWrite).not.toBeNull(); // Stream may arrive before the receipt. + store.getState().acknowledge(new Set()); + store.getState().observe([environment]); + expect(store.getState().begin(plan, expectation)).toBe(false); // Receipt alone cannot release stale data. + store.getState().observe([updatedEnvironment]); + expect(store.getState().pendingWrite).toBeNull(); + const next = planPrismRolePatch(scope, [updatedEnvironment], { + kind: "lane", + role: "worker", + lane: "hard", + models: models, + }); + expect(store.getState().begin(next, { ...expectation, lane: "hard" })).toBe(true); + const settings = applyServerSettingsPatch( + updatedEnvironment.serverConfig.settings, + next.serverWrites[0]!.patch, + ); + expect(settings.prismRoles.worker.lanes.easy).toEqual(models); + expect(settings.prismRoles.worker.lanes.hard).toEqual(models); + }); + + it("releases failed writes without expecting nonexistent snapshots and retains their error", () => { + const store = createPrismSaveStore(); + store.getState().begin(plan, expectation); + store.getState().acknowledge(new Set([environment.environmentId])); + store.getState().setSaveError("one failed"); + store.getState().observe([environment]); + expect(store.getState().pendingWrite).toBeNull(); + expect(store.getState().saveError).toBe("one failed"); + expect(store.getState().begin(plan, expectation)).toBe(true); + expect(store.getState().saveError).toBeNull(); + store.getState().fail("Connection lost"); + expect(store.getState().pendingWrite).toBeNull(); + expect(store.getState().saveError).toBe("Connection lost"); + }); +}); diff --git a/apps/web/src/components/settings/PrismSettings.state.ts b/apps/web/src/components/settings/PrismSettings.state.ts new file mode 100644 index 0000000000..e353bb82c2 --- /dev/null +++ b/apps/web/src/components/settings/PrismSettings.state.ts @@ -0,0 +1,60 @@ +import { createStore } from "zustand/vanilla"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { + planPrismRolePatch, + prismWriteObserved, + type PrismWriteExpectation, +} from "./PrismSettings.logic"; + +type Plan = ReturnType; +interface PrismSaveState { + pendingWrite: { plan: Plan; expectation: PrismWriteExpectation; acknowledged: boolean } | null; + saveError: string | null; + begin: (plan: Plan, expectation: PrismWriteExpectation) => boolean; + acknowledge: (failed: ReadonlySet) => void; + observe: (environments: Parameters[1]) => void; + fail: (message: string) => void; + setSaveError: (message: string | null) => void; +} + +export function createPrismSaveStore() { + return createStore((set, get) => ({ + pendingWrite: null, + saveError: null, + begin: (plan, expectation) => { + if (get().pendingWrite) return false; + set({ pendingWrite: { plan, expectation, acknowledged: false }, saveError: null }); + return true; + }, + acknowledge: (failed) => { + const pending = get().pendingWrite; + if (!pending) return; + set({ + pendingWrite: { + ...pending, + acknowledged: true, + plan: { + ...pending.plan, + serverWrites: pending.plan.serverWrites.filter( + (write) => !failed.has(write.environmentId), + ), + }, + }, + }); + }, + observe: (environments) => { + const pending = get().pendingWrite; + if ( + pending?.acknowledged && + prismWriteObserved(pending.plan, environments, pending.expectation) + ) { + set({ pendingWrite: null }); + } + }, + fail: (saveError) => set({ pendingWrite: null, saveError }), + setSaveError: (saveError) => set({ saveError }), + })); +} + +// Scope navigation remounts the settings page. The write and its snapshot barrier must outlive it. +export const prismSaveStore = createPrismSaveStore(); diff --git a/apps/web/src/components/settings/PrismSettings.tsx b/apps/web/src/components/settings/PrismSettings.tsx new file mode 100644 index 0000000000..b7f1b89bda --- /dev/null +++ b/apps/web/src/components/settings/PrismSettings.tsx @@ -0,0 +1,722 @@ +import { + PRISM_ROLE_LABELS, + PRISM_SWITCHABLE_ROLES, + PrismLane, + type PrismModelPreference, + type PrismRole, + type PrismSwitchableRole, +} from "@t3tools/contracts"; +import { + closestCenter, + DndContext, + type DragEndEvent, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { restrictToVerticalAxis } from "@dnd-kit/modifiers"; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { Link } from "@tanstack/react-router"; +import { type ReactNode, useEffect, useRef, useState } from "react"; +import { useStore } from "zustand"; +import { useEnvironments } from "../../state/environments"; +import { prismSaveStore } from "./PrismSettings.state"; +import { + ArrowDownIcon, + ArrowUpIcon, + ChevronsUpIcon, + GripVerticalIcon, + HammerIcon, + LockIcon, + RotateCcwIcon, + RouteIcon, + SearchCheckIcon, + TrashIcon, +} from "lucide-react"; +import { getCustomModelOptionsByInstance } from "../../modelSelection"; +import { + applyProviderInstanceSettings, + deriveProviderInstanceEntries, +} from "../../providerInstances"; +import { cn } from "../../lib/utils"; +import { useThreadShells } from "../../state/entities"; +import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { Button } from "../ui/button"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { Switch } from "../ui/switch"; +import { Toggle, ToggleGroup } from "../ui/toggle-group"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { SettingsGroup } from "./SettingsGroup"; +import { useSettingsScope } from "./SettingsScopeContext"; +import { SettingsPageContainer, SettingsSection } from "./settingsLayout"; +import { useScopedSettings } from "./useScopedSettings"; +import { useScopedModelDisabledReason } from "./useScopedModelAvailability"; +import { persistScopedSettingsPatch, planScopedSettingsClear } from "./scopedSettings"; +import { + movePrismPreference, + planPrismRolePatch, + prismModelChoices, + prismModelKey, + type PrismRoleUpdate, + type PrismWriteExpectation, +} from "./PrismSettings.logic"; + +type ModelChoice = ReturnType[number]; + +/** The planner is whoever submits the job; it is not configured here. */ +const PAGE_ROLES = [ + "promachos", + "dispatcher", + "worker", + "reviewer", + "retry", + "escalation", +] as const; +type PageRole = (typeof PAGE_ROLES)[number]; + +const ROLE_DETAILS: Record< + PageRole, + { icon: ReactNode; description: string; lockedReason?: string } +> = { + promachos: { + icon: , + description: "Starts a new Promachos conversation on the first model with capacity.", + lockedReason: "Every new Promachos conversation starts from this list.", + }, + dispatcher: { + icon: , + description: "Owns a job: runs workers, handles problems and delivers one PR.", + lockedReason: "Needed until router #117 decides whether jobs keep a dispatcher.", + }, + worker: { + icon: , + description: "Implements the task and commits as it works.", + lockedReason: "Required.", + }, + reviewer: { + icon: , + description: "Reviews the finished change before the PR is ready.", + lockedReason: "Independent review is required.", + }, + retry: { + icon: , + description: "Takes over a failed worker turn on another model.", + }, + escalation: { + icon: , + description: "Moves stuck work to a stronger model before asking the planner.", + }, +}; + +const LANE_LABELS: Record = { easy: "Easy", medium: "Medium", hard: "Hard" }; + +const isSwitchable = (role: PrismRole): role is PrismSwitchableRole => + (PRISM_SWITCHABLE_ROLES as readonly PrismRole[]).includes(role); + +type DraftPreference = PrismModelPreference & { entryId: number }; +const editablePreference = (model: PrismModelPreference, entryId: number): DraftPreference => ({ + ...model, + entryId, +}); +const savedPreferences = (models: readonly DraftPreference[]) => + models.map(({ entryId: _entryId, ...model }) => model); + +function ModelRow({ + entry, + index, + count, + title, + choice, + disabled, + onChange, + onMove, + onRemove, +}: { + entry: DraftPreference; + index: number; + count: number; + title: string; + choice: ModelChoice | undefined; + disabled: boolean; + onChange: (effort: string) => void; + onMove: (direction: -1 | 1) => void; + onRemove: () => void; +}) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: entry.entryId, + disabled, + }); + const efforts = choice?.efforts ?? []; + const unknownEffort = entry.effort && !efforts.some((effort) => effort.id === entry.effort); + return ( +
  • + + + {index === 0 ? "Primary" : `Fallback ${index}`} + + + {choice?.label ?? `${entry.instanceId} / ${entry.model}`} + {!choice && ( + + Unavailable on one or more selected targets. + + )} + + + + + +
  • + ); +} + +/** One ordered list, saved on every change like the Providers page. */ +function ModelList({ + title, + saved, + choices, + mixed, + disabled, + save, +}: { + title: string; + saved: readonly PrismModelPreference[]; + choices: readonly ModelChoice[]; + mixed: boolean; + disabled: boolean; + save: (models: readonly PrismModelPreference[]) => Promise; +}) { + const [draft, setDraft] = useState(() => + saved.map(editablePreference), + ); + const nextEntryId = useRef(saved.length); + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); + const commit = (next: readonly DraftPreference[]) => { + setDraft(next); + // The page shows the failure; the list returns to what is saved. + save(savedPreferences(next)).catch(() => setDraft(saved.map(editablePreference))); + }; + const onDragEnd = ({ active, over }: DragEndEvent) => { + const from = draft.findIndex((entry) => entry.entryId === active.id); + const to = draft.findIndex((entry) => entry.entryId === over?.id); + if (from >= 0 && to >= 0 && from !== to) commit(arrayMove([...draft], from, to)); + }; + return ( +
    + {mixed && ( +

    + Preferences differ across this scope. A change replaces this list on the selected targets. +

    + )} + {draft.length === 0 && ( +

    No preferred models configured.

    + )} + + entry.entryId)} + strategy={verticalListSortingStrategy} + > +
      + {draft.map((entry, index) => ( + prismModelKey(candidate.preference) === prismModelKey(entry), + )} + disabled={disabled} + onChange={(effort) => + commit( + draft.map((model, position) => + position === index + ? { + entryId: model.entryId, + instanceId: model.instanceId, + model: model.model, + ...(effort ? { effort } : {}), + } + : model, + ), + ) + } + onMove={(direction) => commit(movePrismPreference(draft, index, direction))} + onRemove={() => commit(draft.filter((_, position) => position !== index))} + /> + ))} +
    +
    +
    + + {choices.length === 0 && ( +

    + Enable models in Providers for this scope to add preferences. +

    + )} +
    + ); +} + +function RoleRow({ + role, + enabled, + selected, + disabled, + onSelect, + onEnabledChange, +}: { + role: PageRole; + enabled: boolean; + selected: boolean; + disabled: boolean; + onSelect: () => void; + onEnabledChange: (enabled: boolean) => void; +}) { + const { icon, description, lockedReason } = ROLE_DETAILS[role]; + const name = PRISM_ROLE_LABELS[role]; + return ( +
    +
    +
    + + {lockedReason ? : null} + {lockedReason ? ( + + + + + } + /> + {lockedReason} + + ) : ( + onEnabledChange(Boolean(checked))} + aria-label={`Enable ${name}`} + /> + )} + +
    + ); +} + +export function PrismSettings() { + const { scope, target, targets, environment, environments, connectedEnvironments } = + useSettingsScope(); + const settings = useScopedSettings(); + const { pendingWrite, saveError, setSaveError } = useStore(prismSaveStore); + const { environments: allEnvironments } = useEnvironments(); + const persist = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: false }); + useEffect(() => { + if (pendingWrite?.acknowledged) prismSaveStore.getState().observe(allEnvironments); + }, [allEnvironments, pendingWrite]); + async function savePlan( + plan: ReturnType, + expectation: PrismWriteExpectation, + ) { + if (plan.unavailableReason) throw new Error(plan.unavailableReason); + if (!prismSaveStore.getState().begin(plan, expectation)) { + throw new Error("Wait for the current settings update to finish."); + } + let result; + try { + result = await persistScopedSettingsPatch(plan, persist, () => {}); + } catch (cause) { + prismSaveStore + .getState() + .fail(cause instanceof Error ? cause.message : "Could not save preferences."); + throw cause; + } + const failed = new Set(result.failedEnvironments.map((env) => env.environmentId)); + prismSaveStore.getState().acknowledge(failed); + if (failed.size) { + const message = `Could not save preferences on ${result.failedEnvironments.map((env) => env.label).join(", ")}.${result.savedEnvironmentCount ? " Other selected environments saved the change." : ""}`; + // The representative update remounts its list editor, so failures also live on the page. + setSaveError(message); + throw new Error(message); + } + } + const saveRole = (update: PrismRoleUpdate) => + savePlan(planPrismRolePatch(scope, environments, update), update).catch((cause: unknown) => { + setSaveError(cause instanceof Error ? cause.message : "Could not save preferences."); + throw cause; + }); + const [selectedRole, setSelectedRole] = useState("dispatcher"); + const [workerLane, setWorkerLane] = useState("medium"); + const roleEnabled = (role: PageRole) => + isSwitchable(role) ? settings.prismRoles[role].enabled : true; + + const providers = environment?.serverConfig?.providers ?? EMPTY_SERVER_PROVIDERS; + const entries = applyProviderInstanceSettings(deriveProviderInstanceEntries(providers), settings); + const options = getCustomModelOptionsByInstance(settings, providers); + const disabledReason = useScopedModelDisabledReason(settings, entries); + const choices = prismModelChoices(entries, options, disabledReason); + const threads = useThreadShells(); + const thread = threads + .filter((candidate) => + targets.some( + (selected) => + selected.environmentId === candidate.environmentId && + (selected.projectId === null || selected.projectId === candidate.projectId), + ), + ) + .toSorted((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0]; + return ( + + { + void savePlan(planScopedSettingsClear(scope, environments, ["prismRoles"]), { + kind: "inherit", + }).catch((cause: unknown) => + setSaveError( + cause instanceof Error ? cause.message : "Could not reset role settings.", + ), + ); + }} + > + Use environment role settings + + ) : null + } + > +

    + The first model in a list is primary; the rest are fallbacks. Only models enabled in + Providers for the selected scope are eligible. Changes save automatically. +

    + {!target && ( +

    + Connect an environment to edit Prism preferences. +

    + )} + {saveError && ( +

    + {saveError} +

    + )} + +
    + {PAGE_ROLES.map((role) => ( + setSelectedRole(role)} + onEnabledChange={(enabled) => { + if (isSwitchable(role)) + void saveRole({ kind: "enabled", role, enabled }).catch(() => {}); + }} + /> + ))} +
    +
    +
    + + {ROLE_DETAILS[selectedRole].icon} + +
    +

    {PRISM_ROLE_LABELS[selectedRole]}

    +

    + {ROLE_DETAILS[selectedRole].description} +

    +

    + {ROLE_DETAILS[selectedRole].lockedReason + ? `Always on. ${ROLE_DETAILS[selectedRole].lockedReason}` + : roleEnabled(selectedRole) + ? "On. Switch it off to skip this step." + : "Off. Prism skips this step."} +

    +
    + {pendingWrite && ( + + Saving… + + )} +
    + {selectedRole === "worker" ? ( + <> + { + const lane = PrismLane.literals.find((candidate) => candidate === next[0]); + if (lane) setWorkerLane(lane); + }} + > + {PrismLane.literals.map((lane) => ( + + {LANE_LABELS[lane]} + + ))} + + + JSON.stringify(candidate.settings.prismRoles.worker.lanes[workerLane]) !== + JSON.stringify(settings.prismRoles.worker.lanes[workerLane]), + )} + save={(models) => + saveRole({ kind: "lane", role: "worker", lane: workerLane, models }) + } + /> + + ) : ( + + JSON.stringify(candidate.settings.prismRoles[selectedRole].models) !== + JSON.stringify(settings.prismRoles[selectedRole].models), + )} + save={(models) => saveRole({ kind: "models", role: selectedRole, models })} + /> + )} +
    +
    +
    + +
    + {connectedEnvironments.map((env) => ( +
    +

    {env.label}

    + {(env.serverConfig?.providers ?? []).map((provider) => ( +
    +

    + {provider.displayName ?? provider.instanceId} + {!provider.enabled ? " (disabled)" : ""} +

    + {provider.usageLimits?.checkedAt && ( +

    + Checked {new Date(provider.usageLimits.checkedAt).toLocaleString()} +

    + )} + {provider.usageLimits?.unavailable && ( +

    + {provider.usageLimits.unavailable.message ?? + (provider.usageLimits.unavailable.reason === "probeFailed" + ? "Usage refresh failed; showing the last reading if available." + : "Usage reporting is unsupported.")} +

    + )} + {!provider.usageLimits?.windows.length && ( +

    No usage windows reported.

    + )} + {provider.usageLimits?.windows.map((window) => ( +
    +
    + + {window.label}: {window.usedPercent}% used + + + {window.resetsAt + ? `Resets ${new Date(window.resetsAt).toLocaleString()}` + : "Reset time not reported"} + +
    + +
    + ))} +
    + ))} +
    + ))} + {connectedEnvironments.length === 0 && ( +

    + Connect an environment to see its capacity. +

    + )} +
    +
    + +
    +

    + Recent jobs are not included in this snapshot. Open a thread to see its delegated tasks + in Lineage. +

    + {thread ? ( + + Open thread + + ) : ( + + Open a thread to view Lineage + + )} +
    +
    +
    + ); +} diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index 048e19b7a6..67f80e5bb8 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -11,6 +11,7 @@ import { } from "react"; import { ArchiveIcon, + RouteIcon, BlocksIcon, BotIcon, createLucideIcon, @@ -83,6 +84,7 @@ const SETTINGS_SECTION_ICONS: Readonly< "/settings/keybindings": KeyboardIcon, "/settings/snap-shot": SnapShotIcon, "/settings/providers": BotIcon, + "/settings/prism": RouteIcon, "/settings/integrations": BlocksIcon, "/settings/scheduled-tasks": CalendarClockIcon, "/settings/source-control": GitBranchIcon, diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 48b455f150..e039abf7ce 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -17,6 +17,7 @@ export type SettingsPath = | "/settings/appearance" | "/settings/keybindings" | "/settings/snap-shot" + | "/settings/prism" | "/settings/providers" | "/settings/integrations" | "/settings/scheduled-tasks" @@ -93,6 +94,7 @@ export const SETTINGS_SECTION_LABELS: Readonly> = { "/settings/keybindings": "Keybindings", "/settings/snap-shot": "SnapShots", "/settings/providers": "Providers", + "/settings/prism": "Prism", "/settings/integrations": "Integrations", "/settings/scheduled-tasks": "Scheduled Tasks", "/settings/source-control": "Source Control", @@ -134,6 +136,22 @@ const KEYBINDING_SEARCH_ITEMS = STATIC_KEYBINDING_COMMANDS.toSorted((left, right * that may not be mounted point at their nearest stable section instead. */ export const SETTINGS_SEARCH_ITEMS = [ + { + id: "prism-roles", + title: "Prism role preferences", + to: "/settings/prism", + targetId: "prism-roles", + scope: "project-defaults", + searchTerms: ["model", "routing", "worker", "lane", "kit", "effort"], + }, + { + id: "prism-capacity", + title: "Prism live capacity", + to: "/settings/prism", + targetId: "prism-capacity", + scope: "environment", + searchTerms: ["usage", "limits", "provider"], + }, { id: "storage-worktrees", title: "Worktree cleanup", @@ -943,6 +961,7 @@ const SETTINGS_CATEGORY_SCOPES: Readonly SettingsRoute, } as any) +const SettingsPrismRoute = SettingsPrismRouteImport.update({ + id: '/prism', + path: '/prism', + getParentRoute: () => SettingsRoute, +} as any) const SettingsOpenSourceLicensesRoute = SettingsOpenSourceLicensesRouteImport.update({ id: '/open-source-licenses', @@ -186,6 +192,7 @@ export interface FileRoutesByFullPath { '/settings/integrations': typeof SettingsIntegrationsRoute '/settings/keybindings': typeof SettingsKeybindingsRoute '/settings/open-source-licenses': typeof SettingsOpenSourceLicensesRoute + '/settings/prism': typeof SettingsPrismRoute '/settings/projects': typeof SettingsProjectsRoute '/settings/providers': typeof SettingsProvidersRoute '/settings/scheduled-tasks': typeof SettingsScheduledTasksRoute @@ -212,6 +219,7 @@ export interface FileRoutesByTo { '/settings/integrations': typeof SettingsIntegrationsRoute '/settings/keybindings': typeof SettingsKeybindingsRoute '/settings/open-source-licenses': typeof SettingsOpenSourceLicensesRoute + '/settings/prism': typeof SettingsPrismRoute '/settings/projects': typeof SettingsProjectsRoute '/settings/providers': typeof SettingsProvidersRoute '/settings/scheduled-tasks': typeof SettingsScheduledTasksRoute @@ -241,6 +249,7 @@ export interface FileRoutesById { '/settings/integrations': typeof SettingsIntegrationsRoute '/settings/keybindings': typeof SettingsKeybindingsRoute '/settings/open-source-licenses': typeof SettingsOpenSourceLicensesRoute + '/settings/prism': typeof SettingsPrismRoute '/settings/projects': typeof SettingsProjectsRoute '/settings/providers': typeof SettingsProvidersRoute '/settings/scheduled-tasks': typeof SettingsScheduledTasksRoute @@ -271,6 +280,7 @@ export interface FileRouteTypes { | '/settings/integrations' | '/settings/keybindings' | '/settings/open-source-licenses' + | '/settings/prism' | '/settings/projects' | '/settings/providers' | '/settings/scheduled-tasks' @@ -297,6 +307,7 @@ export interface FileRouteTypes { | '/settings/integrations' | '/settings/keybindings' | '/settings/open-source-licenses' + | '/settings/prism' | '/settings/projects' | '/settings/providers' | '/settings/scheduled-tasks' @@ -325,6 +336,7 @@ export interface FileRouteTypes { | '/settings/integrations' | '/settings/keybindings' | '/settings/open-source-licenses' + | '/settings/prism' | '/settings/projects' | '/settings/providers' | '/settings/scheduled-tasks' @@ -447,6 +459,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsProjectsRouteImport parentRoute: typeof SettingsRoute } + '/settings/prism': { + id: '/settings/prism' + path: '/prism' + fullPath: '/settings/prism' + preLoaderRoute: typeof SettingsPrismRouteImport + parentRoute: typeof SettingsRoute + } '/settings/open-source-licenses': { id: '/settings/open-source-licenses' path: '/open-source-licenses' @@ -559,6 +578,7 @@ interface SettingsRouteChildren { SettingsIntegrationsRoute: typeof SettingsIntegrationsRoute SettingsKeybindingsRoute: typeof SettingsKeybindingsRoute SettingsOpenSourceLicensesRoute: typeof SettingsOpenSourceLicensesRoute + SettingsPrismRoute: typeof SettingsPrismRoute SettingsProjectsRoute: typeof SettingsProjectsRoute SettingsProvidersRoute: typeof SettingsProvidersRoute SettingsScheduledTasksRoute: typeof SettingsScheduledTasksRoute @@ -576,6 +596,7 @@ const SettingsRouteChildren: SettingsRouteChildren = { SettingsIntegrationsRoute: SettingsIntegrationsRoute, SettingsKeybindingsRoute: SettingsKeybindingsRoute, SettingsOpenSourceLicensesRoute: SettingsOpenSourceLicensesRoute, + SettingsPrismRoute: SettingsPrismRoute, SettingsProjectsRoute: SettingsProjectsRoute, SettingsProvidersRoute: SettingsProvidersRoute, SettingsScheduledTasksRoute: SettingsScheduledTasksRoute, diff --git a/apps/web/src/routes/settings.prism.tsx b/apps/web/src/routes/settings.prism.tsx new file mode 100644 index 0000000000..1ab7334891 --- /dev/null +++ b/apps/web/src/routes/settings.prism.tsx @@ -0,0 +1,4 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { PrismSettings } from "../components/settings/PrismSettings"; + +export const Route = createFileRoute("/settings/prism")({ component: PrismSettings }); diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 716d861698..103338a303 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -67,3 +67,4 @@ export * from "./worktreeSetup.ts"; export * from "./secretRequest.ts"; export * from "./clientRpcPermissions.ts"; export * from "./people.ts"; +export * from "./prism.ts"; diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 5da5caa106..481e63a7d6 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -2879,6 +2879,9 @@ export const OrchestrationV2Command = Schema.Union([ titleSeed: Schema.optional(TrimmedNonEmptyString), modelSelection: Schema.optional(ModelSelection), sourcePlanRef: Schema.optional(Schema.Struct({ threadId: ThreadId, planId: PlanId })), + /** Fork retry identity; automatic delivery never acknowledges retirement. */ + forkPrismRetryOfRunId: Schema.optional(RunId), + forkPrismOriginalRunId: Schema.optional(RunId), restartContinuationOfRunId: Schema.optional(RunId), usageLimitContinuationOfRunId: Schema.optional(RunId), manualContinuationOfRunId: Schema.optional(RunId), diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index afa2e110c5..79fb020740 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -1,3 +1,4 @@ +import { PrismLane, PrismRole } from "./prism.ts"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; @@ -171,6 +172,8 @@ export type OrchestratorMcpTerminalDelegatedTaskStatus = export const OrchestratorMcpDelegateTaskInput = Schema.Struct({ projectId: Schema.optional(ProjectId), workspaceStrategy: Schema.optional(OrchestrationV2ThreadLaunchWorkspaceStrategy), + lane: Schema.optional(PrismLane), + prismRole: Schema.optional(PrismRole), task: OrchestratorMcpPrompt.annotate({ description: "Self-contained task for one delegated child agent/subagent.", }), diff --git a/packages/contracts/src/prism.test.ts b/packages/contracts/src/prism.test.ts new file mode 100644 index 0000000000..7f0f179512 --- /dev/null +++ b/packages/contracts/src/prism.test.ts @@ -0,0 +1,174 @@ +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { DEFAULT_PRISM_ROLE_KITS, PrismRoleKits, PrismRoleKitsPatch } from "./prism.ts"; +import { ProjectSettingsOverrides, ServerSettings } from "./settings.ts"; + +const decodeServerSettings = Schema.decodeSync(ServerSettings); +const decodeProjectOverrides = Schema.decodeSync(ProjectSettingsOverrides); +const decodeKitsPatch = Schema.decodeSync(PrismRoleKitsPatch); +const encodeKits = Schema.encodeSync(PrismRoleKits); + +describe("Prism role kits", () => { + it("enables Chromeria reset recovery by default and preserves a saved opt-out through patches", () => { + expect(decodeServerSettings({}).autoResumeLimitedThreads).toBe(true); + const saved = decodeServerSettings({ autoResumeLimitedThreads: false }); + expect(saved.autoResumeLimitedThreads).toBe(false); + const updated = decodeServerSettings({ + ...Schema.encodeSync(ServerSettings)(saved), + prismRoles: { planner: { instructions: "Plan." } }, + }); + expect(updated.autoResumeLimitedThreads).toBe(false); + }); + it("defaults every role to no preferred models", () => { + for (const kit of Object.values(DEFAULT_PRISM_ROLE_KITS)) { + if ("models" in kit) expect(kit.models).toEqual([]); + } + expect(DEFAULT_PRISM_ROLE_KITS.promachos.models).toEqual([]); + expect(DEFAULT_PRISM_ROLE_KITS.worker.lanes).toEqual({ easy: [], medium: [], hard: [] }); + expect(DEFAULT_PRISM_ROLE_KITS.dispatcher.models).toEqual([]); + expect(DEFAULT_PRISM_ROLE_KITS.retry.enabled).toBe(true); + expect(DEFAULT_PRISM_ROLE_KITS.escalation.enabled).toBe(true); + expect("enabled" in DEFAULT_PRISM_ROLE_KITS.reviewer).toBe(false); + }); + + it("preserves the Promachos preference order and effort through saved settings", () => { + const models = [ + { instanceId: "codex", model: "gpt-6.1-sol", effort: "high" }, + { instanceId: "claudeAgent", model: "claude-opus-5-5" }, + ]; + const settings = decodeServerSettings({ prismRoles: { promachos: { models } } }); + expect(encodeKits(settings.prismRoles).promachos).toMatchObject({ models }); + expect(decodeKitsPatch({ promachos: { models } })).toEqual({ promachos: { models } }); + expect( + decodeProjectOverrides({ prismRoles: { promachos: { models } } }).prismRoles?.promachos + .models, + ).toEqual(models); + }); + + it("loads older settings without a Promachos kit and permits clearing its preferences", () => { + const old = decodeServerSettings({ prismRoles: { dispatcher: { instructions: "Lead." } } }); + expect(old.prismRoles.promachos.models).toEqual([]); + expect(old.prismRoles.dispatcher.instructions).toBe("Lead."); + expect(decodeKitsPatch({ promachos: { models: [] } })).toEqual({ promachos: { models: [] } }); + }); + + it("ship no role instructions or skills by default", () => { + for (const kit of Object.values(DEFAULT_PRISM_ROLE_KITS)) { + expect(kit.instructions).toBe(""); + expect(kit.skills).toEqual([]); + } + }); + + it("load saved settings that still carry a thread-tool scope, dropping it", () => { + const settings = decodeServerSettings({ + prismRoles: { + dispatcher: { instructions: "Lead.", threadTools: "children" }, + worker: { threadTools: "none", lanes: { easy: [] } }, + correction: { threadTools: "none" }, + }, + }); + expect(settings.prismRoles.dispatcher.instructions).toBe("Lead."); + const encoded = encodeKits(settings.prismRoles) as Record; + for (const role of ["dispatcher", "worker", "retry"] as const) { + expect("threadTools" in settings.prismRoles[role]).toBe(false); + expect("threadTools" in encoded[role]!).toBe(false); + } + }); + + it("load Retry and Escalation saved under their legacy keys", () => { + const luna = { instanceId: "codex", model: "gpt-5.6-luna" }; + const settings = decodeServerSettings({ + prismRoles: { + correction: { models: [luna], enabled: false }, + recovery: { instructions: "Escalate." }, + }, + }); + expect(settings.prismRoles.retry).toMatchObject({ models: [luna], enabled: false }); + expect(settings.prismRoles.escalation).toMatchObject({ + instructions: "Escalate.", + enabled: true, + }); + expect(Object.keys(encodeKits(settings.prismRoles))).not.toContain("correction"); + // A kit saved under the new key wins over one left under the legacy key. + const both = decodeServerSettings({ + prismRoles: { retry: { enabled: true }, correction: { enabled: false } }, + }); + expect(both.prismRoles.retry.enabled).toBe(true); + const overrides = decodeProjectOverrides({ prismRoles: { recovery: { enabled: false } } }); + expect(overrides.prismRoles?.escalation.enabled).toBe(false); + }); + + it("load saved settings that still name a role runtime mode, dropping it", () => { + const settings = decodeServerSettings({ + prismRoles: { reviewer: { instructions: "Read.", runtimeMode: "approval-required" } }, + }); + expect(settings.prismRoles.reviewer.instructions).toBe("Read."); + expect("runtimeMode" in settings.prismRoles.reviewer).toBe(false); + }); + + it("keep a saved per-lane list as the single list of a role other than the worker", () => { + const opus = { instanceId: "claudeAgent", model: "claude-opus-5-5", effort: "high" }; + const luna = { instanceId: "codex", model: "gpt-5.6-luna" }; + const settings = decodeServerSettings({ + prismRoles: { + dispatcher: { instructions: "Lead.", lanes: { easy: [luna], medium: [opus], hard: [] } }, + reviewer: { lanes: { hard: [opus] } }, + retry: { models: [luna], lanes: { medium: [opus] }, enabled: false }, + }, + }); + expect(settings.prismRoles.dispatcher.models).toEqual([opus]); + expect(settings.prismRoles.dispatcher.instructions).toBe("Lead."); + expect("lanes" in settings.prismRoles.dispatcher).toBe(false); + expect(settings.prismRoles.reviewer.models).toEqual([]); + expect(settings.prismRoles.retry).toMatchObject({ models: [luna], enabled: false }); + expect(encodeKits(settings.prismRoles).dispatcher).toEqual({ + instructions: "Lead.", + skills: [], + models: [opus], + }); + }); + + it("fill missing roles and fields when a settings file names one role", () => { + const settings = decodeServerSettings({ + prismRoles: { + worker: { + lanes: { + medium: [ + { instanceId: "claudeAgent", model: "claude-opus-5-5", effort: "medium" }, + { instanceId: "opencode", model: "opencode/muse", effort: "medium" }, + ], + hard: [{ instanceId: "claudeAgent", model: "claude-opus-5-5", effort: "xhigh" }], + }, + }, + }, + }); + expect(settings.prismRoles.worker.lanes.medium.map((entry) => entry.model)).toEqual([ + "claude-opus-5-5", + "opencode/muse", + ]); + expect(settings.prismRoles.worker.lanes.hard[0]?.effort).toBe("xhigh"); + expect(settings.prismRoles.worker.lanes.easy).toEqual([]); + expect(settings.prismRoles.escalation.models).toEqual([]); + expect(settings.prismRoles.reviewer.instructions).toBe(""); + }); + + it("accept a project override of the whole kit set", () => { + const overrides = decodeProjectOverrides({ + prismRoles: { + reviewer: { models: [{ instanceId: "codex", model: "gpt-5.6-luna" }] }, + }, + }); + expect(overrides.prismRoles?.reviewer.models[0]?.model).toBe("gpt-5.6-luna"); + expect(overrides.prismRoles?.planner.models).toEqual([]); + }); + + it("patch one lane of the worker or one field of another role without defaults", () => { + expect(decodeKitsPatch({ worker: { lanes: { hard: [] } } })).toEqual({ + worker: { lanes: { hard: [] } }, + }); + expect(decodeKitsPatch({ escalation: { enabled: false } })).toEqual({ + escalation: { enabled: false }, + }); + }); +}); diff --git a/packages/contracts/src/prism.ts b/packages/contracts/src/prism.ts new file mode 100644 index 0000000000..7040dea2a6 --- /dev/null +++ b/packages/contracts/src/prism.ts @@ -0,0 +1,214 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as SchemaTransformation from "effect/SchemaTransformation"; + +import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; +import { ProviderInstanceId } from "./providerInstance.ts"; + +/** + * Prism (Model Router) roles and their kits (toolboxmd/model-router#115). + * + * A role is a kit (instructions, skills) plus an ordered list of models; + * only the worker keeps one list per lane. Every role may use every thread + * tool. The kits live in server settings under `prismRoles`, overridable per + * project like any key in `PROJECT_SCOPED_SERVER_SETTING_KEYS`; + * `delegate_task` and `t3_thread_launch` apply them through Prism. + */ +export const PRISM_ROLES = [ + "promachos", + "planner", + "dispatcher", + "reviewer", + "worker", + "retry", + "escalation", +] as const; +export const PrismRole = Schema.Literals(PRISM_ROLES); +export type PrismRole = typeof PrismRole.Type; + +/** + * Keys Retry and Escalation had before they were renamed. Settings and + * child thread ids saved with them still read as the new roles. + */ +export const LEGACY_PRISM_ROLE_KEYS: Readonly> = { + correction: "retry", + recovery: "escalation", +}; + +/** Work difficulty a job or spawn runs at; the worker keeps one model list per lane. */ +const PRISM_LANES = ["easy", "medium", "hard"] as const; +export const PrismLane = Schema.Literals(PRISM_LANES); +export type PrismLane = typeof PrismLane.Type; +export const DEFAULT_PRISM_LANE: PrismLane = "medium"; + +/** + * One entry of a role's model list. The same model at another effort is a + * separate entry. + */ +export const PrismModelPreference = Schema.Struct({ + instanceId: ProviderInstanceId, + model: TrimmedNonEmptyString, + /** Claude effort, Codex/Grok reasoningEffort, OpenCode variant. */ + effort: Schema.optionalKey(TrimmedNonEmptyString), +}); +export type PrismModelPreference = typeof PrismModelPreference.Type; + +const modelList = Schema.Array(PrismModelPreference).pipe( + Schema.withDecodingDefault(Effect.succeed([])), +); + +export const PrismLaneModels = Schema.Struct({ + easy: modelList, + medium: modelList, + hard: modelList, +}).pipe(Schema.withDecodingDefault(Effect.succeed({}))); +export type PrismLaneModels = typeof PrismLaneModels.Type; + +/** Roles the user may switch off in Prism; the router then skips that step. */ +export const PRISM_SWITCHABLE_ROLES = ["retry", "escalation"] as const; +export type PrismSwitchableRole = (typeof PRISM_SWITCHABLE_ROLES)[number]; + +/** Names shown to people. */ +export const PRISM_ROLE_LABELS: Record = { + promachos: "Promachos", + planner: "Planner", + dispatcher: "Dispatcher", + reviewer: "Reviewer", + worker: "Worker", + retry: "Retry", + escalation: "Escalation", +}; + +/** Saved kits may still carry `threadTools`; decoding drops it like any unknown key. */ +const kitFields = { + /** Prepended to the first message of every thread started in this role. */ + instructions: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), + /** Skill names the role is told to use. */ + skills: Schema.Array(TrimmedNonEmptyString).pipe(Schema.withDecodingDefault(Effect.succeed([]))), +}; + +/** + * The worker keeps one list per lane: the primary model first and fallbacks + * after it. Eligible = these ∩ models enabled in Providers for the project + * and environment. + */ +const workerKit = Schema.Struct({ ...kitFields, lanes: PrismLaneModels }).pipe( + Schema.withDecodingDefault(Effect.succeed({})), +); + +/** + * Every other role keeps one ordered list. Settings saved before that kept + * one list per lane; the medium list becomes the single list. + */ +const singleListKit = (extra: Fields) => { + const kit = Schema.Struct({ ...kitFields, models: modelList, ...extra }); + return Schema.Record(Schema.String, Schema.Unknown).pipe( + Schema.decodeTo( + kit, + SchemaTransformation.transform({ + decode: ({ lanes, ...fields }) => { + const legacy = (lanes as { medium?: unknown } | undefined)?.medium; + return ( + fields.models === undefined && legacy !== undefined + ? { ...fields, models: legacy } + : fields + ) as typeof kit.Encoded; + }, + encode: (fields) => fields as Record, + }), + ), + Schema.withDecodingDefault(Effect.succeed({})), + ); +}; + +const switchable = { + /** Off: the router skips this step of its ladder. */ + enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), +}; + +const roleKits = Schema.Struct({ + promachos: singleListKit({}), + planner: singleListKit({}), + dispatcher: singleListKit({}), + reviewer: singleListKit({}), + worker: workerKit, + retry: singleListKit(switchable), + escalation: singleListKit(switchable), +}); + +/** All role kits; a role saved under its legacy key loads as the renamed role. */ +export const PrismRoleKits = Schema.Record(Schema.String, Schema.Unknown).pipe( + Schema.decodeTo( + roleKits, + SchemaTransformation.transform({ + decode: (saved) => { + const kits: Record = {}; + for (const [key, kit] of Object.entries(saved)) { + const role = LEGACY_PRISM_ROLE_KEYS[key]; + if (role === undefined) kits[key] = kit; + else if (saved[role] === undefined) kits[role] = kit; + } + return kits as typeof roleKits.Encoded; + }, + encode: (kits) => kits as Record, + }), + ), +); +export type PrismRoleKits = typeof PrismRoleKits.Type; +export type PrismRoleKit = PrismRoleKits[PrismRole]; + +export const DEFAULT_PRISM_ROLE_KITS: PrismRoleKits = Schema.decodeSync(PrismRoleKits)({}); + +/** The ordered list a spawn in `role` walks: the worker's lane, else the role's single list. */ +export function prismRoleModels( + kits: PrismRoleKits, + role: PrismRole, + lane: PrismLane, +): ReadonlyArray { + return role === "worker" ? kits.worker.lanes[lane] : kits[role].models; +} + +const kitPatchFields = { + instructions: Schema.optionalKey(TrimmedString), + skills: Schema.optionalKey(Schema.Array(TrimmedNonEmptyString)), +}; +const singleListKitPatch = Schema.Struct({ + ...kitPatchFields, + models: Schema.optionalKey(Schema.Array(PrismModelPreference)), +}); +const switchableKitPatch = Schema.Struct({ + ...singleListKitPatch.fields, + enabled: Schema.optionalKey(Schema.Boolean), +}); + +/** Per-role, per-field, per-lane update; arrays (skills, a model list) replace whole. */ +export const PrismRoleKitsPatch = Schema.Struct({ + promachos: Schema.optionalKey(singleListKitPatch), + planner: Schema.optionalKey(singleListKitPatch), + dispatcher: Schema.optionalKey(singleListKitPatch), + reviewer: Schema.optionalKey(singleListKitPatch), + worker: Schema.optionalKey( + Schema.Struct({ + ...kitPatchFields, + lanes: Schema.optionalKey( + Schema.Struct({ + easy: Schema.optionalKey(Schema.Array(PrismModelPreference)), + medium: Schema.optionalKey(Schema.Array(PrismModelPreference)), + hard: Schema.optionalKey(Schema.Array(PrismModelPreference)), + }), + ), + }), + ), + retry: Schema.optionalKey(switchableKitPatch), + escalation: Schema.optionalKey(switchableKitPatch), +}); +export type PrismRoleKitsPatch = typeof PrismRoleKitsPatch.Type; + +/** + * Thread activity recorded at each turn end with the turn's provider stream + * statistics (toolboxmd/t3code#55). Measurement only: clients hide it. + */ +export const PRISM_STREAM_STATS_ACTIVITY_KIND = "prism.stream-stats"; + +export const PrismStaleReason = Schema.Literals(["silence", "provider-dead", "provider-error"]); +export type PrismStaleReason = typeof PrismStaleReason.Type; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 6ce9f49201..3163218aec 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1,4 +1,5 @@ import { forkOpaqueSettingsFields } from "./forkSettings.ts"; +import { PrismRoleKits, PrismRoleKitsPatch } from "./prism.ts"; import { SshDeviceHostConfigs } from "./device.ts"; import { AuthSettingsWriteScope, @@ -1206,6 +1207,7 @@ export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [ "sidebarAutoSettleAfterDays", "continueThreadsAfterServerUpdate", "responseStreamingMode", + "prismRoles", ] as const; export type ProjectScopedServerSettingKey = (typeof PROJECT_SCOPED_SERVER_SETTING_KEYS)[number]; @@ -1238,6 +1240,7 @@ export const ProjectSettingsOverrides = Schema.Struct({ sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean), responseStreamingMode: Schema.optionalKey(ResponseStreamingMode), + prismRoles: Schema.optionalKey(PrismRoleKits), } satisfies Record); export type ProjectSettingsOverrides = typeof ProjectSettingsOverrides.Type; @@ -1276,6 +1279,7 @@ export type StorageCleanupSettings = typeof StorageCleanupSettings.Type; export const ServerSettings = Schema.Struct({ ...forkOpaqueSettingsFields, autoUpdateProviders: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + prismRoles: PrismRoleKits.pipe(Schema.withDecodingDefault(Effect.succeed({}))), worktreeCleanup: WorktreeCleanup.pipe(Schema.withDecodingDefault(Effect.succeed(null))), storageCleanup: StorageCleanupSettings.pipe( Schema.withDecodingDefault(Effect.succeed(Schema.decodeSync(StorageCleanupSettings)({}))), @@ -1367,7 +1371,7 @@ export const ServerSettings = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)), ), snoozeLimitedThreads: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), - autoResumeLimitedThreads: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + autoResumeLimitedThreads: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), sidebarAutoSettleOnMerge: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), backgroundActivity: BackgroundActivitySettings, // Legacy flat fields retained for old settings files and old clients. New @@ -1677,6 +1681,7 @@ const OpenCodeSettingsPatch = Schema.Struct({ }); export const ServerSettingsPatch = Schema.Struct({ + prismRoles: Schema.optionalKey(PrismRoleKitsPatch), worktreeCleanup: Schema.optionalKey( Schema.NullOr( Schema.Union([ From 5ad18354520c786a4cf6b9ca81f523432e080322 Mon Sep 17 00:00:00 2001 From: lukemaj Date: Thu, 8 Oct 2026 18:24:38 +0200 Subject: [PATCH 02/10] wip: wire persisted Prism recovery and approved kit semantics --- .../src/orchestration-v2/runtimeLayer.ts | 20 ++ apps/server/src/prism/PrismService.test.ts | 37 ++ .../src/prism/Recovery.integration.test.ts | 324 ++++++++++++++++++ .../src/prism/RecoveryCoordinator.test.ts | 26 ++ apps/server/src/prism/RecoveryCoordinator.ts | 7 +- apps/server/src/prism/RecoveryHooks.ts | 72 ++-- apps/server/src/prism/RecoveryReactor.test.ts | 89 +++++ apps/server/src/prism/RecoveryReactor.ts | 105 ++++++ .../server/src/prism/RecoverySettings.test.ts | 16 +- .../src/prism/recoveryAdmission.test.ts | 48 +++ .../src/components/settings/PrismSettings.tsx | 71 +--- packages/contracts/src/prism.test.ts | 3 +- 12 files changed, 722 insertions(+), 96 deletions(-) create mode 100644 apps/server/src/prism/Recovery.integration.test.ts create mode 100644 apps/server/src/prism/RecoveryReactor.test.ts create mode 100644 apps/server/src/prism/RecoveryReactor.ts diff --git a/apps/server/src/orchestration-v2/runtimeLayer.ts b/apps/server/src/orchestration-v2/runtimeLayer.ts index 9cf3351f5d..c73383e159 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.ts @@ -1,3 +1,6 @@ +import * as PrismRecovery from "../prism/RecoveryCoordinator.ts"; +import * as PrismRecoveryHooks from "../prism/RecoveryHooks.ts"; +import * as PrismRecoveryReactor from "../prism/RecoveryReactor.ts"; import * as UsageLimitRecoveryWorker from "./UsageLimitRecoveryWorker.ts"; import * as Scheduler from "../scheduling/Scheduler.ts"; import * as Layer from "effect/Layer"; @@ -201,6 +204,11 @@ const layerRunFinalizationServiceProvided = RunFinalizationService.layer.pipe( Layer.provide(Layer.merge(layerCheckpointCaptureServiceProvided, ProjectionStore.layer)), ); +const layerPrismRecoveryProvided = PrismRecovery.layer; +const layerPrismRecoveryHooksProvided = PrismRecoveryHooks.layer.pipe( + Layer.provide(Layer.mergeAll(layerPrismRecoveryProvided, ProjectionStore.layer)), +); + const layerOrchestratorProvided = Orchestrator.layer.pipe( Layer.provide( Layer.mergeAll( @@ -222,6 +230,7 @@ const layerOrchestratorProvided = Orchestrator.layer.pipe( layerProviderSwitchServiceProvided, layerRunExecutionServiceProvided, ThreadForkService.layer, + layerPrismRecoveryHooksProvided, ), ), ); @@ -349,6 +358,17 @@ export const layerProduction = Layer.mergeAll( layerThreadLifecycleProvided, layerScheduledTaskProvided, layerSecretRequestsProvided, + PrismRecoveryReactor.layer.pipe( + Layer.provide( + Layer.mergeAll( + layerPrismRecoveryProvided, + ProjectionStore.layer, + layerThreadManagementProvided, + layerOrchestratorProvided, + layerEventSinkProvided, + ), + ), + ), UsageLimitRecoveryWorker.layer.pipe( Layer.provide(Layer.mergeAll(ProjectionStore.layer, layerThreadManagementProvided)), ), diff --git a/apps/server/src/prism/PrismService.test.ts b/apps/server/src/prism/PrismService.test.ts index 4a1711530c..28070e3459 100644 --- a/apps/server/src/prism/PrismService.test.ts +++ b/apps/server/src/prism/PrismService.test.ts @@ -195,6 +195,43 @@ describe("PrismService", () => { assert.strictEqual(error.code, "invalid_request"); }), ); + it.effect("explicit retry and escalation kits still route with legacy enabled false", () => + Effect.gen(function* () { + const p = yield* provider; + yield* Effect.gen(function* () { + const service = yield* Prism.PrismService; + for (const role of ["retry", "escalation"] as const) { + const result = yield* service.resolve({ projectId, role }); + assert.strictEqual(result.modelSelection.model, "configured"); + assert.strictEqual(result.kitText, "Planner requested kit."); + } + }).pipe( + Effect.provide( + Prism.layer.pipe( + Layer.provide( + Layer.mergeAll( + registry([p]), + Settings.layerTest({ + prismRoles: { + retry: { + enabled: false, + models: [selection], + instructions: "Planner requested kit.", + }, + escalation: { + enabled: false, + models: [selection], + instructions: "Planner requested kit.", + }, + }, + }), + ), + ), + ), + ), + ); + }), + ); it("maps every upstream role to the fixed kit", () => { assert.strictEqual(Prism.delegatedPrismRole("implementation"), "worker"); assert.strictEqual(Prism.delegatedPrismRole("test"), "worker"); diff --git a/apps/server/src/prism/Recovery.integration.test.ts b/apps/server/src/prism/Recovery.integration.test.ts new file mode 100644 index 0000000000..dbca575c80 --- /dev/null +++ b/apps/server/src/prism/Recovery.integration.test.ts @@ -0,0 +1,324 @@ +import { assert, it } from "@effect/vitest"; +import { + CommandId, + EventId, + MessageId, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + type OrchestrationV2ProviderFailure, + type OrchestrationV2Run, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as SqlClient from "effect/sql/SqlClient"; +import * as Effect from "effect/Effect"; +import * as TestClock from "effect/testing/TestClock"; +import * as Layer from "effect/Layer"; +import * as Projection from "../orchestration-v2/ProjectionStore.ts"; +import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; +import * as Threads from "../orchestration-v2/ThreadManagementService.ts"; +import * as Registry from "../orchestration-v2/ProviderAdapterRegistry.ts"; +import type { ProviderAdapterV2Shape } from "../orchestration-v2/ProviderAdapter.ts"; +import { CodexProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; +import * as Harness from "../orchestration-v2/testkit/ProviderReplayHarness.ts"; +import * as Persistence from "../persistence/Sqlite.ts"; +import * as Settings from "../serverSettings.ts"; +import * as Scheduler from "../scheduling/Scheduler.ts"; +import { ServerActivation } from "../serverActivation.ts"; +import * as Hooks from "./RecoveryHooks.ts"; +import * as Coordinator from "./RecoveryCoordinator.ts"; +import * as Reactor from "./RecoveryReactor.ts"; +import { limitRecoveryCommand } from "../orchestration-v2/UsageLimitRecoveryWorker.ts"; +import { errorFor } from "./recovery.testkit.ts"; + +const instanceId = ProviderInstanceId.make("codex"); +const selection = { + instanceId, + model: "fixed", + options: [{ id: "reasoningEffort", value: "high" }], +}; +const adapter = { + instanceId, + driver: ProviderDriverKind.make("codex"), + getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), + planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" as const }), + openSession: () => Effect.die("This proof uses durable dispatch, not a live provider"), +} as ProviderAdapterV2Shape; +const database = Persistence.layerMemory; +const foundation = Layer.mergeAll( + database, + Settings.layerTest(), + Projection.layer.pipe(Layer.provide(database)), +); +const recovery = Coordinator.layer.pipe(Layer.provide(foundation)); +const hooks = Hooks.layer.pipe(Layer.provide(Layer.mergeAll(foundation, recovery))); +const replay = Harness.layerWithRegistry( + { name: "prism-recovery" }, + Registry.layerFromAdapters([adapter]), + { + databaseLayer: database, + runEffectWorker: false, + }, +).pipe(Layer.provide(hooks)); +const dependencies = Layer.mergeAll( + foundation, + recovery, + replay, + Threads.layer.pipe(Layer.provide(replay)), + Layer.mock(Scheduler.Scheduler)({ register: () => Effect.void }), + Layer.succeed(ServerActivation, Effect.never), +); +const layer = Layer.mergeAll(dependencies, Reactor.layer.pipe(Layer.provide(dependencies))); +const parentId = ThreadId.make("parent:recovery"); +const setup = Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("parent:create"), + threadId: parentId, + projectId: ProjectId.make("project:recovery"), + title: "Parent", + modelSelection: selection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("parent:start"), + threadId: parentId, + messageId: MessageId.make("parent:message"), + text: "Delegate", + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + const parent = (yield* orchestrator.getThreadProjection(parentId)).runs[0]!; + yield* orchestrator.dispatch({ + type: "delegated_task.request", + commandId: CommandId.make("child:delegate"), + parentThreadId: parentId, + parentRunId: parent.id, + parentNodeId: parent.rootNodeId!, + task: "Work", + modelSelection: selection, + runtimeMode: "full-access", + interactionMode: "default", + completionWake: "always", + createdBy: "agent", + creationSource: "mcp", + }); + const childId = (yield* orchestrator.getThreadProjection(parentId)).subagents[0]!.childThreadId!; + return { + orchestrator, + childId, + run: (yield* orchestrator.getThreadProjection(childId)).runs[0]!, + }; +}); +const finish = (run: OrchestrationV2Run, failure: OrchestrationV2ProviderFailure | null) => + Effect.gen(function* () { + const projections = yield* Projection.ProjectionStoreV2; + const now = yield* DateTime.now; + const ended = { + ...run, + startedAt: run.startedAt ?? run.requestedAt, + status: failure === null ? ("completed" as const) : ("failed" as const), + completedAt: now, + }; + if (failure !== null) { + const item = errorFor(ended); + if (item.type !== "error") throw new Error("fixture"); + yield* projections.apply({ + id: EventId.make(`error:${run.id}`), + type: "turn-item.updated", + threadId: run.threadId, + occurredAt: now, + payload: { ...item, failure }, + }); + } + yield* projections.apply({ + id: EventId.make(`ended:${run.id}`), + type: "run.updated", + threadId: run.threadId, + occurredAt: now, + payload: ended, + }); + }); +const failure = { + class: "unknown" as const, + message: "Retryable machine failure", + code: null, + retryable: true, +}; +it.effect( + "actual delegated finalization holds the first failure and transfers only the exhausted retry", + () => + Effect.gen(function* () { + const { orchestrator, childId, run } = yield* setup; + yield* finish(run, failure); + assert.strictEqual( + (yield* orchestrator.getThreadProjection(childId)).thread.creationSource, + "mcp", + ); + assert.include( + yield* (yield* Projection.ProjectionStoreV2).getRecoveryThreadIds("subagent-results"), + childId, + ); + yield* orchestrator.recoverDelegatedTasks; + assert.strictEqual( + (yield* orchestrator.getThreadProjection(parentId)).contextTransfers.filter( + (x) => x.type === "subagent_result", + ).length, + 0, + ); + yield* (yield* Reactor.RecoveryReactor).sweep; + const sql = yield* SqlClient.SqlClient; + assert.deepStrictEqual(yield* sql`SELECT state,source_run_id FROM fork_prism_recovery`, [ + { state: "retry_started", source_run_id: run.id }, + ]); + const child = yield* orchestrator.getThreadProjection(childId); + assert.strictEqual(child.runs.length, 2); + assert.deepStrictEqual(child.runs[1]!.modelSelection, selection); + yield* finish(child.runs[1]!, failure); + yield* orchestrator.recoverDelegatedTasks; + const parent = yield* orchestrator.getThreadProjection(parentId); + assert.strictEqual( + parent.contextTransfers.filter((x) => x.type === "subagent_result").length, + 1, + ); + assert.strictEqual(parent.subagents[0]?.status, "failed"); + yield* (yield* Reactor.RecoveryReactor).sweep; + assert.strictEqual((yield* orchestrator.getThreadProjection(childId)).runs.length, 2); + }).pipe(Effect.provide(layer)), +); +it.effect( + "ancestor Stop after failure cancels held recovery and permits eventual parent finalization", + () => + Effect.gen(function* () { + const { orchestrator, childId, run } = yield* setup; + yield* finish(run, failure); + assert.strictEqual( + (yield* orchestrator.getThreadProjection(childId)).thread.creationSource, + "mcp", + ); + assert.include( + yield* (yield* Projection.ProjectionStoreV2).getRecoveryThreadIds("subagent-results"), + childId, + ); + yield* orchestrator.recoverDelegatedTasks; + assert.strictEqual( + (yield* orchestrator.getThreadProjection(parentId)).contextTransfers.filter( + (x) => x.type === "subagent_result", + ).length, + 0, + ); + yield* orchestrator.dispatch({ + type: "thread.stop", + commandId: CommandId.make("stop:parent"), + threadId: parentId, + }); + yield* (yield* Reactor.RecoveryReactor).sweep; + assert.strictEqual((yield* orchestrator.getThreadProjection(childId)).runs.length, 1); + assert.strictEqual( + (yield* orchestrator.getThreadProjection(parentId)).contextTransfers.filter( + (x) => x.type === "subagent_result", + ).length, + 1, + ); + }).pipe(Effect.provide(layer)), +); + +it.effect( + "upstream reset dispatch preserves the held task until its eventual successful result", + () => + Effect.gen(function* () { + const { orchestrator, childId, run } = yield* setup; + const now = yield* DateTime.now; + const resetAt = DateTime.makeUnsafe(DateTime.toEpochMillis(now) + 3_600_000); + yield* finish(run, { + ...failure, + class: "usage_limit", + resetAt: DateTime.formatIso(resetAt), + }); + yield* orchestrator.recoverDelegatedTasks; + assert.strictEqual( + (yield* orchestrator.getThreadProjection(parentId)).contextTransfers.filter( + (x) => x.type === "subagent_result", + ).length, + 0, + ); + const projections = yield* Projection.ProjectionStoreV2; + const candidates = yield* projections.getLimitRecoveryCandidates({ + now, + autoResume: true, + snooze: false, + }); + const candidate = candidates.find((t) => t.id === childId)!; + const arm = limitRecoveryCommand(candidate, true, DateTime.toEpochMillis(now)); + assert.strictEqual(arm?.type, "thread.metadata.update"); + yield* orchestrator.dispatch(arm!); + const armed = (yield* projections.getLimitRecoveryCandidates({ + now: resetAt, + autoResume: true, + snooze: false, + })).find((t) => t.id === childId)!; + const resume = limitRecoveryCommand(armed, true, DateTime.toEpochMillis(resetAt)); + assert.strictEqual(resume?.type, "message.dispatch"); + yield* TestClock.adjust("1 hour"); + yield* orchestrator.dispatch(resume!); + const child = yield* orchestrator.getThreadProjection(childId); + assert.strictEqual(child.runs.length, 2); + assert.deepStrictEqual(child.runs[1]!.modelSelection, selection); + yield* (yield* Reactor.RecoveryReactor).sweep; + assert.strictEqual( + (yield* orchestrator.getThreadProjection(parentId)).contextTransfers.filter( + (x) => x.type === "subagent_result", + ).length, + 0, + ); + yield* finish(child.runs[1]!, null); + yield* orchestrator.recoverDelegatedTasks; + const parent = yield* orchestrator.getThreadProjection(parentId); + assert.strictEqual( + parent.contextTransfers.filter((x) => x.type === "subagent_result").length, + 1, + ); + assert.strictEqual(parent.subagents[0]?.status, "completed"); + }).pipe(Effect.provide(layer)), +); +it.effect("Stop while waiting for reset releases finalization without resuming a provider", () => + Effect.gen(function* () { + const { orchestrator, childId, run } = yield* setup; + const now = yield* DateTime.now; + yield* finish(run, { + ...failure, + class: "usage_limit", + resetAt: DateTime.formatIso(DateTime.makeUnsafe(DateTime.toEpochMillis(now) + 3_600_000)), + }); + yield* orchestrator.recoverDelegatedTasks; + assert.strictEqual( + (yield* orchestrator.getThreadProjection(parentId)).contextTransfers.filter( + (x) => x.type === "subagent_result", + ).length, + 0, + ); + yield* orchestrator.dispatch({ + type: "thread.stop", + commandId: CommandId.make("stop:reset"), + threadId: childId, + }); + yield* (yield* Reactor.RecoveryReactor).sweep; + assert.strictEqual((yield* orchestrator.getThreadProjection(childId)).runs.length, 1); + assert.strictEqual( + (yield* orchestrator.getThreadProjection(parentId)).contextTransfers.filter( + (x) => x.type === "subagent_result", + ).length, + 1, + ); + }).pipe(Effect.provide(layer)), +); diff --git a/apps/server/src/prism/RecoveryCoordinator.test.ts b/apps/server/src/prism/RecoveryCoordinator.test.ts index 4d89526b27..820ae467e4 100644 --- a/apps/server/src/prism/RecoveryCoordinator.test.ts +++ b/apps/server/src/prism/RecoveryCoordinator.test.ts @@ -89,6 +89,32 @@ describe("Prism recovery finalization coordination", () => { assert.strictEqual(yield* coordinator.holdsResult(run.threadId), false); }).pipe(Effect.provide(layer)), ); + it.effect("a persisted reset opt-out releases finalization despite the global default", () => + Effect.gen(function* () { + const coordinator = yield* Coordinator.RecoveryCoordinator; + const item = errorFor(run); + if (item.type !== "error") throw new Error("fixture"); + const resetAt = "2026-10-08T11:00:00Z"; + const limited = { + ...projection, + turnItems: [ + { ...item, failure: { ...item.failure, class: "usage_limit" as const, resetAt } }, + ], + }; + assert.strictEqual((yield* coordinator.observe(limited, false))?.state, "reset_wait"); + yield* coordinator.observe( + { + ...limited, + thread: { + ...limited.thread, + limitRecovery: { runId: run.id, resetAt, autoResume: false, snooze: false }, + }, + }, + false, + ); + assert.strictEqual(yield* coordinator.holdsResult(run.threadId), false); + }).pipe(Effect.provide(layer)), + ); it.effect( "retirement or incomplete ancestry releases a held failure without creating another retry", () => diff --git a/apps/server/src/prism/RecoveryCoordinator.ts b/apps/server/src/prism/RecoveryCoordinator.ts index accfd67119..7b6355f2a3 100644 --- a/apps/server/src/prism/RecoveryCoordinator.ts +++ b/apps/server/src/prism/RecoveryCoordinator.ts @@ -52,12 +52,17 @@ const make = Effect.gen(function* () { projection.thread.settledOverride === "settled" || projection.runtimeRequests.some((request) => request.status === "pending") || !sameModelSelection(projection.thread.modelSelection, run.modelSelection); + const recovery = projection.thread.limitRecovery; + const persistedResetChoice = + recovery?.runId === run.id && recovery.resetAt === failure?.resetAt + ? recovery.autoResume + : undefined; return yield* store.reconcile({ previous: null, run, failure, stoppedOrRetired: blocked, - autoResume: (yield* settings.getSettings).autoResumeLimitedThreads, + autoResume: persistedResetChoice ?? (yield* settings.getSettings).autoResumeLimitedThreads, }); }); const holdsResult = Effect.fn("RecoveryCoordinator.holdsResult")(function* (threadId: ThreadId) { diff --git a/apps/server/src/prism/RecoveryHooks.ts b/apps/server/src/prism/RecoveryHooks.ts index bc601e761a..c1e296e3f2 100644 --- a/apps/server/src/prism/RecoveryHooks.ts +++ b/apps/server/src/prism/RecoveryHooks.ts @@ -12,6 +12,7 @@ import type { SqlError } from "effect/sql/SqlError"; import type { ForkCommitPlan } from "../childThreads/ForkCommitPlan.ts"; import * as Projection from "../orchestration-v2/ProjectionStore.ts"; import * as Coordinator from "./RecoveryCoordinator.ts"; +import { readRetirementState } from "../childThreads/retirement.ts"; import { retryAdmission } from "./recoveryAdmission.ts"; /** Optional fork behavior; upstream orchestration retains its ordinary finalization. */ @@ -26,42 +27,35 @@ export class RecoveryHooks extends Context.Reference<{ }) {} /** The retirement implementation stays owned by child-threads; this layer holds no ancestry state. */ -export const layer = ( - readRetirementState: (threadId: ThreadId) => Effect.Effect< - { - readonly retired: boolean; - readonly complete: boolean; - }, - Projection.ProjectionStoreV2Error - >, -) => - Layer.effect( - RecoveryHooks, - Effect.gen(function* () { - const coordinator = yield* Coordinator.RecoveryCoordinator; - const projections = yield* Projection.ProjectionStoreV2; - return { - holdsFinalization: (threadId: ThreadId, expectedRunId: RunId) => - Effect.gen(function* () { - // The first terminal observer must arm recovery before transferring a failed task result. - const projection = yield* projections.getThreadRecords( - threadId, - ["runs", "turnItems", "runtimeRequests"], - { - turnItemTypes: ["error", "run_interrupt_request"], - }, - ); - const retirement = yield* readRetirementState(threadId); - yield* coordinator.observe(projection, retirement.retired || !retirement.complete); - return ( - latestExecutedRun(projection.runs)?.id !== expectedRunId || - (yield* coordinator.holdsResult(threadId)) - ); - }), - commitPlans: (command: OrchestrationV2ServerCommand) => { - const plan = command.type === "message.dispatch" ? retryAdmission(command) : null; - return plan === null ? [] : [plan]; - }, - }; - }), - ); +export const layer = Layer.effect( + RecoveryHooks, + Effect.gen(function* () { + const coordinator = yield* Coordinator.RecoveryCoordinator; + const projections = yield* Projection.ProjectionStoreV2; + return { + holdsFinalization: (threadId: ThreadId, expectedRunId: RunId) => + Effect.gen(function* () { + // The first terminal observer must arm recovery before transferring a failed task result. + const projection = yield* projections.getThreadRecords( + threadId, + ["runs", "turnItems", "runtimeRequests"], + { + turnItemTypes: ["error", "run_interrupt_request"], + }, + ); + const retirement = yield* readRetirementState(threadId).pipe( + Effect.provideService(Projection.ProjectionStoreV2, projections), + ); + yield* coordinator.observe(projection, retirement.retired || !retirement.complete); + return ( + latestExecutedRun(projection.runs)?.id !== expectedRunId || + (yield* coordinator.holdsResult(threadId)) + ); + }), + commitPlans: (command: OrchestrationV2ServerCommand) => { + const plan = command.type === "message.dispatch" ? retryAdmission(command) : null; + return plan === null ? [] : [plan]; + }, + }; + }), +); diff --git a/apps/server/src/prism/RecoveryReactor.test.ts b/apps/server/src/prism/RecoveryReactor.test.ts new file mode 100644 index 0000000000..3cd4c7bb73 --- /dev/null +++ b/apps/server/src/prism/RecoveryReactor.test.ts @@ -0,0 +1,89 @@ +import { assert, it } from "@effect/vitest"; +import { CommandId, EventId, type OrchestrationV2ServerCommand } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; +import * as EventSink from "../orchestration-v2/EventSink.ts"; +import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; +import * as Threads from "../orchestration-v2/ThreadManagementService.ts"; +import * as Scheduler from "../scheduling/Scheduler.ts"; +import * as Settings from "../serverSettings.ts"; +import * as Coordinator from "./RecoveryCoordinator.ts"; +import * as Reactor from "./RecoveryReactor.ts"; +import { + recoveryTestLayer, + recoveryRun as run, + recoveryEvents, + threadFor, +} from "./recovery.testkit.ts"; + +const dependencies = Layer.mergeAll( + recoveryTestLayer, + Coordinator.layer.pipe(Layer.provide(Layer.mergeAll(recoveryTestLayer, Settings.layerTest()))), +); +it.effect( + "committed failure and idle Stop events trigger retry and release without waiting for a clock tick", + () => + Effect.gen(function* () { + const sink = yield* EventSink.EventSinkV2; + const coordinator = yield* Coordinator.RecoveryCoordinator; + const commands = yield* Queue.unbounded(); + const released = yield* Deferred.make(); + const noFinalization = Effect.gen(function* () { + const held = yield* coordinator.holdsResult(run.threadId); + if (!held) yield* Deferred.succeed(released, undefined); + }); + const external = Layer.mergeAll( + Layer.mock(Scheduler.Scheduler)({ register: () => Effect.void }), + Layer.mock(Threads.ThreadManagementService)({ + dispatch: (command) => + Queue.offer(commands, command).pipe(Effect.as({ sequence: 0, storedEvents: [] })), + }), + Layer.mock(Orchestrator.OrchestratorV2)({ + recoverDelegatedTasks: noFinalization.pipe(Effect.orDie), + }), + ); + yield* Effect.gen(function* () { + yield* sink.commitCommand({ + commandId: CommandId.make("seed:events"), + commandType: "seed", + threadId: run.threadId, + acceptedAt: run.requestedAt, + events: recoveryEvents(run), + effects: [], + }); + yield* sink.write({ + events: [ + { + id: EventId.make("event:failed:observed"), + type: "run.updated", + threadId: run.threadId, + occurredAt: run.requestedAt, + payload: run, + }, + ], + }); + const retry = yield* Queue.take(commands); + assert.strictEqual(retry.commandId, "prism-retry:thread:recovery:run:original"); + assert.strictEqual(yield* coordinator.holdsResult(run.threadId), true); + yield* sink.write({ + events: [ + { + id: EventId.make("event:idle-stop:observed"), + type: "thread.metadata-updated", + threadId: run.threadId, + occurredAt: run.requestedAt, + payload: { + ...threadFor(run), + forkRetirement: { token: CommandId.make("stop:idle") }, + }, + }, + ], + }); + yield* Deferred.await(released); + assert.strictEqual(yield* coordinator.holdsResult(run.threadId), false); + assert.strictEqual(yield* Queue.size(commands), 0); + }).pipe(Effect.provide(Reactor.layer.pipe(Layer.provide(external)))); + }).pipe(Effect.provide(dependencies)), +); diff --git a/apps/server/src/prism/RecoveryReactor.ts b/apps/server/src/prism/RecoveryReactor.ts new file mode 100644 index 0000000000..c36dcd49c3 --- /dev/null +++ b/apps/server/src/prism/RecoveryReactor.ts @@ -0,0 +1,105 @@ +import { ThreadId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/sql/SqlClient"; +import { readRetirementState } from "../childThreads/retirement.ts"; +import * as EventSink from "../orchestration-v2/EventSink.ts"; +import * as Projection from "../orchestration-v2/ProjectionStore.ts"; +import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; +import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; +import * as Scheduler from "../scheduling/Scheduler.ts"; +import { forkParked } from "../serverActivation.ts"; +import * as Coordinator from "./RecoveryCoordinator.ts"; +import { retryCommand } from "./recoveryPolicy.ts"; + +export class RecoveryReactor extends Context.Service< + RecoveryReactor, + { + readonly sweep: Effect.Effect; + } +>()("t3/prism/RecoveryReactor") {} + +export const layer = Layer.effect( + RecoveryReactor, + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const coordinator = yield* Coordinator.RecoveryCoordinator; + const projections = yield* Projection.ProjectionStoreV2; + const threads = yield* ThreadManagement.ThreadManagementService; + const orchestrator = yield* Orchestrator.OrchestratorV2; + const sink = yield* EventSink.EventSinkV2; + const scheduler = yield* Scheduler.Scheduler; + const permit = yield* Semaphore.make(1); + const reconcile = Effect.gen(function* () { + // Closed failures are not polled again; a new original run gets a new opportunity. + const candidates = yield* sql<{ thread_id: string }>` + SELECT thread_id FROM fork_prism_recovery WHERE state <> 'closed' + UNION + SELECT t.thread_id FROM orchestration_v2_projection_threads t + JOIN orchestration_v2_projection_runs r ON r.run_id = ( + SELECT candidate.run_id FROM orchestration_v2_projection_runs candidate + WHERE candidate.thread_id=t.thread_id AND candidate.status <> 'queued' + AND NOT (candidate.status='cancelled' AND json_extract(candidate.payload_json,'$.startedAt') IS NULL) + ORDER BY (candidate.completed_at IS NULL) DESC, candidate.completed_at DESC, candidate.ordinal DESC LIMIT 1 + ) + LEFT JOIN fork_prism_recovery recovery ON recovery.thread_id=t.thread_id + WHERE json_extract(t.payload_json,'$.creationSource')='mcp' AND r.status='failed' + AND (recovery.source_run_id IS NULL OR recovery.source_run_id <> r.run_id)`; + for (const candidate of candidates) { + const threadId = ThreadId.make(candidate.thread_id); + yield* Effect.gen(function* () { + const projection = yield* projections.getThreadRecords( + threadId, + ["runs", "turnItems", "runtimeRequests"], + { + turnItemTypes: ["error", "run_interrupt_request"], + }, + ); + const retirement = yield* readRetirementState(threadId).pipe( + Effect.provideService(Projection.ProjectionStoreV2, projections), + ); + const record = yield* coordinator.observe( + projection, + retirement.retired || !retirement.complete, + ); + if (record === null) return; + const command = retryCommand(record); + // Source eligibility and ancestry are rechecked by the composed commit plans. + if (command !== null) yield* threads.dispatch(command); + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("prism.recovery.reconcile-failed", { threadId, cause }), + ), + ); + } + // Closing Stop/reset/retry state releases upstream's eventual result transfer. + if (candidates.length > 0) yield* orchestrator.recoverDelegatedTasks; + }); + const sweep = reconcile.pipe( + permit.withPermits(1), + Effect.catchCause((cause) => Effect.logWarning("prism.recovery.sweep-failed", { cause })), + ); + yield* scheduler.register("prism-recovery", sweep); + const sequence = yield* sink.latestSequence().pipe(Effect.orDie); + // Event replay covers failures between subscription and server activation; the scheduler covers restarts. + yield* forkParked( + sink.stream({ afterSequence: sequence }).pipe( + Stream.filter( + (stored) => + stored.event.type === "run.updated" || + (stored.event.type === "thread.metadata-updated" && + (stored.event.payload.forkRetirement !== undefined || + stored.event.payload.limitRecovery !== undefined)), + ), + Stream.runForEach(() => sweep.pipe(Effect.andThen(orchestrator.recoverDelegatedTasks))), + Effect.catchCause((cause) => + Effect.logWarning("prism.recovery.observer-failed", { cause }), + ), + ), + ); + return RecoveryReactor.of({ sweep }); + }), +); diff --git a/apps/server/src/prism/RecoverySettings.test.ts b/apps/server/src/prism/RecoverySettings.test.ts index e59d2543c4..6dc746627d 100644 --- a/apps/server/src/prism/RecoverySettings.test.ts +++ b/apps/server/src/prism/RecoverySettings.test.ts @@ -10,6 +10,7 @@ import * as Config from "../config.ts"; import * as Persistence from "../persistence/Sqlite.ts"; import * as Settings from "../serverSettings.ts"; +const decodeSettingsJson = Schema.decodeUnknownEffect(Schema.fromJsonString(ServerSettings)); const settingsLayer = Settings.layer.pipe( Layer.provide(Secrets.layer), Layer.provideMerge(Persistence.layerMemory), @@ -26,10 +27,19 @@ it.effect("Prism settings writes preserve a saved reset-recovery opt-out", () => yield* settings.updateSettings({ prismRoles: { planner: { instructions: "Use the project plan." } }, }); - const persisted = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(ServerSettings))( - yield* fs.readFileString(config.settingsPath), - ); + const persisted = yield* decodeSettingsJson(yield* fs.readFileString(config.settingsPath)); assert.strictEqual(persisted.autoResumeLimitedThreads, false); assert.strictEqual(persisted.prismRoles.planner.instructions, "Use the project plan."); }).pipe(Effect.provide(settingsLayer)), ); + +it.effect("fresh Chromeria settings enable reset recovery by default", () => + Effect.gen(function* () { + const settings = yield* Settings.ServerSettingsService; + assert.strictEqual((yield* settings.getSettings).autoResumeLimitedThreads, true); + yield* settings.updateSettings({ + prismRoles: { planner: { instructions: "Keep the default." } }, + }); + assert.strictEqual((yield* settings.getSettings).autoResumeLimitedThreads, true); + }).pipe(Effect.provide(settingsLayer)), +); diff --git a/apps/server/src/prism/recoveryAdmission.test.ts b/apps/server/src/prism/recoveryAdmission.test.ts index 707706e618..3ed0e3de69 100644 --- a/apps/server/src/prism/recoveryAdmission.test.ts +++ b/apps/server/src/prism/recoveryAdmission.test.ts @@ -3,6 +3,8 @@ import { CommandId, EventId, RunId } from "@t3tools/contracts"; import * as SqlClient from "effect/sql/SqlClient"; import * as Effect from "effect/Effect"; import * as EventSink from "../orchestration-v2/EventSink.ts"; +import { retirementAdmission, explicitMessage } from "../childThreads/retirement.ts"; +import { threadFor } from "./recovery.testkit.ts"; import * as Store from "./RecoveryStore.ts"; import { recoveryTestLayer, @@ -39,6 +41,52 @@ const setup = Effect.gen(function* () { return { sink, store, armed, command, plan }; }); describe("Prism atomic retry admission", () => { + it.effect( + "a Stop committed after recovery planning rejects both composed plans without acknowledgement", + () => + Effect.gen(function* () { + const { sink, store, command, plan } = yield* setup; + const ancestry = retirementAdmission({ threadId: run.threadId }); + assert.strictEqual(explicitMessage(command), false); + yield* sink.write({ + events: [ + { + id: EventId.make("event:stop:after-plan"), + type: "thread.metadata-updated", + threadId: run.threadId, + occurredAt: run.requestedAt, + payload: { + ...threadFor(run), + forkRetirement: { token: CommandId.make("stop:after-plan") }, + }, + }, + ], + }); + const result = yield* sink + .commitCommand({ + commandId: command.commandId, + threadId: command.threadId, + commandType: command.type, + acceptedAt: run.requestedAt, + events: [], + effects: [], + forkPlans: [ancestry, plan], + }) + .pipe(Effect.flip); + assert.strictEqual(result._tag, "ForkCommitGuardRejected"); + if (result._tag === "ForkCommitGuardRejected") assert.strictEqual(result.kind, "retired"); + assert.strictEqual((yield* store.get(run.threadId))?.state, "retry_pending"); + const sql = yield* SqlClient.SqlClient; + assert.strictEqual( + (yield* sql<{ + count: number; + }>`SELECT COUNT(*) AS count FROM orchestration_v2_effect_outbox WHERE command_id=${command.commandId}`)[0] + ?.count, + 0, + ); + }).pipe(Effect.provide(recoveryTestLayer)), + ); + it.effect( "duplicate dispatch commits one retry and receipt replay bypasses consumed eligibility", () => diff --git a/apps/web/src/components/settings/PrismSettings.tsx b/apps/web/src/components/settings/PrismSettings.tsx index b7f1b89bda..3ecac025d4 100644 --- a/apps/web/src/components/settings/PrismSettings.tsx +++ b/apps/web/src/components/settings/PrismSettings.tsx @@ -1,11 +1,4 @@ -import { - PRISM_ROLE_LABELS, - PRISM_SWITCHABLE_ROLES, - PrismLane, - type PrismModelPreference, - type PrismRole, - type PrismSwitchableRole, -} from "@t3tools/contracts"; +import { PRISM_ROLE_LABELS, PrismLane, type PrismModelPreference } from "@t3tools/contracts"; import { closestCenter, DndContext, @@ -109,19 +102,16 @@ const ROLE_DETAILS: Record< }, retry: { icon: , - description: "Takes over a failed worker turn on another model.", + description: 'Model list used when the planner requests prismRole: "retry".', }, escalation: { icon: , - description: "Moves stuck work to a stronger model before asking the planner.", + description: 'Model list used when the planner requests prismRole: "escalation".', }, }; const LANE_LABELS: Record = { easy: "Easy", medium: "Medium", hard: "Hard" }; -const isSwitchable = (role: PrismRole): role is PrismSwitchableRole => - (PRISM_SWITCHABLE_ROLES as readonly PrismRole[]).includes(role); - type DraftPreference = PrismModelPreference & { entryId: number }; const editablePreference = (model: PrismModelPreference, entryId: number): DraftPreference => ({ ...model, @@ -368,18 +358,12 @@ function ModelList({ function RoleRow({ role, - enabled, selected, - disabled, onSelect, - onEnabledChange, }: { role: PageRole; - enabled: boolean; selected: boolean; - disabled: boolean; onSelect: () => void; - onEnabledChange: (enabled: boolean) => void; }) { const { icon, description, lockedReason } = ROLE_DETAILS[role]; const name = PRISM_ROLE_LABELS[role]; @@ -391,12 +375,7 @@ function RoleRow({ selected ? "bg-muted/45" : "hover:bg-muted/25", )} > -
    +
    - - {lockedReason ? : null} - {lockedReason ? ( + {lockedReason && ( + + {lockedReason} - ) : ( - onEnabledChange(Boolean(checked))} - aria-label={`Enable ${name}`} - /> - )} - + + )}
    ); } @@ -483,8 +455,6 @@ export function PrismSettings() { }); const [selectedRole, setSelectedRole] = useState("dispatcher"); const [workerLane, setWorkerLane] = useState("medium"); - const roleEnabled = (role: PageRole) => - isSwitchable(role) ? settings.prismRoles[role].enabled : true; const providers = environment?.serverConfig?.providers ?? EMPTY_SERVER_PROVIDERS; const entries = applyProviderInstanceSettings(deriveProviderInstanceEntries(providers), settings); @@ -551,14 +521,8 @@ export function PrismSettings() { setSelectedRole(role)} - onEnabledChange={(enabled) => { - if (isSwitchable(role)) - void saveRole({ kind: "enabled", role, enabled }).catch(() => {}); - }} /> ))} @@ -572,13 +536,16 @@ export function PrismSettings() {

    {ROLE_DETAILS[selectedRole].description}

    -

    - {ROLE_DETAILS[selectedRole].lockedReason - ? `Always on. ${ROLE_DETAILS[selectedRole].lockedReason}` - : roleEnabled(selectedRole) - ? "On. Switch it off to skip this step." - : "Off. Prism skips this step."} -

    + {ROLE_DETAILS[selectedRole].lockedReason ? ( +

    + Always on. {ROLE_DETAILS[selectedRole].lockedReason} +

    + ) : ( +

    + Automatic recovery retries once on the same provider and model, independently of + these lists. +

    + )} {pendingWrite && ( diff --git a/packages/contracts/src/prism.test.ts b/packages/contracts/src/prism.test.ts index 7f0f179512..5ed9ef75e0 100644 --- a/packages/contracts/src/prism.test.ts +++ b/packages/contracts/src/prism.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vite-plus/test"; import { DEFAULT_PRISM_ROLE_KITS, PrismRoleKits, PrismRoleKitsPatch } from "./prism.ts"; import { ProjectSettingsOverrides, ServerSettings } from "./settings.ts"; +const encodeServerSettings = Schema.encodeSync(ServerSettings); const decodeServerSettings = Schema.decodeSync(ServerSettings); const decodeProjectOverrides = Schema.decodeSync(ProjectSettingsOverrides); const decodeKitsPatch = Schema.decodeSync(PrismRoleKitsPatch); @@ -15,7 +16,7 @@ describe("Prism role kits", () => { const saved = decodeServerSettings({ autoResumeLimitedThreads: false }); expect(saved.autoResumeLimitedThreads).toBe(false); const updated = decodeServerSettings({ - ...Schema.encodeSync(ServerSettings)(saved), + ...encodeServerSettings(saved), prismRoles: { planner: { instructions: "Plan." } }, }); expect(updated.autoResumeLimitedThreads).toBe(false); From 45ec8c214ace0554c27eea33e31d111e90a4b026 Mon Sep 17 00:00:00 2001 From: lukemaj Date: Thu, 8 Oct 2026 19:42:12 +0200 Subject: [PATCH 03/10] wip: prove recovery survives stale propagated Stop --- .../src/prism/Recovery.integration.test.ts | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/apps/server/src/prism/Recovery.integration.test.ts b/apps/server/src/prism/Recovery.integration.test.ts index dbca575c80..5fdf3b3f8a 100644 --- a/apps/server/src/prism/Recovery.integration.test.ts +++ b/apps/server/src/prism/Recovery.integration.test.ts @@ -64,6 +64,7 @@ const replay = Harness.layerWithRegistry( const dependencies = Layer.mergeAll( foundation, recovery, + hooks, replay, Threads.layer.pipe(Layer.provide(replay)), Layer.mock(Scheduler.Scheduler)({ register: () => Effect.void }), @@ -322,3 +323,113 @@ it.effect("Stop while waiting for reset releases finalization without resuming a ); }).pipe(Effect.provide(layer)), ); + +it.effect( + "stale propagated Stop preserves a resumed child's recovery; a newer Stop releases it", + () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threads = yield* Threads.ThreadManagementService; + const projections = yield* Projection.ProjectionStoreV2; + const sql = yield* SqlClient.SqlClient; + const hooks = yield* Hooks.RecoveryHooks; + const rootId = ThreadId.make("root:delayed-recovery-stop"); + const childId = ThreadId.make("child:delayed-recovery-stop"); + for (const threadId of [rootId, childId]) { + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make(`create:${threadId}`), + threadId, + projectId: ProjectId.make("project:recovery"), + title: threadId, + modelSelection: selection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "agent", + creationSource: "mcp", + }); + } + const child = (yield* orchestrator.getThreadProjection(childId)).thread; + yield* projections.apply({ + id: EventId.make("delayed-recovery-lineage"), + type: "thread.metadata-updated", + threadId: childId, + occurredAt: yield* DateTime.now, + payload: { + ...child, + lineage: { + rootThreadId: rootId, + parentThreadId: rootId, + relationshipToParent: "subagent", + }, + }, + }); + const oldToken = CommandId.make("delayed-recovery-stop:old"); + yield* orchestrator.dispatch({ type: "thread.stop", commandId: oldToken, threadId: rootId }); + const resumeId = CommandId.make("delayed-recovery:human-resume"); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: resumeId, + threadId: childId, + messageId: MessageId.make("delayed-recovery:human-message"), + text: "Resume explicitly", + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + const resumed = yield* orchestrator.getThreadProjection(childId); + assert.include(resumed.thread.forkResumedRetirements ?? [], oldToken); + const run = resumed.runs[0]!; + yield* finish(run, failure); + assert.strictEqual(yield* hooks.holdsFinalization(childId, run.id), true); + const recoveryBefore = + yield* sql`SELECT * FROM fork_prism_recovery WHERE thread_id=${childId}`; + const effectsBefore = + yield* sql`SELECT effect_id,status FROM orchestration_v2_effect_outbox WHERE command_id=${resumeId}`; + assert.isNotEmpty(effectsBefore); + yield* threads.stopDelegatedTasks({ threadId: rootId, commandId: oldToken }); + const propagatedId = CommandId.make(`${oldToken}:stop:${childId}`); + assert.deepStrictEqual( + yield* sql`SELECT status FROM orchestration_command_receipts WHERE command_id=${propagatedId}`, + [{ status: "accepted" }], + ); + assert.lengthOf( + yield* sql`SELECT event_id FROM orchestration_events WHERE command_id=${propagatedId}`, + 0, + ); + assert.lengthOf( + yield* sql`SELECT effect_id FROM orchestration_v2_effect_outbox WHERE command_id=${propagatedId}`, + 0, + ); + assert.deepStrictEqual( + yield* sql`SELECT * FROM fork_prism_recovery WHERE thread_id=${childId}`, + recoveryBefore, + ); + assert.deepStrictEqual( + yield* sql`SELECT effect_id,status FROM orchestration_v2_effect_outbox WHERE command_id=${resumeId}`, + effectsBefore, + ); + assert.strictEqual(yield* hooks.holdsFinalization(childId, run.id), true); + const afterStale = yield* orchestrator.getThreadProjection(childId); + assert.deepStrictEqual( + afterStale.thread.forkResumedRetirements, + resumed.thread.forkResumedRetirements, + ); + assert.strictEqual( + afterStale.thread.forkRetirement?.token, + resumed.thread.forkRetirement?.token, + ); + const newToken = CommandId.make("delayed-recovery-stop:new"); + yield* orchestrator.dispatch({ type: "thread.stop", commandId: newToken, threadId: rootId }); + assert.strictEqual(yield* hooks.holdsFinalization(childId, run.id), false); + assert.deepStrictEqual( + yield* sql`SELECT state FROM fork_prism_recovery WHERE thread_id=${childId}`, + [{ state: "closed" }], + ); + yield* (yield* Reactor.RecoveryReactor).sweep; + assert.lengthOf((yield* orchestrator.getThreadProjection(childId)).runs, 1); + }).pipe(Effect.provide(layer)), +); From 4f1832d1a53dd5b7b6b2bfafac69cd6e0410ab58 Mon Sep 17 00:00:00 2001 From: lukemaj Date: Thu, 8 Oct 2026 19:49:06 +0200 Subject: [PATCH 04/10] wip: inventory Prism port and prove complete saved kit preservation --- apps/server/src/serverSettings.test.ts | 91 +++++++++++++------ docs/fork-features.md | 118 +++++++++++++++++++++++++ packages/contracts/src/prism.ts | 4 +- scripts/fork-upstream-edits.txt | 11 +++ 4 files changed, 193 insertions(+), 31 deletions(-) diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index db3a6cb5c5..f2883cf521 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -97,35 +97,68 @@ const recordProviderUsage = (provider: string, instanceId: string | null = provi }); it.layer(NodeServices.layer)("server settings", (it) => { - it.effect("preserves opaque fork settings and project overrides through unrelated writes", () => - Effect.gen(function* () { - const config = yield* ServerConfig.ServerConfig; - const fs = yield* FileSystem.FileSystem; - const service = yield* ServerSettingsModule.ServerSettingsService; - const opaque = { - prismRoles: { future: ["role", { version: 99 }] }, - wightModes: { future: [null, false, "mode"] }, - }; - const project = { ...opaque, defaultAutoPull: true }; - yield* fs.writeFileString( - config.settingsPath, - JSON.stringify({ - ...opaque, - autoUpdateProviders: true, - projectSettingsOverrides: { legacy: project }, - }), - ); - const initial = yield* service.getSettings; - assert.deepEqual(initial.prismRoles, opaque.prismRoles); - assert.deepEqual(initial.wightModes, opaque.wightModes); - assert.deepEqual(initial.projectSettingsOverrides[ProjectId.make("legacy")], project); - yield* service.updateSettings({ responseStreamingMode: "turn" }); - const persisted = JSON.parse(yield* fs.readFileString(config.settingsPath)); - assert.deepEqual(persisted.prismRoles, opaque.prismRoles); - assert.deepEqual(persisted.wightModes, opaque.wightModes); - assert.isTrue(persisted.autoUpdateProviders); - assert.deepEqual(persisted.projectSettingsOverrides.legacy, project); - }).pipe(Effect.provide(layerServerSettings())), + it.effect( + "preserves saved V1 Prism kits and opaque Wight settings through unrelated writes", + () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const service = yield* ServerSettingsModule.ServerSettingsService; + const savedKits = (scope: string) => { + const models = [ + { instanceId: "codex", model: "gpt-5.4", effort: "high" }, + { instanceId: "claude", model: "claude-sonnet-4-6" }, + ]; + const kit = (role: string) => ({ + instructions: `${scope} ${role} instructions.\nPreserve the complete saved kit.`, + skills: ["operations", `${scope}-${role}`], + models, + }); + return { + promachos: kit("promachos"), + planner: kit("planner"), + dispatcher: kit("dispatcher"), + reviewer: kit("reviewer"), + worker: { + instructions: `${scope} worker instructions.`, + skills: ["operations", `${scope}-worker`], + lanes: { + easy: [{ instanceId: "codex", model: "gpt-5.4-mini", effort: "low" }], + medium: models, + hard: [ + { instanceId: "claude", model: "claude-opus-4-6", effort: "high" }, + { instanceId: "codex", model: "gpt-5.4", effort: "xhigh" }, + ], + }, + }, + retry: { ...kit("retry"), enabled: false }, + escalation: { ...kit("escalation"), enabled: false }, + }; + }; + const opaque = { + prismRoles: savedKits("environment"), + wightModes: { future: [null, false, "mode"] }, + }; + const project = { ...opaque, prismRoles: savedKits("project"), defaultAutoPull: true }; + yield* fs.writeFileString( + config.settingsPath, + JSON.stringify({ + ...opaque, + autoUpdateProviders: true, + projectSettingsOverrides: { legacy: project }, + }), + ); + const initial = yield* service.getSettings; + assert.deepEqual(initial.prismRoles, opaque.prismRoles); + assert.deepEqual(initial.wightModes, opaque.wightModes); + assert.deepEqual(initial.projectSettingsOverrides[ProjectId.make("legacy")], project); + yield* service.updateSettings({ responseStreamingMode: "turn" }); + const persisted = JSON.parse(yield* fs.readFileString(config.settingsPath)); + assert.deepEqual(persisted.prismRoles, opaque.prismRoles); + assert.deepEqual(persisted.wightModes, opaque.wightModes); + assert.isTrue(persisted.autoUpdateProviders); + assert.deepEqual(persisted.projectSettingsOverrides.legacy, project); + }).pipe(Effect.provide(layerServerSettings())), ); it.effect("migrates saved token delivery to paragraph buffering without resetting settings", () => diff --git a/docs/fork-features.md b/docs/fork-features.md index ac3cb2e0d9..fae31e7b32 100644 --- a/docs/fork-features.md +++ b/docs/fork-features.md @@ -630,3 +630,121 @@ Child creation paths are owned by child-threads and shared with thread-people. ] } ``` + +## prism-toolkit + +```json +{ + "id": "prism-toolkit", + "purpose": "Route upstream delegation and top-level launches through one Prism service and coordinate persisted same-provider/model recovery. Own Chromeria autoResumeLimitedThreads=true; Wight consumes it and saved false overrides remain respected.", + "issues": ["https://github.com/toolboxmd/chromeria/issues/169"], + "prs": ["https://github.com/toolboxmd/chromeria/compare/fork/v2...feat/169-prism-service-v2"], + "newFiles": [ + "apps/server/src/prism/PrismService.test.ts", + "apps/server/src/prism/PrismService.ts", + "apps/server/src/prism/Recovery.integration.test.ts", + "apps/server/src/prism/RecoveryCoordinator.test.ts", + "apps/server/src/prism/RecoveryCoordinator.ts", + "apps/server/src/prism/RecoveryHooks.ts", + "apps/server/src/prism/RecoveryReactor.test.ts", + "apps/server/src/prism/RecoveryReactor.ts", + "apps/server/src/prism/RecoverySettings.test.ts", + "apps/server/src/prism/RecoveryStore.test.ts", + "apps/server/src/prism/RecoveryStore.ts", + "apps/server/src/prism/recovery.testkit.ts", + "apps/server/src/prism/recoveryAdmission.test.ts", + "apps/server/src/prism/recoveryAdmission.ts", + "apps/server/src/prism/recoveryPolicy.ts", + "packages/contracts/src/prism.test.ts", + "packages/contracts/src/prism.ts" + ], + "upstreamFiles": [ + "apps/server/src/mcp/toolkits/project/tools.ts", + "apps/server/src/orchestration-v2/runtimeLayer.ts" + ], + "sharedFiles": [ + "apps/server/src/mcp/toolkits/project/handlers.test.ts", + "apps/server/src/mcp/toolkits/project/handlers.ts", + "packages/contracts/src/index.ts", + "apps/server/src/mcp/OrchestratorMcpService.activity.test.ts", + "apps/server/src/mcp/OrchestratorMcpService.test.ts", + "apps/server/src/mcp/OrchestratorMcpService.ts", + "apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts", + "apps/server/src/mcp/toolkits/core.test.ts", + "apps/server/src/orchestration-v2/Orchestrator.ts", + "packages/contracts/src/orchestrationV2.ts", + "packages/contracts/src/orchestratorMcp.ts", + "packages/contracts/src/settings.ts" + ], + "keywords": [ + "Prism", + "delegate_task", + "t3_thread_launch", + "prismRole", + "forkPrismRetryOfRunId", + "autoResumeLimitedThreads", + "limitRecovery", + "forkRetirement" + ] +} +``` + +## prism-settings + +```json +{ + "id": "prism-settings", + "purpose": "Keep scoped Prism kits, ordered model lists and worker lanes editable with saved settings replay and honest explicit retry/escalation kit semantics.", + "issues": ["https://github.com/toolboxmd/chromeria/issues/169"], + "prs": ["https://github.com/toolboxmd/chromeria/compare/fork/v2...feat/169-prism-service-v2"], + "newFiles": [ + "apps/web/src/components/settings/PrismSettings.logic.test.ts", + "apps/web/src/components/settings/PrismSettings.logic.ts", + "apps/web/src/components/settings/PrismSettings.state.test.ts", + "apps/web/src/components/settings/PrismSettings.state.ts", + "apps/web/src/components/settings/PrismSettings.tsx", + "apps/web/src/routes/settings.prism.tsx" + ], + "upstreamFiles": [ + "apps/web/src/components/settings/SettingsSidebarNav.tsx", + "apps/web/src/routeTree.gen.ts" + ], + "sharedFiles": [ + "apps/server/src/serverSettings.test.ts", + "apps/web/src/components/settings/settingsSearch.ts", + "packages/contracts/src/settings.ts", + "packages/contracts/src/prism.ts", + "packages/contracts/src/prism.test.ts" + ], + "keywords": ["prismRoles", "PrismSettings", "worker lanes", "prismRole", "Retry", "Escalation"] +} +``` + +## tool-instructions + +```json +{ + "id": "tool-instructions", + "purpose": "Give every provider runtime consistent visible T3 delegation and Prism routing/recovery guidance.", + "issues": ["https://github.com/toolboxmd/chromeria/issues/169"], + "prs": ["https://github.com/toolboxmd/chromeria/compare/fork/v2...feat/169-prism-service-v2"], + "newFiles": ["apps/server/src/mcp/toolInstructions.ts"], + "upstreamFiles": [ + "apps/server/src/mcp/toolkits/orchestrator/tools.ts", + "apps/server/src/provider/RuntimeInstructions.ts", + "apps/server/src/provider/T3OrchestrationInstructions.ts" + ], + "sharedFiles": [ + "apps/server/src/mcp/toolkits/project/tools.ts", + "packages/contracts/src/orchestratorMcp.ts" + ], + "keywords": [ + "t3_code_tool_use", + "delegate_task", + "t3_thread_launch", + "Prism", + "prismRole", + "shell agent" + ] +} +``` diff --git a/packages/contracts/src/prism.ts b/packages/contracts/src/prism.ts index 7040dea2a6..34db4dae83 100644 --- a/packages/contracts/src/prism.ts +++ b/packages/contracts/src/prism.ts @@ -64,7 +64,7 @@ export const PrismLaneModels = Schema.Struct({ }).pipe(Schema.withDecodingDefault(Effect.succeed({}))); export type PrismLaneModels = typeof PrismLaneModels.Type; -/** Roles the user may switch off in Prism; the router then skips that step. */ +/** Roles with legacy enabled flags retained for saved-settings compatibility. */ export const PRISM_SWITCHABLE_ROLES = ["retry", "escalation"] as const; export type PrismSwitchableRole = (typeof PRISM_SWITCHABLE_ROLES)[number]; @@ -122,7 +122,7 @@ const singleListKit = (extra: Fields) => { }; const switchable = { - /** Off: the router skips this step of its ladder. */ + /** Legacy preference only; explicit kit requests and automatic recovery ignore it. */ enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), }; diff --git a/scripts/fork-upstream-edits.txt b/scripts/fork-upstream-edits.txt index 1c0266d622..61702a93d9 100644 --- a/scripts/fork-upstream-edits.txt +++ b/scripts/fork-upstream-edits.txt @@ -136,3 +136,14 @@ packages/contracts/src/auth.ts packages/contracts/src/environmentHttp.ts packages/contracts/src/orchestrationV2.ts apps/server/src/orchestration-v2/SubagentProjection.ts +# Prism routing, settings and shared runtime guidance (#169) +apps/server/src/mcp/toolkits/orchestrator/tools.ts +apps/server/src/mcp/toolkits/project/handlers.test.ts +apps/server/src/mcp/toolkits/project/handlers.ts +apps/server/src/mcp/toolkits/project/tools.ts +apps/server/src/orchestration-v2/runtimeLayer.ts +apps/server/src/provider/RuntimeInstructions.ts +apps/server/src/provider/T3OrchestrationInstructions.ts +apps/web/src/components/settings/SettingsSidebarNav.tsx +apps/web/src/routeTree.gen.ts +packages/contracts/src/index.ts From 86eeb2fd00ac7fbe9f11966d2ae074ac70b85851 Mon Sep 17 00:00:00 2001 From: lukemaj Date: Thu, 8 Oct 2026 20:23:50 +0200 Subject: [PATCH 05/10] wip: correct Prism integration fixture and typed saved settings proof --- ...OrchestratorMcpToolkit.integration.test.ts | 1 + apps/server/src/serverSettings.test.ts | 24 +++++++++++++++---- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index 87573d3610..d3d486852b 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -680,6 +680,7 @@ describe("orchestrator MCP toolkit", () => { McpHttpServer.layerOrchestratorToolkit, McpHttpServer.layerThreadToolkit, ).pipe( + Layer.provide(PrismServerSettings.layerTest()), Layer.provideMerge(McpServer.McpServer.layer), Layer.provideMerge(layerOrchestration), Layer.provide(layerRegistry), diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index f2883cf521..a45debe8ee 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -106,8 +106,8 @@ it.layer(NodeServices.layer)("server settings", (it) => { const service = yield* ServerSettingsModule.ServerSettingsService; const savedKits = (scope: string) => { const models = [ - { instanceId: "codex", model: "gpt-5.4", effort: "high" }, - { instanceId: "claude", model: "claude-sonnet-4-6" }, + { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4", effort: "high" }, + { instanceId: ProviderInstanceId.make("claude"), model: "claude-sonnet-4-6" }, ]; const kit = (role: string) => ({ instructions: `${scope} ${role} instructions.\nPreserve the complete saved kit.`, @@ -123,11 +123,25 @@ it.layer(NodeServices.layer)("server settings", (it) => { instructions: `${scope} worker instructions.`, skills: ["operations", `${scope}-worker`], lanes: { - easy: [{ instanceId: "codex", model: "gpt-5.4-mini", effort: "low" }], + easy: [ + { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4-mini", + effort: "low", + }, + ], medium: models, hard: [ - { instanceId: "claude", model: "claude-opus-4-6", effort: "high" }, - { instanceId: "codex", model: "gpt-5.4", effort: "xhigh" }, + { + instanceId: ProviderInstanceId.make("claude"), + model: "claude-opus-4-6", + effort: "high", + }, + { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + effort: "xhigh", + }, ], }, }, From c68e5db64f7808eaa06151b679835f5607cb302a Mon Sep 17 00:00:00 2001 From: lukemaj Date: Thu, 8 Oct 2026 21:07:25 +0200 Subject: [PATCH 06/10] wip: retain landed ownership while composing Prism imports --- apps/server/src/mcp/toolkits/project/handlers.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index 772fec808e..7a6aa05acb 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -1,5 +1,11 @@ import * as Prism from "../../../prism/PrismService.ts"; -import { MessageId, ThreadId, OrchestratorMcpFailure, ProjectId, threadOwner } from "@t3tools/contracts"; +import { + MessageId, + ThreadId, + OrchestratorMcpFailure, + ProjectId, + threadOwner, +} from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; From 927f0b4fe7b21a08de2c592aba98bc64c95681d7 Mon Sep 17 00:00:00 2001 From: lukemaj Date: Thu, 8 Oct 2026 21:30:46 +0200 Subject: [PATCH 07/10] wip: validate Prism launch admission and refresh configured providers --- .../src/mcp/OrchestratorMcpService.test.ts | 228 ++++++++++-------- .../src/mcp/toolkits/project/handlers.test.ts | 75 ++++++ .../src/mcp/toolkits/project/handlers.ts | 6 + apps/server/src/mcp/toolkits/project/tools.ts | 2 + apps/server/src/prism/PrismService.ts | 22 +- 5 files changed, 225 insertions(+), 108 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index e5d534b666..91683769e6 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -1340,114 +1340,132 @@ describe("OrchestratorMcpService provider resolution", () => { }), ); - it.effect("re-probes an unavailable target once before refusing it", () => - Effect.gen(function* () { - const claudeInstanceId = ProviderInstanceId.make("claudeAgent"); - const claudeDriver = ProviderDriverKind.make("claudeAgent"); - const task = { - id: taskId, - threadId: parentThreadId, - runId: parentRunId, - parentNodeId, - origin: "app_owned", - createdBy: "agent", - driver: claudeDriver, - providerInstanceId: claudeInstanceId, - providerThreadId: null, - childThreadId, - nativeTaskRef: null, - prompt: "Review the diff.", - title: null, - model: "claude-opus-5-5", - status: "running", - result: null, - startedAt: null, - completedAt: null, - }; - const healthy = providerSnapshot({ - instanceId: claudeInstanceId, - driver: claudeDriver, - model: "claude-opus-5-5", - }); - const missingCli: ServerProvider = { - ...healthy, - installed: false, - status: "error", - message: "Claude Agent CLI (`claude`) was not found on PATH.", - }; - const codex = providerSnapshot({ - instanceId: codexInstanceId, - driver: ProviderDriverKind.make("codex"), - model: "gpt-5.4", - }); - // The cache still says the CLI is missing; a probe reports `cliInstalled`. - let cliInstalled = false; - const probes = yield* Ref.make(0); - const dispatched = yield* Ref.make(0); - const layerDependencies = Layer.mergeAll( - NodeServices.layer, - Layer.mock(ThreadLaunch.ThreadLaunchService)({}), - Layer.mock(GitVcsDriver.GitVcsDriver)({}), - Layer.mock(ThreadManagementService.ThreadManagementService)({ - getThreadRecords: (threadId) => - Effect.succeed( - threadId === parentThreadId ? parentProjection([task]) : childProjection, - ), - dispatch: () => - Ref.update(dispatched, (count) => count + 1).pipe( - Effect.as({ - sequence: 1, - storedEvents: [ - { - sequence: 1, - commandId: null, - event: { type: "subagent.updated", payload: task }, - }, - ], - } as never), - ), - }), - Layer.mock(ProviderRegistry.ProviderRegistry)({ - getProviders: Effect.succeed([codex, missingCli]), - refreshInstance: () => - Ref.update(probes, (count) => count + 1).pipe( - Effect.as([codex, cliInstalled ? healthy : missingCli]), - ), - }), - adapterRegistryLayer([codexInstanceId, claudeInstanceId]), - Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), - Layer.mock(ProjectService.ProjectService)({}), - Layer.mock(SecretRequests.SecretRequests)({}), - ); + it.effect.each([false, true])( + "re-probes an unavailable explicit or configured target: %s", + (configured) => + Effect.gen(function* () { + const claudeInstanceId = ProviderInstanceId.make("claudeAgent"); + const claudeDriver = ProviderDriverKind.make("claudeAgent"); + const task = { + id: taskId, + threadId: parentThreadId, + runId: parentRunId, + parentNodeId, + origin: "app_owned", + createdBy: "agent", + driver: claudeDriver, + providerInstanceId: claudeInstanceId, + providerThreadId: null, + childThreadId, + nativeTaskRef: null, + prompt: "Review the diff.", + title: null, + model: "claude-opus-5-5", + status: "running", + result: null, + startedAt: null, + completedAt: null, + }; + const healthy = providerSnapshot({ + instanceId: claudeInstanceId, + driver: claudeDriver, + model: "claude-opus-5-5", + }); + const missingCli: ServerProvider = { + ...healthy, + availability: "unavailable", + models: configured ? [] : healthy.models, + installed: false, + status: "error", + message: "Claude Agent CLI (`claude`) was not found on PATH.", + }; + const codex = providerSnapshot({ + instanceId: codexInstanceId, + driver: ProviderDriverKind.make("codex"), + model: "gpt-5.4", + }); + // The cache still says the CLI is missing; a probe reports `cliInstalled`. + let cliInstalled = false; + const probes = yield* Ref.make(0); + const dispatched = yield* Ref.make(0); + const layerDependencies = Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadLaunch.ThreadLaunchService)({}), + Layer.mock(GitVcsDriver.GitVcsDriver)({}), + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: (threadId) => + Effect.succeed( + threadId === parentThreadId ? parentProjection([task]) : childProjection, + ), + dispatch: () => + Ref.update(dispatched, (count) => count + 1).pipe( + Effect.as({ + sequence: 1, + storedEvents: [ + { + sequence: 1, + commandId: null, + event: { type: "subagent.updated", payload: task }, + }, + ], + } as never), + ), + }), + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed([codex, missingCli]), + refreshInstance: () => + Ref.update(probes, (count) => count + 1).pipe( + Effect.as([codex, cliInstalled ? healthy : missingCli]), + ), + }), + adapterRegistryLayer([codexInstanceId, claudeInstanceId]), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + ); - yield* Effect.gen(function* () { - const service = yield* OrchestratorMcpService.OrchestratorMcpService; - const delegate = (clientRequestId: string) => - service.delegateTask(scope, { - task: "Review the diff.", - target: { providerInstanceId: claudeInstanceId, model: "claude-opus-5-5" }, - mode: "async", - clientRequestId, - }); + yield* Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const delegate = (clientRequestId: string) => + service.delegateTask(scope, { + task: "Review the diff.", + ...(configured + ? {} + : { target: { providerInstanceId: claudeInstanceId, model: "claude-opus-5-5" } }), + mode: "async", + clientRequestId, + }); - const error = yield* delegate("delegate-recheck-1").pipe(Effect.flip); - assert.equal(error.code, "provider_unavailable"); - assert.equal(yield* Ref.get(probes), 1); - assert.equal(yield* Ref.get(dispatched), 0); + const error = yield* delegate("delegate-recheck-1").pipe(Effect.flip); + assert.equal(error.code, "provider_unavailable"); + assert.equal(yield* Ref.get(probes), 1); + assert.equal(yield* Ref.get(dispatched), 0); - cliInstalled = true; - const result = yield* delegate("delegate-recheck-2"); - assert.equal(result.providerInstanceId, claudeInstanceId); - assert.equal(yield* Ref.get(probes), 2); - assert.equal(yield* Ref.get(dispatched), 1); - }).pipe( - Effect.provide( - OrchestratorMcpService.layer - .pipe(Layer.provide(ServerSettings.layerTest())) - .pipe(Layer.provide(layerDependencies)), - ), - ); - }), + cliInstalled = true; + const result = yield* delegate("delegate-recheck-2"); + assert.equal(result.providerInstanceId, claudeInstanceId); + assert.equal(yield* Ref.get(probes), 2); + assert.equal(yield* Ref.get(dispatched), 1); + }).pipe( + Effect.provide( + OrchestratorMcpService.layer + .pipe( + Layer.provide( + ServerSettings.layerTest({ + prismRoles: { + worker: { + lanes: { + medium: [{ instanceId: claudeInstanceId, model: "claude-opus-5-5" }], + }, + }, + }, + }), + ), + ) + .pipe(Layer.provide(layerDependencies)), + ), + ); + }), ); it.effect( diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index 9415ba5904..e7b95e23b8 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -1,3 +1,4 @@ +import * as AdapterRegistry from "../../../orchestration-v2/ProviderAdapterRegistry.ts"; import { ProviderDriverKind } from "@t3tools/contracts"; import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts"; import * as ServerSettings from "../../../serverSettings.ts"; @@ -32,6 +33,10 @@ import * as ProjectHandlers from "./handlers.ts"; import { ProjectToolkit } from "./tools.ts"; const prismDependencies = Layer.mergeAll( + Layer.mock(AdapterRegistry.ProviderAdapterRegistryV2)({ + list: () => + Effect.succeed([ProviderInstanceId.make("codex"), ProviderInstanceId.make("claude")]), + }), ServerSettings.layerTest(), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([ @@ -522,3 +527,73 @@ it.effect("a launch binds only an existing checkout that is one of the project's }); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + +it.effect( + "launch falls through an advertised provider without an adapter, but explicit selection refuses", + () => + Effect.gen(function* () { + const launched: Array = []; + const { projectId, dependencies } = clientLaunchHarness({ + runtimeModeCeiling: "auto-accept-edits", + launched, + }); + const codex = ProviderInstanceId.make("codex"); + const claude = ProviderInstanceId.make("claude"); + const validated: string[] = []; + const routing = Layer.mergeAll( + prismDependencies, + ServerSettings.layerTest({ + prismRoles: { + planner: { + models: [ + { instanceId: codex, model: "gpt-5" }, + { instanceId: claude, model: "claude-opus", effort: "high" }, + ], + }, + }, + }), + Layer.mock(AdapterRegistry.ProviderAdapterRegistryV2)({ + list: () => + Effect.sync(() => { + validated.push("lookup"); + return [claude]; + }), + }), + ); + const toolkit = yield* ProjectToolkit.pipe( + Effect.provide( + McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe( + Layer.provide(routing), + Layer.provide(dependencies), + ), + ), + ); + const handle = (params: Parameters>[1]) => + toolkit + .handle("t3_thread_launch", params) + .pipe( + Stream.unwrap, + Stream.runCollect, + Effect.provide(Layer.mergeAll(dependencies, routing)), + ); + const result = yield* handle({ title: "Fix", projectId, message: "Fix the bug" }); + expect(result.at(-1)?.result).toMatchObject({ + projectId, + modelSelection: { + instanceId: claude, + model: "claude-opus", + options: [{ id: "effort", value: "high" }], + }, + }); + expect(launched).toHaveLength(1); + expect(launched[0]?.runtimeMode).toBe("auto-accept-edits"); + expect(validated.length).toBeGreaterThan(0); + const refused = yield* handle({ + title: "Explicit", + projectId, + modelSelection: { instanceId: codex, model: "gpt-5" }, + }); + expect(refused.at(-1)?.result).toMatchObject({ code: "provider_unavailable" }); + expect(launched).toHaveLength(1); + }), +); diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index 7a6aa05acb..b9517a1126 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -1,4 +1,5 @@ import * as Prism from "../../../prism/PrismService.ts"; +import { ProviderAdapterRegistryV2 } from "../../../orchestration-v2/ProviderAdapterRegistry.ts"; import { MessageId, ThreadId, @@ -116,12 +117,17 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { (yield* readProject)?.defaultModelSelection ?? undefined; const prism = yield* Prism.PrismService; + const adapters = yield* ProviderAdapterRegistryV2; const { modelSelection, kitText } = yield* prism.resolve({ projectId, role: input.prismRole ?? "planner", lane: input.lane, explicit: input.modelSelection, inherited, + validate: (selection) => + Prism.validateLaunchSelection(selection).pipe( + Effect.provideService(ProviderAdapterRegistryV2, adapters), + ), }); if (modelSelection === undefined) return yield* new OrchestratorMcpFailure({ diff --git a/apps/server/src/mcp/toolkits/project/tools.ts b/apps/server/src/mcp/toolkits/project/tools.ts index 6d4a2c30a9..a7b89e962d 100644 --- a/apps/server/src/mcp/toolkits/project/tools.ts +++ b/apps/server/src/mcp/toolkits/project/tools.ts @@ -1,4 +1,5 @@ import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts"; +import { ProviderAdapterRegistryV2 } from "../../../orchestration-v2/ProviderAdapterRegistry.ts"; import * as ServerSettings from "../../../serverSettings.ts"; import { PrismLane, PrismRole } from "@t3tools/contracts"; import { McpAttachmentInput } from "../attachment/input.ts"; @@ -146,6 +147,7 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", { ...shared.dependencies, ThreadLaunchService.ThreadLaunchService, ProviderRegistry.ProviderRegistry, + ProviderAdapterRegistryV2, ServerSettings.ServerSettingsService, ManagedProjectFolders.ManagedProjectFolders, GitVcsDriver.GitVcsDriver, diff --git a/apps/server/src/prism/PrismService.ts b/apps/server/src/prism/PrismService.ts index fd4b7729ab..7b25386cd9 100644 --- a/apps/server/src/prism/PrismService.ts +++ b/apps/server/src/prism/PrismService.ts @@ -16,6 +16,20 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import * as ServerSettings from "../serverSettings.ts"; +import { ProviderAdapterRegistryV2 } from "../orchestration-v2/ProviderAdapterRegistry.ts"; + +/** Native launch callers validate adapter admission before creating a thread/run. */ +export const validateLaunchSelection = Effect.fn("Prism.validateLaunchSelection")(function* ( + selection: ModelSelection, +) { + const adapters = yield* ProviderAdapterRegistryV2; + if (!(yield* adapters.list()).includes(selection.instanceId)) + return yield* new OrchestratorMcpFailure({ + code: "provider_unavailable", + message: `No V2 provider adapter is registered for ${selection.instanceId}.`, + }); + return selection; +}); export function delegatedPrismRole(role?: OrchestratorMcpTaskRole): PrismRole { switch (role) { @@ -106,12 +120,14 @@ const make = Effect.gen(function* () { for (const candidate of candidates) { const provider = providers.find((p) => p.instanceId === candidate.instanceId); if (!provider?.enabled || blocked(provider, now)) continue; - if (provider.availability === "unavailable" && (preferences.length > 0 || !input.validate)) - continue; + // A validator can re-probe stale unavailable snapshots before refusing the candidate. + const rechecking = provider.availability === "unavailable" && input.validate !== undefined; + if (provider.availability === "unavailable" && !rechecking) continue; const model = provider.models.find( (m) => m.slug === candidate.model || m.aliases?.includes(candidate.model), ); - if (!model && (preferences.length > 0 || provider.models.length > 0)) continue; + if (!model && !rechecking && (preferences.length > 0 || provider.models.length > 0)) + continue; const effort = "effort" in candidate ? candidate.effort : undefined; const selection: ModelSelection = { instanceId: candidate.instanceId, From 90e2dbd6c92cc4c51c81c17bd8faad934890f1ca Mon Sep 17 00:00:00 2001 From: lukemaj Date: Thu, 8 Oct 2026 21:44:41 +0200 Subject: [PATCH 08/10] fix(server): revalidate stale Prism launch providers --- .../src/mcp/toolkits/project/handlers.test.ts | 96 +++++++++++++++++++ .../src/mcp/toolkits/project/handlers.ts | 3 + apps/server/src/prism/PrismService.ts | 35 ++++++- 3 files changed, 133 insertions(+), 1 deletion(-) diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index e7b95e23b8..e77815c3c0 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -597,3 +597,99 @@ it.effect( expect(launched).toHaveLength(1); }), ); + +it.effect.each(["unavailable", "missing-model", "recovered"] as const)( + "launch rechecks a registered stale provider before selection: %s", + (outcome) => + Effect.gen(function* () { + const launched: Array = []; + const { projectId, dependencies } = clientLaunchHarness({ + runtimeModeCeiling: "auto-accept-edits", + launched, + }); + const codex = ProviderInstanceId.make("codex"); + const claude = ProviderInstanceId.make("claude"); + const providers = yield* ProviderRegistry.ProviderRegistry.pipe( + Effect.flatMap((registry) => registry.getProviders), + Effect.provide(prismDependencies), + ); + const stale = providers.map((provider) => + provider.instanceId === codex + ? { ...provider, availability: "unavailable" as const, models: [] } + : provider, + ); + const refreshed = providers.map((provider) => + provider.instanceId === codex + ? outcome === "unavailable" + ? stale[0]! + : outcome === "missing-model" + ? { ...provider, models: [{ ...provider.models[0]!, slug: "different-model" }] } + : provider + : provider, + ); + const probes: string[] = []; + const routing = Layer.mergeAll( + prismDependencies, + ServerSettings.layerTest({ + prismRoles: { + planner: { + models: [ + { instanceId: codex, model: "gpt-5" }, + { instanceId: claude, model: "claude-opus", effort: "high" }, + ], + }, + }, + }), + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed(stale), + refreshInstance: (instanceId) => + Effect.sync(() => { + probes.push(instanceId); + return refreshed; + }), + }), + ); + const toolkit = yield* ProjectToolkit.pipe( + Effect.provide( + McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe( + Layer.provide(routing), + Layer.provide(dependencies), + ), + ), + ); + const handle = (params: Parameters>[1]) => + toolkit + .handle("t3_thread_launch", params) + .pipe( + Stream.unwrap, + Stream.runCollect, + Effect.provide(Layer.mergeAll(dependencies, routing)), + ); + const result = yield* handle({ title: "Fix", projectId, message: "Fix the bug" }); + expect(probes).toEqual([codex]); + expect(result.at(-1)?.result).toMatchObject({ + modelSelection: + outcome === "recovered" + ? { instanceId: codex, model: "gpt-5" } + : { + instanceId: claude, + model: "claude-opus", + options: [{ id: "effort", value: "high" }], + }, + }); + expect(launched).toHaveLength(1); + expect(launched[0]?.runtimeMode).toBe("auto-accept-edits"); + if (outcome !== "recovered") { + const refused = yield* handle({ + title: "Explicit", + projectId, + modelSelection: { instanceId: codex, model: "gpt-5" }, + }); + expect(refused.at(-1)?.result).toMatchObject({ + code: outcome === "unavailable" ? "provider_unavailable" : "model_unavailable", + }); + expect(launched).toHaveLength(1); + expect(probes).toEqual([codex, codex]); + } + }), +); diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index b9517a1126..f083d1b9a5 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -1,3 +1,4 @@ +import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts"; import * as Prism from "../../../prism/PrismService.ts"; import { ProviderAdapterRegistryV2 } from "../../../orchestration-v2/ProviderAdapterRegistry.ts"; import { @@ -118,6 +119,7 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { undefined; const prism = yield* Prism.PrismService; const adapters = yield* ProviderAdapterRegistryV2; + const providerRegistry = yield* ProviderRegistry.ProviderRegistry; const { modelSelection, kitText } = yield* prism.resolve({ projectId, role: input.prismRole ?? "planner", @@ -127,6 +129,7 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { validate: (selection) => Prism.validateLaunchSelection(selection).pipe( Effect.provideService(ProviderAdapterRegistryV2, adapters), + Effect.provideService(ProviderRegistry.ProviderRegistry, providerRegistry), ), }); if (modelSelection === undefined) diff --git a/apps/server/src/prism/PrismService.ts b/apps/server/src/prism/PrismService.ts index 7b25386cd9..ed317e91f6 100644 --- a/apps/server/src/prism/PrismService.ts +++ b/apps/server/src/prism/PrismService.ts @@ -1,5 +1,6 @@ import { DEFAULT_PRISM_LANE, + isProviderAvailable, OrchestratorMcpFailure, prismRoleModels, type ModelSelection, @@ -28,7 +29,39 @@ export const validateLaunchSelection = Effect.fn("Prism.validateLaunchSelection" code: "provider_unavailable", message: `No V2 provider adapter is registered for ${selection.instanceId}.`, }); - return selection; + const registry = yield* ProviderRegistry.ProviderRegistry; + const eligible = (provider: ServerProvider | undefined) => + provider !== undefined && + provider.enabled && + provider.installed && + isProviderAvailable(provider) && + provider.status !== "error" && + provider.status !== "disabled" && + provider.auth.status !== "unauthenticated"; + let provider = (yield* registry.getProviders).find( + (candidate) => candidate.instanceId === selection.instanceId, + ); + // An unattended caller can retain a stale unavailable snapshot after a provider is repaired. + if (!eligible(provider)) { + provider = (yield* registry.refreshInstance(selection.instanceId)).find( + (candidate) => candidate.instanceId === selection.instanceId, + ); + } + if (!provider || !eligible(provider)) + return yield* new OrchestratorMcpFailure({ + code: "provider_unavailable", + message: `Provider ${selection.instanceId} is unavailable for launch.`, + }); + const model = provider.models.find( + (candidate) => + candidate.slug === selection.model || candidate.aliases?.includes(selection.model), + ); + if (!model) + return yield* new OrchestratorMcpFailure({ + code: "model_unavailable", + message: `Model ${selection.model} is not advertised by provider ${selection.instanceId}.`, + }); + return { ...selection, model: model.slug }; }); export function delegatedPrismRole(role?: OrchestratorMcpTaskRole): PrismRole { From a052dbe7597be24c5b3e3e1451506aad6ab25c82 Mon Sep 17 00:00:00 2001 From: lukemaj Date: Thu, 8 Oct 2026 21:59:34 +0200 Subject: [PATCH 09/10] fix(prism): satisfy CI exports and default restore coverage --- apps/server/src/mcp/toolkits/orchestrator/tools.ts | 2 +- apps/server/src/prism/recovery.testkit.ts | 2 +- .../settings/SettingsPanels.restore.test.tsx | 7 +++++-- docs/fork-features.md | 7 ++++--- packages/contracts/src/prism.ts | 12 +++--------- scripts/fork-upstream-edits.txt | 1 + 6 files changed, 15 insertions(+), 16 deletions(-) diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 476b412161..29eed8fc76 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -63,7 +63,7 @@ const OrchestratorCapabilitiesTool = Tool.make("orchestrator_capabilities", { export const DelegateTaskTool = Tool.make("delegate_task", { description: - "Needs an agent running inside a T3 thread. Delegate one task to a T3-owned child agent/subagent of THIS thread and run it with only the supplied task prompt, without copying parent conversation history. Prism chooses provider, model and effort from the configured role kit and usage headroom. Omit target unless the user chose a provider/model explicitly; an explicit target wins. Optional lane chooses worker difficulty; prismRole chooses an explicit kit. Use visible T3 delegation instead of native or shell agent runners. For every T3 delegated review round, call delegate_task again with the original brief, prior findings, responses, and unresolved objections in the task prompt. Track each round by its own taskId and use a distinct clientRequestId per round, stable across retries of that round. The childThreadId is backing storage, not the target for starting another delegated review round through t3_thread_send. Unconfigured roles inherit the parent model. Runtime mode and interaction mode retain the upstream ceiling. Prefer mode='async' for long work; mode='wait' blocks until completion or timeout. timeoutMs on mode=wait is only the parent's wait budget and does not cancel the child. waitTimedOut on that wait call means the timeout fired; keep that taskId and read status on later task_status. An async child's completion wakes this thread through a notification, steered into active turns where supported or queued otherwise, so end the turn instead of polling or spawning watchers; use task_status only when the result is needed mid-turn.", + "Needs an agent running inside a T3 thread. Delegate one task to a T3-owned child agent/subagent of THIS thread and run it with only the supplied task prompt, without copying parent conversation history. Prism chooses provider, model and effort from the configured role kit and usage headroom. Omit target unless the user chose a provider/model explicitly; an explicit target wins. Optional lane chooses worker difficulty; prismRole chooses an explicit kit. Use visible T3 delegation for same-provider and cross-provider work instead of native or shell agent runners. For every T3 delegated review round, call delegate_task again with the original brief, prior findings, responses, and unresolved objections in the task prompt. Track each round by its own taskId and use a distinct clientRequestId per round, stable across retries of that round. The childThreadId is backing storage, not the target for starting another delegated review round through t3_thread_send. Unconfigured roles inherit the parent model. Runtime mode and interaction mode retain the upstream ceiling. Prefer mode='async' for long work; mode='wait' blocks until completion or timeout. timeoutMs on mode=wait is only the parent's wait budget and does not cancel the child. waitTimedOut on that wait call means the timeout fired; keep that taskId and read status on later task_status. An async child's completion wakes this thread through a notification, steered into active turns where supported or queued otherwise, so end the turn instead of polling or spawning watchers; use task_status only when the result is needed mid-turn.", parameters: OrchestratorMcpDelegateTaskInput, success: OrchestratorMcpDelegateTaskResult, failure: OrchestratorMcpFailure, diff --git a/apps/server/src/prism/recovery.testkit.ts b/apps/server/src/prism/recovery.testkit.ts index 67e383893f..974b71f02e 100644 --- a/apps/server/src/prism/recovery.testkit.ts +++ b/apps/server/src/prism/recovery.testkit.ts @@ -38,7 +38,7 @@ export const recoveryRun: OrchestrationV2Run = { contextHandoffId: null, }; -export const database = Persistence.layerMemory; +const database = Persistence.layerMemory; const stores = Layer.mergeAll( database, EventStore.layer.pipe(Layer.provide(database)), diff --git a/apps/web/src/components/settings/SettingsPanels.restore.test.tsx b/apps/web/src/components/settings/SettingsPanels.restore.test.tsx index 88c9cb1776..0379a6c03d 100644 --- a/apps/web/src/components/settings/SettingsPanels.restore.test.tsx +++ b/apps/web/src/components/settings/SettingsPanels.restore.test.tsx @@ -66,7 +66,7 @@ describe("restoring V2 settings", () => { ["autoResumeLimitedThreads", "Auto-resume limited threads"], ["snoozeLimitedThreads", "Snooze limited threads"], ] as const)("restores %s when it is the only changed setting", async (key, label) => { - state.settings = { ...DEFAULT_UNIFIED_SETTINGS, [key]: true }; + state.settings = { ...DEFAULT_UNIFIED_SETTINGS, [key]: !DEFAULT_UNIFIED_SETTINGS[key] }; hooks.beginRender(); const restore = useSettingsRestore(); @@ -79,7 +79,10 @@ describe("restoring V2 settings", () => { }); it("does not reset settings after cancellation", async () => { - state.settings = { ...DEFAULT_UNIFIED_SETTINGS, autoResumeLimitedThreads: true }; + state.settings = { + ...DEFAULT_UNIFIED_SETTINGS, + autoResumeLimitedThreads: !DEFAULT_UNIFIED_SETTINGS.autoResumeLimitedThreads, + }; state.confirm.mockResolvedValue(false); hooks.beginRender(); diff --git a/docs/fork-features.md b/docs/fork-features.md index fae31e7b32..88e24b08f1 100644 --- a/docs/fork-features.md +++ b/docs/fork-features.md @@ -638,7 +638,7 @@ Child creation paths are owned by child-threads and shared with thread-people. "id": "prism-toolkit", "purpose": "Route upstream delegation and top-level launches through one Prism service and coordinate persisted same-provider/model recovery. Own Chromeria autoResumeLimitedThreads=true; Wight consumes it and saved false overrides remain respected.", "issues": ["https://github.com/toolboxmd/chromeria/issues/169"], - "prs": ["https://github.com/toolboxmd/chromeria/compare/fork/v2...feat/169-prism-service-v2"], + "prs": ["https://github.com/toolboxmd/chromeria/pull/183"], "newFiles": [ "apps/server/src/prism/PrismService.test.ts", "apps/server/src/prism/PrismService.ts", @@ -659,6 +659,7 @@ Child creation paths are owned by child-threads and shared with thread-people. "packages/contracts/src/prism.ts" ], "upstreamFiles": [ + "apps/web/src/components/settings/SettingsPanels.restore.test.tsx", "apps/server/src/mcp/toolkits/project/tools.ts", "apps/server/src/orchestration-v2/runtimeLayer.ts" ], @@ -696,7 +697,7 @@ Child creation paths are owned by child-threads and shared with thread-people. "id": "prism-settings", "purpose": "Keep scoped Prism kits, ordered model lists and worker lanes editable with saved settings replay and honest explicit retry/escalation kit semantics.", "issues": ["https://github.com/toolboxmd/chromeria/issues/169"], - "prs": ["https://github.com/toolboxmd/chromeria/compare/fork/v2...feat/169-prism-service-v2"], + "prs": ["https://github.com/toolboxmd/chromeria/pull/183"], "newFiles": [ "apps/web/src/components/settings/PrismSettings.logic.test.ts", "apps/web/src/components/settings/PrismSettings.logic.ts", @@ -727,7 +728,7 @@ Child creation paths are owned by child-threads and shared with thread-people. "id": "tool-instructions", "purpose": "Give every provider runtime consistent visible T3 delegation and Prism routing/recovery guidance.", "issues": ["https://github.com/toolboxmd/chromeria/issues/169"], - "prs": ["https://github.com/toolboxmd/chromeria/compare/fork/v2...feat/169-prism-service-v2"], + "prs": ["https://github.com/toolboxmd/chromeria/pull/183"], "newFiles": ["apps/server/src/mcp/toolInstructions.ts"], "upstreamFiles": [ "apps/server/src/mcp/toolkits/orchestrator/tools.ts", diff --git a/packages/contracts/src/prism.ts b/packages/contracts/src/prism.ts index 34db4dae83..5040c08512 100644 --- a/packages/contracts/src/prism.ts +++ b/packages/contracts/src/prism.ts @@ -14,7 +14,7 @@ import { ProviderInstanceId } from "./providerInstance.ts"; * project like any key in `PROJECT_SCOPED_SERVER_SETTING_KEYS`; * `delegate_task` and `t3_thread_launch` apply them through Prism. */ -export const PRISM_ROLES = [ +const PRISM_ROLES = [ "promachos", "planner", "dispatcher", @@ -30,7 +30,7 @@ export type PrismRole = typeof PrismRole.Type; * Keys Retry and Escalation had before they were renamed. Settings and * child thread ids saved with them still read as the new roles. */ -export const LEGACY_PRISM_ROLE_KEYS: Readonly> = { +const LEGACY_PRISM_ROLE_KEYS: Readonly> = { correction: "retry", recovery: "escalation", }; @@ -65,7 +65,7 @@ export const PrismLaneModels = Schema.Struct({ export type PrismLaneModels = typeof PrismLaneModels.Type; /** Roles with legacy enabled flags retained for saved-settings compatibility. */ -export const PRISM_SWITCHABLE_ROLES = ["retry", "escalation"] as const; +const PRISM_SWITCHABLE_ROLES = ["retry", "escalation"] as const; export type PrismSwitchableRole = (typeof PRISM_SWITCHABLE_ROLES)[number]; /** Names shown to people. */ @@ -204,11 +204,5 @@ export const PrismRoleKitsPatch = Schema.Struct({ }); export type PrismRoleKitsPatch = typeof PrismRoleKitsPatch.Type; -/** - * Thread activity recorded at each turn end with the turn's provider stream - * statistics (toolboxmd/t3code#55). Measurement only: clients hide it. - */ -export const PRISM_STREAM_STATS_ACTIVITY_KIND = "prism.stream-stats"; - export const PrismStaleReason = Schema.Literals(["silence", "provider-dead", "provider-error"]); export type PrismStaleReason = typeof PrismStaleReason.Type; diff --git a/scripts/fork-upstream-edits.txt b/scripts/fork-upstream-edits.txt index 61702a93d9..e485ea8dc9 100644 --- a/scripts/fork-upstream-edits.txt +++ b/scripts/fork-upstream-edits.txt @@ -147,3 +147,4 @@ apps/server/src/provider/T3OrchestrationInstructions.ts apps/web/src/components/settings/SettingsSidebarNav.tsx apps/web/src/routeTree.gen.ts packages/contracts/src/index.ts +apps/web/src/components/settings/SettingsPanels.restore.test.tsx From 1e9d17a28f28e28f28df618c73a8a1b5a2a73859 Mon Sep 17 00:00:00 2001 From: lukemaj Date: Thu, 8 Oct 2026 22:30:26 +0200 Subject: [PATCH 10/10] fix(test): refresh recorded Muse runtime guidance for Prism --- .../testkit/ReplayTranscriptNdjson.ts | 52 +++++++++- .../src/prism/ReplayInstructions.test.ts | 99 +++++++++++++++++++ docs/fork-features.md | 8 +- scripts/fork-upstream-edits.txt | 1 + 4 files changed, 155 insertions(+), 5 deletions(-) create mode 100644 apps/server/src/prism/ReplayInstructions.test.ts diff --git a/apps/server/src/orchestration-v2/testkit/ReplayTranscriptNdjson.ts b/apps/server/src/orchestration-v2/testkit/ReplayTranscriptNdjson.ts index 1b62ca0f12..6dbe12a498 100644 --- a/apps/server/src/orchestration-v2/testkit/ReplayTranscriptNdjson.ts +++ b/apps/server/src/orchestration-v2/testkit/ReplayTranscriptNdjson.ts @@ -103,9 +103,11 @@ export function materializeReplayTranscriptRuntimeInstructions( ? "Cursor" : runtime.driver === "grok" ? "Grok" - : runtime.driver === "acpRegistry" - ? "acpRegistry" - : undefined; + : runtime.driver === "muse" + ? "Muse Code" + : runtime.driver === "acpRegistry" + ? "acpRegistry" + : undefined; if (harness === undefined) return transcript; const instructions = buildRuntimeInstructions({ harness, model: runtime.model }); @@ -115,6 +117,50 @@ export function materializeReplayTranscriptRuntimeInstructions( if (entry.type !== "expect_outbound") return entry; const frame = entry.frame; if (typeof frame !== "object" || frame === null) return entry; + if ( + runtime.driver === "muse" && + "method" in frame && + frame.method === "turn/start" && + "params" in frame && + typeof frame.params === "object" && + frame.params !== null && + "input" in frame.params && + Array.isArray(frame.params.input) + ) { + const [context, ...input] = frame.params.input; + if ( + typeof context !== "object" || + context === null || + context.type !== "text" || + typeof context.text !== "string" || + !context.text.startsWith("") + ) + return entry; + const reasoningEffort = + "reasoningEffort" in frame.params && typeof frame.params.reasoningEffort === "string" + ? frame.params.reasoningEffort + : undefined; + return { + ...entry, + frame: { + ...frame, + params: { + ...frame.params, + input: [ + { + ...context, + text: buildRuntimeInstructions({ + harness, + model: runtime.model, + reasoningEffort, + }), + }, + ...input, + ], + }, + }, + }; + } if ( runtime.driver === "cursor" && "type" in frame && diff --git a/apps/server/src/prism/ReplayInstructions.test.ts b/apps/server/src/prism/ReplayInstructions.test.ts new file mode 100644 index 0000000000..bf71f77289 --- /dev/null +++ b/apps/server/src/prism/ReplayInstructions.test.ts @@ -0,0 +1,99 @@ +import { ProviderDriverKind } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "@effect/vitest"; +import { + decodeProviderReplayNdjson, + materializeReplayTranscriptRuntimeInstructions, +} from "../orchestration-v2/testkit/ReplayTranscriptNdjson.ts"; + +const museDriver = Schema.decodeUnknownSync(ProviderDriverKind)("muse"); + +const recorded = + decodeProviderReplayNdjson(`{"type":"transcript_start","provider":"muse","protocol":"muse.msp-jsonl","version":"1","scenario":"runtime-context"} +{"type":"expect_outbound","frame":{}}`); + +const userInput = [ + { type: "text", text: "Keep this user message exact." }, + { type: "image", url: "attachment://fixture" }, +]; + +function transcript(context: string, reasoningEffort?: string) { + return recorded.pipe( + Effect.map((header) => ({ + ...header, + entries: [ + { + type: "expect_outbound" as const, + frame: { + jsonrpc: "2.0", + id: 17, + method: "turn/start", + params: { + sessionId: "fixture-session", + input: [{ type: "text", text: context }, ...userInput], + displayText: "exact display", + ...(reasoningEffort === undefined ? {} : { reasoningEffort }), + }, + }, + }, + ], + })), + ); +} + +describe("Muse replay runtime materialization", () => { + it.effect.each([undefined, "high"])( + "refreshes only recorded runtime context with effort %s", + (effort) => + Effect.gen(function* () { + const original = yield* transcript("old context", effort); + const result = materializeReplayTranscriptRuntimeInstructions(original, { + driver: museDriver, + model: "muse-fixture-model", + }); + const frame = + result.entries[0]?.type === "expect_outbound" ? result.entries[0].frame : undefined; + expect(frame).toEqual({ + ...original.entries[0]!.frame, + params: { + ...original.entries[0]!.frame.params, + input: [{ type: "text", text: expect.stringContaining("Prism") }, ...userInput], + }, + }); + expect(frame).toHaveProperty( + "params.input.0.text", + expect.stringContaining("through the Muse Code harness, as muse-fixture-model"), + ); + if (effort === undefined) { + expect(frame).toHaveProperty( + "params.input.0.text", + expect.not.stringContaining("reasoning effort"), + ); + } else { + expect(frame).toHaveProperty( + "params.input.0.text", + expect.stringContaining("high reasoning effort"), + ); + } + expect( + materializeReplayTranscriptRuntimeInstructions(result, { + driver: museDriver, + model: "muse-fixture-model", + }), + ).toEqual(result); + }), + ); + + it.effect("does not manufacture missing runtime context or change a user block", () => + Effect.gen(function* () { + const original = yield* transcript("This is user content, not runtime context.", "high"); + expect( + materializeReplayTranscriptRuntimeInstructions(original, { + driver: museDriver, + model: "muse-fixture-model", + }), + ).toEqual(original); + }), + ); +}); diff --git a/docs/fork-features.md b/docs/fork-features.md index 88e24b08f1..2644567026 100644 --- a/docs/fork-features.md +++ b/docs/fork-features.md @@ -729,11 +729,15 @@ Child creation paths are owned by child-threads and shared with thread-people. "purpose": "Give every provider runtime consistent visible T3 delegation and Prism routing/recovery guidance.", "issues": ["https://github.com/toolboxmd/chromeria/issues/169"], "prs": ["https://github.com/toolboxmd/chromeria/pull/183"], - "newFiles": ["apps/server/src/mcp/toolInstructions.ts"], + "newFiles": [ + "apps/server/src/mcp/toolInstructions.ts", + "apps/server/src/prism/ReplayInstructions.test.ts" + ], "upstreamFiles": [ "apps/server/src/mcp/toolkits/orchestrator/tools.ts", "apps/server/src/provider/RuntimeInstructions.ts", - "apps/server/src/provider/T3OrchestrationInstructions.ts" + "apps/server/src/provider/T3OrchestrationInstructions.ts", + "apps/server/src/orchestration-v2/testkit/ReplayTranscriptNdjson.ts" ], "sharedFiles": [ "apps/server/src/mcp/toolkits/project/tools.ts", diff --git a/scripts/fork-upstream-edits.txt b/scripts/fork-upstream-edits.txt index e485ea8dc9..92e5d2229b 100644 --- a/scripts/fork-upstream-edits.txt +++ b/scripts/fork-upstream-edits.txt @@ -148,3 +148,4 @@ apps/web/src/components/settings/SettingsSidebarNav.tsx apps/web/src/routeTree.gen.ts packages/contracts/src/index.ts apps/web/src/components/settings/SettingsPanels.restore.test.tsx +apps/server/src/orchestration-v2/testkit/ReplayTranscriptNdjson.ts