Skip to content

Commit e928214

Browse files
Release 0.1.8 (#122)
* Release 0.1.8 Three defects closed, each reproduced before it was fixed and each carrying a test that is red without its fix. The MCP `execute` path, where one budget covered both a park and the work it authorised, so a human approving near the end of the park left ~120s for a tree-mutating run — and the docstring told the agent a timeout was safe to reissue, which the #100 guard could not catch because the stamp comes after the run returns. The live view, whose cursor was read from `stat()` after the events, so an append landing in that window left it claiming bytes the snapshot never saw and a finished run streamed as running for as long as the page stayed open. And the deadline guard's teardown, which an interrupt arriving inside its own critical section left holding a 50ms timer that re-raised into a pooled thread indefinitely. The last two were the sweep's remaining unverified findings; both are now demonstrated rather than suspected, the guard leak by measurement — six interrupts queued after the guard was released. Also the lockfile's copy of the version, which drifted through 0.1.6 and 0.1.7 reading 0.1.5 because nothing looked. All three declarations are checked now, which is what caught this bump being complete. CHANGELOG has the full account of each. The deep dive's figures are re-derived: 2,190 selected, 13 deselected, and `0.1.8` on PyPI once this ships. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Release 0.1.8: the version the docs quote, too The three declarations CI checks are not the only copies. The cookbook quotes the version it was verified against, and the serving chapter embeds it in two `/health` payloads that `tests/test_cookbook_serving.py` byte-compares against real output — so a bump that stops at pyproject, `__init__` and the lockfile fails those tests, which is what it should do. The README's footer carries it as well. Found by CI on the release PR rather than by looking, which is the right way round: the checks exist so the bump cannot be two-thirds done. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 3476104 commit e928214

8 files changed

Lines changed: 29 additions & 9 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,26 @@ and "used to be true" — the two things a reader most needs kept apart.
77

88
Entries are newest-last within a release, matching the order they were written.
99

10+
## 0.1.8
11+
12+
- **the MCP `execute` timeout could SIGKILL an approved mutating run mid-mutation, then tell the agent it was safe to reissue.** One budget, `approval_timeout + 120s`, covered both halves of a mutating call: the park, where a human is being asked, and the work, where an approved graph runs. So a human who answered near the end of the park left roughly two minutes for the run itself — and a governed run's agent phases delegate to Claude Code, which reads files, edits them and verifies. The likely outcome was not a wedged process being cleaned up; it was a human-approved, tree-mutating run being killed partway through mutating the tree. The non-mutating branch passed `timeout=None` and so was not bounded at all. The tool's own docstring then told the calling agent that "a timeout leaves the plan unexecuted and this call safe to reissue", which is true of a park that expired unanswered and false of everything else — and the #100 reissue guard could not catch the reissue either, because `executed_run_id` is stamped only after `loop.run()` returns, so a killed run leaves a record saying it never executed. Three changes, one per failure. The park and the work now draw on separate budgets, the work's being `DEFAULT_WORK_TIMEOUT` (1800s, the number `GRAPHARC_SLACK_WORK_TIMEOUT` already uses for this exact case) and overridable by `GRAPHARC_MCP_WORK_TIMEOUT`, which refuses a non-numeric or non-positive value rather than substituting a ceiling nobody chose. The reissue guard reads the **trace** — written as the run proceeds, and so the only place evidence of a half-run survives a kill — rather than pre-stamping the record, which would refuse a plan a human had merely *denied*. And the kill reaches the whole process group (`start_new_session=True` plus a group SIGKILL), because `process.kill()` signals the direct child only and a delegated Claude Code process was outliving the run that spawned it, still holding the workspace. The phase vocabulary the trace check depends on now has one owner in `observe.trace` instead of a copy in each of four modules; an unlisted phase reads as an execution, which is the safe direction — a new bookkeeping phase makes `go` refuse a plan it could have run, recoverable with `--again`, where the opposite would re-run a half-finished mutating plan and spend a human approval given once.
13+
- **the live view streamed a finished run as a running one, forever.** `frames()` polls the trace file's size, rebuilds a snapshot when it changes, and stores that snapshot's `size` as "everything up to here has been sent". `build_snapshot` took that number from `path.stat()` *after* reading the events, and the two disagree in the direction that matters: the read stops at the last newline, and — this being a live view — the run is appending while the snapshot is built, so bytes can land between the read and the stat. The cursor then claimed events the snapshot never saw, the next poll found an unchanged file size and rebuilt nothing, and once the writer stopped the file never changed size again. Reproduced rather than argued: with one append landing in that window the cursor came back *equal to the file's final size* while `done` was still false — the exact pair that leaves the stream with no reason to rebuild and nothing left to learn. `TailRecorder.read_tail()` now returns the events and the byte count they came from, `read_events()` keeps its signature over it for every other caller, and only the mtime is still read from the file's current state — safe to be too new, because it delays an idle verdict by one poll rather than stopping the stream. The cursor now falls short of the file, so the next poll rebuilds and the missed event arrives.
14+
- **an interrupt landing inside the deadline guard's own teardown left a timer raising into the thread forever.** `fire` queues its asynchronous exception while holding the guard's lock, so a guard already blocked on that same lock inside `disarm` is handed the exception the moment it acquires it — at the next bytecode, which is before `armed` is cleared and before the re-armed timer is cancelled. `disarm` propagated, and the 50ms timer it was meant to cancel stayed alive still reading `armed` as true: it re-raised `NodeDeadlineExceeded` into that thread every 50ms for the life of the thread, long after the run that armed it had finished. On a pooled thread that is an unattributable crash in whatever ran next — which is precisely what `test_no_interrupt_survives_the_node_that_earned_it` exists to rule out, reached by a path it did not cover. The lock was never the flaw: both sides do take it, and what a lock cannot do is stop an asynchronous exception arriving between two bytecodes inside the section it protects. The teardown is retried rather than abandoned now, and clears `armed` outside the lock as a last resort, that single store being what stops `fire` re-arming; swallowing the interrupt there costs nothing, because the guard still decides the outcome from whether the timer fired and still raises on it. The SIGALRM mechanism is unaffected — CPython runs the Python-level handler at a bytecode boundary, so `setitimer(ITIMER_REAL, 0)` has already completed when the handler raises.
15+
- **the lockfile's copy of the version drifted, twice, unnoticed.** `ci.yml` checks that `pyproject.toml` and `grapharc.__version__` agree, and its comment says why: if they drift, `pip show` and `import` disagree about what is installed. There is a third copy — `uv.lock` carries an entry for this project — and both 0.1.6 and 0.1.7 shipped with it reading `0.1.5`, because nothing re-locked after the bump and no job looked. Milder than the other two, since it misreports the project to a reader of the lockfile and to `uv sync --locked` rather than to an installed import, but the same class of bug as the one that step already guards. The check now parses `uv.lock` as well, names `uv lock` as the remedy, and refuses anything other than exactly one entry for the project so a rename cannot make it silently vacuous.
16+
17+
Tooling, in the same release and not defects in the shipped package: the suite
18+
now runs weekly against dependencies re-resolved from scratch rather than only
19+
against `uv.lock`, and a failure opens or updates an issue instead of reporting
20+
to an Actions tab nobody is watching — `pages.yml` had failed on two
21+
consecutive pushes and sat unnoticed for the better part of two months, which
22+
is the failure mode that step exists to avoid. And the **Verified this pass**
23+
figure in the deep dive can be re-derived with
24+
`GRAPHARC_UPDATE_FIGURES=1 pytest tests/test_deep_dive.py` instead of edited by
25+
hand: the check stays strict in CI, but it had been failing every branch that
26+
added a test until someone hand-edited a number in a docs file they had no
27+
reason to know existed, which cost an outside contributor's first pull request
28+
a month of being red for a reason that was not its code.
29+
1030
## 0.1.7
1131

1232
- **admission accepted a sentinel pointing the wrong way.** `START` is the graph's entry and `END` its exit, but the endpoint check tested only *whether* an endpoint was a sentinel, never which side of the edge it sat on. So a proposal carrying `END -> x` or `x -> START` was admitted and then died in `Materializer` with `StateGraph`'s own "END cannot be a start node". The run does not proceed either way; what was wrong is where the failure landed. `GovernedLoop` charges a `MaterializationError` to `max_consecutive_execution_failures` (2) rather than `max_consecutive_rejections` (3), so a planner got *fewer* retries for a mistake admission is supposed to catch than for one it does catch, and two in a row ended the run as `EXECUTION_FAILED` — a stop reason claiming the graph ran when nothing had. And a rejection is meant to be data: `feedback()` hands the planner codes and remedies, where this handed it prose scraped off an exception, with nothing on the `admission` event's failed-check list because admission had not failed. The prompt already told models the rule; the gate is what did not hold when one ignored it. Refused now under `Check.REGISTRY` as `sentinel_wrong_direction`, with a remedy naming the side the sentinel belongs on, and both endpoints still reported rather than the first.

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,4 +210,4 @@ The edges are documented, not denied — the full list with mechanisms is in the
210210
- Policy documents govern planning; the tool plane still reads CLI flags.
211211
- The MCP gate binds the MCP surface, not the host: an agent with its own file tools in the run directory could forge the approval decision. The trust boundary is the working directory, as it is for the Slack workspace.
212212

213-
Version `0.1.7` · [changelog](CHANGELOG.md) · [roadmap](ROADMAP.md) · [website](https://codegraphcontext.github.io/GraphARC/) · MIT
213+
Version `0.1.8` · [changelog](CHANGELOG.md) · [roadmap](ROADMAP.md) · [website](https://codegraphcontext.github.io/GraphARC/) · MIT

‎docs/cookbook/01-basics.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ projection instead and says which fields it dropped.
1414
`tests/test_cookbook_basics.py` reproduces every recipe here and asserts these
1515
exact strings, so the page cannot rot quietly.
1616

17-
Verified against `grapharc 0.1.7`, Python 3.14.6, `langgraph 1.2.9`,
17+
Verified against `grapharc 0.1.8`, Python 3.14.6, `langgraph 1.2.9`,
1818
`langchain-core 1.5.1`, `pydantic 2.13.4`.
1919

2020
Each snippet is a complete file. Save it and run it; nothing carries over between
@@ -41,7 +41,7 @@ uv run grapharc --version
4141
Output:
4242

4343
```
44-
grapharc 0.1.7
44+
grapharc 0.1.8
4545
```
4646

4747
Everything below uses only the base install — no API key, no network, no optional

‎docs/cookbook/06-serving-and-ops.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1062,7 +1062,7 @@ with TestClient(app) as client:
10621062
```
10631063

10641064
```
1065-
health : {'status': 'ok', 'version': '0.1.7', 'graphs': ['qa']}
1065+
health : {'status': 'ok', 'version': '0.1.8', 'graphs': ['qa']}
10661066
created: 201 queued
10671067
status : succeeded
10681068
answer : Budgets cap iterations, tokens and time.
@@ -1259,7 +1259,7 @@ graphs : qa
12591259
ctrl-c to stop
12601260

12611261
$ curl -s localhost:8124/healthz
1262-
{"status":"ok","version":"0.1.7","graphs":["qa"]}
1262+
{"status":"ok","version":"0.1.8","graphs":["qa"]}
12631263

12641264
$ curl -s -X POST localhost:8124/sessions -H 'content-type: application/json' \
12651265
-d '{"graph":"qa","input":{"question":"how do budgets work?"}}'

‎docs/deep-dive.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,7 @@ A stable system is not one that claims to have no edges — it is one whose edge
254254
- **`.env` and `grapharc.toml` follow the same discovery rule: the working directory, and nowhere else.** Neither searches parent directories — a run must not be governed by a file you did not know about, and must not be *billed* to one either. **This is a behaviour change:** the credential loader used to walk up to `/`, so a `.env` in an ancestor directory (a `$HOME` one on a shared box, a client project one above a demo checkout) was picked up silently. If you relied on that, move the file into the directory you run from, `export` the variable, or pass `env_file=` to name it explicitly. A real environment variable still beats any file.
255255
- **`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.
256256

257-
**Verified this pass:** `pytest` → green, 2,190 selected and 13 deselected (the live ones); `ruff check .` clean; all eight `grapharc demo` stages green, plus the `trace` / `metrics` / `viz` / `replay` tour against a freshly recorded demo trace; the wheel builds and imports all submodules in a clean virtualenv with `[all]`, and `0.1.7` on PyPI is that wheel. The counts are a snapshot, not a property of the project — `pytest` re-derives them in one command, which is the only reason they are quoted, and `tests/test_deep_dive.py` fails this line rather than letting it drift.
257+
**Verified this pass:** `pytest` → green, 2,190 selected and 13 deselected (the live ones); `ruff check .` clean; all eight `grapharc demo` stages green, plus the `trace` / `metrics` / `viz` / `replay` tour against a freshly recorded demo trace; the wheel builds and imports all submodules in a clean virtualenv with `[all]`, and `0.1.8` on PyPI is that wheel. The counts are a snapshot, not a property of the project — `pytest` re-derives them in one command, which is the only reason they are quoted, and `tests/test_deep_dive.py` fails this line rather than letting it drift.
258258

259259
[ROADMAP.md](../ROADMAP.md) tracks what is built and what is not, item by item.
260260

‎grapharc/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from grapharc.runtime.graph import GraphARC, WritePermissionError
1010
from grapharc.runtime.state import GraphARCState
1111

12-
__version__ = "0.1.7"
12+
__version__ = "0.1.8"
1313

1414
__all__ = [
1515
"GraphARC",

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "grapharc"
3-
version = "0.1.7"
3+
version = "0.1.8"
44
description = "A graph engineering toolkit on LangGraph: typed state contracts, per-node write permissions, enforced budgets, and JSONL traces that double as replay points."
55
readme = "README.md"
66
license = "MIT"

‎uv.lock‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)