Skip to content

Commit b44099d

Browse files
author
Matthew
authored
feat(cli): wire all Budget dimensions into grapharc run (#14)
--max-tokens was the only reachable ceiling. Operators can now also set --max-iterations, --max-seconds, and --max-concurrency; unset flags stay unlimited. Values appear in the --json payload and optional grapharc.toml keys.
1 parent 263c6c4 commit b44099d

5 files changed

Lines changed: 90 additions & 6 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -493,7 +493,7 @@ Re-derived on 2026-07-28 by running each item, not by reading the commit log.
493493
- **A session turn is synchronous**, and a runner claim is a claim rather than a lease — nothing reclaims a session whose runner died holding it.
494494
- **A bare model spec resolves to the paid `claude-cli` backend.** `--model mock` does not reach the scripted double; it becomes the model name `mock` on the subscription backend. Only the slash form (`mock/anything`) reaches the double. A mistyped backend *with* a slash is rejected properly, exit 2.
495495
- **`.env` is found by walking up parent directories; `grapharc.toml` is not.** The config layer refuses an upward search on purpose — a run must not be governed by a file you did not know about. The credential loader predates that decision and still searches upward, so the thing that *spends money* is discovered more eagerly than the thing that *constrains* it.
496-
- **`grapharc run` has no budget unless you give it one.** `--max-tokens` is the only ceiling; without it the gate's budget dimension is unlimited and admits a topology of any worst-case cost.
496+
- **`grapharc run` has no budget unless you give it one.** Set any of `--max-tokens`, `--max-iterations`, `--max-seconds`, or `--max-concurrency`; without them each dimension is unlimited and the gate admits a topology of any worst-case cost.
497497

498498
**Verified this pass:** `pytest` → 1,533 passed, 12 deselected (the live ones); `ruff check .` clean; all eight `grapharc demo` stages green, plus `grapharc plan` and `grapharc run`; the wheel builds and imports all 103 submodules in a clean virtualenv with `[all]`. The test count is a snapshot, not a property of the project — `pytest` re-derives it in one command, which is the only reason it is quoted.
499499

grapharc/cli/config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@
5555
"memory": str,
5656
"max_rounds": int,
5757
"max_tokens": int,
58+
"max_iterations": int,
59+
"max_seconds": float,
60+
"max_concurrency": int,
5861
}
5962

6063

