From 17a8d03c7b6f8daa26ee0c311f446f3338b98b0f Mon Sep 17 00:00:00 2001 From: David Witwer Date: Sat, 5 Sep 2026 16:05:42 -0700 Subject: [PATCH 1/3] feat(tools): deadlineMinutes on run specs, awaiting_capacity in RunStatus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Authors have no way to say "I can wait" on a run, so every run is dispatched immediately and billed at the most expensive lane. This adds the author-facing half of the label + deadline vocabulary: the author states the kind of call (`model`) and how long they can wait (`deadlineMinutes`), and the platform derives the billing lane (run_now / priority / standard / flex) from that. No lane is ever named by the caller. `deadlineMinutes` is optional on both `CodingRunSpec` and `ModelRunSpec` and rides the wire as `deadline_minutes`, matching the module's existing snake_case mapping. When unset it stays `undefined`, so `JSON.stringify` drops the key — the server has to be able to tell "no deadline" from a `0` or a `null`, and an existing caller's request is byte-identical to before. `RunStatus` gains `awaiting_capacity`, the state a deferred run reports while it waits for a lane. It is deliberately kept out of `TERMINAL`: `run()`'s poll loop and a `launch()` handle keep polling through it, so a deferred run is never resolved with a null result. The server ignores `deadline_minutes` until the wire ticket (SAP-3202) lands, so this ships with zero behavior change. `agents.run` is left alone — it dispatches a deployed orchestration, not an LLM call, so it has no billing lane to derive. Refs SAP-3201, SAP-3195 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_019s65cc2Q5ravSTn9cZUj7e --- .changeset/deadline-minutes-run-specs.md | 7 ++ packages/tools/src/models/README.md | 2 + packages/tools/src/models/index.ts | 48 ++++++++++++- packages/tools/src/models/launch.spec.ts | 71 ++++++++++++++++++++ packages/tools/src/models/run-launch.spec.ts | 34 +++++++++- 5 files changed, 160 insertions(+), 2 deletions(-) create mode 100644 .changeset/deadline-minutes-run-specs.md diff --git a/.changeset/deadline-minutes-run-specs.md b/.changeset/deadline-minutes-run-specs.md new file mode 100644 index 000000000..4c7f889d8 --- /dev/null +++ b/.changeset/deadline-minutes-run-specs.md @@ -0,0 +1,7 @@ +--- +"@sapiom/tools": minor +--- + +Coding runs and model runs take an optional `deadlineMinutes` — how long you're willing to wait, sent on the wire as `deadline_minutes`. The platform derives the billing lane (`run_now` / `priority` / `standard` / `flex`) from it, so a run you can wait on costs less. Omitting it is unchanged behavior: the key never reaches the wire and the run dispatches immediately. + +`RunStatus` also gains `awaiting_capacity`, the non-terminal state a deferred run reports while it waits for a lane. `run()` and a `launch()` handle keep polling through it rather than resolving with no result. diff --git a/packages/tools/src/models/README.md b/packages/tools/src/models/README.md index d1eac2414..bbc037e2f 100644 --- a/packages/tools/src/models/README.md +++ b/packages/tools/src/models/README.md @@ -29,6 +29,8 @@ if (run.result?.success) await repo.pushFromSandbox(run.sandbox, { message: "fea - **Each run is billed.** Runs that fail or are aborted still cost. Check `run.result?.success` and `run.error` before relying on a run's output. +- **`deadlineMinutes` buys a cheaper run if you can wait.** Available on both `agent.coding.run`/`launch` and `agent.run`/`launch`. You say the kind of call (`model`) and how long you're willing to wait (`deadlineMinutes`); the platform derives the billing lane (`run_now` / `priority` / `standard` / `flex`) from that — you never name a lane. Leave it unset and the run dispatches immediately at `run_now`, exactly as before. Set it and the run may sit in the non-terminal `awaiting_capacity` status until a cheaper lane frees up; `run` keeps polling through it and a `launch` handle keeps waiting, so a deferred run is never resolved with a null result. + - **Coding HTTP failures are structured.** `run`, `launch`, `handle.status()`, and `handle.wait()` throw `CodingRunHttpError`. Inspect `status`, `code`, `requestId`, and `body`; workflow steps can return `fail(error.message)` for `repository_not_found` and rethrow other errors. ## Reference diff --git a/packages/tools/src/models/index.ts b/packages/tools/src/models/index.ts index bc4104268..e8e43b38d 100644 --- a/packages/tools/src/models/index.ts +++ b/packages/tools/src/models/index.ts @@ -42,13 +42,26 @@ const DEFAULT_BASE_URL = */ export const CODING_RESULT_SIGNAL = "models.coding.result"; -/** Run lifecycle, mirrored from the gateway's `ModelsRunStatus`. */ +/** + * Run lifecycle, mirrored from the gateway's `ModelsRunStatus`. + * + * `awaiting_capacity` is the deferred state a run sits in when it carried a + * {@link CodingRunSpec.deadlineMinutes} and the platform parked it for a + * cheaper lane instead of dispatching immediately. It is NOT terminal: `run()` + * and a `launch()` handle keep polling through it. + */ export type RunStatus = | "pending" | "queued" + | "awaiting_capacity" | "running" | "completed" | "failed"; +/** + * The states `run()`'s poll loop and `handle.wait()` stop on. Deliberately just + * the two end states: every other status — `awaiting_capacity` included — keeps + * the loop polling, so a deferred run is never resolved with a null result. + */ const TERMINAL = new Set(["completed", "failed"]); export interface CodingRunSpec { @@ -70,6 +83,19 @@ export interface CodingRunSpec { * pass `"small"`/`"medium"`/`"large"` only to pick a billing class deliberately. */ model?: ModelLabel; + /** + * How long you're willing to wait for this run, in minutes — the deadline + * half of the label + deadline vocabulary. You say the kind of call + * (`model`) and how long you can wait; the platform derives the billing lane + * (`run_now` / `priority` / `standard` / `flex`) from it — you never name a + * lane yourself. + * + * Omit it (the default) for `run_now`: the request is byte-identical to one + * sent before this field existed, and the run dispatches immediately. Give it + * a value and the platform may park the run in `awaiting_capacity` until a + * cheaper lane is free, dispatching in time to finish within the deadline. + */ + deadlineMinutes?: number; } /** @@ -193,6 +219,7 @@ export class CodingResultSchemaError extends Error {} const RUN_STATUSES: readonly RunStatus[] = [ "pending", "queued", + "awaiting_capacity", "running", "completed", "failed", @@ -352,6 +379,9 @@ function buildBody(spec: CodingRunSpec): Record { working_directory: spec.workingDirectory, keep_sandbox: spec.keepSandbox ?? true, model: spec.model, + // Left `undefined` when unset, so `JSON.stringify` drops the key entirely — + // the server must be able to tell "no deadline" from a `0` or a `null`. + deadline_minutes: spec.deadlineMinutes, }; } @@ -489,6 +519,19 @@ export interface ModelRunSpec { * pass `"small"`/`"medium"`/`"large"` only to pick a billing class deliberately. */ model?: ModelLabel; + /** + * How long you're willing to wait for this run, in minutes — the deadline + * half of the label + deadline vocabulary. You say the kind of call + * (`model`) and how long you can wait; the platform derives the billing lane + * (`run_now` / `priority` / `standard` / `flex`) from it — you never name a + * lane yourself. + * + * Omit it (the default) for `run_now`: the request is byte-identical to one + * sent before this field existed, and the run dispatches immediately. Give it + * a value and the platform may defer the run until a cheaper lane is free, + * dispatching in time to finish within the deadline. + */ + deadlineMinutes?: number; /** Max output tokens per turn. */ maxTokens?: number; /** Remote MCP servers the agent may call tools on (network round-trip per call). */ @@ -686,6 +729,9 @@ function buildModelBody(spec: ModelRunSpec): Record { prompt: spec.prompt, system: spec.system, model: spec.model, + // Same encoding as the coding body: unset ⇒ `undefined` ⇒ the key is + // absent on the wire, never `0` or `null`. + deadline_minutes: spec.deadlineMinutes, max_tokens: spec.maxTokens, mcps: spec.mcps, }; diff --git a/packages/tools/src/models/launch.spec.ts b/packages/tools/src/models/launch.spec.ts index fe45df7ce..226e654df 100644 --- a/packages/tools/src/models/launch.spec.ts +++ b/packages/tools/src/models/launch.spec.ts @@ -79,6 +79,77 @@ describe("agent.coding.launch — dispatch handle", () => { }); }); +describe("agent.coding.launch — deadlineMinutes", () => { + it("sends the deadline as snake_case deadline_minutes", async () => { + const capture: Capture = {}; + const sapiom = createClient({ + apiKey: "k", + fetch: fakeLaunchFetch(capture), + }); + + await sapiom.models.coding.launch({ task: "do a thing", deadlineMinutes: 30 }); + + expect(capture.body).toMatchObject({ deadline_minutes: 30 }); + expect(capture.body).not.toHaveProperty("deadlineMinutes"); + }); + + it("omits the key entirely when no deadline is given — not null, not 0", async () => { + // The server has to tell "no deadline" (dispatch now) from "zero minutes", + // so an unset deadline must not reach the wire at all. + const capture: Capture = {}; + const sapiom = createClient({ + apiKey: "k", + fetch: fakeLaunchFetch(capture), + }); + + await sapiom.models.coding.launch({ task: "do a thing" }); + + expect(capture.body).not.toHaveProperty("deadline_minutes"); + }); +}); + +describe("agent.coding — awaiting_capacity is not terminal", () => { + it("keeps polling a deferred run instead of resolving it", async () => { + // A run parked on a deadline reports awaiting_capacity for as long as it + // waits for a lane. Resolving there would hand the caller a null result. + const statuses = ["awaiting_capacity", "awaiting_capacity", "completed"]; + let polls = 0; + const fetch = (async (_url: string, init: RequestInit = {}) => { + const isPost = (init.method ?? "GET") === "POST"; + const status = isPost ? "pending" : (statuses[polls++] ?? "completed"); + return { + ok: true, + status: isPost ? 202 : 200, + json: async () => ({ + data: { + id: "run-deferred", + attributes: { + status, + summary: status === "completed" ? "done" : null, + result: null, + error: null, + }, + relationships: { execution_environment: { data: { id: "env-1" } } }, + }, + }), + text: async () => "", + } as unknown as Response; + }) as unknown as typeof globalThis.fetch; + const sapiom = createClient({ apiKey: "k", fetch }); + + const handle = await sapiom.models.coding.launch({ + task: "do a thing", + deadlineMinutes: 30, + }); + expect(await handle.status()).toBe("awaiting_capacity"); + + const result = await handle.wait({ pollMs: 1 }); + + expect(result.status).toBe("completed"); + expect(polls).toBe(statuses.length); + }); +}); + describe("agent.coding — typed HTTP failures", () => { const repositoryMessage = "The requested git_repository is not an active Sapiom repository available to this tenant. " + diff --git a/packages/tools/src/models/run-launch.spec.ts b/packages/tools/src/models/run-launch.spec.ts index dc1c78d2b..9675bed03 100644 --- a/packages/tools/src/models/run-launch.spec.ts +++ b/packages/tools/src/models/run-launch.spec.ts @@ -7,7 +7,11 @@ import { createClient } from "../index.js"; import { MODEL_RUN_RESULT_SIGNAL, modelRunResultSchema } from "./index.js"; function fakeFetch(opts: { - capture?: { headers?: Record; url?: string }; + capture?: { + headers?: Record; + url?: string; + body?: Record; + }; terminal?: boolean; wireResult?: Record; }): typeof globalThis.fetch { @@ -15,6 +19,11 @@ function fakeFetch(opts: { if (opts.capture) { opts.capture.headers = init.headers as Record; opts.capture.url = url; + if (init.body) + opts.capture.body = JSON.parse(init.body as string) as Record< + string, + unknown + >; } const isPost = (init.method ?? "GET") === "POST"; const attributes = isPost @@ -65,6 +74,29 @@ describe("agent.launch — dispatch handle", () => { }); }); +describe("agent.launch — deadlineMinutes", () => { + it("sends the deadline as snake_case deadline_minutes", async () => { + const capture: { body?: Record } = {}; + const sapiom = createClient({ apiKey: "k", fetch: fakeFetch({ capture }) }); + + await sapiom.models.launch({ prompt: "say OK", deadlineMinutes: 30 }); + + expect(capture.body).toMatchObject({ deadline_minutes: 30 }); + expect(capture.body).not.toHaveProperty("deadlineMinutes"); + }); + + it("omits the key entirely when no deadline is given — not null, not 0", async () => { + // Same contract as the coding surface: the server has to tell "no + // deadline" from "zero minutes", so an unset deadline never hits the wire. + const capture: { body?: Record } = {}; + const sapiom = createClient({ apiKey: "k", fetch: fakeFetch({ capture }) }); + + await sapiom.models.launch({ prompt: "say OK" }); + + expect(capture.body).not.toHaveProperty("deadline_minutes"); + }); +}); + describe("agent.run — terminal result mapping", () => { it("maps the wire result (snake_case) to the SDK shape", async () => { const sapiom = createClient({ apiKey: "k", fetch: fakeFetch({}) }); From d50f5b3b7c60cc431b7e961e24b982e1683eb38e Mon Sep 17 00:00:00 2001 From: David Witwer Date: Sat, 5 Sep 2026 16:16:36 -0700 Subject: [PATCH 2/3] fix(tools): widen wait()'s budget to the deadline, represent deferred model runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 1 on #839. A deferred run polls until it reaches a terminal state, but `wait()`'s default budget was a fixed 20 min (coding) / 10 min (model). `run()` takes no `timeoutMs`, so `coding.run({ task, deadlineMinutes: 60 })` — the exact call this feature exists for — would have thrown at 20 minutes the moment the platform actually deferred it. The handle's default is now derived from the spec's deadline (deadline + the surface default as slack, since the deadline bounds when the run finishes, not when it starts). It only ever widens, and an explicit `wait({ timeoutMs })` still wins. `ModelRunSpec` accepts a deadline, so `ModelRunStatus` has to be able to hold the state that deadline produces. It gains `awaiting_capacity` too — otherwise the success case of an option we ship would mis-type `handle.status()` and make `modelRunResultSchema.parse` reject a real payload. It stays out of `MODEL_TERMINAL`, same as the coding side. Changeset and README no longer claim a deadline makes a run cheaper today: the platform doesn't honor the field until SAP-3202 lands, and a changeset is compiled into a published CHANGELOG that can't be retracted. The changeset also now warns consumers with exhaustive switches that a new union member is a compile error on their side. Refs SAP-3201 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_019s65cc2Q5ravSTn9cZUj7e --- .changeset/deadline-minutes-run-specs.md | 6 +- packages/tools/src/models/README.md | 4 +- packages/tools/src/models/index.ts | 57 +++++++++++++-- packages/tools/src/models/launch.spec.ts | 77 ++++++++++++++++++++ packages/tools/src/models/run-launch.spec.ts | 67 +++++++++++++++++ 5 files changed, 201 insertions(+), 10 deletions(-) diff --git a/.changeset/deadline-minutes-run-specs.md b/.changeset/deadline-minutes-run-specs.md index 4c7f889d8..6375d9026 100644 --- a/.changeset/deadline-minutes-run-specs.md +++ b/.changeset/deadline-minutes-run-specs.md @@ -2,6 +2,8 @@ "@sapiom/tools": minor --- -Coding runs and model runs take an optional `deadlineMinutes` — how long you're willing to wait, sent on the wire as `deadline_minutes`. The platform derives the billing lane (`run_now` / `priority` / `standard` / `flex`) from it, so a run you can wait on costs less. Omitting it is unchanged behavior: the key never reaches the wire and the run dispatches immediately. +Coding runs and model runs take an optional `deadlineMinutes` — how long you're willing to wait — sent on the wire as `deadline_minutes`. It is the deadline half of the label + deadline vocabulary: you state the kind of call (`model`) and how long you can wait, and the platform derives the billing lane from that rather than you naming one. **In this release the field is accepted and sent on the wire only; lane derivation lands in a later platform release, so a deadline does not yet change how a run is dispatched or priced.** Omitting it is unchanged behavior — the key never reaches the wire and the run dispatches immediately. -`RunStatus` also gains `awaiting_capacity`, the non-terminal state a deferred run reports while it waits for a lane. `run()` and a `launch()` handle keep polling through it rather than resolving with no result. +`RunStatus` and `ModelRunStatus` gain `awaiting_capacity`, the non-terminal state a deferred run reports while it waits for a lane. `run()` and a `launch()` handle keep polling through it rather than resolving with no result, and `wait()`'s default poll budget now widens to cover the deadline you asked for (an explicit `timeoutMs` still wins). + +Note for consumers who `switch` exhaustively over `RunStatus` or `ModelRunStatus`: a new union member is a compile error against a `default: assertNever(status)` arm. Handle `awaiting_capacity` as non-terminal — the run is still in flight. diff --git a/packages/tools/src/models/README.md b/packages/tools/src/models/README.md index bbc037e2f..6e52a9e22 100644 --- a/packages/tools/src/models/README.md +++ b/packages/tools/src/models/README.md @@ -29,7 +29,9 @@ if (run.result?.success) await repo.pushFromSandbox(run.sandbox, { message: "fea - **Each run is billed.** Runs that fail or are aborted still cost. Check `run.result?.success` and `run.error` before relying on a run's output. -- **`deadlineMinutes` buys a cheaper run if you can wait.** Available on both `agent.coding.run`/`launch` and `agent.run`/`launch`. You say the kind of call (`model`) and how long you're willing to wait (`deadlineMinutes`); the platform derives the billing lane (`run_now` / `priority` / `standard` / `flex`) from that — you never name a lane. Leave it unset and the run dispatches immediately at `run_now`, exactly as before. Set it and the run may sit in the non-terminal `awaiting_capacity` status until a cheaper lane frees up; `run` keeps polling through it and a `launch` handle keeps waiting, so a deferred run is never resolved with a null result. +- **`deadlineMinutes` says how long you can wait.** Available on both `agent.coding.run`/`launch` and `agent.run`/`launch`. You say the kind of call (`model`) and how long you're willing to wait; the platform derives the billing lane (`run_now` / `priority` / `standard` / `flex`) from that — you never name a lane. **The platform does not honor the deadline yet**: today the field is accepted and sent, and every run still dispatches immediately, so setting it does not yet change dispatch or price. Leaving it unset stays the immediate-dispatch default either way. + +- **A deferred run reports `awaiting_capacity`, which is not terminal.** Once deadlines are honored, a run waiting for a cheaper lane sits in this status. `run` keeps polling through it and a `launch` handle keeps waiting, so a deferred run is never resolved with a null result. `wait()`'s default timeout widens to cover the deadline you asked for — a 60-minute deadline doesn't blow the 20-minute default — and an explicit `wait({ timeoutMs })` still overrides it. - **Coding HTTP failures are structured.** `run`, `launch`, `handle.status()`, and `handle.wait()` throw `CodingRunHttpError`. Inspect `status`, `code`, `requestId`, and `body`; workflow steps can return `fail(error.message)` for `repository_not_found` and rethrow other errors. diff --git a/packages/tools/src/models/index.ts b/packages/tools/src/models/index.ts index e8e43b38d..c81e7c1c0 100644 --- a/packages/tools/src/models/index.ts +++ b/packages/tools/src/models/index.ts @@ -94,6 +94,7 @@ export interface CodingRunSpec { * sent before this field existed, and the run dispatches immediately. Give it * a value and the platform may park the run in `awaiting_capacity` until a * cheaper lane is free, dispatching in time to finish within the deadline. + * `wait()`'s default poll budget widens to cover the deadline you asked for. */ deadlineMinutes?: number; } @@ -318,6 +319,27 @@ function workflowResumeHeaders( return token ? { "x-sapiom-workflow-token": token } : {}; } +/** + * Default poll budget for a handle's `wait()`, widened to cover a deadline the + * caller asked for. A deferred run sits in `awaiting_capacity` for up to its + * deadline, so a fixed default would throw on exactly the runs the deadline + * exists for — `run({ deadlineMinutes: 60 })` against a 20-minute default. + * + * The deadline bounds when the run FINISHES, so the surface default is added on + * top as slack rather than replaced: it covers dispatch latency at the far end + * of the window plus poll granularity. Never shrinks the default (a deadline + * under it, or a non-positive one, leaves the default alone), and an explicit + * `wait({ timeoutMs })` still wins over all of this. + */ +function defaultWaitMs( + deadlineMinutes: number | undefined, + surfaceDefaultMs: number, +): number { + if (typeof deadlineMinutes !== "number" || !(deadlineMinutes > 0)) + return surfaceDefaultMs; + return deadlineMinutes * 60_000 + surfaceDefaultMs; +} + // --- wire shapes (snake_case, as served by the gateway serializer) --- interface WireResult { @@ -447,7 +469,10 @@ export async function codingLaunch( async status() { return (await fetchDoc()).data.attributes.status; }, - async wait({ timeoutMs = 20 * 60_000, pollMs = 3_000 } = {}) { + async wait({ + timeoutMs = defaultWaitMs(spec.deadlineMinutes, 20 * 60_000), + pollMs = 3_000, + } = {}) { const deadline = Date.now() + timeoutMs; // eslint-disable-next-line no-constant-condition while (true) { @@ -494,8 +519,22 @@ export const coding = { run: codingRun, launch: codingLaunch }; */ export const MODEL_RUN_RESULT_SIGNAL = "models.run.result"; -/** Run lifecycle, mirrored from the gateway's `ModelRunStatus` (no `queued`). */ -export type ModelRunStatus = "pending" | "running" | "completed" | "failed"; +/** + * Run lifecycle, mirrored from the gateway's `ModelRunStatus` (no `queued`). + * + * `awaiting_capacity` carries the same meaning as on {@link RunStatus} and is + * likewise NOT terminal. This surface accepts a + * {@link ModelRunSpec.deadlineMinutes}, so a deferred model run is a state the + * types have to be able to represent — a union too narrow to hold a value the + * server can send would mis-type `handle.status()` and make + * `modelRunResultSchema.parse` reject a real payload. + */ +export type ModelRunStatus = + | "pending" + | "awaiting_capacity" + | "running" + | "completed" + | "failed"; const MODEL_TERMINAL = new Set(["completed", "failed"]); /** A remote MCP server (Streamable HTTP) the agent may call tools on. */ @@ -528,8 +567,9 @@ export interface ModelRunSpec { * * Omit it (the default) for `run_now`: the request is byte-identical to one * sent before this field existed, and the run dispatches immediately. Give it - * a value and the platform may defer the run until a cheaper lane is free, - * dispatching in time to finish within the deadline. + * a value and the platform may park the run in `awaiting_capacity` until a + * cheaper lane is free, dispatching in time to finish within the deadline. + * `wait()`'s default poll budget widens to cover the deadline you asked for. */ deadlineMinutes?: number; /** Max output tokens per turn. */ @@ -632,7 +672,7 @@ export const modelRunResultSchema = { if (!value || typeof value !== "object") fail("not an object"); const v = value as Record; if (typeof v.runId !== "string") fail("runId must be a string"); - if (!(["pending", "running", "completed", "failed"] as ModelRunStatus[]).includes(v.status as ModelRunStatus)) + if (!(["pending", "awaiting_capacity", "running", "completed", "failed"] as ModelRunStatus[]).includes(v.status as ModelRunStatus)) fail("status must be a valid ModelRunStatus"); if (v.output !== null && typeof v.output !== "string") fail("output must be a string or null"); if (v.result !== null && (typeof v.result !== "object" || !v.result)) fail("result must be an object or null"); @@ -767,7 +807,10 @@ export async function launch( async status() { return (await fetchDoc()).data.attributes.status; }, - async wait({ timeoutMs = 10 * 60_000, pollMs = 2_000 } = {}) { + async wait({ + timeoutMs = defaultWaitMs(spec.deadlineMinutes, 10 * 60_000), + pollMs = 2_000, + } = {}) { const deadline = Date.now() + timeoutMs; // eslint-disable-next-line no-constant-condition while (true) { diff --git a/packages/tools/src/models/launch.spec.ts b/packages/tools/src/models/launch.spec.ts index 226e654df..24a073be1 100644 --- a/packages/tools/src/models/launch.spec.ts +++ b/packages/tools/src/models/launch.spec.ts @@ -150,6 +150,83 @@ describe("agent.coding — awaiting_capacity is not terminal", () => { }); }); +describe("agent.coding — the deadline widens wait()'s poll budget", () => { + /** + * Holds the run in awaiting_capacity for two polls, jumping the clock past + * the 20-minute surface default between them, then completes it. Time is + * moved with a Date.now stub rather than fake timers so the loop's real + * `setTimeout(pollMs)` still runs. + */ + function deferredPastDefault(): typeof globalThis.fetch { + const realNow = Date.now(); + let offsetMs = 0; + let polls = 0; + jest.spyOn(Date, "now").mockImplementation(() => realNow + offsetMs); + return (async (_url: string, init: RequestInit = {}) => { + const isPost = (init.method ?? "GET") === "POST"; + const deferred = !isPost && polls++ === 0; + // The first poll finds the run parked; by the time the loop re-checks its + // budget, 25 minutes have passed — past the 20-minute surface default. + if (deferred) offsetMs = 25 * 60_000; + const status = isPost + ? "pending" + : deferred + ? "awaiting_capacity" + : "completed"; + return { + ok: true, + status: isPost ? 202 : 200, + json: async () => ({ + data: { + id: "run-slow", + attributes: { status, summary: null, result: null, error: null }, + relationships: { execution_environment: { data: { id: "env-1" } } }, + }, + }), + text: async () => "", + } as unknown as Response; + }) as unknown as typeof globalThis.fetch; + } + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("run() survives a deadline longer than the surface default", async () => { + // `coding.run` takes no timeoutMs, so a fixed 20-minute default would throw + // on exactly the runs a 60-minute deadline exists for. + const sapiom = createClient({ apiKey: "k", fetch: deferredPastDefault() }); + + const result = await sapiom.models.coding.run({ + task: "do a thing", + deadlineMinutes: 60, + }); + + expect(result.status).toBe("completed"); + }, 10_000); // one real 3s poll sleep — run() takes no pollMs + + it("still times out at the surface default when no deadline was asked for", async () => { + // The widening is opt-in: an ordinary run keeps the 20-minute budget. + const sapiom = createClient({ apiKey: "k", fetch: deferredPastDefault() }); + + await expect( + sapiom.models.coding.run({ task: "do a thing" }), + ).rejects.toThrow(/timed out after 1200000ms \(last status: awaiting_capacity\)/); + }); + + it("an explicit timeoutMs still wins over the deadline", async () => { + const sapiom = createClient({ apiKey: "k", fetch: deferredPastDefault() }); + const handle = await sapiom.models.coding.launch({ + task: "do a thing", + deadlineMinutes: 60, + }); + + await expect(handle.wait({ timeoutMs: 1_000 })).rejects.toThrow( + /timed out after 1000ms/, + ); + }); +}); + describe("agent.coding — typed HTTP failures", () => { const repositoryMessage = "The requested git_repository is not an active Sapiom repository available to this tenant. " + diff --git a/packages/tools/src/models/run-launch.spec.ts b/packages/tools/src/models/run-launch.spec.ts index 9675bed03..69cebf9b8 100644 --- a/packages/tools/src/models/run-launch.spec.ts +++ b/packages/tools/src/models/run-launch.spec.ts @@ -97,6 +97,73 @@ describe("agent.launch — deadlineMinutes", () => { }); }); +describe("agent.run — awaiting_capacity is not terminal here either", () => { + /** Parks the run for one poll, jumping the clock past the 10-minute default. */ + function deferredPastDefault(): typeof globalThis.fetch { + const realNow = Date.now(); + let offsetMs = 0; + let polls = 0; + jest.spyOn(Date, "now").mockImplementation(() => realNow + offsetMs); + return (async (_url: string, init: RequestInit = {}) => { + const isPost = (init.method ?? "GET") === "POST"; + const deferred = !isPost && polls++ === 0; + if (deferred) offsetMs = 15 * 60_000; + const attributes = isPost + ? { status: "pending" } + : deferred + ? { status: "awaiting_capacity", output: null, result: null, error: null } + : { status: "completed", output: "OK", result: null, error: null }; + return { + ok: true, + status: isPost ? 202 : 200, + json: async () => ({ data: { id: "run-slow", attributes } }), + text: async () => "", + } as unknown as Response; + }) as unknown as typeof globalThis.fetch; + } + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("keeps polling a deferred run, within a budget widened by the deadline", async () => { + // This surface accepts deadlineMinutes, so it has to survive being + // deferred: awaiting_capacity is out of MODEL_TERMINAL and a 30-minute + // deadline lifts wait()'s default past the 10-minute one. + const sapiom = createClient({ apiKey: "k", fetch: deferredPastDefault() }); + + const result = await sapiom.models.run({ + prompt: "say OK", + deadlineMinutes: 30, + }); + + expect(result.status).toBe("completed"); + expect(result.output).toBe("OK"); + }, 10_000); // one real 2s poll sleep — run() takes no pollMs + + it("still times out at the surface default when no deadline was asked for", async () => { + const sapiom = createClient({ apiKey: "k", fetch: deferredPastDefault() }); + + await expect(sapiom.models.run({ prompt: "say OK" })).rejects.toThrow( + /timed out after 600000ms \(last status: awaiting_capacity\)/, + ); + }); + + it("modelRunResultSchema accepts the deferred status", () => { + // A union too narrow to hold a status the server can send would make the + // resumed-step validator reject a real payload. + expect( + modelRunResultSchema.parse({ + runId: "run-abc", + status: "awaiting_capacity", + output: null, + result: null, + error: null, + }).status, + ).toBe("awaiting_capacity"); + }); +}); + describe("agent.run — terminal result mapping", () => { it("maps the wire result (snake_case) to the SDK shape", async () => { const sapiom = createClient({ apiKey: "k", fetch: fakeFetch({}) }); From 7cfd75edfa1707f62f6d5313cf16f94f71ee983f Mon Sep 17 00:00:00 2001 From: David Witwer Date: Sat, 5 Sep 2026 16:23:38 -0700 Subject: [PATCH 3/3] perf(tools): back off polling while a run sits in awaiting_capacity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 2 on #839. Widening wait()'s budget to cover the deadline (previous commit) raised the poll ceiling with it: at a flat 2s interval, a `deadlineMinutes: 480` model run parked for its whole window would issue ~14,700 GETs for one run, against ~300 before. That is the caller's rate limit and request bill spent learning nothing, since a parked run has nothing to report until the platform dispatches it. The interval now doubles while the status is `awaiting_capacity`, capped at 60s — a few hundred polls for an 8-hour deadline. Every other status, `running` included, snaps straight back to the caller's `pollMs`, so a run that is actually moving is still observed at full cadence and a terminal transition is caught promptly. Also corrects the `ModelRunStatus` doc comment, which still said the union was mirrored from the gateway while declaring a member the gateway does not emit on that surface. It is reserved, not mirrored, and now says so and why. Refs SAP-3201 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_019s65cc2Q5ravSTn9cZUj7e --- .changeset/deadline-minutes-run-specs.md | 2 +- packages/tools/src/models/README.md | 2 +- packages/tools/src/models/index.ts | 61 ++++++++++++---- packages/tools/src/models/launch.spec.ts | 92 ++++++++++++++++++++++++ 4 files changed, 142 insertions(+), 15 deletions(-) diff --git a/.changeset/deadline-minutes-run-specs.md b/.changeset/deadline-minutes-run-specs.md index 6375d9026..da3406c72 100644 --- a/.changeset/deadline-minutes-run-specs.md +++ b/.changeset/deadline-minutes-run-specs.md @@ -4,6 +4,6 @@ Coding runs and model runs take an optional `deadlineMinutes` — how long you're willing to wait — sent on the wire as `deadline_minutes`. It is the deadline half of the label + deadline vocabulary: you state the kind of call (`model`) and how long you can wait, and the platform derives the billing lane from that rather than you naming one. **In this release the field is accepted and sent on the wire only; lane derivation lands in a later platform release, so a deadline does not yet change how a run is dispatched or priced.** Omitting it is unchanged behavior — the key never reaches the wire and the run dispatches immediately. -`RunStatus` and `ModelRunStatus` gain `awaiting_capacity`, the non-terminal state a deferred run reports while it waits for a lane. `run()` and a `launch()` handle keep polling through it rather than resolving with no result, and `wait()`'s default poll budget now widens to cover the deadline you asked for (an explicit `timeoutMs` still wins). +`RunStatus` and `ModelRunStatus` gain `awaiting_capacity`, the non-terminal state a deferred run reports while it waits for a lane. `run()` and a `launch()` handle keep polling through it rather than resolving with no result, and `wait()`'s default poll budget now widens to cover the deadline you asked for (an explicit `timeoutMs` still wins). While a run is parked, polling backs off — doubling up to a minute between checks — and returns to the caller's interval as soon as the run is moving, so a long deadline costs a few hundred requests rather than thousands. Note for consumers who `switch` exhaustively over `RunStatus` or `ModelRunStatus`: a new union member is a compile error against a `default: assertNever(status)` arm. Handle `awaiting_capacity` as non-terminal — the run is still in flight. diff --git a/packages/tools/src/models/README.md b/packages/tools/src/models/README.md index 6e52a9e22..97d22bbfa 100644 --- a/packages/tools/src/models/README.md +++ b/packages/tools/src/models/README.md @@ -31,7 +31,7 @@ if (run.result?.success) await repo.pushFromSandbox(run.sandbox, { message: "fea - **`deadlineMinutes` says how long you can wait.** Available on both `agent.coding.run`/`launch` and `agent.run`/`launch`. You say the kind of call (`model`) and how long you're willing to wait; the platform derives the billing lane (`run_now` / `priority` / `standard` / `flex`) from that — you never name a lane. **The platform does not honor the deadline yet**: today the field is accepted and sent, and every run still dispatches immediately, so setting it does not yet change dispatch or price. Leaving it unset stays the immediate-dispatch default either way. -- **A deferred run reports `awaiting_capacity`, which is not terminal.** Once deadlines are honored, a run waiting for a cheaper lane sits in this status. `run` keeps polling through it and a `launch` handle keeps waiting, so a deferred run is never resolved with a null result. `wait()`'s default timeout widens to cover the deadline you asked for — a 60-minute deadline doesn't blow the 20-minute default — and an explicit `wait({ timeoutMs })` still overrides it. +- **A deferred run reports `awaiting_capacity`, which is not terminal.** Once deadlines are honored, a run waiting for a cheaper lane sits in this status. `run` keeps polling through it and a `launch` handle keeps waiting, so a deferred run is never resolved with a null result. `wait()`'s default timeout widens to cover the deadline you asked for — a 60-minute deadline doesn't blow the 20-minute default — and an explicit `wait({ timeoutMs })` still overrides it. Polling backs off while a run is parked (doubling, up to a minute between checks) so a long deadline doesn't spend thousands of requests waiting; it returns to the normal interval as soon as the run is moving. - **Coding HTTP failures are structured.** `run`, `launch`, `handle.status()`, and `handle.wait()` throw `CodingRunHttpError`. Inspect `status`, `code`, `requestId`, and `body`; workflow steps can return `fail(error.message)` for `repository_not_found` and rethrow other errors. diff --git a/packages/tools/src/models/index.ts b/packages/tools/src/models/index.ts index c81e7c1c0..9ee22f58f 100644 --- a/packages/tools/src/models/index.ts +++ b/packages/tools/src/models/index.ts @@ -340,6 +340,31 @@ function defaultWaitMs( return deadlineMinutes * 60_000 + surfaceDefaultMs; } +/** Ceiling for the deferred-run backoff below. */ +const DEFERRED_POLL_CAP_MS = 60_000; + +/** + * Poll interval for the next tick of a `wait()` loop. + * + * A run parked in `awaiting_capacity` has nothing new to report until the + * platform dispatches it, and a long deadline widens the poll budget to match — + * so at a flat interval an 8-hour deadline would spend ~14,000 GETs of the + * caller's rate limit doing nothing. While deferred, the interval doubles up to + * {@link DEFERRED_POLL_CAP_MS}, which brings that back to a few hundred. + * + * Every other status — including `running` — snaps straight back to the + * caller's `pollMs`, so a run that is actually moving is still observed at full + * cadence and a terminal transition is caught promptly. + */ +function nextPollMs( + status: string, + currentMs: number, + callerPollMs: number, +): number { + if (status !== "awaiting_capacity") return callerPollMs; + return Math.min(currentMs * 2, DEFERRED_POLL_CAP_MS); +} + // --- wire shapes (snake_case, as served by the gateway serializer) --- interface WireResult { @@ -474,16 +499,19 @@ export async function codingLaunch( pollMs = 3_000, } = {}) { const deadline = Date.now() + timeoutMs; + let intervalMs = pollMs; // eslint-disable-next-line no-constant-condition while (true) { const d = await fetchDoc(); - if (TERMINAL.has(d.data.attributes.status)) return toResult(d); + const status = d.data.attributes.status; + if (TERMINAL.has(status)) return toResult(d); if (Date.now() > deadline) { throw new Error( - `coding run ${runId} timed out after ${timeoutMs}ms (last status: ${d.data.attributes.status})`, + `coding run ${runId} timed out after ${timeoutMs}ms (last status: ${status})`, ); } - await new Promise((r) => setTimeout(r, pollMs)); + intervalMs = nextPollMs(status, intervalMs, pollMs); + await new Promise((r) => setTimeout(r, intervalMs)); } }, }; @@ -520,14 +548,18 @@ export const coding = { run: codingRun, launch: codingLaunch }; export const MODEL_RUN_RESULT_SIGNAL = "models.run.result"; /** - * Run lifecycle, mirrored from the gateway's `ModelRunStatus` (no `queued`). + * Run lifecycle, mirrored from the gateway's `ModelRunStatus` (no `queued`), + * plus `awaiting_capacity`. * - * `awaiting_capacity` carries the same meaning as on {@link RunStatus} and is - * likewise NOT terminal. This surface accepts a - * {@link ModelRunSpec.deadlineMinutes}, so a deferred model run is a state the - * types have to be able to represent — a union too narrow to hold a value the - * server can send would mis-type `handle.status()` and make - * `modelRunResultSchema.parse` reject a real payload. + * `awaiting_capacity` is RESERVED rather than mirrored: the gateway does not + * emit it on this surface today. It is declared because this surface accepts a + * {@link ModelRunSpec.deadlineMinutes}, and the only reason to send a deadline + * is for the platform to defer — so the day it does, a union too narrow to hold + * the value would mis-type `handle.status()` and make + * `modelRunResultSchema.parse` reject a real payload. Reserving it costs a + * consumer one branch that is currently unreachable; omitting it would cost a + * silent type lie. It carries the same meaning as on {@link RunStatus} and is + * likewise NOT terminal. */ export type ModelRunStatus = | "pending" @@ -812,16 +844,19 @@ export async function launch( pollMs = 2_000, } = {}) { const deadline = Date.now() + timeoutMs; + let intervalMs = pollMs; // eslint-disable-next-line no-constant-condition while (true) { const d = await fetchDoc(); - if (MODEL_TERMINAL.has(d.data.attributes.status)) return toResult(d); + const status = d.data.attributes.status; + if (MODEL_TERMINAL.has(status)) return toResult(d); if (Date.now() > deadline) { throw new Error( - `agent run ${runId} timed out after ${timeoutMs}ms (last status: ${d.data.attributes.status})`, + `agent run ${runId} timed out after ${timeoutMs}ms (last status: ${status})`, ); } - await new Promise((r) => setTimeout(r, pollMs)); + intervalMs = nextPollMs(status, intervalMs, pollMs); + await new Promise((r) => setTimeout(r, intervalMs)); } }, }; diff --git a/packages/tools/src/models/launch.spec.ts b/packages/tools/src/models/launch.spec.ts index 24a073be1..993f9ca89 100644 --- a/packages/tools/src/models/launch.spec.ts +++ b/packages/tools/src/models/launch.spec.ts @@ -227,6 +227,98 @@ describe("agent.coding — the deadline widens wait()'s poll budget", () => { }); }); +describe("agent.coding — polling backs off while a run is deferred", () => { + /** + * Records the delay each poll sleep ASKS for while firing it immediately, so + * the backoff schedule is observable without the test taking that long. + */ + function recordPollDelays(): number[] { + const delays: number[] = []; + const realSetTimeout = globalThis.setTimeout; + jest.spyOn(globalThis, "setTimeout").mockImplementation((( + fn: () => void, + ms?: number, + ) => { + delays.push(ms ?? 0); + return realSetTimeout(fn, 0); + }) as unknown as typeof globalThis.setTimeout); + return delays; + } + + function fetchStatuses(statuses: string[]): typeof globalThis.fetch { + let polls = 0; + return (async (_url: string, init: RequestInit = {}) => { + const isPost = (init.method ?? "GET") === "POST"; + const status = isPost + ? "pending" + : (statuses[polls++] ?? "completed"); + return { + ok: true, + status: isPost ? 202 : 200, + json: async () => ({ + data: { + id: "run-parked", + attributes: { status, summary: null, result: null, error: null }, + relationships: { execution_environment: { data: { id: "env-1" } } }, + }, + }), + text: async () => "", + } as unknown as Response; + }) as unknown as typeof globalThis.fetch; + } + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("doubles the interval while deferred and snaps back once the run moves", async () => { + // A parked run has nothing new to report, so polling it at full cadence for + // the whole widened budget would burn the caller's rate limit. A run that is + // actually `running` goes straight back to the caller's interval. + const delays = recordPollDelays(); + const sapiom = createClient({ + apiKey: "k", + fetch: fetchStatuses([ + "awaiting_capacity", + "awaiting_capacity", + "awaiting_capacity", + "running", + "running", + "completed", + ]), + }); + const handle = await sapiom.models.coding.launch({ + task: "do a thing", + deadlineMinutes: 60, + }); + + await handle.wait({ pollMs: 1 }); + + expect(delays).toEqual([2, 4, 8, 1, 1]); + }); + + it("caps the backoff so a long deadline still polls periodically", async () => { + const delays = recordPollDelays(); + const sapiom = createClient({ + apiKey: "k", + fetch: fetchStatuses([ + "awaiting_capacity", + "awaiting_capacity", + "completed", + ]), + }); + const handle = await sapiom.models.coding.launch({ + task: "do a thing", + deadlineMinutes: 480, + }); + + await handle.wait({ pollMs: 40_000 }); + + // 40s doubles to 80s, clamped to the 60s ceiling and held there. + expect(delays).toEqual([60_000, 60_000]); + }); +}); + describe("agent.coding — typed HTTP failures", () => { const repositoryMessage = "The requested git_repository is not an active Sapiom repository available to this tenant. " +