fix(sleep): thread-safe backend cache + redact exports - #251
Conversation
- Guard CliBackend _cache/_tokens with a lock on the opt-in parallel replay path (SKILLOPT_SLEEP_WORKERS>1) so a concurrent miss cannot corrupt state or lose the token cost metric; the model call stays outside the lock so parallel workers still overlap. - Redact report.json (redact_secrets) before staging. - Redact harvest --output and --json exports (_redact_deep). - Add tests for CliBackend caching/thread-safety.
|
Please add boundary-level tests for both harvest output/file and |
Address maintainer review on microsoft#251: - Use redact_secrets (mapping-key aware) instead of _redact_deep for harvest --output/--json and handoff exports, so nested api_key/token mappings are redacted (not just bare string leaves). - Redact report_md before writing it alongside report.json. - Add boundary tests (nested api_key/token + report.md).
Address maintainer review on microsoft#251 (thread-safety): - Add _cache_get/_cache_pop/_cache_pop_if locked helpers and route the Pi and OpenCode _cached_call overrides through them (they previously read/pop the cache outside the lock). - tokens_used() now reads _tokens under the lock. - Popping a failed entry is conditional (_cache_pop_if): a failed caller only drops its own empty value, never another worker's just-stored success. - Add tests: barrier-forced overlapping misses stay consistent, and pop-if does not delete a successful entry.
Address maintainer review on microsoft#251 (last thread-safety item): - Record each model call's token delta on the calling thread (thread-local), so parallel replay_one() charges its own cost instead of a before/after global total that an overlapping worker inflates. - replay_one reads backend.token_delta() (falling back to the text-length heuristic for backends that don't track tokens). - Add tests for call-local and thread-isolated token deltas.
Address maintainer review on microsoft#251 (deepen thread-safety): - _cached_call no longer caches empty (transient-failure) results and prefers a concurrently cached success, so an empty/duplicate cannot clobber or delete another worker's successful entry. - Add a Pi subclass-level concurrency test (barrier-forced empty-vs-success) asserting the success survives.
|
Yifan Yang (@Yif-Yang) — thank you for the thorough review and guidance! I've addressed the feedback:
Thanks again for the detailed review! |
|
Yifan Yang (@Yif-Yang) — thank you for the careful review and guidance. In fact I've done almost all of these submissions through DSH, which is exactly why I have a bold idea. Across your recent PR reviews I noticed that you consistently apply a set of quality baselines: fail-closed handling, structure-aware and boundary-consistent redaction, thread-safety with call-local accounting, validating against real contracts, PR hygiene, and so on. I'd like to distill that into a reusable |
|
Thanks — several original races and the
Please add boundary-level stdout coverage for mapping-key secrets, a same-thread miss→hit regression, a barrier-forced same-key concurrent-miss test that checks paid-call accounting, and a |
- Redefine _redact_deep to delegate to the key-aware redact_secrets walker so
{"api_key": "x"} is scrubbed at every boundary (--json, digests/snapshot
files, gate_trials, extra, display), not just the --json link.
- Reset _thread_local.delta on cache hit so a later hit doesn't reuse the
previous call's delta.
- Charge every real call's tokens on a concurrent miss (the dedup worker used
to be free, undercounting).
- Add DualBackend.token_delta() so replay_one() reads the target's call cost.
- Regressions: cache-hit delta reset, barrier-forced concurrent charge,
DualBackend token_delta, key-aware _redact_deep.
- The barrier-forced concurrency tests waited 5s for all workers to reach the barrier; under a slow/loaded CI that can break the barrier mid-test and turn a pass into a spurious failure. Raise the wait to 15s (no semantic change).
|
Already addressed the review feedback and updated this branch (#251):
Also added a comment on |
Document that token_delta() is target-only by design (replay drives the target), that the optimizer only appears in replay via the rare model-judge fallback (rule/exact/answer tasks are scored locally, 0 tokens), and that the aggregate tokens_used() still counts both sub-backends so the total is not undercounted.
|
Thanks — the original cache-hit, concurrent-call, boundary-redaction, and |
…daction test - Set the thread-local delta in every attempt_with_tools override that charged _tokens directly (Claude CLI, OpenCode, Codex, Cursor), so replay_one() reads real call-local usage instead of falling back to a response-length estimate. - Update the stale test_redact_deep_loses_mapping_key_context to assert redaction (it was asserting the old leak bug). - Add tool-replay regressions: attempt_with_tools sets call-local delta, and the dual-backend path surfaces the target's delta.
|
Thanks for the re-review. Addressed both blockers: the stale est_redact_deep_loses_mapping_key_context now asserts redaction (was asserting the leak), and every �ttempt_with_tools override that charged _tokens now sets the call-local thread delta (Claude CLI, OpenCode, Codex, Cursor), so |
- Add CliBackend._record_cost(prompt, response) as the single path to charge an inference's token cost (aggregate _tokens under _lock + call-local _thread_local.delta), so no path under- or over-counts. - Route _cached_call miss, all attempt_with_tools overrides (Claude/OpenCode/Codex/Cursor), and reflect through it; the OpenCode error path keeps its prompt-only charge. - No behavior change: identical delta model, just centralized — removing the duplicated len//4 accounting that caused the microsoft#251-class bugs to recur.
|
Thanks — the tool-aware paths now set a call-local delta, but two accounting issues remain. |
|
Thanks to Teacher Yifan for the careful review, please wait a moment. |
…unting helper Address the review: all token charging now routes through one locked helper (_record_cost), and the call-local delta is reset on every no-call / early-return path (_reset_call_delta), so a reused worker never reports a previous call's token count — covering the OpenCode disabled-tool-replay early return, cache hits, and the start of every tool-aware path. Add regressions: barrier-forced concurrent _record_cost (no lost updates) and same-thread prior-call -> disabled-tool-replay delta reset.
- Add _record_delta(delta) and route the Azure/OpenCode real-usage accounting through it, so those backends also set the call-local _thread_local.delta — the last accounting path that did not (replay_one() saw 0/stale and fell back to a length estimate for these backends).
|
Thanks for the re-review - both accounting issues are resolved (commits 3ef661, c8ff7f8, 8a17aa6):
|
|
Thanks — the previous early-return and locking issues are fixed, but one accounting blocker remains on
Minimal reproduction with a fake chat-completions response reporting 10 prompt + 20 completion tokens, a 400-character prompt, and a 40-character response: A two-attempt reproduction (7 tokens on an empty response, then 11 on success) similarly reports: Thus both aggregate cost and Please establish one owner for accounting: preferably have Please add fake-client regressions for both Also, |
A backend that self-reports provider usage (AzureOpenAI/AzureResponses) must not be charged twice: _cached_call/_reflect now skip the len//4 estimate when the call already recorded its own exact usage (via a _charged_in_call marker), so a 30-token provider usage is charged once as 30, not as the ~110 estimate. - AzureOpenAI/AzureResponses _call accumulate usage across every paid attempt and record _record_delta(total) once; the call-local delta equals that increment. - OpenCodeCliBackend.attempt_with_tools error path routes prompt-only cost through _record_delta (no manual _tokens / _thread_local.delta update). - The _call str return contract is preserved, so llm_miner/rollout/slow_update keep working and their Azure cost stays accounted. - Added fake-client regressions for both Azure backends (single call, empty-retry + success accumulation, cache-hit no-charge) + OpenCode error-path.
0712e44 to
c72b1b4
Compare
Move _charged_in_call from a shared instance attribute to _thread_local.charged_in_call so parallel workers can never read another worker's marker; the marker already gets reset to False before every _call on the current thread.
|
Yifan Yang (@Yif-Yang) — addressed the remaining accounting blocker on Summary:
Added fake-client regressions ( |
|
Thanks for the single-owner accounting fix. Re-reviewing Offline fake-client reproduction through
Please finalize the accumulated usage on every exhausted-retry exit (as the Responses path already does), while distinguishing genuinely unavailable usage from known zero/known partial usage. Add an empty-paid-response -> terminal-error regression, including the cache/aggregate delta checks. The full shipped suite passes (1521 passed, 9 skipped); this added negative-path reproduction fails. |
AzureOpenAI._call recorded usage_total only when last_exc was 'empty-response'; if the final attempt raised (after a paid empty response), it returned without _record_delta, so _cached_call substituted its len//4 estimate and dropped the known paid usage. Now always record the accumulated usage + set the marker on any exhausted-retry exit (usage_total 0 = genuinely no paid usage). Regression: empty-paid-response -> terminal-error keeps the exact 7 tokens.
|
Yifan Yang (@Yif-Yang) — fixed on |
|
Thanks for There is still a separate backward-compatibility regression in I reproduced this offline with a minimal The independent matrix covers direct/wrapped ( Please introduce the call-local accounting contract compatibly, migrate supported implementations/wrappers or provide an appropriate compatibility path, and add these existing-backend regressions alongside the parallel-replay tests. Do not simply restore unconditional shared-total subtraction for concurrent CliBackend calls, since that would reintroduce the race this PR is meant to solve. Accurate reported per-attempt cost must not silently become a different estimate just because an existing backend lacks the new optional method. |
…()-only backends replay_one no longer substitutes a text-length estimate when a backend implements only the older tokens_used() contract (no token_delta): it reports the same-thread before/after total difference instead, and DualBackend does the same for a target that lacks token_delta (snapshotted in attempt/attempt_with_tools). CliBackend and DualBackend-with-modern-target keep the thread-safe per-call delta, so the shared total is still never reduced by subtraction for concurrent CliBackend calls. Added existing-backend regressions (direct + DualBackend, tokens_used-only).
|
Yifan Yang (@Yif-Yang) — fixed on
|
A replay served from the backend cache has no new model call: _cached_call() resets the per-thread delta to zero and the aggregate ledger is unchanged. replay_one() was still treating any zero as 'backend does not track tokens' and substituting a text-length estimate, so ReplayResult.tokens disagreed with the backend ledger (102 vs 0 in reproduction). With token_delta() present, the returned cost is authoritative - including a known zero cache hit. Only backends without the per-call interface fall back to before/after totals and the length estimate.
|
Yifan Yang (@Yif-Yang) Follow-up to With |
|
Re-reviewed The cache/accounting/export selection, including the independent regressions, passes 35 tests on Linux/Python 3.11. I am not continuing to treat those earlier reproductions as unfixed. One useful follow-up is to clarify the concurrency contract for legacy cumulative-only targets. The new inline note about a shared Official CI for this exact head is still awaiting maintainer approval. The local results resolve the cited accounting regressions, not the final full-review/CI merge gate. |
The cumulative-only fallback in DualBackend.token_delta() is sequential-only: `_target_tokens_before` is shared instance state, so two workers overlapping in one DualBackend overwrite each other's baseline. That is not the same guarantee as a target that reports a thread-local per-call delta, and it is not the same as a bare legacy backend (whose before/after snapshot replay_one takes on the worker's own thread). replay_batch()'s docstring now lists the four supported combinations, the DualBackend constructor note and its token_delta() fallback say which branch is sequential-only, and replay_one()'s comment no longer implies that any token_delta()-bearing backend is thread-safe. Tests: a regression for the modern path - replay_batch(workers=4) through a DualBackend over a target with a thread-local delta must produce exactly the same per-task costs as the sequential run, with the tasks spread far enough apart in size that the check is not vacuous.
|
Yifan Yang (@Yif-Yang) — addressed on Documentation of the supported combinations:
Regression for the modern DualBackend parallel path:
Verification on Linux / Python 3.11, on the merge with
Nothing here claims thread-safety for the cumulative-only fallback, and no test asserts it. |
…ckends The first version of this contract filed a bare backend with only the older tokens_used() contract under "safe to share". It is not: replay_one differences a counter that the other workers are also spending, so an overlapping worker's tokens land in this task. Two workers against a backend where every attempt costs 37 report [74, 37]. Only a thread-local per-call delta is safe to share; every cumulative-total shape over-counts when workers overlap. The docstring now says that and names the failure direction - budgets see more spend than actually happened - rather than implying that legacy backends are fine. The parallel regression did not bite either: with no real overlap, a backend that had lost its thread-locality still produced costs identical to the sequential run. The fake target now blocks on a barrier until every worker is in flight, so the assertion fails when that locality goes away. Verified by substituting a cumulative delta (test fails) and by five consecutive runs with the real one (test passes).
|
Yifan Yang (@Yif-Yang) — correcting myself on
So the contract now reads: only a thread-local per-call delta is safe to share — The regression was weaker than I claimed as well. It compared a parallel run against a sequential one, but with no real overlap a backend that had lost its thread-locality still produced costs identical to sequential, so it would not have caught the regression it was written for. The fake target now blocks on a barrier until every worker is in flight. Verified both ways: substituting a cumulative delta fails the test ( Re-verified on Linux / Python 3.11 (scratch branch, deleted afterwards; workflow file not part of this PR):
If you would rather have the guard than the documentation — |
Sleep-cycle hardening.
tokens_used()contract keeps its real per-attempt cost.Parallel-replay accounting contract
Only a thread-local per-call delta is safe to share across workers:
CliBackend, and aDualBackendwhose target definestoken_delta(). Everything built on a cumulative total is sequential-only and over-counts when workers overlap - the bare legacy backend as much as theDualBackendfallback, becausereplay_onedifferences a counter the other workers are also spending. Measured with a barrier-forced overlap, where every attempt really costs 37 tokens:replay_batch()'s docstring now lists the supported combinations,DualBackend's constructor andtoken_delta()say which branch is sequential-only, andreplay_one()no longer reads as "anything with atoken_delta()is thread-safe". A regression covers the modern path: the fake target blocks on a barrier until every worker is in flight, so the test fails if that call-locality is lost - verified by substituting a cumulative delta (it fails) and by five consecutive clean runs with the real one.Test plan
tests/test_cli_backend_cache.py(20 passed),tests/test_azure_usage_accounting.py(8 passed),tests/test_export_redaction.py(3 passed)action_requiredpending maintainer approval