Skip to content

fix(flowing): close sessions a flow can no longer reach - #82

Merged
futrime merged 1 commit into
feat/new-flow-apifrom
flow-api/u13-session-lifetime
Sep 25, 2026
Merged

futrime merged 1 commit into
feat/new-flow-apifrom
flow-api/u13-session-lifetime

Conversation

@futrime

@futrime futrime commented Sep 25, 2026

Copy link
Copy Markdown
Member

Summary

The flow API has no Session.close, and the engine closed a session only as the flow call that opened it ended, holding every SessionView strongly until then. A loop that opens a fresh session a round (ralph_loop, flame_chase, rlar's reviewer, lane runtimes, which can run for days) kept all of them open. With stream harnesses that is one CLI process per round.

Session lifetime (viewing.py, engine.py)

  • The engine now holds a SessionView only weakly, through an Opened record (a weakref.ref subclass). The call's cleanup and the agent's hooks keep the record instead of the view.
  • When a flow lets go of a view, the session is closed on the run's loop. This works whichever thread dropped the last reference or ran the collection that found it. The close runs in an eagerly started task, and both the call's cleanup and the run's cleanup wait for it.
  • The next spawn first closes whatever was let go of, so a flow that never yields to the loop (a fake one, say) also keeps few sessions open.
  • Each session is closed exactly once, whether it is let go of first or its call ends first. A session handed up to a caller still closes when the call that opened it ends.
  • Call.res is keyed by id, so a session that closes early leaves it.
  • A hook that fires for a session the flow let go of gets a stand-in view that is already closed.
  • A fork keeps the session it was forked from open until the fork's own first turn succeeds, since that turn is where a harness actually cuts the fork.
  • A hook that fails between turns no longer holds its session in a reference cycle through the hook's frame.
  • steer now refuses a session that is closed.

Hard deadlines under a sooner graceful one (a follow-up bug from the coordinator)

  • Call.limits now also returns the hard deadline that a sooner graceful deadline hides from the driver. AgentView.run arms a timer for it that interrupts the turn and raises DurationExceeded. Only a SessionError from the interrupted turn becomes DurationExceeded; a turn that answered in time keeps its answer.
  • A turn that is hard for its cost or tokens is now told the hard deadline, not the graceful one.
  • The SPI is unchanged.
  • A graceful deadline now sends its cancel from the loop. Before, a flow that returned just as its last turn ended passed a bare CancelledError up to its caller.

Fakes (fakes.py)

  • FakeAgentDriver.live and .peak count how many sessions are open now, and the most open at once.
  • A fake turn that its reply holds open (until_steered()) is cut off at a hard deadline, as a real turn is.
  • FakeEnvDriver releases temporary-copy holds when the run that took them closes (or when the root fake closes), and keeps the copies. A run resumed on the same fake gets its copy back instead of TempCloneBusy.

The spec (specs/runtime/flowing.md, the cleanup section only), the Session docstring, and the docs (reference/flows.md, features/budgets.md, weaver/testing-flows.md) are updated to match.

Tests

  • tests/unit/flows/test_engine_sessions.py (new):
    • 10,000 rounds with a fresh session each, with instant and yielding replies: never more than 2 sessions open at once, and every session closed at the end. The same holds for gathered batches (at most one batch open) and for 10 loops gathered (at most 2 open per loop).
    • An on-disk flow run through run_flow.
    • Sessions kept in a local variable, a list or a dict stay open.
    • Forks: one outlives the session it was forked from, one whose first turn failed still holds that session, and a chain of forks keeps few sessions open.
    • Exactly-once close: when the view goes as the call ends, and when a close is still under way as the call ends.
    • A session let go of on another thread (released, or found by a collection) is closed on the loop; the check runs with the loop in debug mode.
    • A hook sees a stand-in for a session the flow let go of.
    • A hook that fails between turns no longer keeps its session open.
  • test_engine_budget.py:
    • Hard deadline cases: the turn's own hard deadline with a sooner graceful one from the run or from its flow; a sooner hard deadline with a later graceful one; the flow's hard deadline with a sooner graceful one from the run.
    • A turn that is hard for its cost is told the hard deadline.
    • A flow that returns as its graceful deadline lets its turn finish gets its result.
    • Timers are cleaned up after 10,000 turns.
  • test_engine_resume.py and test_fakes.py: a resumed run takes its copy again, and fake holds are released when the fake closes.
  • test_engine_scale.py: a new micro-benchmark compares spawn + turn + close per round, letting go of each session versus keeping all of them. Result: 20.8 µs vs 20.0 µs (1.04×). The existing thresholds are unchanged and still pass. Per-call engine cost is the same as the base (1.88 µs vs 1.89 µs).
  • Every new test fails on the base branch and passes here.
  • uv run pre-commit run --all-files passes. uv run pytest passes (4149 passed, 110 skipped). The system tier ran without --run-agents, so no real harnesses were driven.

E2E: an on-disk flow run through run_flow on the fake kit, 1,000 rounds with a fresh session each: at most 2 sessions open at once, 0 open at the end, all 1,000 closed, in about 38 ms. On the base branch the peak is 1,000.

🤖 Generated with Claude Code

The flow API has no `Session.close`, and the engine closed a session only
as the flow call that opened it ended, holding every view strongly until
then. A loop opening a fresh session a round -- ralph_loop, flame_chase,
rlar's reviewer, lane runtimes, for days -- kept every one of them open,
and a stream harness keeps a CLI process per session.

- The engine now holds a SessionView only weakly, through an `Opened`
  record (a `weakref.ref` subclass) the call's cleanup and the agent's
  hooks keep instead. A view let go of has its session closed on the
  run's loop -- marshalled there from whichever thread let go of it or a
  collection found it -- in an eagerly started task the call's and the
  run's cleanup wait for; the next spawn drains what was let go of first,
  so a flow that never yields holds few open too. A session is closed
  exactly once whichever of that and its call's end comes first, and one
  handed up to a caller still closes as the call that opened it ends.
  `Call.res` is keyed by id, so a session closed early leaves it.
- A hook heard for a session let go of gets a closed stand-in; a fork
  keeps the session it was cut from open until its own first turn
  succeeds; a hook failing between turns no longer ties its session into
  a reference cycle through its own frame; `steer` refuses a session that
  is over.
- A turn's hard deadline is kept when a sooner graceful one is what its
  driver is told: `Call.limits` also answers the hard deadline, and the
  engine interrupts the turn there and raises `DurationExceeded`; a turn
  hard for its cost or tokens is told the hard deadline, not the graceful
  one. A graceful deadline's cancel is sent from the loop, so a flow that
  returns as its last turn ends no longer leaks a bare CancelledError to
  its caller.
- Fakes: `FakeAgentDriver.live` and `.peak` count open sessions; a fake
  turn held open by its reply is cut at a hard deadline, as a real one
  is; `FakeEnvDriver` lets go of temporary-copy holds as the run that
  took them closes (or as the root fake closes), so a run resumed on the
  same fake takes its copy again instead of raising TempCloneBusy.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@futrime
futrime merged commit 0b7e896 into feat/new-flow-api Sep 25, 2026
2 of 3 checks passed
@futrime
futrime deleted the flow-api/u13-session-lifetime branch September 25, 2026 12:59
futrime added a commit to humanfia/flowverse that referenced this pull request Sep 25, 2026
…t go of

humanize now closes a session a flow can no longer reach instead of holding
it until the call that opened it ends (humanfia/humanize#82), so the
compliance check's reviewer session is gone by the second round too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
futrime pushed a commit that referenced this pull request Sep 25, 2026
fix(flowing): close sessions a flow can no longer reach
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