fix: serialize goal progress accounting (#26155)

## Why

Goal progress accounting can be reached from multiple completion paths
for the same thread. Each path takes a progress snapshot, writes the
usage delta, and then marks that snapshot as accounted. When two
tool-completion hooks run at the same time, they can both observe the
same unaccounted delta and charge it twice.

## What changed

- Added a per-thread progress-accounting permit to
`GoalAccountingState`.
- Held that permit across the snapshot/write/mark-accounted critical
section for active-turn, idle, and tool-finish accounting.
- Added regression coverage for parallel tool-finish hooks so a shared
token delta is charged once and only one progress event is emitted.

## Testing

- Not run locally.
- Added `parallel_tool_finish_accounts_active_goal_progress_once`.
This commit is contained in:
jif authored and GitHub committed 2026-06-03 15:10:16 +02:00
1 parent 3389fa554e
commit 7db18c8241
5 files changed
+105 -1

No files matched your search

+9
View File
@@ -291,6 +291,15 @@ impl GoalToolExecutor {
let Some(turn_id) = self.accounting_state.current_turn_id() else {
return Ok(None);
};
let _accounting_permit = self
.accounting_state
.progress_accounting_permit()
.await
.map_err(|err| {
FunctionCallError::Fatal(format!(
"goal progress accounting semaphore closed: {err}"
))
})?;
let Some(snapshot) = self.accounting_state.progress_snapshot(turn_id.as_str()) else {
return Ok(None);
};