From 6cc58f2f90c7af960528c7564be0d5052e07b101 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Tue, 27 Oct 2026 18:05:39 -0700 Subject: [PATCH] fix(pstack): respect provider cooldowns and pool availability Use delegation's project-aware provider constraints when resolving both default and explicit pstack roles. Skip cooling defaults and preserve the visible parent fallback for unavailable targets. Add service regressions for cooling accounts and exhausted or project-restricted pools. --- .../src/mcp/OrchestratorMcpService.test.ts | 4 + apps/server/src/pstack/PstackPack.test.ts | 125 +++++++++++++++++- apps/server/src/pstack/PstackPack.ts | 4 + apps/server/src/pstack/pstackModels.test.ts | 43 +++++- apps/server/src/pstack/pstackModels.ts | 36 ++--- 5 files changed, 193 insertions(+), 19 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 31b06c82e9bf..ef62a664e933 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, @@ -3002,6 +3003,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 1f9ebeb2d88d..7cf297d3b956 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 eea837304149..6520f7668ee2 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 06b112d6df57..4312c9a97f54 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 6124ef5ec6b4..4c7331a30756 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[] = [];