Skip to content

feat(budget): v1.0 budget cap core; version 1.0.0 - #36

Merged
moneytool merged 2 commits into
mainfrom
v1-budget-core
Oct 8, 2026
Merged

moneytool merged 2 commits into
mainfrom
v1-budget-core

Conversation

@moneytool

Copy link
Copy Markdown
Owner

Implementation step 1 of the v1.0 budget cap design (§10). This adds aegis_core.budget as a library. Nothing calls it yet: the hook integration (step 2) and the aegis budget status|check commands 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: loads budget.yaml.
    • The file is signed, and its principal must hold the new budget class.
    • unit is required: usd or tokens.
    • Settings: session and project/day limits with warn_at; the day's time zone tz; agents (only listed agents are measured); on_unknown_log; on_unknown_price; signed pricing overrides; copilot.premium_requests.
    • Every shape problem is a load error.
  • prices.yaml and pricing.py: the price table.
    • Anthropic, OpenAI and Google list prices, read 2026-10-07 from the vendors' own pages.
    • Choices that keep estimates from running low: Anthropic cache writes use the 1-hour rate; Google uses the regular (non-promotional) price and the >200k-token tier.
    • An unpriced model gets the highest rate per billing category: from its vendor's entries, or from the whole table if the vendor is unknown. This is labelled as an estimate.
    • A model whose entry lacks a category falls back for that category only.
    • Usage is stored as tokens per model, so adding a signed override re-prices earlier usage.
  • logs.py: one usage reader per agent, based on what each agent actually writes:
    • Claude Code: each request is counted once per (message.id, requestId), because usage is repeated on every content block. Sub-agent logs in <session>/subagents/ are read as part of the parent.
    • Codex: usage is the difference between successive token_count running totals. Sub-agent rollouts are linked through parent_thread_id.
    • Gemini CLI: messages are keyed by ID, because Gemini rewrites them. The working directory comes from .project_root.
    • OpenCode: opened read-only, and only the session and message tables are read. Rows are updated in place, so each is keyed by message ID. Child sessions are included.
    • Copilot CLI: counts premium requests, from running totals that stay monotonic across resumed sessions.
  • accounting.py: implements §4.1 as reviewed.
    • Session records live in the cache. A durable revision counter and the session lock live under $XDG_STATE_HOME.
    • The project/day map is merged entry by entry and keeps the newer revision. The lock order is always session → project.
    • A session inventory is kept with checkpoints at most once a minute.
    • Rebuilds read each session under its own lock only, then merge, and report the day as complete, partial or unknown.
    • Usage is bucketed by stable recorded time; usage with no recorded time is counted for the session only.
    • A refresh on a linked sub-agent's session is redirected to its parent.
  • evaluate.py: decides allow / warn / deny. The result carries the measures, the observed overshoot, estimated-price warnings or deny, and on_unknown_log handling.
  • Example authority.yaml: gives admin the budget class. 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).

  • Log readers run against redacted samples of real logs from this machine in tests/fixtures/budget/. Only structural and usage fields are kept, with paths, IDs and content replaced.
  • Every §10 step-5 validation case is covered, including the review round-3 case: deleting only the session cache while a high-revision project entry exists, then appending usage. The next hook updates the total immediately, and a stale rebuild cannot overwrite it. The same runs with the log replaced and with the counter deleted.
  • Also covered: crash recovery, rebuilds racing four hook threads (checking for deadlock and lost usage), linked sub-agents for Claude and Codex, missing history (partial vs unknown, never "0 missing"), the midnight rebuild, and the policy time zone.
  • I checked the accounting tests by breaking the code on purpose (accepting any revision; taking revisions from the cache record only; bucketing by read time). Each break failed at least one targeted test.

Measured locally

  • Every reader ran against all the real logs on this Mac with no unreadable records: Claude Code 58 sessions, Codex 72, Gemini 3, OpenCode 5, Copilot 7.
  • A no-change refresh of this long Claude session took 1.7 ms, against the 20 ms target. The formal latency benchmark is step 3.

…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 moneytool left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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). With on_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.

Comment thread src/aegis_core/budget/accounting.py Outdated
try:
with open(path) as f:
for line in f:
with contextlib.suppress(json.JSONDecodeError):

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 13362d6.

  • Read status: read_inventory now returns the valid lines together with a status: ok, missing, unreadable or corrupt. 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 also unknown. The status is kept as ProjectDay.history and 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.

Comment thread src/aegis_core/budget/pricing.py Outdated
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:

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
@moneytool
moneytool merged commit 0ef870d into main Oct 8, 2026
9 checks passed
@moneytool
moneytool deleted the v1-budget-core branch October 8, 2026 22:44
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.

1 participant