docs/design/acp-preheat-contract.md
The daemon exposes POST /workspace/acp/preheat and
GET /workspace/acp/status, but released clients cannot discover those routes
through /capabilities. The TypeScript SDK also sends both calls through its
active ACP transport by default even though they are daemon REST control-plane
routes. Finally, an HTTP waiter that times out currently clears the workspace
service's shared preheat promise while the underlying channel initialization
continues.
This change makes the existing primary-workspace routes discoverable and reliable. It does not introduce a durable readiness state or move the first Session barrier. A Session remains the authoritative operation: preheat and Session creation coalesce through the bridge's shared channel initialization, and Session creation revalidates the channel after any point-in-time status or preheat response.
The daemon advertises two always-on v1 capability tags:
workspace_acp_preheat for POST /workspace/acp/preheatworkspace_acp_status for GET /workspace/acp/statusEach tag means that the named route contract exists. Neither tag says that the ACP channel is currently live. The routes remain singular and primary- workspace-only. Clients must not use them for a secondary workspace or fall back from a secondary workspace to the primary runtime.
Workspace-qualified ACP warmup requires separate ownership, trust, draining, and resource-limit semantics and is outside this change.
GET /workspace/acp/status returns a point-in-time snapshot:
{
channelLive: boolean;
}
POST /workspace/acp/preheat preserves its existing response shape:
interface WorkspaceAcpPreheatResult {
ready: boolean;
channelLive: boolean;
durationMs: number;
reason?: 'timeout' | 'error';
error?: string;
}
The following invariants apply:
ready always equals channelLive.ready: true without reason or error.reason: 'timeout' only if the channel is still not
live when the response is built.reason: 'error'.durationMs is a finite, non-negative integer measured with a monotonic
clock. It is the current HTTP call's elapsed time, not the lifetime of a
shared initialization that the call may have joined.Operational timeout and initialization failure continue to use HTTP 200 so existing clients can inspect the result. Invalid input, authentication, rate limit, and deferred-runtime startup failures retain their existing HTTP error contracts.
The workspace service keeps one shared preheat promise until that promise settles. Every request races the same promise against its own timeout. A waiter timeout ends only that request; it neither cancels the bridge operation nor clears the shared promise. Settlement clears the promise only when its identity still matches the current shared operation, so an older completion cannot erase a newer attempt.
Once the shared operation settles, a later request may retry if the channel is not live. A channel that exits after a successful response is not covered by a lease: status reports the new snapshot and the next Session or preheat starts a new channel.
The TypeScript SDK sends both routes through its REST fetch path regardless of the configured ACP transport. It does not automatically fetch capabilities; callers decide when to preflight.
The Web UI uses the routes only in its deferred, no-session bootstrap flow. It
requires workspace_acp_preheat, gates the optional status optimization on
workspace_acp_status, and requires the effective workspace to exactly match
capabilities.workspaceCwd. An exact comparison can conservatively skip a
preheat for an alternate spelling of the primary path, but it cannot warm the
wrong runtime.
If an older daemon omits the capabilities, the Web UI makes no ACP status or preheat request and the first Session follows the existing lazy initialization path. Preheat failure remains best-effort and cannot fail connection or Session creation.