Repository navigation
feat(budget): v1.0 budget cap core; version 1.0.0 - #36
Conversation
…ounting, evaluation; version 1.0.0 aegis_core.budget implements design v1.0 §3-§6 as a library; the hook integration and the 'aegis budget' commands follow in separate PRs. - budget.yaml loader: signed, principal needs the new 'budget' class, unit required, only listed agents measured, every shape problem a load error - dated price table (Anthropic/OpenAI/Google, 2026-10-07); per-category fallback estimates; on_unknown_price; usage kept as tokens so overrides re-price - log readers for Claude Code, Codex, Gemini CLI, OpenCode, Copilot CLI with linked sub-agents, tested on redacted real-log samples - accounting per §4.1: revisioned merge with a durable counter, session inventory, rebuild with completeness, recorded-time day buckets - evaluation: allow/warn/deny, overshoot, unknown-log handling - version 1.0.0 (unreleased); example authority gives admin 'budget'
moneytool
left a comment
There was a problem hiding this comment.
Not ready to merge yet. All 96 existing budget tests pass, but four additional local regression cases reproduce the three issues below. Existing CI is green. This review covers the core library; hook/CLI integration remains outside this PR's stated scope.
| incoming[key] = _entry(rec, day) | ||
|
|
||
| if inventory is not None: | ||
| completeness = "partial" if missing else "complete" |
There was a problem hiding this comment.
[P1] Propagate unverifiable sessions into project completeness. missing is incremented only when rec.log_missing and not rec.sources, and records with problem_total or unknown-day usage are not reflected in the project entry. Thus a missing log with cached usage, or a malformed record in another session, leaves the project marked complete. Reproduced: refresh s1 and s2, delete s2's log while retaining its session cache, delete the project/day map, then refresh s1: completeness is complete and on_unknown_log: deny returns allow. A second reproduction appends malformed JSON to s2, rebuilds, and evaluates s1: s2 has problem_total=1 but the project still returns allow. Preserve cached usage as a lower bound, but carry each contributing session's verification status in project entries and aggregate it for rebuilds and ordinary refreshes. Strict project-day enforcement must deny when any contributing session is unverifiable. Add both missing-log-with-cache and malformed-other-session regression cases.
There was a problem hiding this comment.
Fixed in 13362d6.
- Verification status per entry: each project entry now carries its session's verification status,
unverified: [log-missing | unreadable-records | unknown-time]. It is set on both ordinary refreshes and rebuilds, so a status change takes a new revision like any usage change. A checkpoint-only entry is marked[log-missing, from-checkpoint]. - Lower bound: a missing log with a cached record keeps its usage and counts toward
missing_sessions. - Evaluation: a session's evaluation looks at every other contributing session's status through
ProjectDay.unverified(exclude=self). Withon_unknown_log: deny, any unverifiable contributor denies. - Regression tests: your missing-log-with-cache case (completeness
partial, s2 flagged, strict → deny), and the malformed-record-in-another-session case run two ways: through s2's own refresh and through a rebuild.
| try: | ||
| with open(path) as f: | ||
| for line in f: | ||
| with contextlib.suppress(json.JSONDecodeError): |
There was a problem hiding this comment.
[P1] Do not treat unreadable/corrupt inventories as valid empty history. _inventory_lines silently skips malformed JSON and turns OSError into an empty list; inventory() returns that list whenever the path exists. rebuild() therefore interprets an unreadable or corrupt inventory as present and complete. Reproduced: refresh s1/s2, remove s2's log, replace the day's inventory with malformed JSON, delete the cache, and refresh s1. The project reports complete, known_sessions=0, and strict evaluation returns allow after losing s2's usage. Return inventory records together with an explicit read/validation status, retain valid checkpoints where possible, and classify unavailable or corrupt history as unknown/partial so on_unknown_log: deny fails closed. Test malformed, unreadable, and truncated inventories rather than only a deleted file.
There was a problem hiding this comment.
Fixed in 13362d6.
- Read status:
read_inventorynow returns the valid lines together with a status:ok,missing,unreadableorcorrupt. Corrupt covers malformed JSON, non-inventory objects and a truncated last line. - Completeness: a damaged history is
unknown, and valid checkpoints are still used. A corrupt day index is alsounknown. The status is kept asProjectDay.historyand shown in the evaluation message. - Regression tests: your reproduction with malformed, truncated, foreign-object and unreadable inventories, plus a corrupt index; strict evaluation denies in each case.
- Also fixed: the unreadable case showed that an inventory append failing (for example, the path is a directory) crashed the refresh. Appends are now best-effort and retried on a later hook.
| raise ValueError(f"{where}: missing price field(s) {missing}") | ||
| rates: dict[str, float] = {} | ||
| for cat, value in entry.items(): | ||
| if isinstance(value, bool) or not isinstance(value, (int, float)) or value < 0: |
There was a problem hiding this comment.
[P2] Reject non-finite prices and limits. The numeric checks accept YAML .nan because comparisons such as value < 0 are false for NaN. A signed override with input: .nan passes check_override, makes computed cost NaN, and every >= comparison in Measure.level is false, so an otherwise over-budget session returns allow. I reproduced this with usage normally costing $100 against a $10 session limit. Add math.isfinite validation for all rates and budget numbers, including _number in policy.py (USD .nan/.inf limits are also accepted), and raise a normal load error. Cover NaN and positive/negative infinity in both price overrides and limits.
There was a problem hiding this comment.
Fixed in 13362d6. math.isfinite is now checked for every rate (overrides and the built-in table), every limit (session, project_day, copilot.premium_requests) and warn_at. Any non-finite value is a normal load error. Tests cover .nan, .inf and -.inf in each place, plus a NaN in a table entry.
…aged inventories are unknown, non-finite numbers refused - project entries carry each session's verification status (log missing, unreadable records, usage with no recorded time); evaluation of any session in the project sees the others', so strict mode fails closed - inventory reads report ok / missing / unreadable / corrupt (malformed, foreign or truncated lines); damaged history or a corrupt day index is 'unknown', valid checkpoints still used; inventory writes that fail no longer crash the hook - NaN and +/-inf rejected in limits, warn_at, price overrides and the table
Implementation step 1 of the v1.0 budget cap design (§10). This adds
aegis_core.budgetas a library. Nothing calls it yet: the hook integration (step 2) and theaegis budget status|checkcommands and docs (step 3) are separate PRs. As you asked, the version is now 1.0.0, with the CHANGELOG entry marked Unreleased. The release happens only when the tag is pushed after step 4.What's in it
policy.py: loadsbudget.yaml.principalmust hold the newbudgetclass.unitis required:usdortokens.warn_at; the day's time zonetz;agents(only listed agents are measured);on_unknown_log;on_unknown_price; signedpricingoverrides;copilot.premium_requests.prices.yamlandpricing.py: the price table.logs.py: one usage reader per agent, based on what each agent actually writes:(message.id, requestId), because usage is repeated on every content block. Sub-agent logs in<session>/subagents/are read as part of the parent.token_countrunning totals. Sub-agent rollouts are linked throughparent_thread_id..project_root.sessionandmessagetables are read. Rows are updated in place, so each is keyed by message ID. Child sessions are included.accounting.py: implements §4.1 as reviewed.$XDG_STATE_HOME.evaluate.py: decides allow / warn / deny. The result carries the measures, the observed overshoot, estimated-price warnings ordeny, andon_unknown_loghandling.authority.yaml: givesadminthebudgetclass. It is re-signed with the example key and synced to_examples/.Tests
There are 96 new tests; the full suite passes locally (1,887).
tests/fixtures/budget/. Only structural and usage fields are kept, with paths, IDs and content replaced.Measured locally