Skip to content

feat(core): reapChatSession — close a managed session the reap policy never releases - #441

Merged
edspencer merged 1 commit into
mainfrom
feat/force-reap-session
Aug 4, 2026
Merged

feat(core): reapChatSession — close a managed session the reap policy never releases#441
edspencer merged 1 commit into
mainfrom
feat/force-reap-session

Conversation

@edspencer

@edspencer edspencer commented Aug 4, 2026

Copy link
Copy Markdown
Owner

The gap

decideReap keeps a streaming session alive for exactly as long as it holds live background work, and says so plainly in its own header: "No idle timer, no max-lifetime backstop, no idle-concurrency cap."

That is the right default — reaping a session with work in flight kills the work. But it left consumers with no way out when a session becomes permanently unreapable, and there are at least two ordinary ways that happens:

  1. A background task never exits. A model-authored until loop whose sentinel never arrives, so backgroundTasks never drains.
  2. A re-invocation turn dies without firing a Stop hook — on a subscription usage limit, say. activity has already cleared awaitingTasks, so every later background_tasks_changed returns early at the isAwaitingTasks() guard, and only a turn_end could re-arm it or reap. None comes. The session is stranded live with no pending reap.

In both cases the session's message stream never ends, so a consumer rendering that stream shows the session as running until the process restarts. There was no API to end it.

The change

  • SessionReaper.forceReap(sessionId): boolean — close a live managed session regardless of what it is holding.
  • FleetManager.reapChatSession(sessionId): boolean — public exposure, since sessionLifecycle is private on the fleet.

Both idempotent; both return false for an unknown, unmanaged or already-reaped id.

Why on the reaper, and not just close()

A consumer holds the RuntimeSession and could call close() itself. That is not equivalent — it closes the query behind the reaper's bookkeeping:

forceReap routes through the same private reap the policy uses, so markDone() unregisters the id and drains the reap waiters before close(). The consumer observes an ordinary end-of-stream and unwinds through its ordinary path — no new teardown contract to get right.

Worth stating explicitly: interrupt() is not a substitute. It targets an in-flight model turn, and a session held open purely for background work has none.

Scope

Policy is untouched — nothing reaps on its own that didn't before. This only adds a door that can be opened deliberately.

The reap log line now carries a reason; Reaping idle session … is unchanged, a forced one reads Reaping force-reaped on request session ….

Tests

Four new reaper tests and three FleetManager tests. The one that matters is rescues a session stranded live by a re-invocation turn that never ends — it drives the real signal sequence (turn_end keepAlive → background_tasks_changed drained → activity, then nothing) and asserts the session is genuinely stuck (onReap never fires, close() never called) before showing forceReap releases it.

pnpm typecheck green across the monorepo; core suite passes (3675) and the docs site builds. One pre-existing failure locally, directory.test.ts > throws StateDirectoryCreateError when parent directory is not writable, is environmental — it fails on main too because this box runs as uid 0, so a chmod-unwritable parent is still writable.

Docs

library-reference/fleet-manager.mdx gains a reapChatSession() section; concepts/sessions.md notes that the keep-alive rule has no backstop and points at it.

Unblocks edspencer/paddock#528.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added an API to force-close live managed chat sessions on demand.
    • Returns whether a session was successfully closed and safely handles unknown or already-closed sessions.
    • Forced closures use standard cleanup and are recorded distinctly from automatic reaping.
  • Documentation

    • Documented session behavior when background work remains unfinished.
    • Added reference documentation for the new session-reaping API.
  • Tests

    • Added coverage for forced closure, idempotency, waiting callers, and sessions stranded during unfinished work.

… never releases

`decideReap` keeps a streaming session alive for exactly as long as it holds live
background work, with no idle timer and no max-lifetime backstop. That is the
right default — reaping a session with work in flight kills the work — but it
left consumers with no way out when a session becomes permanently unreapable,
which happens in at least two ordinary ways:

- A background task never exits, so `backgroundTasks` never drains.
- A re-invocation turn dies without firing a Stop hook (a subscription usage
  limit, say). `activity` has already cleared `awaitingTasks`, so every later
  `background_tasks_changed` returns early at the guard, and only a `turn_end`
  could re-arm it or reap. None comes.

In both cases the session's message stream never ends, so a consumer rendering
that stream shows the session as running until the process restarts.

`SessionReaper.forceReap(sessionId)` closes a live managed session regardless of
what it holds; `FleetManager.reapChatSession(sessionId)` exposes it, since the
lifecycle manager is private on the fleet. Both are idempotent and return false
for an unknown, unmanaged or already-reaped id.

