fix(tools): bound the shaped-append side channel; total error containment; recorded spill snapshot

Responding to ds-review-bot round 2 on #661:

- logWork is bounded: past maxParallelSubCalls pending shaped-append tasks
  the ordered commit lane holds (Promise.race drains one), so a slow spill
  backend backpressures the run instead of accumulating unbounded pending
  I/O and retained results. Tasks self-remove on settlement; run
  settlement still drains every task inside the open turn. New spill test
  drives three oversized reads against a hung backend at cap 1 and proves
  the third dispatch cannot start until a save drains.
- shapeDispatchLog's catch uses errorMessage() (total), so a thrown value
  with a throwing toString cannot escape the containment and lose the
  settle event.
- CodeDispatchLog.content documented as the RENDERED result projection
  (native tool/result vocabulary), not what the program received — the
  program gets the structured value; doc pair + type-equiv re-synced.
- New RECORDED tui-agent snapshot scenario code-mode-dispatch-spill: the
  real Loader-visible composition (worker runtime + spill-local + policy)
  drives an oversized bash sub-call end-to-end; replay proves the durable
  dispatch copy is bounded to preview + locator while the program value
  stays whole (the outer result carries just the line count).

Agent Note updated (both languages).
This commit is contained in:
Tianyi Cui
2026-07-26 18:29:09 +08:00
parent 442a3dd884
commit c3c10820ba
14 changed files with 384 additions and 23 deletions

View File

@@ -359,12 +359,9 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
// entries, awaits the live pool, and drains the ordered commit lane —
// including a commit already in progress when the program returned.
await drive()
// Every settle's shaped append lands inside the open run_code turn.
while (logWork.size > 0) {
const pending = [...logWork]
await Promise.allSettled(pending)
for (const done of pending) logWork.delete(done)
}
// Every settle's shaped append lands inside the open run_code turn
// (tasks self-remove on settlement).
while (logWork.size > 0) await Promise.allSettled([...logWork])
}
// Read through a call, not a bare property: the abort state genuinely
@@ -406,7 +403,7 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
: { isError: false, value: result.value })
const agent = exec.agent
if (agent === undefined) return
logWork.add((async () => {
const task: Promise<void> = (async () => {
// The durable copy may be reshaped (e.g. spilled to a preview +
// locator) by the log-shaping waterfall; the program's value
// and the model contract are untouched.
@@ -428,7 +425,8 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
isError: result.isError,
content: logged,
})
})())
})().finally(() => { logWork.delete(task) })
logWork.add(task)
}
pendingQueue.push({
flight: Promise.resolve(),
@@ -470,6 +468,12 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
exec.deferContext(context)
}
settle(result)
// Backpressure on the shaped-append side channel: pending log
// tasks (each retaining a full result while a slow backend
// stores it) are bounded by the pool cap — beyond it the
// ordered lane waits, so later sub-calls cannot start and
// pending I/O/memory cannot grow without bound.
while (logWork.size > maxParallel) await Promise.race(logWork)
},
})
wakeup()