The bridge replaces its serialization queue with a pool that reuses the native concurrency contract: submissions classify through registry.executionMode (fail-closed isConcurrencySafe), start strictly in submission order, overlap up to the validated maxParallelSubCalls config (default 10; 1 restores serial), and exclusive calls drain the pool, run alone, and bar later calls. Each started sub-call logs a tool/code-dispatch-start event at pool entry; the existing tool/code-dispatch settles the pair (started ⇔ settles exactly once; abandoned queued calls log neither). SDK prompt guidance now states the true Promise.all contract — re-recorded across every code/both-mode snapshot (plus the stale cordis-dynamic-toolchain fixture gaining the required description arg). Client: CodeSubCall widens to RunningToolCall | ToolResultNode — starts land the running shape (rows wear the native running ring), settles replace in place preserving start order, callTime pairs to the start time. Fixture emits start/settle pairs; jsdom pins the running sub-row; runtime specs pin in-place settlement and out-of-order completion.
4.7 KiB
Agent Note: Code Mode live dispatch lifecycle and native-contract parallelism
Status: implemented
English | 中文
Scope: the third PR of the Code Mode UI stack — the
tool/code-dispatch-startevent, per-sub-call running state in the web chat, and the bridge's scheduler reusing the native concurrency contract. Builds on the host foundation and chat sub-call rows; the native contract itself is owned by the parallel tool-call note.
Problem
Two gaps remained after the first two PRs. Sub-call rows appeared only when each dispatch settled — while one ran, the UI showed nothing for it, so a slow sub-call read as a stalled parent. And the bridge serialized every binding call ("even Promise.all executes one at a time"), a placeholder from before tools carried concurrency metadata: isConcurrencySafe now exists, the loop scheduler already runs native siblings in bounded pools, and a Code Mode program awaiting three independent reads paid 3× the latency the native path would.
Decision
One lifecycle pair, one scheduling contract, shared with native.
- Event pair:
tool/code-dispatch-start(parent/sub ids, name, normalized args) is appended when the scheduler actually starts a call — not at submission, so a queued call abandoned by run settlement logs nothing. The existingtool/code-dispatchsettles the pair (samesubCallId); every started call settles exactly once (aborts settle asisErroroutcomes through the pipeline). Timing = the two events'timefields. Both stay log-only; model context is untouched; format stays v0. - Bridge scheduler: submitted calls are classified at submission via
registry.executionMode(the SAME fail-closedisConcurrencySafecontract the loop uses) and start strictly in submission order. Consecutive parallel-classified calls overlap up tomaxParallelSubCalls(a validated registryConfigfield, default 10 — the loop scheduler's own default;1restores serial dispatch); an exclusive call drains the pool, runs alone, and bars later calls. This is the loop's group semantics adapted to calls that arrive over time instead of in one parsed batch. Run settlement aborts in-flight dispatches and abandons queued-unstarted ones (binding rejection, no events), then drains to quiescence before the outer result closes the turn. - Client:
CodeSubCallwidens toRunningToolCall | ToolResultNode— a start event lands the running shape in the dispatch index (rows derive the running ring from the shape, exactly as for native in-flight calls), and its settle replaces the entry in place, preserving start order under parallel completion and carrying the start'stimeascallTime(duration source). A settle with no observed start (window cut mid-pair, or a pre-start-event log) appends directly, so old logs keep rendering. - SDK prompt: the model-facing "calls execute sequentially" sentence is replaced with the true contract (independent safe calls may overlap under
Promise.all; dependent work sequences withawait) — a model-visible change, re-recorded across every code-mode snapshot.
Alternatives considered
Unrestricted parallelism (let Promise.all overlap everything). Rejected: writes could race; the native scheduler exists precisely because the tool, not the caller, owns the safety claim. One concurrency vocabulary across native and Code Mode was the settled requirement.
Emit the start event at submission instead of pool entry. Rejected: a submission-time start would show queued-but-never-run calls as "running" and would force a third "abandoned" terminal event to reconcile the log. Start-at-entry keeps the invariant started ⇔ settles exactly once and needs no third event.
Reuse the loop scheduler's implementation directly. Rejected: the loop schedules a fully-parsed batch with model-order result commitment; the bridge schedules an open-ended stream of submissions whose results return to the program (not the transcript), so only the contract (classification, pool, barriers) is shared, not the machinery.
Consequences
Programs get native-grade latency for independent reads with no new model-side API — Promise.all simply works better, and prompt guidance changed accordingly. The web UI shows per-sub-call running rings live (fixture emits start/settle pairs; jsdom pins the running shape; the runtime spec pins in-place settlement, out-of-order completion, and callTime pairing). PR6 (trajectory/waterfall spans) can now draw truthful spans from the pair's timing. The spill PR (next) inherits the settle event as its single bounding point.