Files
deepseek-harness/.agents/notes/implemented/feature/2026-07-26-code-mode-live-parallel-dispatch.md
Tianyi Cui f9cc62266c fix(tools): single ordered driver lane for the sub-dispatch scheduler; validate the cap
Responding to ds-review-bot round 2 on #658 (three critical findings, one
warning — all rooted in the pump/commit split racing ordered stages):

- ONE driver lane now owns every ordered stage: the start append, prepare
  (pre-execute/guards), and the head-of-line commit (post-execute, context
  deferral, settle append). start() is awaited before the next entry can
  start, so concurrent submissions can no longer run pre-execute pipelines
  concurrently; only the around-dispatch/body stage overlaps, matching the
  native loop's fillPool sequencing.
- An exclusive call's barrier now holds through its COMMIT: later starts
  wait for the exclusive pipeline (post-execute included) to finish, the
  native exclusive-group semantics.
- drainDispatches() awaits the driver run itself, so a commit already
  mid-flight when the program returns is drained before run_code closes
  the turn — the settle event and deferred contexts land inside it.
- maxParallelSubCalls is resolved and validated at construction (positive
  integer), so direct construction can no longer wedge the pool with 0.

New tests: overlapping-submission ordered-prepare, barrier-through-commit,
drain-mid-commit, cap rejection. 96 keyless snapshots replay unchanged;
Agent Note updated (both languages).
2026-07-26 15:26:52 +08:00

5.2 KiB
Raw Blame History

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-start event, 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 existing tool/code-dispatch settles the pair (same subCallId); every started call settles exactly once (aborts settle as isError outcomes through the pipeline). Timing = the two events' time fields. Both stay log-only; model context is untouched; format stays v0.
  • Bridge scheduler: submitted calls are classified at start time via registry.executionMode (the SAME fail-closed isConcurrencySafe contract the loop uses) and start strictly in submission order. One single-lane driver owns every ORDERED stage — the start append, prepare (pre-execute/guards), the head-of-line finalize/finish commit (post-execute + context deferral + settle append) — so ordered policy stages never overlap each other and only the around-dispatch/body stage runs concurrently, exactly the native loop's sequencing (fillPool awaits startCall then commitReady). Consecutive parallel-classified calls overlap up to maxParallelSubCalls (a Config field validated by the Loader schema AND re-validated at direct construction, default 10 — the loop scheduler's own default; 1 restores serial dispatch); an exclusive call drains the pool, runs alone, and holds its barrier until its COMMIT completes (post-execute included), like a native exclusive group. Run settlement aborts in-flight dispatches and abandons queued-unstarted ones (binding rejection, no events), then drains to quiescence — including a commit already mid-flight when the program returned — before the outer result closes the turn.
  • Client: CodeSubCall widens to RunningToolCall | 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's time as callTime (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 with await) — 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.