Skip to content

Commit 0ade7ca

Browse files
session: a session whose runner died can be reclaimed deliberately (#127)
Closes #19. A runner's claim is a compare-and-set, not a lease: it stops a second runner from claiming a session and cannot notice one that died holding it. The row stayed `running` with the dead `runner_pid` still on it -- so the store knew who died and offered no way to act on it. Every later `run()` raised `SessionBusy` forever, and the only remedy was a hand-written UPDATE, which skips the lifecycle check and writes no transition row, corrupting the audit trail the store exists to keep. `SessionStore.release_dead_runner`, surfaced as `SessionManager.reclaim`. **`failed`, not `interrupted`.** `interrupted` says a turn stopped somewhere it can be picked up from; a runner that died left no such point. `failed` says the turn did not settle, which is true, and is already resumable. **Open holds survive.** `pending_approval` is left alone, so a session waiting on a human is still waiting afterwards. Reclaiming a wedged session must not be a way past an approval gate, and a test asserts the hold is still there. **The liveness check runs inside `BEGIN IMMEDIATE`**, not before it, so two operators reclaiming at once serialise on it. A check outside the write lock is the same deferred-read race `_transaction`'s docstring already describes for claiming. Four refusals, each with a test: the session is not `running`; the pid is alive (or belongs to another user, `PermissionError`, read as alive on purpose); the pid is this process; a `running` row carries no `runner_pid` at all, which is inconsistent in its own right and not something to paper over. Pid reuse is not solved and is not pretended to be: a recycled pid reads as alive, which is a refusal -- the safe direction. That is why nothing calls this automatically. An automatic sweep would be a way for two live runners to fight over one session, which is what the claim exists to prevent. The crash is a real one in the tests: a child claims the session and `os._exit`s, skipping every cleanup path, which is what a SIGKILL looks like from the store's side. The alive-refusal test parks a real child on a file barrier rather than inventing a pid, and the dead pid used elsewhere is spawned and reaped rather than guessed -- a made-up number can belong to something, and the check under test refuses a live pid. The three honesty paragraphs this obsoletes are updated: `store.transition`'s docstring, `session/runtime.py`'s limitations list, and the deep dive's. The README no longer states it -- that sentence moved to the deep dive since the issue was filed. Verified: 2210 selected, 13 deselected, ruff clean. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 2e4b836 commit 0ade7ca

4 files changed

Lines changed: 301 additions & 6 deletions

File tree

‎docs/deep-dive.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -250,11 +250,11 @@ A stable system is not one that claims to have no edges — it is one whose edge
250250
- **A node's tokens are its own, not the run's movement while it ran.** Worth stating because it was the other way round: an `end` event carried the difference between two readings of the run's *shared* meter, so under fan-out the workers' windows overlapped and each was credited with its siblings' concurrent spend. Three workers costing 8 tokens each traced as 24/16/8, and `metrics` and `cost` agreed on 48 for 24 tokens of real work — doubling the estimated bill purely because the work ran in parallel. Attribution now comes from a per-node scope on the meter, so the same work costs the same serially and in parallel; a hand charge the usage callback never saw still lands on the node that made it.
251251
- **A planning round is an envelope, not a measurement.** A `round` event used to carry the planner's `tokens` and the round's `duration_ms`, both of which `metrics`, `cost` and `replay` add on top of node totals — and the planner's spend was already reported by its own `plan` event, so it was counted twice, and a round's duration encloses the plan plus every node it ran. Neither is on the event now; both are on its `state_delta` as `round_tokens` / `round_iterations` / `round_duration_ms`, where no reader sums them. `RoundRecord.iterations` also holds a figure now rather than always `0`.
252252
- **The Claude CLI backend is completion-only, and an agent node on it is *delegated* rather than governed.** The CLI has no tool-calling wire format, so GraphARC cannot run its own gated loop over it. Rather than refuse, `AgentNode` hands the whole loop to Claude Code's headless agent, in one of two named tiers. `allowlist`, the default, pre-approves exactly the Claude Code twins of the node's own registered tools (`read_file`→`Read`, … `run_command`→`Bash`), so one operator declaration governs both the governed loop and the delegated one — but the enforcement is Claude Code's own gating, not this graph's per-call policy, there are no per-tool trace events, and anything unlisted falls to headless default gating, which fails closed. `bypass`, explicit opt-in only, runs `bypassPermissions`: every tool Claude Code has, no checks at all. In either tier the calls are not confined by the sandbox executor and the token figure is the sub-agent's own rather than one GraphARC metered call by call; a `--max-tokens` the delegated path cannot enforce is refused rather than silently unapplied. The workspace boundary and the wall-clock ceiling still hold — the CLI runs in its own session, and the deadline kills the whole process group, not just the direct child. It warns on `DelegatedToolUseWarning` at construction, naming the tier, and marks every trace event `executor=delegated` with its `delegated_mode`; filter that warning to an error to get the old refusal back. Structured output still needs an OpenAI-wire backend: `openrouter`, `openai`, or a local `ollama`.
253-
- **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.
253+
- **A session turn is synchronous**, and a runner claim is a claim rather than a lease — a runner that dies holding a session does not release it by itself. `SessionManager.reclaim(session_id)` releases one deliberately, refusing unless the recorded runner pid is gone from this host; it is not automatic, because pid liveness is host-local and pids are recycled.
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,205 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.
257+
**Verified this pass:** `pytest` → green, 2,210 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/session/runtime.py‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,12 @@
5050
stops. The kernel grew `astream` while this was being written; an async turn
5151
is now buildable and is simply not built yet.
5252
- **One runner at a time is a claim, not a lease.** `SessionStore.transition`
53-
stops a second runner from claiming a session, and nothing reclaims one whose
54-
runner died holding it — see that method's docstring.
53+
stops a second runner from claiming a session; it cannot notice one that died
54+
holding it. A session wedged in `running` after a crash is released
55+
deliberately with `SessionManager.reclaim`, which refuses unless the recorded
56+
`runner_pid` is gone from this host and records the release as a transition.
57+
Deliberate rather than automatic: pid liveness is host-local and pids are
58+
recycled, so an automatic sweep is a way for two live runners to fight.
5559
- **A hold names a node, not one particular task.** When a `Send` fan-out puts
5660
the *same* gated node on the boundary several times over, each of those tasks
5761
is held separately and each needs its own decision — so the count is exact
@@ -816,6 +820,25 @@ def resume(self, session_id: str) -> Session:
816820
record = self.store.require(session_id)
817821
return Session(record=record, manager=self, spec=self.registry.get(record.graph))
818822

823+
def reclaim(self, session_id: str, *, reason: str = "") -> SessionRecord:
824+
"""Release a session whose runner process died holding it.
825+
826+
A runner's claim is a compare-and-set rather than a lease, so a crash
827+
mid-turn leaves the session `running` and every later `run()` raises
828+
`SessionBusy`. This is the deliberate release: it refuses unless the
829+
recorded `runner_pid` is gone from this host, moves the session to
830+
`failed`, and records the reclaim as a transition naming the dead pid —
831+
so the audit trail shows what happened instead of a hand-written UPDATE
832+
hiding it.
833+
834+
Open approval holds are kept: a session waiting on a human is still
835+
waiting afterwards. Nothing calls this automatically, because pid
836+
liveness is host-local and pids are recycled — see
837+
`SessionStore.release_dead_runner` for what it will and will not accept
838+
as evidence that a runner is gone.
839+
"""
840+
return self.store.release_dead_runner(session_id, reason=reason)
841+
819842
def list(
820843
self, *, status: SessionStatus | Iterable[SessionStatus] | None = None
821844
) -> list[SessionRecord]:

‎grapharc/session/store.py‎

Lines changed: 114 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
from grapharc.session.errors import (
4242
InvalidTransition,
4343
SessionBusy,
44+
SessionError,
4445
SessionExistsError,
4546
SessionTerminated,
4647
ThreadInUseError,
@@ -113,6 +114,30 @@ class SessionStatus(StrEnum):
113114
)
114115

115116

117+
def _pid_alive(pid: int) -> bool:
118+
"""Whether `pid` names a live process *on this host*.
119+
120+
Signal 0 checks for existence without delivering anything. `PermissionError`
121+
means the process exists and belongs to someone else, which counts as alive:
122+
the conservative answer is the one that refuses a reclaim.
123+
124+
Host-local by construction, which is why the store does not try to be a
125+
lease. A pid from another machine is meaningless here, and a recycled pid
126+
reads as alive — a refusal, which is the direction to fail in.
127+
"""
128+
try:
129+
os.kill(pid, 0)
130+
except ProcessLookupError:
131+
return False
132+
except PermissionError:
133+
return True
134+
except OSError:
135+
# An unusable pid (negative, 0, out of range) is not a live runner, and
136+
# 0 would signal our own process group rather than asking about a pid.
137+
return False
138+
return True
139+
140+
116141
class _Keep:
117142
"""Sentinel: leave this column as it is (distinct from setting it to NULL)."""
118143

@@ -417,8 +442,8 @@ def transition(
417442
was found instead. That is the whole of the "one runner at a time"
418443
guard — it stops a second runner from *claiming* a session, and it does
419444
not detect a runner that died holding one. A session stuck in `running`
420-
after a crash has to be released deliberately, which is a legal
421-
`running -> idle` transition.
445+
after a crash is released by `release_dead_runner`, which checks the
446+
recorded pid is gone and then makes a legal `running -> failed` move.
422447
"""
423448
with self._transaction() as conn:
424449
row = conn.execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone()
@@ -461,6 +486,93 @@ def transition(
461486
)
462487
return self.require(session_id)
463488

489+
def release_dead_runner(self, session_id: str, *, reason: str = "") -> SessionRecord:
490+
"""Release a `running` session whose runner process no longer exists.
491+
492+
A runner's claim is a compare-and-set, not a lease: it stops a second
493+
runner from claiming a session and cannot notice one that died holding
494+
it. Without this, a crash mid-turn left the row `running` forever and
495+
every later `Session.run()` raised `SessionBusy`, with the only remedy
496+
being a hand-written UPDATE — which skips the lifecycle check and writes
497+
no transition row, corrupting the audit trail this store exists to keep.
498+
499+
Lands on `failed`, not `interrupted`. `interrupted` says a turn stopped
500+
somewhere it can be picked up from, and a runner that died left no such
501+
point; `failed` says the turn did not settle, which is true and is
502+
already resumable.
503+
504+
**Open holds survive.** `pending_approval` is not touched, so a session
505+
waiting on a human is still waiting after the reclaim. Reclaiming a
506+
wedged session must not be a way past an approval gate.
507+
508+
The liveness check runs *inside* the write transaction, not before it,
509+
so two operators reclaiming at once serialise on it — a check outside the
510+
lock is the same deferred-read race `_transaction` already describes.
511+
512+
Refused, deliberately, when:
513+
514+
- the session is not `running` — there is no claim to release;
515+
- the recorded pid is alive, or belongs to another user (`PermissionError`,
516+
treated as alive). A live pid does not prove the *runner* lives, but it
517+
is enough to prove it might;
518+
- the pid is this process — that is a caller bug, not a crash;
519+
- a `running` row carries no `runner_pid` at all, which is inconsistent
520+
in its own right and not something to paper over.
521+
522+
Pid reuse is not solved here and is not pretended to be: a recycled pid
523+
makes a dead runner look alive, which produces a refusal — the safe
524+
direction. That is why this is a deliberate operator action and why
525+
nothing calls it automatically.
526+
"""
527+
with self._transaction() as conn:
528+
row = conn.execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone()
529+
if row is None:
530+
raise UnknownSessionError(f"no session {session_id!r} in {self.path}")
531+
current = SessionStatus(row["status"])
532+
if current is not SessionStatus.RUNNING:
533+
raise SessionBusy(session_id, (SessionStatus.RUNNING.value,), current.value)
534+
535+
pid = row["runner_pid"]
536+
if pid is None:
537+
raise SessionError(
538+
f"session {session_id!r} is running but records no runner_pid, so "
539+
"there is no process to prove dead; this row is inconsistent and "
540+
"wants looking at rather than reclaiming"
541+
)
542+
pid = int(pid)
543+
if pid == os.getpid():
544+
raise SessionError(
545+
f"session {session_id!r} names this process ({pid}) as its runner; "
546+
"a runner does not reclaim its own session"
547+
)
548+
if _pid_alive(pid):
549+
raise SessionError(
550+
f"session {session_id!r} is held by pid {pid}, which is still "
551+
"alive — refusing to reclaim a session that may be running. If "
552+
"that process is not a GraphARC runner, stop it first."
553+
)
554+
555+
detail = f"runner pid {pid} no longer exists"
556+
note = f"{reason} ({detail})" if reason else f"reclaimed: {detail}"
557+
conn.execute(
558+
"UPDATE sessions SET status = ?, updated_at = ?, runner_pid = NULL, "
559+
"last_error = ? WHERE id = ?",
560+
(SessionStatus.FAILED.value, _now(), detail, session_id),
561+
)
562+
conn.execute(
563+
"INSERT INTO session_transitions (session_id, from_status, to_status, "
564+
"reason, at, pid) VALUES (?, ?, ?, ?, ?, ?)",
565+
(
566+
session_id,
567+
current.value,
568+
SessionStatus.FAILED.value,
569+
note,
570+
_now(),
571+
os.getpid(),
572+
),
573+
)
574+
return self.require(session_id)
575+
464576
def history(self, session_id: str) -> list[StatusChange]:
465577
"""Every status change this session has made, oldest first."""
466578
with self._lock:

0 commit comments

Comments
 (0)