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
2026-06-03 15:10:16 +02:00
committed by GitHub
parent 3389fa554e
commit 7db18c8241
5 changed files with 105 additions and 1 deletions
+8
View File
@@ -348,6 +348,10 @@ impl GoalRuntimeHandle {
budget_limited_goal_disposition: BudgetLimitedGoalDisposition,
) -> Result<Option<AccountedGoalProgress>, String> {
let accounting = self.accounting_state();
let _accounting_permit = accounting
.progress_accounting_permit()
.await
.map_err(|err| err.to_string())?;
let Some(snapshot) = accounting.progress_snapshot(turn_id) else {
return Ok(None);
};
@@ -398,6 +402,10 @@ impl GoalRuntimeHandle {
budget_limited_goal_disposition: BudgetLimitedGoalDisposition,
) -> Result<Option<AccountedGoalProgress>, String> {
let accounting = self.accounting_state();
let _accounting_permit = accounting
.progress_accounting_permit()
.await
.map_err(|err| err.to_string())?;
let Some(snapshot) = accounting.idle_progress_snapshot() else {
return Ok(None);
};