feat(tools): deadlineMinutes on the coding + model run specs, awaiting_capacity in RunStatus [SAP-3201] - #839
feat(tools): deadlineMinutes on the coding + model run specs, awaiting_capacity in RunStatus [SAP-3201]#839gwitwer wants to merge 3 commits into
Conversation
…atus 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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019s65cc2Q5ravSTn9cZUj7e
Review — PR #8391.
|
… model runs 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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019s65cc2Q5ravSTn9cZUj7e
|
Thanks — finding 1 is a real bug and finding 3 is a real inconsistency this PR introduced. Both fixed in d50f5b3, along with 2 and 4. One partial disagreement, on the taxonomy half of 2. 1.
|
Follow-up review — PR #839 (delta since
|
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019s65cc2Q5ravSTn9cZUj7e
|
Round 2 addressed in 7cfd75e. Both new items were fair; the poll-count one was self-inflicted by round 1's fix. New 1 — flat poll interval against a widened budget: fixedRight, and it's the direct cost of the round-1 change: widening the budget raised the poll ceiling with it. Your arithmetic holds — a The interval now doubles while the status is function nextPollMs(status: string, currentMs: number, callerPollMs: number): number {
if (status !== "awaiting_capacity") return callerPollMs;
return Math.min(currentMs * 2, DEFERRED_POLL_CAP_MS);
}That's ~484 polls for the 8-hour case, back in the same order as the old ceiling. Every other status — Two tests, recording the delay each sleep asks for while firing it immediately: Declining the second half — exposing New 2 — stale copy in the PR body and the JSDoc: fixedBoth were genuinely contradictory, and the PR body one would have landed in the merge commit. The Scope section is rewritten to say The JSDoc now says the member is reserved, not mirrored — the gateway does not emit it on this surface today; it's declared because this surface accepts a deadline, and the only reason to send a deadline is for the platform to defer. I've stated it that way rather than citing a backend guarantee, because there isn't one to cite yet: SAP-3202 hasn't landed. Reserving costs one unreachable branch, omitting costs a silent type lie. On the lanes and the round-1 correctionNoted, and thanks for the correction on Verification: |
What
Gives the SDK a typed way to say "I can wait N minutes" on a run, and teaches the run-status union about the deferred state the server will start returning.
deadlineMinutes?: numberonCodingRunSpecandModelRunSpec, sent on the wire asdeadline_minutesby bothcodingLaunch/codingRunandmodels.run/models.launch.RunStatusgainsawaiting_capacity— added to the union and toRUN_STATUSES(socodingResultSchemaaccepts it), and deliberately not toTERMINAL.Why
Today every run dispatches immediately and is billed at the most expensive lane, because an author has no way to express slack. This is the author-facing half of the label + deadline vocabulary from
plans/model-execution-surface/interfaces.md: the author states the kind of call (model) and how long they can wait (deadlineMinutes); the platform derives the billing lane (run_now/priority/standard/flex). The caller never names a lane.Two details worth checking in review
Omitted means absent, not zero.
deadline_minutes: spec.deadlineMinutesleaves the valueundefinedwhen unset, soJSON.stringifydrops the key entirely. The server has to distinguish "no deadline" from "zero minutes", and an existing caller's request body stays byte-identical to before this change. Covered by a test on each surface.awaiting_capacityis non-terminal.TERMINALstays{"completed", "failed"}.run()'s poll loop and alaunch()handle'swait()only stop on those two, so a deferred run keeps polling instead of resolving with a null result. There's a test that drives a run through twoawaiting_capacitypolls intocompletedand asserts the loop kept going.Scope
agents.run(AgentRunSpec) is left alone. It dispatches a deployed orchestration rather than an LLM call, so it has no billing lane to derive — the ticket lists that as a nice-to-have "if it costs nothing", and it doesn't.ModelRunStatusdoes gainawaiting_capacity, reversed from this PR's first revision after review. The original reasoning was that the ticket scopes the new status toRunStatusand the gateway doesn't emit the deferred state on the model-run surface. That was the wrong way round:deadlineMinutesships onModelRunSpec(an explicit acceptance criterion), 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 mis-typeshandle.status()and makesmodelRunResultSchema.parsereject a real payload. Reserving the member costs a consumer one branch that is currently unreachable; omitting it costs a silent type lie. It's kept out ofMODEL_TERMINALexactly as on the coding side, and its doc comment says it is reserved rather than mirrored.Ships on its own
The server ignores
deadline_minutesuntil SAP-3202 lands, so this is zero behavior change. Includes a minor changeset for@sapiom/tools— the wire ticket'sexecution-surface.contract.jsonfixture is checked against the published package, so this has to publish before that ticket can go green.Verification
pnpm build,pnpm typecheck,pnpm lint— all clean across the workspace.npx jest --maxWorkers=1 src/models/{launch,run-launch,coding-result,resume-payload}.spec.ts— 40 passed. (Full suite left to CI per repo policy.)provider-neutral-copy-checkandagent-studio-terminology-checkpass.Closes SAP-3201
🤖 Generated with Claude Code
https://claude.ai/code/session_019s65cc2Q5ravSTn9cZUj7e