This belongs on the reaper rather than being a `close()` the consumer calls on
the RuntimeSession it already holds: closing the query directly leaves `liveById`
holding a stale entry, so `whenSessionReaped` never resolves (a later resume
stalls until its ceiling, #403) and `WakeRegistry` skips that session's wakes
forever. `forceReap` routes through the same private `reap` the policy uses, so
the id is unregistered, reap waiters drain, and the consumer just sees an
ordinary end-of-stream.

Policy is unchanged: nothing reaps on its own that didn't before. Tests cover the
stranded-session shape above, the waiter drain, and idempotency.

Unblocks edspencer/paddock#528.

Co-Authored-By: Claude <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 4, 2026

Copy link
Copy Markdown

Deploying herdctl with  Cloudflare Pages  Cloudflare Pages

Latest commit: 2aede3a
Status: ✅  Deploy successful!
Preview URL: https://0727402f.herdctl.pages.dev
Branch Preview URL: https://feat-force-reap-session.herdctl.pages.dev

View logs

@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This change adds SessionReaper.forceReap and exposes it through FleetManager.reapChatSession. The operation uses normal cleanup, supports idempotent results, distinguishes forced-reap logs, and closes managed sessions retained by unfinished background work.

Changes

Managed session force-reaping

Layer / File(s) Summary
SessionReaper force-reap lifecycle
packages/core/src/session/session-reaper.ts, packages/core/src/session/__tests__/session-reaper.test.ts
forceReap now closes live managed sessions through normal cleanup and returns false for unknown or inactive sessions. Tests cover cleanup, waiter release, idempotency, and stranded sessions.
FleetManager API integration
packages/core/src/fleet-manager/fleet-manager.ts, packages/core/src/fleet-manager/__tests__/reap-chat-session.test.ts
FleetManager.reapChatSession now delegates to the session reaper. Integration tests cover initialization, forced closure, repeated calls, unknown sessions, liveness, and deferred closure.
API documentation and release metadata
docs/src/content/docs/library-reference/fleet-manager.mdx, docs/src/content/docs/concepts/sessions.md, .changeset/force-reap-session.md
The documentation describes the new API and indefinite session lifetime conditions. The Changeset records the minor release and behavior.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant FleetManager
  participant SessionReaper
  participant RuntimeSession
  Caller->>FleetManager: reapChatSession(sessionId)
  FleetManager->>SessionReaper: forceReap(sessionId)
  SessionReaper->>RuntimeSession: close through normal reap cleanup
  SessionReaper-->>FleetManager: return boolean result
  FleetManager-->>Caller: return boolean result
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding an API to reap managed sessions that automatic policy cannot release.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/force-reap-session

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
packages/core/src/fleet-manager/__tests__/reap-chat-session.test.ts (1)

11-13: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

Mock the file system in this test.

The test creates and removes real directories through node:fs/promises. This makes the lifecycle test depend on host filesystem behavior.

Use the repository's in-memory filesystem utility or mock the filesystem calls. If this must remain an integration test, move it to a location with an explicit integration-test exemption.

As per coding guidelines, “Mock external dependencies (SDK, file system, GitHub API) in tests.” <coding_guidelines>

Also applies to: 56-78

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/fleet-manager/__tests__/reap-chat-session.test.ts` around
lines 11 - 13, Update the test’s filesystem setup and cleanup around the
lifecycle test to use the repository’s in-memory filesystem utility or mocks
instead of real node:fs/promises calls; replace the mkdir, mkdtemp, rm, and
writeFile usage while preserving the existing test behavior and assertions.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/core/src/fleet-manager/__tests__/reap-chat-session.test.ts`:
- Around line 11-13: Update the test’s filesystem setup and cleanup around the
lifecycle test to use the repository’s in-memory filesystem utility or mocks
instead of real node:fs/promises calls; replace the mkdir, mkdtemp, rm, and
writeFile usage while preserving the existing test behavior and assertions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 74465938-fee0-48e4-84a8-10a75a7cfdf1

📥 Commits

Reviewing files that changed from the base of the PR and between 9f15eac and 2aede3a.

📒 Files selected for processing (7)
  • .changeset/force-reap-session.md
  • docs/src/content/docs/concepts/sessions.md
  • docs/src/content/docs/library-reference/fleet-manager.mdx
  • packages/core/src/fleet-manager/__tests__/reap-chat-session.test.ts
  • packages/core/src/fleet-manager/fleet-manager.ts
  • packages/core/src/session/__tests__/session-reaper.test.ts
  • packages/core/src/session/session-reaper.ts

@edspencer

Copy link
Copy Markdown
Owner Author

Mock the file system in this test. Use the repository's in-memory filesystem utility or mock the filesystem calls.

Skipping this one — the premise doesn't hold on two counts:

  1. There is no in-memory filesystem utility in this repo. No memfs, mock-fs or unionfs in any package.json, and no shared test-fs helper. The suggestion points at something that doesn't exist.

  2. Real temp dirs are the established pattern for FleetManager tests — 14 of the 17 files in packages/core/src/fleet-manager/__tests__/ use mkdtemp. reap-chat-session.test.ts is deliberately modelled on session-control.test.ts, which does the same mkdtemp/mkdir/writeFile/rm dance for the same reason: FleetManager.initialize() reads a real herdctl.yaml and initialises a real state dir, and that wiring is exactly what the test exists to exercise. It reaches sessionLifecycle — a private field — through the public getSessionLifecycle(), so stubbing the fs out would leave it asserting against a construction path that isn't the production one.

The guideline it cites ("mock external dependencies") is aimed at the SDK, network and GitHub API — non-deterministic or costly dependencies. A mkdtemp under os.tmpdir(), torn down in afterEach, isn't in that category, and the policy behaviour itself is covered with no filesystem at all in session/__tests__/session-reaper.test.ts.

Happy to revisit if an in-memory fs helper is introduced and the other 14 files move over — but this one test shouldn't be the odd one out.

@edspencer
edspencer merged commit 2a60e82 into main Aug 4, 2026
8 checks passed
@edspencer
edspencer deleted the feat/force-reap-session branch August 4, 2026 18:29
@github-actions github-actions Bot mentioned this pull request Aug 4, 2026
edspencer pushed a commit to edspencer/paddock that referenced this pull request Aug 4, 2026
The reapChatSession/forceReap API this fix depends on shipped in
edspencer/herdctl#441, released as core 5.31.0. package.json already asked for
it; this refreshes the lockfile off the published tarball, so CI installs the
same package the tests were re-verified against rather than a local build.

Co-Authored-By: Claude <noreply@anthropic.com>
edspencer added a commit to edspencer/paddock that referenced this pull request Aug 4, 2026
…662)

