Skip to content

fix(consolidation): an unreported provider cost is unknown, not a free 0.0 (#733, slice 2) - #962

Merged
cdeust merged 2 commits into
mainfrom
fix/733-unknown-provider-cost
Nov 10, 2026
Merged

cdeust merged 2 commits into
mainfrom
fix/733-unknown-provider-cost

Conversation

@cdeust

@cdeust cdeust commented Nov 10, 2026

Copy link
Copy Markdown
Owner

Summary

Slice 2 of #733 (slice 1: #895). An unknown provider cost was reported as 0.0, so a headless-authoring or distillation cycle whose claude --print calls reported no cost read as a free cycle.

Root cause. _parse_invoke_response (mcp_server/handlers/consolidation/claude_invoke.py) returned cost_usd=0.0 on a non-zero exit, empty stdout, non-JSON stdout or an absent/null total_cost_usd; _claude_invoke did the same after a timeout. cycle_budget_charge summed those zeros into usd_spent (probe reproduced in docs/audits/operation-consumption-inventory-2026-11-05.md).

Change.

  • InvokeResult.cost_usd is float | None: None when the CLI reported no cost. A reported 0.0 stays 0.0; a spawn failure (no child process ran) stays a known 0.0.
  • cycle_budget_charge(budget, None) increments CycleBudget.unpriced_calls instead of adding to usd_spent.
  • CycleSummary, DistillCycleSummary and the consolidate response's headless_authoring block report unpriced_calls beside usd_spent (usd_spent is a lower bound whenever unpriced_calls > 0). scripts/groomer.py serialises the summaries with asdict, so its journal carries the field without a change.
  • docs/groomer-scheduling.md states the meaning of the two fields.

Refs #733 (the issue stays open: slices 3–5 remain, listed in the issue comment).

Type of change

  • Bug fix (non-breaking change which fixes an issue)

Test plan

  • New: tests_py/handlers/consolidation/test_unknown_cost_733.py (9 tests): the five unreported-cost outputs give None; a reported 0.0 stays 0.0; a reported cost is read; an unpriced charge is counted, not summed, and does not exhaust the USD cap; a distillation cycle of three unpriced calls reports unpriced_calls == 3, usd_spent == 0.0. Mutation caught: restoring or 0.0 fails the five parametrised cases.
  • pytest tests_py/handlers tests_py/hooks --deselect tests_py/hooks/test_entry_auto_dispatch.py: 2868 passed, 113 skipped (PostgreSQL), 0 failed.
  • tests_py/hooks/test_entry_auto_dispatch.py: 2 failures that reproduce identically on origin/main (7b81ab1) in this container (torch not installed: the PyTorch index is unreachable here); not caused by this diff.
  • ruff check / ruff format --check (0.17.3, from uv.lock) on the changed files: clean. python scripts/check_craftsmanship.py: OK.

Audit notes

  • Not verified: a live claude --print run (no CLI call made here), the PostgreSQL-backed tests (skipped), pyright (not run locally; CI runs it).

Breaking changes

InvokeResult.cost_usd and DistillDrainResult.cost_usd can now be None. Every in-repo consumer goes through cycle_budget_charge, which accepts it; the JSON outputs gain the unpriced_calls key.


Generated by Claude Code

…e 0.0 (#733, slice 2)

_parse_invoke_response returned cost_usd=0.0 when `claude --print` exited
non-zero, printed nothing, printed non-JSON or omitted total_cost_usd, and
_claude_invoke did the same on a timeout. CycleBudget summed those zeros,
so a cycle whose calls reported no cost read as a free cycle in the
consolidate response and the groomer journal.

InvokeResult.cost_usd is now float | None: None when the CLI reported no
cost (a reported 0.0 stays 0.0; a spawn failure, where no child ran, stays
a known 0.0). cycle_budget_charge counts a None as an unpriced call
instead of adding it, and CycleSummary, DistillCycleSummary and the
consolidate `headless_authoring` block report `unpriced_calls` beside
`usd_spent`, which is a lower bound whenever unpriced_calls > 0.

Refs #733

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018kyZYq7srjBjYA18eWLVgL
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 10, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-10T05:32:28.341925Z 5111e02 New commits
🔒 Security Review ✅ Completed 2026-11-10T05:33:59.235076Z 5111e02 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@claude

claude Bot commented Nov 10, 2026 •

Copy link
Copy Markdown

Claude finished @cdeust's task in 2m 19s —— View job


Review 1 of 3: PR #962

  • Read the diff and every consumer of cost_usd / usd_spent
  • Run five reviews (quality, security, performance, test coverage, docs accuracy)
  • Run lint, the craftsmanship gate and tests locally: not done, because the commands needed approval in this runner. CI (Pipeline) was still running.
  • Post the verdict: review comment

No Blockers. Three Notes are in the review comment: the _reported_cost TypeError and input validation, the stale ADR-0361 text, and the untested _claude_invoke timeout and spawn paths.

Verdict: approve

@claude

claude Bot commented Nov 10, 2026

Copy link
Copy Markdown

Review 1 of 3: PR #962

I ran five reviews (quality, security, performance, test coverage, docs accuracy). None found a Blocker. Every consumer of cost_usd goes through cycle_budget_charge or asdict, so None is handled everywhere. The USD cap still trips exactly where it did before: an unreported cost adds nothing to usd_spent, as the old 0.0 did. No subprocess runs that did not run before, and no existing test asserts the old 0.0 failure behaviour.

Not verified here: I could not run pytest, ruff or the craftsmanship gate in this runner because the commands need approval. CI (Pipeline) was still running when I posted this.

Notes, no action before merge

  • claude_invoke.py:74: the _reported_cost docstring says it raises ValueError, but float() raises TypeError for a list or dict, and that is not caught at :104. {} and [] used to give 0.0 via or 0.0; now they escape _parse_invoke_response. Rejecting non-finite, negative and bool values there would also stop a NaN from disabling the cap; that hole was already there.
  • docs/adr/ADR-0361-…headless-authoring-py.md:113-115 and wiki/adr/migration/0361-…:111 still say cost_usd is 0.0 when the field is absent or the JSON fails to parse. That is no longer true.
  • Tests: neither _claude_invoke path (timeout → None, spawn failure → 0.0) is tested. Nothing checks that unpriced_calls appears in the headless_authoring block of the consolidate response.

Verdict: approve

cdeust commented Nov 10, 2026

Copy link
Copy Markdown
Owner Author

Merci pour la revue 1 sur 3. Aucun Blocker, donc pas de push (ADR-1119). Voici ce que je fais des trois Notes :

  • TypeError sur un total_cost_usd qui serait un objet ou une liste : c'est vrai, et c'est ce diff qui l'introduit ({} or 0.0 donnait 0.0). En pratique, le CLI émet un nombre ou rien, donc la régression est théorique. Le correctif, qui consiste à capturer TypeError et à rejeter les valeurs non finies, négatives ou booléennes (ce qui comble aussi le trou NaN préexistant), part dans la tranche 3 de feat: expose local operation receipts with attributable resource consumption #733.
  • ADR-0361 (docs/adr/…, wiki/adr/migration/…) : les deux fichiers décrivent encore 0.0. Je les mets à jour dans la même tranche.
  • Tests _claude_invoke (timeout → None, spawn → 0.0) et clé unpriced_calls dans la réponse de consolidate : reportés aussi, car ce sont des Notes et elles élargiraient la surface de la PR.

Generated by Claude Code

The mutation gate of check `unit` (run 42758075984) selects a module's tests by
the dotted import (scripts/ci_mutation_diff.py select_tests). The #733 test
reached cycle_types only through headless_authoring's re-export, so
cycle_budget_charge's mutants ran against the throttle test alone and survived;
no test imported cycle_orchestration; nothing called _claude_invoke's early
returns; and no test read the headless_authoring stanza's unpriced_calls key.

The budget test now imports cycle_types itself, and a new file pins the no-child
(known 0.0) and killed-child (unknown) returns of _claude_invoke, the
_summarize_cycle roll-up, and run_wiki_maintenance's unpriced_calls key.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018kyZYtAKnComJzh2PytToW

cdeust commented Nov 10, 2026

Copy link
Copy Markdown
Owner Author

CI autopilot, tentative 1 : la gate de mutation de unit (run 42758075984, ubuntu-26.04).

Diagnostic : la gate choisit les tests d'un module par leur import pointé (scripts/ci_mutation_diff.py select_tests). Or test_unknown_cost_733.py n'atteignait cycle_types qu'à travers le ré-export de headless_authoring. Résultat : les mutants 2 à 4 de cycle_budget_charge ne tournaient que contre le test de throttle, et ils ont survécu. En plus, aucun test n'importait cycle_orchestration, rien n'appelait les deux retours anticipés de _claude_invoke (mutants 29 et 30 non tués), et aucun test ne lisait la clé unpriced_calls de la strophe headless_authoring (mutants 162, 181 et 182 de run_wiki_maintenance). merge échoue seulement parce que unit a échoué.

Commit poussé : 5111e02 fix(tests): …. Aucun changement de code source : le test du budget importe maintenant cycle_types, et test_unpriced_calls_surfaces_733.py couvre trois choses. D'abord les deux retours de _claude_invoke : aucun enfant lancé donne un coût connu de 0.0, un enfant sans réponse donne un coût inconnu. Ensuite le cumul _summarize_cycle. Enfin run_wiki_maintenance["headless_authoring"]["unpriced_calls"].

Reçus locaux :

Non vérifié : la suite complète, parce que torch n'est pas téléchargeable ici et PostgreSQL est absent. Les tests gatés par PostgreSQL ont donc été sautés. Le verdict de la CI sur 5111e02 n'est pas encore connu. La branche a aussi 1 commit de retard sur main. Le rebase avec --force-with-lease a été refusé par les permissions de cette session.


Generated by Claude Code

cdeust commented Nov 10, 2026

Copy link
Copy Markdown
Owner Author

Échec unit (run 42758075984, job 129639582278) : les 12360 tests passent ; c'est la gate de mutation par diff (ADR-1122) qui échoue. Des mutants survivent dans claude_invoke._claude_invoke, cycle_types.cycle_budget_charge et la clé unpriced_calls de wiki_maintenance, et aucun test n'importe cycle_orchestration. Cause : les tests de #733 ne joignaient ces modules qu'à travers la ré-exportation de headless_authoring, alors que select_tests choisit les tests d'un module par son import pointé.

J'avais préparé le même correctif, mais une autre session a poussé 5111e02 sur la branche juste avant moi, avec des tests équivalents. Je n'ai donc rien poussé, pour ne pas doublonner. Reçu local sur 5111e02 (GITHUB_BASE_REF=main python scripts/ci_mutation_diff.py, mutmut 3.8.1) : >>> mutation: 5 file(s), 0 failed, 0 untested, rc=0.

Non vérifié ici : le reste du check unit sur ce head, qui doit tourner avec PostgreSQL. La CI de 5111e02 est en file d'attente.


Generated by Claude Code

@claude

claude Bot commented Nov 10, 2026 •

Copy link
Copy Markdown

Claude finished @cdeust's task in 2m 17s —— View job


Review 2 of 3: PR #962

  • Find the commits pushed after 29e4166: only 5111e02, which changes tests only. Review 1 had no Blockers to re-check.
  • Run five reviews (quality, security, performance, test coverage, docs accuracy) on that commit
  • Run pytest, ruff and the craftsmanship gate locally: not done, because the commands needed approval in this runner. Pipeline was still queued.
  • Post the verdict: review comment

No Blockers. Three Notes are in the review comment: two tests share the value 2 across fields, one docstring phrase is too narrow, and AttributeError on non-object JSON belongs with slice 3.

Verdict: approve

@claude

claude Bot commented Nov 10, 2026

Copy link
Copy Markdown

Review 2 of 3: PR #962

Scope: commit 5111e02, the only commit after 29e4166. It changes tests only: test_unknown_cost_733.py now imports from cycle_types directly, and test_unpriced_calls_surfaces_733.py is new. Review 1 found no Blockers, so there was nothing to re-check.

I ran five reviews (quality, security, performance, test coverage, docs accuracy). None found a Blocker. The patched names and call signatures match the production code. No test starts a real claude process, touches the network or the wiki root, or reads a live database. These mutations each make a test fail: the timeout path returning 0.0, the spawn path returning None, _summarize_cycle dropping unpriced_calls, and wiki_maintenance omitting the key. The mutation-gate comment matches _imports/select_tests in scripts/ci_mutation_diff.py.

Not verified here: I could not run pytest, ruff or the craftsmanship gate in this runner because the commands need approval. Pipeline was still queued when I posted this.

Notes, no action before merge

  • test_unpriced_calls_surfaces_733.py:82-87: drains_attempted, drains_filled and unpriced_calls are all 2, so the test would still pass if wiki_maintenance.py:197 read the wrong field. Give unpriced_calls a value no other field uses.
  • test_unpriced_calls_surfaces_733.py:6: "a killed child is unknown" is too narrow. Any None from _communicate_with_timeout gives an unknown cost, and the test uses a child that exited without being killed.
  • claude_invoke.py:101-103 (outside this commit): valid JSON that is not an object (42, []) raises AttributeError on .get. It fits with the TypeError hardening planned for slice 3.

Verdict: approve

cdeust commented Nov 10, 2026

Copy link
Copy Markdown
Owner Author

Revue 2 sur 3 : approuvée, sans Blocker, donc pas de push (ADR-1119). Ses trois Notes partent dans la tranche 3 de #733 :

  • donner à unpriced_calls une valeur distincte de drains_attempted et drains_filled dans test_unpriced_calls_surfaces_733.py ;
  • élargir la docstring sur le cas « killed child » ;
  • traiter l'AttributeError sur un JSON qui n'est pas un objet, avec le durcissement TypeError déjà prévu.

Generated by Claude Code

@cdeust
cdeust enabled auto-merge November 10, 2026 07:33
@cdeust
cdeust merged commit b47c967 into main Nov 10, 2026
18 checks passed
@cdeust
cdeust deleted the fix/733-unknown-provider-cost branch November 10, 2026 07:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants