diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index dd6a9c3c105d7..41b4bc835c3cb 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -4,6 +4,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import { CommandId, + DEFAULT_SERVER_SETTINGS, EnvironmentId, MessageId, NodeId, @@ -3012,6 +3013,9 @@ describe("OrchestratorMcpService provider resolution", () => { }, ]; const resolved = resolveRoles({ + settings: DEFAULT_SERVER_SETTINGS, + projectId, + nowMs: 0, file: parseModelsFile( "architect runners: claudeAgent/claude-opus-5-5 effort=xhigh, codex/gpt-6-astra reasoningEffort=high", ), diff --git a/apps/server/src/pstack/PstackPack.test.ts b/apps/server/src/pstack/PstackPack.test.ts index 1f9ebeb2d88d9..7cf297d3b956f 100644 --- a/apps/server/src/pstack/PstackPack.test.ts +++ b/apps/server/src/pstack/PstackPack.test.ts @@ -12,7 +12,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as PstackPack from "./PstackPack.ts"; import * as ServerSettings from "../serverSettings.ts"; -import { ProjectId } from "@t3tools/contracts"; +import { ProjectId, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; import { claudeProvider, makeFixture } from "./PstackPack.testkit.ts"; import { BUILT_IN_DRIVERS } from "../provider/builtInDrivers.ts"; @@ -347,6 +347,129 @@ it.layer(NodeServices.layer)("PstackPack", (it) => { }).pipe(Effect.provide(Layer.merge(fixture.layer, fixture.settingsLayer))); }), ); + it.effect("default roles skip cooling instances and choose a ready instance", () => + Effect.gen(function* () { + const ready = { + ...claudeProvider, + instanceId: ProviderInstanceId.make("claudeAgent_ready"), + }; + const fixture = yield* makeFixture( + [{ ...claudeProvider, limitedUntil: "2099-01-01T00:00:00Z" }, ready], + ServerSettings.layerTest({ + pstackEnabled: true, + providerInstances: { + [ready.instanceId]: { driver: ready.driver, enabled: true, config: {} }, + }, + }), + ); + const expansion = yield* Effect.gen(function* () { + return yield* (yield* PstackPack.PstackPack).expandComposer( + { text: "$pstack:architect", records: [], projectId: ProjectId.make("pack-test") }, + { createdBy: "user" }, + ); + }).pipe(Effect.provide(fixture.layer)); + assert.include( + expansion.text, + '- architect runners: [1] {"providerInstanceId":"claudeAgent_ready"', + ); + assert.notInclude(expansion.text, "Fallback: architect runners seat 1"); + }), + ); + it.effect("explicit cooling targets fall back to the parent", () => + Effect.gen(function* () { + const fixture = yield* makeFixture([ + { ...claudeProvider, limitedUntil: "2099-01-01T00:00:00Z" }, + ]); + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(`${fixture.stateDir}/pstack`, { recursive: true }); + yield* fs.writeFileString( + `${fixture.stateDir}/pstack/models.md`, + "architect runners: claudeAgent/claude-opus-5-5", + ); + const expansion = yield* Effect.gen(function* () { + return yield* (yield* PstackPack.PstackPack).expandComposer( + { text: "$pstack:architect", records: [], projectId: ProjectId.make("pack-test") }, + { createdBy: "user" }, + ); + }).pipe(Effect.provide(fixture.layer)); + assert.include(expansion.text, "- architect runners: [1] inherit"); + assert.include(expansion.text, "Provider is rate limited. Retry after its cooldown expires."); + }), + ); + it.effect.each(["all cooling", "healthy member disabled", "healthy member available"] as const)( + "pool roles use project-aware delegation availability: %s", + (scenario) => + Effect.gen(function* () { + const projectId = ProjectId.make("pack-test"); + const poolId = ProviderInstanceId.make("pool_claudeAgent"); + const workId = ProviderInstanceId.make("claudeAgent_work"); + const members: ServerProvider[] = [claudeProvider.instanceId, workId].map( + (instanceId, index) => ({ + ...claudeProvider, + instanceId, + pool: { role: "member", poolInstanceId: poolId }, + ...(index === 0 || scenario === "all cooling" + ? { limitedUntil: "2099-01-01T00:00:00Z" } + : {}), + }), + ); + const fixture = yield* makeFixture( + [ + ...members, + { + ...claudeProvider, + instanceId: poolId, + pool: { + role: "pool", + memberInstanceIds: members.map((member) => member.instanceId), + droppedModels: [], + }, + }, + ], + ServerSettings.layerTest({ + pstackEnabled: true, + providerInstances: { + [workId]: { driver: claudeProvider.driver, enabled: true, config: {} }, + }, + providerPools: { + [claudeProvider.driver]: { + enabled: true, + memberInstanceIds: members.map((member) => member.instanceId), + }, + }, + projectSettingsOverrides: { + [projectId]: { + providerInstances: { + [workId]: { enabled: scenario !== "healthy member disabled" }, + }, + }, + }, + }), + ); + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(`${fixture.stateDir}/pstack`, { recursive: true }); + yield* fs.writeFileString( + `${fixture.stateDir}/pstack/models.md`, + "architect runners: pool_claudeAgent/claude-opus-5-5", + ); + const expansion = yield* Effect.gen(function* () { + return yield* (yield* PstackPack.PstackPack).expandComposer( + { text: "$pstack:architect", records: [], projectId }, + { createdBy: "user" }, + ); + }).pipe(Effect.provide(fixture.layer)); + if (scenario === "healthy member available") { + assert.include( + expansion.text, + '- architect runners: [1] {"providerInstanceId":"pool_claudeAgent"', + ); + assert.notInclude(expansion.text, "Fallback:"); + } else { + assert.include(expansion.text, "- architect runners: [1] inherit"); + assert.include(expansion.text, "Every available pool member is rate limited."); + } + }), + ); it.effect("concurrent materializers tolerate a losing rename and keep the stable pack", () => Effect.gen(function* () { const fixture = yield* makeFixture(); diff --git a/apps/server/src/pstack/PstackPack.ts b/apps/server/src/pstack/PstackPack.ts index eea8373041492..6520f7668ee2d 100644 --- a/apps/server/src/pstack/PstackPack.ts +++ b/apps/server/src/pstack/PstackPack.ts @@ -13,6 +13,7 @@ import { } from "@t3tools/pstack"; import { parseSkillFrontmatter } from "@t3tools/shared/skillFrontmatter"; import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -299,6 +300,9 @@ const make = Effect.fn("PstackPack.make")(function* (sourceRoot?: string) { resolved: resolveRoles({ file: file === undefined ? undefined : parseModelsFile(file), defaults: pack.defaults, + settings: currentSettings, + projectId: input.projectId, + nowMs: yield* Clock.currentTimeMillis, ...providers, providers: providers.providers.map((provider) => ({ ...provider, diff --git a/apps/server/src/pstack/pstackModels.test.ts b/apps/server/src/pstack/pstackModels.test.ts index 06b112d6df576..4312c9a97f54f 100644 --- a/apps/server/src/pstack/pstackModels.test.ts +++ b/apps/server/src/pstack/pstackModels.test.ts @@ -1,5 +1,7 @@ import { assert, it } from "@effect/vitest"; import { + DEFAULT_SERVER_SETTINGS, + ProjectId, ProviderDriverKind, ProviderInstanceId, type ProviderOptionDescriptor, @@ -62,12 +64,18 @@ const defaults = [ }, ]; const capableInstanceIds = new Set(providers.map((provider) => provider.instanceId)); +const availability = { + settings: DEFAULT_SERVER_SETTINGS, + projectId: ProjectId.make("pstack"), + nowMs: Date.parse("2026-10-25T00:00:00Z"), +}; const file = "# budget: balanced\narchitect runners: claudeAgent/claude-opus-5-5 effort=xhigh, codex/gpt-6-astra reasoningEffort=high"; it.effect("completes every omitted Opus and Astra option with its advertised current value", () => Effect.sync(() => { const resolved = resolveRoles({ + ...availability, file: parseModelsFile(file), defaults, providers, @@ -101,6 +109,7 @@ it.effect("completes every omitted Opus and Astra option with its advertised cur it.effect("disabled providers fall back visibly without shrinking panels", () => Effect.sync(() => { const result = resolveRoles({ + ...availability, file: parseModelsFile(file), defaults, providers: providers.map((provider) => ({ ...provider, enabled: false })), @@ -115,7 +124,13 @@ it.effect("disabled providers fall back visibly without shrinking panels", () => ); it.effect("missing models file maps upstream family, effort and fast defaults", () => Effect.sync(() => { - const result = resolveRoles({ file: undefined, defaults, providers, capableInstanceIds }); + const result = resolveRoles({ + ...availability, + file: undefined, + defaults, + providers, + capableInstanceIds, + }); assert.deepEqual(result.diagnostics, []); const seat = result.roles[0]!.seats[1]!; assert.deepEqual(seat, { @@ -136,6 +151,7 @@ it.effect.each(["effort=ultra", "unknown=true", "fastMode=yes", "effort=high eff (option) => Effect.sync(() => { const result = resolveRoles({ + ...availability, file: parseModelsFile(`architect runners: claudeAgent/claude-opus-5-5 ${option}`), defaults, providers, @@ -150,7 +166,13 @@ it.effect("preserves explicit panel cardinality, aliases, comments and model sla const parsed = parseModelsFile( "# budget: high\narchitect runners: auto, inherit-parent, missing/vendor/model option=false # note", ); - const result = resolveRoles({ file: parsed, defaults, providers, capableInstanceIds }); + const result = resolveRoles({ + ...availability, + file: parsed, + defaults, + providers, + capableInstanceIds, + }); assert.equal(result.roles[0]!.seats.length, 3); assert.include(result.diagnostics[0]!, "missing/vendor/model"); }), @@ -159,6 +181,7 @@ it.effect("recognizes Pi-style thinking as effort and drops unadvertised fast hi Effect.sync(() => { const piStyle = provider("custom-codex", "codex", "gpt-6-astra", [effort("thinking")]); const result = resolveRoles({ + ...availability, file: undefined, defaults: [{ label: "runner", defaultValue: "gpt-6-astra-xhigh-fast" }], providers: [piStyle], @@ -180,6 +203,7 @@ it.effect("recognizes Pi-style thinking as effort and drops unadvertised fast hi it.effect("unregistered adapters and missing current values cannot leak parent options", () => Effect.sync(() => { const unsupported = resolveRoles({ + ...availability, file: parseModelsFile(file), defaults, providers, @@ -195,6 +219,7 @@ it.effect("unregistered adapters and missing current values cannot leak parent o }, ]); const result = resolveRoles({ + ...availability, file: parseModelsFile("architect runners: codex/gpt-6-astra"), defaults, providers: [incomplete], @@ -235,6 +260,7 @@ it.effect.each(["claude-opus-5-5", "claude-sonnet-4-6"])( (model) => Effect.sync(() => { const result = resolveRoles({ + ...availability, file: parseModelsFile(`architect runners: claudeAgent/${model}`), defaults, providers, @@ -263,6 +289,7 @@ it.effect("completes a Pi thinking select from isDefault", () => Effect.sync(() => { const pi = provider("pi", "pi", "vendor/model", [effort("thinking")]); const result = resolveRoles({ + ...availability, file: parseModelsFile("architect runners: pi/vendor/model"), defaults, providers: [pi], @@ -291,6 +318,7 @@ it.effect("default seats skip unhealthy and incapable instances offering the mod }; const incapable = { ...first, instanceId: ProviderInstanceId.make("incapable") }; const result = resolveRoles({ + ...availability, file: undefined, defaults, providers: [unhealthy, incapable, ...providers], @@ -325,6 +353,7 @@ it.effect("clamps default effort below its budget and accepts the advertised fas }, ]); const result = resolveRoles({ + ...availability, file: undefined, defaults: [{ label: "architect runners", defaultValue: "gpt-6-astra-xhigh-fast" }], providers: [codex], @@ -349,6 +378,7 @@ it.effect("clamps default effort below its budget and accepts the advertised fas it.effect("reports unknown role labels and ignores empty comma entries", () => Effect.sync(() => { const result = resolveRoles({ + ...availability, file: parseModelsFile("archtect runners: auto\narchitect runners: auto,, inherit-parent, "), defaults, providers, @@ -364,12 +394,19 @@ it.effect.each(["", " ,,", " # comment"])( (value) => Effect.sync(() => { const result = resolveRoles({ + ...availability, file: parseModelsFile(`architect runners:${value}`), defaults, providers, capableInstanceIds, }); - const expected = resolveRoles({ file: undefined, defaults, providers, capableInstanceIds }); + const expected = resolveRoles({ + ...availability, + file: undefined, + defaults, + providers, + capableInstanceIds, + }); assert.deepEqual(result.roles, expected.roles); assert.include(result.diagnostics.join("\n"), "empty role architect runners; using defaults"); }), diff --git a/apps/server/src/pstack/pstackModels.ts b/apps/server/src/pstack/pstackModels.ts index 6124ef5ec6b48..4c7331a30756d 100644 --- a/apps/server/src/pstack/pstackModels.ts +++ b/apps/server/src/pstack/pstackModels.ts @@ -5,7 +5,10 @@ import { } from "@t3tools/contracts"; import { getProviderOptionCurrentValue } from "@t3tools/shared/model"; import { type PstackRoleDefault } from "@t3tools/pstack"; -import { invalidOptionSelections, providerConstraints } from "../provider/orchestrationTargets.ts"; +import { + delegationProviderConstraints, + invalidOptionSelections, +} from "../provider/orchestrationTargets.ts"; interface Entry { readonly providerInstanceId: string; @@ -81,7 +84,7 @@ const effortLadder = ["low", "medium", "high", "xhigh", "max"]; function defaultSeat( slug: string, providers: ReadonlyArray, - capableInstanceIds: ReadonlySet, + constraintsFor: (provider: ServerProvider) => ReadonlyArray, diagnostics: string[], ): ParsedSeat { if (slug === "inherit-parent" || slug === "auto") return { kind: "inherit" }; @@ -101,7 +104,7 @@ function defaultSeat( const provider = providers.find( (candidate) => candidate.driver === driver && - providerConstraints(candidate, capableInstanceIds.has(candidate.instanceId)).length === 0 && + constraintsFor(candidate).length === 0 && candidate.pool?.role !== "member" && candidate.models.some((candidate) => candidate.slug === model), ); @@ -152,12 +155,19 @@ function defaultSeat( }; } -export function resolveRoles(input: { - readonly file: ReturnType | undefined; - readonly defaults: ReadonlyArray; - readonly providers: ReadonlyArray; - readonly capableInstanceIds: ReadonlySet; -}) { +export function resolveRoles( + input: { + readonly file: ReturnType | undefined; + readonly defaults: ReadonlyArray; + readonly capableInstanceIds: ReadonlySet; + } & Parameters[2], +) { + const constraintsFor = (provider: ServerProvider) => + delegationProviderConstraints( + provider, + input.capableInstanceIds.has(provider.instanceId), + input, + ); const diagnostics = [...(input.file?.diagnostics ?? [])]; for (const role of input.file?.lines.keys() ?? []) { if (!input.defaults.some((entry) => entry.label === role)) @@ -168,9 +178,7 @@ export function resolveRoles(input: { input.file?.lines.get(role.label) ?? role.defaultValue .split(",") - .map((slug) => - defaultSeat(slug.trim(), input.providers, input.capableInstanceIds, diagnostics), - ); + .map((slug) => defaultSeat(slug.trim(), input.providers, constraintsFor, diagnostics)); const seats = entries.map((seat, index): Seat => { if (seat.kind === "inherit") return seat; const entry = seat.entry; @@ -183,9 +191,7 @@ export function resolveRoles(input: { entry.error ?? (provider === undefined ? "provider instance is not registered" - : providerConstraints(provider, input.capableInstanceIds.has(provider.instanceId)).join( - " ", - ) || undefined); + : constraintsFor(provider).join(" ") || undefined); reason ??= model === undefined ? "model is not advertised" : undefined; reason ??= invalidOptionSelections(entry.options, descriptors).join(" ") || undefined; const options: ProviderOptionSelection[] = [];