diff --git a/docs/images/hostler/settings-card.png b/docs/images/hostler/settings-card.png index 53fa0cb1..177b1bdf 100644 Binary files a/docs/images/hostler/settings-card.png and b/docs/images/hostler/settings-card.png differ diff --git a/package-lock.json b/package-lock.json index 3bfef5e0..ff5d549f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "@anthropic-ai/sdk": "^0.98.0", "@electron-toolkit/utils": "^4.0.0", "@floating-ui/dom": "^1.7.6", - "@hostler/sdk": "^0.1.0", + "@hostler/sdk": "^0.2.10", "@modelcontextprotocol/sdk": "^1.26.0", "@opencode-ai/sdk": "^1.15.10", "@tanstack/react-query": "^5.62.0", @@ -1777,9 +1777,9 @@ } }, "node_modules/@hostler/sdk": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@hostler/sdk/-/sdk-0.1.0.tgz", - "integrity": "sha512-+tdzsgVPq+3pz29z5WugTCBZPGfcv+xYcbx6xslNf/f6MniTN26fTunt+OrAQT/393AwapcI70phmspU2+7mDw==", + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@hostler/sdk/-/sdk-0.2.10.tgz", + "integrity": "sha512-LzqcR+4bfv40Woinj+DUAbEjPOQh3fCFAFmVDtXhzKcz/xXsWktu9p7fT6MApwDspF1JjlREuko4aeg517aLgw==", "license": "MIT", "engines": { "node": ">=18" diff --git a/package.json b/package.json index 9f7599d7..79555584 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "@anthropic-ai/sdk": "^0.98.0", "@electron-toolkit/utils": "^4.0.0", "@floating-ui/dom": "^1.7.6", - "@hostler/sdk": "^0.1.0", + "@hostler/sdk": "^0.2.10", "@modelcontextprotocol/sdk": "^1.26.0", "@opencode-ai/sdk": "^1.15.10", "@tanstack/react-query": "^5.62.0", diff --git a/src/main/agents/providers/hostler/hostler-agent-provider.ts b/src/main/agents/providers/hostler/hostler-agent-provider.ts index 41554a64..42b0b4eb 100644 --- a/src/main/agents/providers/hostler/hostler-agent-provider.ts +++ b/src/main/agents/providers/hostler/hostler-agent-provider.ts @@ -6,11 +6,13 @@ import type { AgentConfig, CreateSessionOptions, ModelConfig, + RecoverySafety, SessionEvent, SessionInfo, StreamOptions, ToolResult, } from "@hostler/sdk"; +import { randomUUID } from "node:crypto"; import type { AgentContext, AgentEvent, @@ -34,16 +36,13 @@ import { DEFAULT_HOSTLER_HARNESS } from "../../../../shared/types"; const log = createLogger("hostler-agent"); -/** Default pairing: the opencode harness driving GLM 5.2 through Hostler's - * own model broker (GET /v1/models catalog id "glm-5.2", provider "openai" — - * open-weights models ride the broker's openai wire shape). GLM 5.2 is the - * same model the app's Ollama Cloud integration defaults to - * (DEFAULT_OLLAMA_MODEL), chosen there after a 16-task agent benchmark. - * Model ids are validated against the catalog at session create, so a - * mistyped id fails fast rather than after the sandbox starts billing. */ +/** Default pairing: the opencode harness driving Kimi K3 through Hostler's + * model broker. Kimi uses the broker's OpenAI-compatible wire shape. + * Model ids are validated by Hostler at session create, so a mistyped or + * unavailable id fails fast rather than after the sandbox starts billing. */ export const DEFAULT_HOSTLER_MODEL: ModelConfig = { provider: "openai", - id: "glm-5.2", + id: "kimi-k3", }; /** @@ -73,6 +72,7 @@ export function resolveHostlerModel(selector: string | undefined): ModelConfig { export interface HostlerSessionLike { readonly id: string; info(): Promise; + recoverySafety(options?: { afterSeq?: number; timeoutMs?: number }): Promise; send(text: string, options?: { signal?: AbortSignal }): Promise; interrupt(): Promise; terminate(): Promise; @@ -429,7 +429,17 @@ export class HostlerAgentProvider implements AgentProvider { }; break; } else if (ev.type === "session.status_terminated") { - yield { type: "error", message: `Hostler session terminated: ${ev.reason}` }; + const recovery = await session + .recoverySafety({ afterSeq: ev.seq }) + .catch((): null => null); + const failureCode = ev.failure?.code ? ` [${ev.failure.code}]` : ""; + const recoveryMessage = recovery + ? ` Recovery safety: ${recovery.classification.replaceAll("_", " ")} — ${recovery.explanation.slice(0, 1_000)}` + : ""; + yield { + type: "error", + message: `Hostler session terminated${failureCode}: ${ev.reason}.${recoveryMessage}`, + }; this.sessionSeq.delete(session.id); cleanup(); return { state: "failed", providerTaskId: session.id }; @@ -542,15 +552,33 @@ export class HostlerAgentProvider implements AgentProvider { agentRef: { id: string; version: number }, taskId: string, ): Promise { + // SDK 0.2 lets the caller allocate a team-scoped alias. If session + // creation succeeds server-side but its HTTP response is lost, we can + // recover the exact sandbox through that alias instead of leaking it and + // launching a duplicate. The returned session.id may be a distinct, + // server-generated internal id; both identities work on session routes. + const sessionId = `ses_${randomUUID().replaceAll("-", "")}`; const options: CreateSessionOptions = { + sessionId, agentId: agentRef.id, // Pin the version we just synced — deterministic even if another // device publishes a newer version mid-run. agentVersion: agentRef.version, title: `mail-app:${taskId}`, }; + const createOrRecover = async (): Promise => { + try { + return await client.sessions.create(options); + } catch (err) { + if (!isAmbiguousCreateError(err)) throw err; + const recovered = await client.sessions.get(sessionId).catch(() => null); + if (!recovered) throw err; + log.info(`Recovered Hostler session ${sessionId} after an ambiguous create response`); + return recovered; + } + }; try { - return await client.sessions.create(options); + return await createOrRecover(); } catch (err) { if (errorStatus(err) !== 402) throw err; // Reservations may be held by our own warm sessions OR by orphans the @@ -559,7 +587,7 @@ export class HostlerAgentProvider implements AgentProvider { log.info("Session create hit a credit reservation (402); freeing our sessions and retrying"); await this.orphanSweep?.catch(() => undefined); await this.terminateWarmSessions(); - return await client.sessions.create(options); + return await createOrRecover(); } } @@ -608,6 +636,7 @@ export class HostlerAgentProvider implements AgentProvider { const staleBefore = Date.now() - STALE_RUNNING_SESSION_MS; const orphans = rows.filter((row) => { if (!(row.title ?? "").startsWith("mail-app:")) return false; + if (row.status === "terminated") return false; if (row.status === "idle") return true; const created = Date.parse(row.createdAt); return Number.isFinite(created) && created < staleBefore; @@ -822,6 +851,15 @@ function isConflict(err: unknown): boolean { return status === 409 || status === 404; } +/** A timeout, connection loss, conflict, or server failure can happen after + * Hostler persisted the caller-assigned session id but before the create + * response reached us. Definitive client/auth/billing errors are not + * ambiguous and should surface without a follow-up lookup. */ +function isAmbiguousCreateError(err: unknown): boolean { + const status = errorStatus(err); + return status === null || status === 408 || status === 409 || status >= 500; +} + function describeHostlerError(err: unknown, doing: string): string { const message = err instanceof Error ? err.message : String(err); switch (errorStatus(err)) { diff --git a/src/renderer/components/ExtensionsTab.tsx b/src/renderer/components/ExtensionsTab.tsx index 21153c37..2193fe83 100644 --- a/src/renderer/components/ExtensionsTab.tsx +++ b/src/renderer/components/ExtensionsTab.tsx @@ -1023,7 +1023,7 @@ export function ExtensionsTab({ onOllamaCloudDisabled }: { onOllamaCloudDisabled { @@ -1034,7 +1034,7 @@ export function ExtensionsTab({ onOllamaCloudDisabled }: { onOllamaCloudDisabled />

Bare model id (pairs with Anthropic) or "provider/model" from - Hostler's catalog. Blank uses glm-5.2. + Hostler's catalog. Blank uses Kimi K3.

diff --git a/src/shared/types.ts b/src/shared/types.ts index 074d8939..2ec16dd1 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -508,10 +508,9 @@ export const ConfigSchema = z.object({ // work without an app update — unknown ones fail fast with a 400 that // lists the supported set. harness: z.string().default(DEFAULT_HOSTLER_HARNESS), - // "provider/model" (e.g. "openai/kimi-k2.5") or a bare model id, which - // pairs with "anthropic". Blank uses glm-5.2 from Hostler's brokered - // catalog (GET /v1/models) — the same model family as the app's own - // Ollama Cloud default. + // "provider/model" (e.g. "openai/kimi-k3") or a bare model id, which + // pairs with "anthropic". Blank uses Kimi K3 through Hostler's + // OpenAI-compatible broker route. model: z.string().optional(), // Dev/test escape hatch (e.g. scripts/mock-hostler-server.mjs); no UI, // and the settings IPC only accepts loopback values (see settings.ipc). diff --git a/tests/e2e/hostler-settings.spec.ts b/tests/e2e/hostler-settings.spec.ts index 28f2de92..853e70c4 100644 --- a/tests/e2e/hostler-settings.spec.ts +++ b/tests/e2e/hostler-settings.spec.ts @@ -62,8 +62,13 @@ test.describe("Settings - Hostler", () => { const apiKeyInput = card.getByPlaceholder("cpk_..."); await apiKeyInput.fill(""); await apiKeyInput.fill("cpk_e2e_test"); + if (process.env.E2E_SCREENSHOTS === "true") { + await card.getByPlaceholder("opencode").fill(""); + await card.getByPlaceholder("openai/kimi-k3").fill(""); + await card.screenshot({ path: "docs/images/hostler/settings-card.png" }); + } await card.getByPlaceholder("opencode").fill("codex"); - await card.getByPlaceholder("openai/glm-5.2").fill("openai/test-model"); + await card.getByPlaceholder("openai/kimi-k3").fill("openai/test-model"); await card.getByRole("button", { name: "Save", exact: true }).click(); await expect(card.getByRole("button", { name: "Saved", exact: true })).toBeVisible(); @@ -93,7 +98,7 @@ test.describe("Settings - Hostler", () => { await expect(card.getByPlaceholder("cpk_...")).toHaveValue("cpk_e2e_test"); await expect(card.getByPlaceholder("opencode")).toHaveValue("codex"); - await expect(card.getByPlaceholder("openai/glm-5.2")).toHaveValue("openai/test-model"); + await expect(card.getByPlaceholder("openai/kimi-k3")).toHaveValue("openai/test-model"); } finally { const restored = (await page.evaluate( (hostler) => window.api.settings.set({ hostler }), @@ -149,7 +154,7 @@ test.describe("Settings - Hostler", () => { await expect(toggle).toBeDisabled(); await expect(card.getByPlaceholder("cpk_...")).toBeDisabled(); await expect(card.getByPlaceholder("opencode")).toBeDisabled(); - await expect(card.getByPlaceholder("openai/glm-5.2")).toBeDisabled(); + await expect(card.getByPlaceholder("openai/kimi-k3")).toBeDisabled(); await expect .poll(() => electronApp.evaluate(() => { diff --git a/tests/unit/hostler-agent-provider.spec.ts b/tests/unit/hostler-agent-provider.spec.ts index f0e41b7b..b47279cb 100644 --- a/tests/unit/hostler-agent-provider.spec.ts +++ b/tests/unit/hostler-agent-provider.spec.ts @@ -16,6 +16,7 @@ import { test, expect } from "@playwright/test"; import { z } from "zod"; import type { CreateSessionOptions, + RecoverySafety, SessionEvent, SessionInfo, ToolConfirmation, @@ -66,6 +67,18 @@ class FakeSession implements HostlerSessionLike { terminated = false; streamOptions: { since?: number; signal?: AbortSignal } | undefined; history: SessionEvent[] = []; + recoveryVerdict: RecoverySafety = { + classification: "safe", + observedThroughSeq: null, + checkpointSeq: null, + settledThroughSeq: null, + ambiguousTools: [], + completedActionIds: [], + lastSettledEvent: null, + missingArtifacts: [], + explanation: "The last checkpoint is settled.", + }; + recoverySafetyOptions: { afterSeq?: number; timeoutMs?: number } | undefined; private script: StreamScript; private toolResultWaiters: ((r: ToolResult) => void)[] = []; private postedResults: ToolResult[] = []; @@ -87,8 +100,13 @@ class FakeSession implements HostlerSessionLike { environmentId: null, vaultIds: [], deploymentId: null, + recoverySafety: this.recoveryVerdict, }); } + recoverySafety(options?: { afterSeq?: number; timeoutMs?: number }): Promise { + this.recoverySafetyOptions = options; + return Promise.resolve(this.recoveryVerdict); + } send(text: string): Promise { this.sentMessages.push(text); return Promise.resolve(); @@ -194,6 +212,17 @@ function sessionRow(overrides: Partial & { id: string }): SessionIn environmentId: null, vaultIds: [], deploymentId: null, + recoverySafety: { + classification: "safe", + observedThroughSeq: null, + checkpointSeq: null, + settledThroughSeq: null, + ambiguousTools: [], + completedActionIds: [], + lastSettledEvent: null, + missingArtifacts: [], + explanation: "Settled.", + }, ...overrides, }; } @@ -335,6 +364,7 @@ test("happy path: syncs agent, runs a client tool locally, completes", async () // Session pinned to the synced agent version; first message carries context. expect(createdWith[0]).toMatchObject({ agentId: "agt_1", agentVersion: 1 }); + expect(createdWith[0].sessionId).toMatch(/^ses_[a-f0-9]{32}$/); expect(session.sentMessages).toHaveLength(1); expect(session.sentMessages[0]).toContain("Context for this conversation"); expect(session.sentMessages[0]).toContain("User request: How many unread emails do I have?"); @@ -790,8 +820,10 @@ test("402 on session create reaps warm sessions and retries once", async () => { const warmSession = new FakeSession("ses_warm", answer); const freshSession = new FakeSession("ses_fresh", answer); let creates = 0; + const createOptions: CreateSessionOptions[] = []; const client = makeClient({ - create: async () => { + create: async (options) => { + createOptions.push(options); creates += 1; if (creates === 1) return warmSession; if (creates === 2) throw new FakeHostlerError("insufficient credit balance", 402); @@ -816,6 +848,39 @@ test("402 on session create reaps warm sessions and retries once", async () => { expect(second.result).toEqual({ state: "completed", providerTaskId: "ses_fresh" }); expect(warmSession.terminated).toBe(true); expect(creates).toBe(3); + expect(createOptions[1].sessionId).toBe(createOptions[2].sessionId); +}); + +test("ambiguous session-create response recovers the caller-assigned session", async () => { + let created: FakeSession | null = null; + let assignedId: string | undefined; + let creates = 0; + const client = makeClient({ + create: async (options) => { + creates += 1; + assignedId = options.sessionId; + if (!assignedId) throw new Error("Provider did not assign a session id"); + // SDK 0.2.10 documents sessionId as a team-scoped route alias; the + // returned session keeps its separate server-generated internal id. + created = new FakeSession("ses_internal", simpleAnswer); + // Simulate a lost HTTP response after Hostler persisted the session. + throw new Error("socket closed after request was sent"); + }, + get: async (id) => { + if (created && id === assignedId) return created; + throw new FakeHostlerError("not found", 404); + }, + }); + const provider = new HostlerAgentProvider( + makeFrameworkConfig({ enabled: true, apiKey: "cpk_test" }), + ); + provider._setSdkForTesting(makeSdk(client)); + + const { result } = await drain(provider.run(makeRunParams())); + + expect(creates).toBe(1); + expect(assignedId).toMatch(/^ses_[a-f0-9]{32}$/); + expect(result).toEqual({ state: "completed", providerTaskId: "ses_internal" }); }); async function* simpleAnswer(): AsyncGenerator { @@ -827,11 +892,13 @@ test("boot sweep reaps idle and stale-running mail-app sessions, spares live and const idleOrphan = new FakeSession("ses_idle", simpleAnswer); const staleRunning = new FakeSession("ses_stale", simpleAnswer); const freshRunning = new FakeSession("ses_fresh_run", simpleAnswer); + const alreadyTerminated = new FakeSession("ses_terminated", simpleAnswer); const foreign = new FakeSession("ses_foreign", simpleAnswer); const byId = new Map([ ["ses_idle", idleOrphan], ["ses_stale", staleRunning], ["ses_fresh_run", freshRunning], + ["ses_terminated", alreadyTerminated], ["ses_foreign", foreign], ]); const runSession = new FakeSession("ses_run", simpleAnswer); @@ -850,6 +917,11 @@ test("boot sweep reaps idle and stale-running mail-app sessions, spares live and createdAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), }), sessionRow({ id: "ses_fresh_run", status: "running" }), + sessionRow({ + id: "ses_terminated", + status: "terminated", + createdAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), + }), sessionRow({ id: "ses_foreign", status: "idle", title: "someone-elses-session" }), ], }); @@ -866,6 +938,7 @@ test("boot sweep reaps idle and stale-running mail-app sessions, spares live and expect(idleOrphan.terminated).toBe(true); expect(staleRunning.terminated).toBe(true); expect(freshRunning.terminated).toBe(false); + expect(alreadyTerminated.terminated).toBe(false); expect(foreign.terminated).toBe(false); }); @@ -981,10 +1054,25 @@ test("mcp-locale parks are never executed locally", async () => { test("session terminated mid-stream fails the run without a done event", async () => { async function* script(): AsyncGenerator { yield { ...base(), type: "agent.message_delta", text: "partial…" }; - yield { ...base(), type: "session.status_terminated", reason: "platform maintenance" }; + yield { + ...base(), + type: "session.status_terminated", + reason: "platform maintenance", + failure: { + code: "sandbox_unavailable", + source: "sandbox", + retryable: true, + recoveryHint: "new_session", + }, + }; } const session = new FakeSession("ses_1", script); + session.recoveryVerdict = { + ...session.recoveryVerdict, + classification: "confirmation_required", + explanation: "Confirm whether an external action completed before continuing.", + }; const client = makeClient({ create: async () => session, get: async () => { @@ -1001,7 +1089,11 @@ test("session terminated mid-stream fails the run without a done event", async ( expect(result.state).toBe("failed"); expect(events.some((e) => e.type === "done")).toBe(false); const error = events.find((e) => e.type === "error"); - expect(error && "message" in error ? error.message : "").toContain("platform maintenance"); + const message = error && "message" in error ? error.message : ""; + expect(message).toContain("platform maintenance"); + expect(message).toContain("sandbox_unavailable"); + expect(message).toContain("confirmation required"); + expect(session.recoverySafetyOptions).toMatchObject({ afterSeq: expect.any(Number) }); }); test("events() failure on session reuse fails the run and executes nothing", async () => { @@ -1212,7 +1304,8 @@ test("idle reaper terminates a warm session after the TTL", async () => { }); test("resolveHostlerModel parses selectors", () => { - expect(resolveHostlerModel(undefined)).toEqual(DEFAULT_HOSTLER_MODEL); + expect(DEFAULT_HOSTLER_MODEL).toEqual({ provider: "openai", id: "kimi-k3" }); + expect(resolveHostlerModel(undefined)).toEqual({ provider: "openai", id: "kimi-k3" }); expect(resolveHostlerModel(" ")).toEqual(DEFAULT_HOSTLER_MODEL); expect(resolveHostlerModel("claude-sonnet-4-5")).toEqual({ provider: "anthropic",