From 773642a0777283a6059dd3b4782235a1b2b7b34f Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 21 Aug 2026 16:39:24 -0700 Subject: [PATCH 1/2] Add tests for retrying launch with the tenant's live default model A definition's pinned model can predate the tenant's current connection (a provider was disconnected, or none was connected when the pin was baked in). These prove deployAtHead retries once against fallbackModel when the pinned model has no launchable source, and skips the retry when there is nothing new to try. --- packages/folded-runs/test/launch.test.ts | 118 ++++++++++++++++++++++- 1 file changed, 117 insertions(+), 1 deletion(-) diff --git a/packages/folded-runs/test/launch.test.ts b/packages/folded-runs/test/launch.test.ts index 991849f1..66f5d57a 100644 --- a/packages/folded-runs/test/launch.test.ts +++ b/packages/folded-runs/test/launch.test.ts @@ -38,12 +38,21 @@ let resolveDefinitionSourcesResult: DefinitionSourceResolution = { defaultSource: "off_1", }; const resolveDefinitionSourcesCalls: unknown[] = []; +// A queued sequence of per-call results, consumed oldest-first, for a +// test that must prove `deployAtHead` retries with a SECOND fallback +// model after the first attempt fails — a single shared +// `resolveDefinitionSourcesResult` cannot express "fails, then +// succeeds" across two calls inside the same synchronous resolution. +// Empty (the common case) falls back to the single shared result +// above, unchanged for every other test in this file. +let resolveDefinitionSourcesQueue: DefinitionSourceResolution[] = []; mock.module("@intx/hub-api", () => ({ ...actualHubApi, resolveDefinitionSources: async (...args: unknown[]) => { resolveDefinitionSourcesCalls.push(args[0]); - return resolveDefinitionSourcesResult; + const queued = resolveDefinitionSourcesQueue.shift(); + return queued ?? resolveDefinitionSourcesResult; }, })); @@ -886,6 +895,113 @@ describe("launchFoldedRun", () => { expect(err.message).toMatch(/the workbench host/); }); + // The reproduction this fixes: a definition pinned to a model from a + // provider the tenant has since disconnected (or one baked in before + // any provider was connected at all) must not stay permanently dead + // once a DIFFERENT provider is connected. `deployAtHead` retries once + // against `fallbackModel` — the tenant's current live default — when + // the definition's own pinned model fails to resolve. + test("retries against fallbackModel when the definition's pinned model has no launchable source", async () => { + resolveDefinitionSourcesCalls.length = 0; + resolveDefinitionSourcesQueue = [ + { + ok: false, + message: 'No launchable inference source for model "claude-sonnet-5"', + }, + { + ok: true, + sources: [ + { + id: "off_ollama", + provider: "ollama", + baseURL: "https://home-mac-studio.tail87f5aa.ts.net/v1", + apiKey: "placeholder", + model: "gpt-oss:20b", + }, + ], + defaultSource: "off_ollama", + }, + ]; + + const db = createFakeDb(); + const sessionService = createFakeSessionService(); + + const result = await launchFoldedRun( + { + db: db as never, + sessionService, + assetService: createFakeAssetService(), + sidecarRouter: createFakeSidecarRouter(), + toolGrantsForPins: () => [], + eventCollectors: createFakeEventCollectors(), + }, + { + tenantId: "ten_1", + instanceId: "ins_workbench1", + triggerAddress: "ins_workbench1@ten1.workbench.test", + definitionId: "wfd_workbench1", + // Pinned to a model the tenant no longer (or never did) has a + // credential for. + foldedBody: FOLDED_BODY, + launchLabel: "the workbench host", + // The tenant's current live default, e.g. the connected Ollama + // instance's own resolved model. + fallbackModel: "gpt-oss:20b", + }, + ); + + expect(result.sessionId).toBeTruthy(); + expect(resolveDefinitionSourcesCalls).toHaveLength(2); + expect(resolveDefinitionSourcesCalls[0]).toMatchObject({ + fallbackModel: "claude-sonnet-5", + }); + expect(resolveDefinitionSourcesCalls[1]).toMatchObject({ + fallbackModel: "gpt-oss:20b", + }); + + const deployed = onlyCall(sessionService.adoptedDeployCalls); + expect(deployed.config.defaultSource).toBe("off_ollama"); + }); + + test("does not retry when fallbackModel is absent or matches the definition's own pinned model", async () => { + resolveDefinitionSourcesCalls.length = 0; + resolveDefinitionSourcesQueue = []; + resolveDefinitionSourcesResult = { + ok: false, + message: 'No launchable inference source for model "claude-sonnet-5"', + }; + + const db = createFakeDb(); + let caught: unknown; + try { + await launchFoldedRun( + { + db: db as never, + sessionService: createFakeSessionService(), + assetService: createFakeAssetService(), + sidecarRouter: createFakeSidecarRouter(), + toolGrantsForPins: () => [], + eventCollectors: createFakeEventCollectors(), + }, + { + tenantId: "ten_1", + instanceId: "ins_workbench1", + triggerAddress: "ins_workbench1@ten1.workbench.test", + definitionId: "wfd_workbench1", + foldedBody: FOLDED_BODY, + launchLabel: "the workbench host", + // Same name as the definition's own pin -- nothing new to try. + fallbackModel: "claude-sonnet-5", + }, + ); + } catch (err) { + caught = err; + } + + expect(caught).toBeInstanceOf(InferenceResolutionError); + expect(resolveDefinitionSourcesCalls).toHaveLength(1); + }); + test("uses a caller-supplied sources override verbatim, never touching the catalog", async () => { resolveDefinitionSourcesCalls.length = 0; // Forced to fail if ever consulted: proves the override path skips From 351361bdf6dc2965bce2478011bb6e41dd92f941 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 21 Aug 2026 16:39:32 -0700 Subject: [PATCH 2/2] Make the tenant's live default model authoritative at launch A definition's own pinned model (foldedBody.model) was always tried verbatim and, on failure, thrown as InferenceResolutionError -- even when the tenant had since connected a different provider. A fresh "Just start talking" workbench invited Myra's already-seeded definition, which had claude-sonnet-5 baked in from before any provider was connected (or from a provider since disconnected), so connecting Ollama never helped: the credential-less anthropic pin kept winning every launch. deployAtHead now retries once against fallbackModel -- the tenant's current live default, the same (provider, model) Settings' "Default model & fallbacks" list heads -- whenever the definition's own pinned model fails to resolve. platform-adapter.ts's resolveFallbackModel now always computes that live default (previously only when the definition declared no model of its own), so it is available as the retry target for every launch and wake, not just one with an empty pin. This makes reconnecting a different provider heal every agent already pinned to the old one -- new and existing alike -- rather than only a freshly created one, and a credential-less provider row can no longer permanently outrank a connected one it once preceded. --- packages/chat/src/platform-adapter.ts | 19 ++++--- packages/folded-runs/src/launch.ts | 72 +++++++++++++++++++-------- 2 files changed, 64 insertions(+), 27 deletions(-) diff --git a/packages/chat/src/platform-adapter.ts b/packages/chat/src/platform-adapter.ts index 421304ea..8f7f6df6 100644 --- a/packages/chat/src/platform-adapter.ts +++ b/packages/chat/src/platform-adapter.ts @@ -254,17 +254,22 @@ export function createHubChatPlatform( } /** - * A definition that declares no model of its own resolves the same - * catalog default at every deploy that `launchInvite` used to resolve - * at launch time — every deploy of such a run now goes through a wake - * or a relaunch (launches mint only), and a slept one always did. + * The tenant's current live default model — always resolved, whether + * or not `binding.foldedBody.model` names one of its own. A + * definition that declares no model resolves this same catalog + * default at every deploy, exactly as `launchInvite` used to resolve + * it at launch time (every deploy of such a run now goes through a + * wake or a relaunch — launches mint only — and a slept one always + * did). A definition that DOES name a model still needs this: it is + * `deployAtHead`'s retry target when that pinned model no longer + * resolves against the tenant's current catalog (a provider it was + * pinned against got disconnected, or none was connected yet when it + * was pinned) — so reconnecting a different provider heals an + * already-invited agent, not only a freshly invited one. */ async function resolveFallbackModel( binding: AgentBinding, ): Promise { - if (binding.foldedBody.model !== null) { - return undefined; - } const preferences = (await deps.workbenchHostInferencePreferences?.(binding.tenantId)) ?? []; return preferences[0]?.model; diff --git a/packages/folded-runs/src/launch.ts b/packages/folded-runs/src/launch.ts index 1fa51710..d6cfae3b 100644 --- a/packages/folded-runs/src/launch.ts +++ b/packages/folded-runs/src/launch.ts @@ -231,14 +231,21 @@ export async function deployAtHead( */ sources?: SourcesOverride; /** - * The model to resolve against when `foldedBody.model` is `null` — - * a definition that declares no model requirements of its own - * (e.g. a hand-authored agent created without a `model`). Absent, - * a `null` `foldedBody.model` resolves to the loud - * `InferenceResolutionError` it always has; present, it is what - * lets that same definition still launch by falling back to the - * caller's own tenant-default resolution instead of 409ing. Never - * consulted when `foldedBody.model` is already set. + * The tenant's current live default model — the same (provider, + * model) Settings' "Default model & fallbacks" row heads today + * (see `@corbits/chat`'s `resolveFallbackModel`). Tried first when + * `foldedBody.model` is `null` (a definition that declares no model + * of its own); tried SECOND, as a retry, when `foldedBody.model` is + * set but does not resolve against the tenant's current catalog — + * e.g. it was pinned (by a person, or by the seed that created this + * definition) against a provider the tenant has since disconnected, + * or before it connected any provider at all. Without that retry an + * agent whose pin predates the tenant's current connection stays + * permanently dead even after a working provider is connected, + * because `foldedBody.model` alone would keep resolving to the same + * unlaunchable name forever. Absent, a `foldedBody.model` that fails + * to resolve — or a `null` one with nothing to fall back to — + * resolves to the loud `InferenceResolutionError` it always has. */ fallbackModel?: string; /** @@ -252,20 +259,45 @@ export async function deployAtHead( }, ): Promise { const sourcesOverride = parseSourcesOverride(params.sources); - const resolution = + const resolveAgainst = (model: string | null) => + resolveDefinitionSources({ + db: deps.db, + tenantId: params.tenantId, + modelRequirements: null, + fallbackModel: model, + invokerPreferences: {}, + ...(deps.credentialCipher !== undefined + ? { credentialCipher: deps.credentialCipher } + : {}), + }); + + const definitionModel = params.foldedBody.model; + let resolution = sourcesOverride !== undefined ? { ok: true as const, ...sourcesOverride } - : await resolveDefinitionSources({ - db: deps.db, - tenantId: params.tenantId, - modelRequirements: null, - fallbackModel: - params.foldedBody.model ?? params.fallbackModel ?? null, - invokerPreferences: {}, - ...(deps.credentialCipher !== undefined - ? { credentialCipher: deps.credentialCipher } - : {}), - }); + : await resolveAgainst(definitionModel ?? params.fallbackModel ?? null); + + // The definition's own pinned model failed to resolve — it may have + // been pinned (by a person, or by the seed that created this + // definition) against a provider the tenant has since disconnected, + // or before it connected any provider at all. Retry once against the + // tenant's current live default rather than leaving the agent + // permanently dead: reconnecting a DIFFERENT provider must heal every + // agent already pinned to the old one, not only a freshly created + // one. Never retried for a caller-supplied override (nothing to + // retry against — the tenant catalog is never touched for those) or + // when the definition already declared no model of its own (that + // case already tried `fallbackModel` as its one and only attempt). + if ( + !resolution.ok && + sourcesOverride === undefined && + definitionModel !== null && + params.fallbackModel !== undefined && + params.fallbackModel !== definitionModel + ) { + resolution = await resolveAgainst(params.fallbackModel); + } + if (!resolution.ok) { throw new InferenceResolutionError(params.launchLabel, resolution.message); }