* fix(server): make Stop work while a chat runs background work (#528)

A chat could sit with the spinner and Stop showing forever. Stop did nothing —
no error, no frame, no log line. The composer silently queued anything typed
instead of sending it, and reloading didn't help (the state is
server-authoritative and replays as running). Only a server restart cleared it.

Two independent things had to be wrong at once, and both were.

**No cancellable identity.** Once a session-mode turn's primary `result` lands,
the session can stay open — the reaper holds it while the turn's background work
runs — and autonomous re-invocation turns keep arriving on the same stream.
`makeBackgroundTurnSink` renders that stretch as one hub turn but never called
`setJobId`; it was called at only two of the five turn-start sites, and this was
one of the three that missed. So every frame and every `chat:active` carried
`jobId: null`, the client's deferred cancel (#196) waited for an id that never
arrived, and clicking Stop put nothing on the wire at all — hence silent rather
than errored. The sink now mints a synthetic job id and publishes it when the
turn opens, as the foreground path does via `onJobCreated`.

**Nothing to route to.** `cancel` knew a live turn in `liveSessions` (→
`interrupt()`) and a batch job (→ `cancelJob`). The primary turn's `liveSessions`
entry is dropped the moment it returns, so a background-phase id matched neither
and fell through to `cancelJob(<synthetic uuid>)` → JobNotFoundError → false,
discarded by the WS layer. `interrupt()` was the wrong primitive anyway: it ends
an in-flight model turn, and this session is idle holding background work. Cancel
now routes these to `fleet.reapChatSession()`, so the stream ends and the
existing unwind emits `chat:complete`.

Easiest to hit on a subscription usage limit: sub-agents die, the parent's
re-invocation turn dies without a Stop hook, and the reaper's `awaitingTasks`
(cleared by that turn's `activity`) means no later signal can reap the session.
Also covers the originally reported trigger — a model-authored `until` loop whose
sentinel never arrives.

Requires @herdctl/core >= 5.31.0 (edspencer/herdctl#441), so this must land after
that release.

Co-Authored-By: Claude <noreply@anthropic.com>

* chore: lock @herdctl/core at the released 5.31.0

The reapChatSession/forceReap API this fix depends on shipped in
edspencer/herdctl#441, released as core 5.31.0. package.json already asked for
it; this refreshes the lockfile off the published tarball, so CI installs the
same package the tests were re-verified against rather than a local build.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Paddock <paddock@valfenda.net>
Co-authored-by: Claude <noreply@anthropic.com>
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