ADR 0041: Cold-start-aware calls between releases
Date: 2026-11-10
Status: Proposed. The SDK's part is implemented and runs on defaults; see Implementation. The platform changes in section 1 and section 6 are requests to the platform, not observations of it, and nothing here was tried against a deployed release.
Owners: Main Sequence SDK maintainers.
Related: ADR 0029 (Agent message send), ADR 0038 (MCP on FastAPI releases), ADR 0040 (context that travels with a call), ms-tau-sdk ADR 0022 (readiness before MCP).
Context
FastAPI releases, MCP endpoints and Agents run as Knative services, and many scale to zero. A project built from several of them calls in a chain, for example API A → API B → API C → Agent P, each hop an HTTP request with its own timeout.
A caller cannot tell a request that hangs from a request that waits for the target to start. A request to a release at zero replicas is held until a replica serves, so a call that takes 60 seconds warm can take 360 seconds when the target needs 300 seconds to start. The caller's 60-second timeout then fires on a healthy call. In a chain the starts add up, and the outermost caller's timeout must cover every start below it.
The platform already answers the readiness question. At SDK revision
origin/main of 2026-11-10:
POST …/resource-release/<uid>/resolve-runtime-access/returnsruntime_access.state(ready,waking,unavailable),can_requestandretry_after_ms, andruntime_presencewith aphase(idle,provisioning,pulling_image,starting,serving, …), desired and actual replicas, and the wake operation'srequested_atanddeadline_at. The SDK reads it asResourceReleaseRuntimeAccess.ResourceRelease.wait_for_runtime_access()polls that endpoint until the release can take a request. Nothing calls it for an application's own HTTP calls: an application that calls another release withrequestsorhttpxgets no readiness handling.AgentSessionmessage send has its own loop over the transient stateschecking,starting,waking,updating, with a 60-second access cache.- ms-tau-sdk has a third loop, for MCP applications and credential hydrate
releases, with a readiness limit (
TAU_MCP_APPLICATION_READY_TIMEOUT_SECONDS, default 120 seconds) separate from the tool's execution limit. It does not depend on this SDK and talks to the platform overhttpx.
So the same wait is written three times, with three default limits (600, 120 and per-turn), and the most common case, one release calling another, has none. The answer also lacks what a caller needs to choose a limit: the SDK's model carries no minimum instance count, no idle window and no expected start time.
Decision
Readiness is asked of the platform, never guessed by the caller. The SDK provides one routine for calling a release, which waits for readiness under its own limit and only then starts the request's limit, and one process cache that makes the routine cheap on a warm path. Other projects follow the same contract.
1. The platform states a release's scaling facts
Requested of the platform: resolve-runtime-access and the release detail
carry a scaling object.
| Field | Meaning |
|---|---|
min_instances |
Replicas kept when idle. 0 means the release scales to zero. |
idle_timeout_seconds |
How long the last replica stays after its last request. |
expected_cold_start_seconds |
The platform's measured time from wake request to serving, a high percentile over recent wakes of the active revision. null until measured. |
serving_since |
When the oldest current replica began serving. null at zero replicas. |
min_instances and idle_timeout_seconds are release configuration, changed
by the release's editor like its other scaling settings. The SDK reads all
four tolerantly (ADR 0032): a response
without scaling reads as unknown, and the routine below then uses its
defaults.
Requested as well: a read of the same answer that does not wake the release, for showing state without starting anything.
2. One routine calls a release
ResourceRelease.request(method, path, *, timeout, ready_timeout=None,
idempotent=False, **kwargs) and its asynchronous twin arequest are the way
an application calls another release. ResourceRelease.by_uid(uid) makes the
handle to call them on without fetching the release, so no line of the
documented pattern asks the platform unconditionally. arequest runs request on a worker
thread: the SDK's transport is requests, and a second transport is not worth
a dependency. Each call:
- Resolves access. From the cache of section 3
when the release is known warm, otherwise from
resolve-runtime-access, which also wakes the release. - Waits while
waking. It followsretry_after_ms, at least 100 ms apart, under the readiness limit. A state other thanreadyorwakingends the call at once. - Sends the request to the returned
rpc_urlwith the returned token, undertimeout.timeoutstarts here, so it measures the target's work and nothing else.
The readiness limit is ready_timeout when given; otherwise twice
expected_cold_start_seconds, no less than 30 seconds; otherwise
MAINSEQUENCE_RELEASE_READY_TIMEOUT_SECONDS, default 300.
wait_for_runtime_access() stays for code that wants access without sending
a request, and runs the same wait and fills the same cache. Concurrent first
calls to a cold release share one wait.
Agent message send keeps its own budget rule: waiting for a starting Agent runtime spends the call's own timeout, as ADR 0029 decided and its tests hold. It sends the deadline header of section 6. Giving it a separate readiness limit would reverse that decision and is left to its owners.
3. A process cache of release runtime state
mainsequence.client.release_runtime_cache is one in-memory, thread-safe
cache per process, keyed by release UID. An entry holds the access grant and
its expiry, the scaling facts, the time of the last successful response from
the release, and the last cold start this process observed (when it began and
how long it took).
The routine skips step 1's platform call when the entry's token is still valid and the release is known warm:
min_instances >= 1, or- the last sign that it was serving, a response below 500 or a
readyanswer from the platform, is younger thanidle_timeout_secondsminus a margin (the larger of 10 seconds and 20% of the window). Without the platform's value the window isMAINSEQUENCE_RELEASE_IDLE_TIMEOUT_SECONDS, default 60.
Otherwise it asks the platform. The cache is an optimisation and never the truth: another caller may have kept the target warm, a deployment may have restarted it, and this process may be one of several replicas. A wrong "warm" costs one held request, which section 4 classifies. A wrong "cold" costs one platform call.
The cache is not persisted and not shared between processes. Applications do
not write their own: ResourceRelease.runtime_status() returns the entry
(warm, cold or unknown, the scaling facts, the last observed cold start)
for code that wants to decide something from it, such as showing "starting"
to a user.
4. Failures name their phase
| Error | Raised when |
|---|---|
ReleaseReadinessTimeoutError |
The readiness limit ended while the release was waking. Carries the limit, the last phase and the wake's requested_at. |
ReleaseUnavailableError |
The platform answered a terminal state. |
ReleaseColdStartTimeoutError |
timeout ended and the platform then reports the release waking or below one serving replica: the request was held by a start, not by the application. |
ReleaseRequestTimeoutError |
timeout ended and the release is serving: the application was slow or hung. |
After a request timeout the routine asks the platform once to tell the last
two apart, and forgets that the release was warm. A start that finished before
that question is recognised by serving_since being later than the send;
without scaling such a call reads as ReleaseRequestTimeoutError. It sends the request again only when
the caller passed idempotent=True, once, after waiting for readiness. A call
that may have reached the application is never replayed otherwise, as
ADR 0038 already holds for tool
calls.
5. The target reports its own start
install_request_identity adds two headers to the HTTP responses of a
verified caller, never to public ingress, preflight or refused requests:
Mainsequence-Process-Started-At (RFC 3339) and
Mainsequence-Cold-Start: true on the first such request a process serves.
The routine records the start time in the cache entry, and logs
release.call.cold_start with the target's UID and the measured wait when it
waited for a start. This is
how a project learns when each dependency last started and how long it took,
from its own logs.
6. A deadline travels with the call
An HTTP header Mainsequence-Request-Timeout-Ms carries the time the caller
will still wait, as a duration, so clocks need not agree.
install_request_identityreads it into the request's scope.requestinside that scope reduces its limits to what remains, forwards the remainder, and fails before sending, withReleaseDeadlineTooShortError, when what remains is belowexpected_cold_start_secondsfor a cold target. The error names the target, the remainder and the expected start, so the outermost caller learns which hop its limit could not cover.- Without the header nothing is reduced.
Requested of the platform: the gateway forwards the header unchanged on
release requests and Agent Task dispatch, as it forwards baggage and
traceparent (ADR 0040).
The value is advisory. The platform neither enforces nor trusts it.
7. Dependencies start together
prewarm_releases(uids) resolves access for each release without waiting, so
they start in parallel. An application calls it from its FastAPI lifespan with
the releases it depends on, and may call it at the start of a request that
will reach several. A chain of three cold releases then costs about the
slowest start, not the sum.
8. Long work is a Task, not a held request
A call whose warm duration plus the chain's starts can pass the gateway's
request limit is submitted and polled, as Agent message send already is
(ADR 0029). The routine does not hide this:
ReleaseDeadlineTooShortError and the cold-start timeout are the signals to
change the shape of the call.
9. A scaffold skill teaches it
A new scaffold skill, application_surfaces/release_calls, tells an agent
building a project:
- call another release only through
ResourceRelease.request/arequest, never a hand-built URL, a stored token or a bare HTTP client; - pass
timeoutas the target's own work time, and leave readiness to the routine; - never keep a private "last used" clock or readiness cache: read
runtime_status(); - declare dependencies once and prewarm them in the lifespan;
- mark a call
idempotent=Trueonly when replaying it is safe; - move a call that exceeds its deadline to submit-and-poll.
The API surfaces skill and the orchestration and releases skill link to it.
Changes in each project
| Project | Change |
|---|---|
| Platform (Django, gateway) | The scaling object and its configuration; measuring cold starts; a read that does not wake; forwarding Mainsequence-Request-Timeout-Ms. Knative's own request and progress limits must exceed a release's expected start. |
| mainsequence-sdk | Sections 2 to 7 and 9; the Resource releases and FastAPI knowledge pages; Agent message send on the shared wait and cache. |
| ms-tau-sdk | It does not depend on this SDK, so it implements the same contract over httpx: its two readiness loops (MCP applications, credential hydrate releases) become one, with the limit rule of section 2, the cache rule of section 3 and the error phases of section 4, and its runtime forwards and honours the deadline header. Recorded there as an amendment to its ADR 0022. |
| Command Center and other HTTP clients | Read scaling to show "starting" rather than an error. No contract change. |
Consequences
- A caller's
timeoutmeans the same thing warm and cold. - A warm call costs no extra platform request. A cold or unknown one costs one.
- Three wait loops and three default limits become one rule.
- A project can answer, from its logs, which dependency was cold, when it started and how long that took.
- The readiness wait holds a worker of the caller while it lasts. Prewarming and submit-and-poll are the remedies. The routine does not queue.
- Until the platform returns
scaling, the routine runs on defaults: every call to a release that showed no sign of serving in the last 50 seconds asks the platform first.
Alternatives considered
- The caller tracks last use and infers cold starts. Rejected as the source of truth, for the reasons in section 3. Kept as the cache's warm-path rule.
- Raise every timeout to cover the worst start. Rejected: a hung application is then found after minutes, and chains multiply the padding.
- Keep every release at one instance. Rejected as a rule: it is the cost
that scale to zero removes. It stays a per-release setting, which
min_instancesmakes visible to callers. - Retry on timeout. Rejected as a default: a held request may have
reached the application. Opt-in through
idempotent=True. - Carry the deadline in
baggage. Rejected: the platform gives baggage no meaning (ADR 0040), and a deadline changes at every hop. - ms-tau-sdk depends on this SDK for the routine. Not decided here. The
contract is small enough to implement twice, and the dependency would bring
requestsand the ORM into the runtime.
Implementation
In the SDK, on defaults until the platform returns scaling:
mainsequence/client/release_calls.py: the routine, the cache (release_runtime_cache),ReleaseRuntimeStatus, the five errors andprewarm_releases.ResourceRelease.request,arequestandruntime_statuscall it.ResourceReleaseScaling, read as the optionalscalingofResourceReleaseRuntimeAccess.mainsequence/_request_deadline.pyandinstall_request_identity: the deadline header in and out, and the two start headers.- The scaffold skill
application_surfaces/release_calls, the "Call a release" section of the Resource releases guide and "Start time and deadlines" in the FastAPI guide. - Tests:
tests/client/test_release_calls.pyand one intests/test_fastapi_request_identity.py, against a stand-in for the platform and a local HTTP server.
Not done:
- No call was made to a deployed release. Whether the gateway forwards
Mainsequence-Request-Timeout-Ms, and passes the two response headers back, is unverified. - Agent message send still spends its own timeout on a starting runtime (see section 2). It sends the deadline header and nothing else changed.
- The platform is unchanged. ms-tau-sdk follows the contract in its own code, recorded as an amendment to its ADR 0022.
Open questions
- Does the platform already hold
min_instancesand the idle window per release, and can it expose them without a migration? - Which percentile and window define
expected_cold_start_seconds, and is it per revision or per release? - Should the platform start a release's declared dependencies when it wakes the release, making section 7 automatic?
- The header names of sections 5 and 6.