grapharc/cli/graphrun.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,9 @@ def run_graph(
101101
registry_target: str | None = None,
102102
policy_path: Path | None = None,
103103
max_tokens: int | None = None,
104+
max_iterations: int | None = None,
105+
max_seconds: float | None = None,
106+
max_concurrency: int | None = None,
104107
tenant: str | None = None,
105108
trace_path: Path | None = None,
106109
run_id: str | None = None,
@@ -131,6 +134,9 @@ def run_graph(
131134
policy_path = settings.resolve_path("policy", policy_path)
132135
tenant = settings.resolve("tenant", tenant, "default")
133136
max_tokens = settings.resolve("max_tokens", max_tokens)
137+
max_iterations = settings.resolve("max_iterations", max_iterations)
138+
max_seconds = settings.resolve("max_seconds", max_seconds)
139+
max_concurrency = settings.resolve("max_concurrency", max_concurrency)
134140
document = load_topology(Path(graph_path))
135141
proposal = build_proposal(document)
136142
bundle = resolve_registry(registry_target)
@@ -153,10 +159,15 @@ def run_graph(
153159
checker = AdmissionChecker(registry=registry, edge_policy=edge_policy, trace=trace)
154160

155161
# `Budget()` is genuinely unlimited on every dimension, so with no
156-
# `--max-tokens` the budget check passes anything. An earlier comment here
157-
# claimed the opposite; an adversarial run admitted a 200-node chain whose
158-
# worst case was 400,000 tokens. The meter is real only when a ceiling is.
159-
meter = BudgetMeter(Budget(max_tokens=max_tokens) if max_tokens else Budget())
162+
# ceilings the budget check passes anything. The meter is real only when
163+
# at least one dimension is set.
164+
budget = Budget(
165+
max_tokens=max_tokens,
166+
max_iterations=max_iterations,
167+
max_seconds=max_seconds,
168+
max_concurrency=max_concurrency,
169+
)
170+
meter = BudgetMeter(budget)
160171
# D2: the admission event has to carry the run id the operator chose, or
161172
# `grapharc metrics <trace> <run-id>` finds nothing under that id.
162173
rid = run_id or uuid.uuid4().hex[:12]
@@ -179,6 +190,9 @@ def run_graph(
179190
],
180191
"run_id": rid,
181192
"max_tokens": max_tokens,
193+
"max_iterations": max_iterations,
194+
"max_seconds": max_seconds,
195+
"max_concurrency": max_concurrency,
182196
"trace": str(trace_path),
183197
**settings.provenance(policy_source=policy_source),
184198
}

grapharc/cli/main.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,9 @@ def _cmd_run(args: argparse.Namespace) -> int:
313313
trace_path=args.trace,
314314
run_id=args.run_id,
315315
max_tokens=args.max_tokens,
316+
max_iterations=args.max_iterations,
317+
max_seconds=args.max_seconds,
318+
max_concurrency=args.max_concurrency,
316319
check_only=args.check_only,
317320
config_path=args.config,
318321
as_json=args.json,
@@ -630,11 +633,33 @@ def build_parser() -> argparse.ArgumentParser:
630633
"--max-tokens",
631634
type=int,
632635
default=None,
636+
metavar="N",
633637
help=(
634638
"refuse a topology whose worst case exceeds this many tokens. "
635-
"Without it the budget dimension is unlimited and admits anything"
639+
"Without it this budget dimension is unlimited"
636640
),
637641
)
642+
run.add_argument(
643+
"--max-iterations",
644+
type=int,
645+
default=None,
646+
metavar="N",
647+
help="stop the run after this many node iterations (unlimited if omitted)",
648+
)
649+
run.add_argument(
650+
"--max-seconds",
651+
type=float,
652+
default=None,
653+
metavar="SEC",
654+
help="wall-clock run ceiling; interrupts a running node (unlimited if omitted)",
655+
)
656+
run.add_argument(
657+
"--max-concurrency",
658+
type=int,
659+
default=None,
660+
metavar="N",
661+
help="max concurrent node executions (unlimited if omitted)",
662+
)
638663
run.add_argument(
639664
"--check-only",
640665
action="store_true",

tests/test_cli.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1376,6 +1376,48 @@ def test_run_can_be_given_a_token_ceiling(tmp_path, capsys):
13761376
assert "over_token_budget" in [r["code"] for r in bounded["rejections"]]
13771377

13781378

1379+
1380+
1381+
def test_run_budget_flags_reach_json_payload(tmp_path, capsys):
1382+
"""All four Budget dimensions are settable from `grapharc run` (#5)."""
1383+
graph = _write_graph(tmp_path, _LEGAL_GRAPH)
1384+
code, payload, _ = call_json(
1385+
[
1386+
"run",
1387+
str(graph),
1388+
"--check-only",
1389+
"--trace",
1390+
str(tmp_path / "t.jsonl"),
1391+
"--max-tokens",
1392+
"5000",
1393+
"--max-iterations",
1394+
"12",
1395+
"--max-seconds",
1396+
"3.5",
1397+
"--max-concurrency",
1398+
"2",
1399+
],
1400+
capsys,
1401+
)
1402+
assert code == 0
1403+
assert payload["max_tokens"] == 5000
1404+
assert payload["max_iterations"] == 12
1405+
assert payload["max_seconds"] == 3.5
1406+
assert payload["max_concurrency"] == 2
1407+
1408+
1409+
def test_run_unset_budget_flags_stay_unlimited_in_json(tmp_path, capsys):
1410+
graph = _write_graph(tmp_path, _LEGAL_GRAPH)
1411+
code, payload, _ = call_json(
1412+
["run", str(graph), "--check-only", "--trace", str(tmp_path / "t.jsonl")],
1413+
capsys,
1414+
)
1415+
assert code == 0
1416+
assert payload["max_tokens"] is None
1417+
assert payload["max_iterations"] is None
1418+
assert payload["max_seconds"] is None
1419+
assert payload["max_concurrency"] is None
1420+
13791421
def test_viz_answers_an_unknown_run_id_as_a_document(tmp_path, capsys):
13801422
"""Every other reading command did; `viz` let `ReplayError` out raw."""
13811423
graph = _write_graph(tmp_path, _LEGAL_GRAPH)

0 commit comments

Comments
 (0)