Skip to content

fix: preserve sessions with extension-owned work - #705

Merged
agegr merged 3 commits into
agegr:mainfrom
vcing:fix/extension-background-liveness
Sep 5, 2026
Merged

agegr merged 3 commits into
agegr:mainfrom
vcing:fix/extension-background-liveness

Conversation

@vcing

@vcing vcing commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • add a small, versioned registry that lets server-side extensions report exact-session liveness
  • consult extension liveness only when the normal 10-minute idle timer would otherwise evict a session
  • preserve explicit shutdown, reload/replacement, process exit, and Stop fallback cleanup semantics

Problem

Pi Web currently decides whether a session is idle from foreground AgentSession state only: queued prompts, streaming, compaction, and bash execution. An extension can therefore own detached work after the parent turn has ended while the host sees the session as idle.

At the timeout boundary, Pi Web disposes the session and its extension runtime. Detached work may continue elsewhere, but the extension state or completion notifier that was supposed to wake the parent is gone. This affects long-running background tasks and is the structural limitation described in #629 and #572.

A configurable global timeout such as #665 is useful operationally, but it cannot distinguish a disposable session from one with live extension-owned work. This PR is complementary: extensions opt in per exact session, and the ordinary timeout remains unchanged for every other session.

Design

The host publishes Symbol.for("@agegr/pi-web/session-liveness/v1") with a synchronous provider registry. A provider supplies:

  • a name
  • the exact sessionId and optional sessionFile
  • a cheap synchronous isActive() probe

Each registration receives an independent idempotent release function. Providers are matched only against the wrapper's exact session identity. A matching provider that throws or returns a non-boolean value fails safe by preserving that session and logging the error.

AgentSessionWrapper.isRunning() is deliberately unchanged because it is also used for UI state and prompt/session coordination. The registry is consulted only inside the automatic idle-eviction callback, after forceShutdownOnIdle has been checked.

Validation

  • env -u PI_WEB_PASSWORD npm test — 850 passed
  • npm run lint
  • npx tsc --noEmit
  • focused session-liveness and shutdown tests — 24 passed
  • git diff --check

Per the repository development guidance, next build was not run.

Related: #629, #572, #665

Companion provider-side integration: nicobailon/pi-subagents#1857 (merged as 11ea7e9). Until this host-side registry ships, that integration intentionally degrades to a no-op.

@agegr
agegr merged commit f57b565 into agegr:main Sep 5, 2026
sleepinginsummer pushed a commit to sleepinginsummer/pi-web that referenced this pull request Sep 9, 2026
* fix: preserve sessions with extension-owned work

* test: lock extension liveness protocol contract

* fix: safely log session liveness provider errors

---------

Co-authored-by: Alex Yang <agegcn@gmail.com>
agegr added a commit that referenced this pull request Oct 19, 2026
… own

The event stream's lease is what keeps the selected session's idle wrapper
from being reaped (#705 liveness, "keep selected sessions alive"). A hidden
page that let go of the stream also let go of that lease and stopped
renewing, so ten minutes later (PI_WEB_IDLE_TIMEOUT_MS) the wrapper shut
down: an extension's timer in it never fired, and nothing ran to announce
by push either, while the session was still selected in that tab.

The page now holds a lease of its own in the stream's place, at the release
and on each renewal tick (POST /api/agent/[id]/lease?holder=<page id>,
holdSessionLivenessLease()): one per page and session, ended a TTL after the
page stops renewing it. The return renews the stream's lease again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
agegr added a commit that referenced this pull request Oct 20, 2026
* fix(sessions): let hidden pages release idle event streams

Over HTTP/1.1 a browser opens at most six connections per origin, shared
by all its tabs. Every pi-web tab kept the selected session's agent event
stream open even while idle (the warm-session effect; the idle grace in
scheduleEventStreamClose() skips the selected session), plus the active
file tab's watch while the right panel was open. A few background tabs
filled the pool, so Settings loads and message sync queued in the browser
until one tab closed.

A page hidden for HIDDEN_PAGE_GRACE_MS (30 s, hooks/usePageHidden.ts)
now asks GET /api/agent/[id] whether anything is under way: a run, a `!`
command, a compaction, an open extension dialog, or an unsettled subagent
at any depth (new subagentsActive, hasUnsettledSubagentRun()). Only when
nothing is does it close the selected session's stream
(canReleaseHiddenEventStream()); otherwise it checks again each grace. A
quick tab switch closes nothing, and a run that settles while hidden
starts the grace again, which covers an extension's follow-up right after
prompt_done. eventStreamReleasedRef keeps shouldMaintain() and the lease
renewal from reopening it. On return the stream reopens and the session
is read again with its state, applied as the mount does
(applyLoadedAgentState()), so work still under way shows as such; nothing
is resent.

The file watch pauses after the same grace. On reconnect the viewers
compare the file's size and mtime (now in type=meta and type=read, the
pair the watch's change events carry) and reload only a changed file, so
coming back keeps a text file's Load more chunks and scroll position,
media playback and a PDF's place.

What a released page no longer hears: a run started in the session after
the release (another device or tab, an extension timer, a subagent
notification between two checks) plays no chime and shows no in-page
completion or attention notification in that tab until it returns. Web
push still announces completion when subscribed. Terminal streams are
left alone: the server kills a shell that has had no listener for two
minutes. Feedback in the UI when requests are stuck is not addressed.

Refs #1283

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

* fix(sessions): bound the hidden-page idle check; count pending subagent notices

Second review follow-up to the hidden-page release.

The release waited on GET /api/agent/[id] with no timeout and kept the
stream when no reply came. In the very state #1283 is about, a pool
already full of streams, that request queues in the browser behind them
and never leaves, so no hidden tab ever let go. The check now aborts
after HIDDEN_RELEASE_CHECK_TIMEOUT_MS (5 s); no answer in time, like no
network, releases (canReleaseHiddenEventStream("unanswered")). An error
reply from the server still keeps the stream until the next grace.

A background subagent run leaves the run map before notifyParent() starts
the parent's turn (after waitUntilReady() and the idle poll), so a check
in that window saw no subagent and released. notifyParent() now records
the delivery until it is sent or fails, and hasUnsettledSubagentRun()
counts it.

The text viewer skipped the git diff along with the content on an
unchanged reconnect, leaving the gutter stale after a commit made while
hidden; it reads the diff again.

Refs #1283

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

* fix(sessions): read the hidden-page idle check without the system prompt

After #1306 status reads go through agentStatusUrl() (?systemPrompt=0).
The hidden-page release asked GET /api/agent/[id] with the full state
every grace per hidden tab; it is a status read too.

Refs #1283

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

* fix(sessions): keep a released page's session alive on a lease of its own

The event stream's lease is what keeps the selected session's idle wrapper
from being reaped (#705 liveness, "keep selected sessions alive"). A hidden
page that let go of the stream also let go of that lease and stopped
renewing, so ten minutes later (PI_WEB_IDLE_TIMEOUT_MS) the wrapper shut
down: an extension's timer in it never fired, and nothing ran to announce
by push either, while the session was still selected in that tab.

The page now holds a lease of its own in the stream's place, at the release
and on each renewal tick (POST /api/agent/[id]/lease?holder=<page id>,
holdSessionLivenessLease()): one per page and session, ended a TTL after the
page stops renewing it. The return renews the stream's lease again.

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

* fix(sessions): keep the selected session alive on the page's lease, not its stream

The selected session's liveness lease was carried by its event stream, so
a hidden page that let go of the stream (#1283) let go of the session too;
539e4cc patched that with a second, page-held lease taken only at the
release. The lease now belongs to the page alone: POST
/api/agent/[id]/lease?holder=<page id> creates or renews one lease per
page and session (holdSessionLivenessLease()), at selection, every 30 s and
on return, whether or not the stream is open. A closed tab's lease ends a
TTL (90 s) after its last renewal. The stream no longer takes a lease, and
the route answers 400 without a holder.

A renewal that found no lease used to mean a dead stream and reconnected
it. The page's own hold always finds one, so that probe could no longer
see a dead stream. The server now sends its 30 s heartbeat as a data
event ({ type: "heartbeat" }; an SSE comment never reaches EventSource),
and AgentEventConnection drops a ready stream silent for 75 s and
reconnects (staleAfterMs), which catches half-open connections directly.
The heartbeat never reaches onEvent.

Refs #1283

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

* fix(sessions): let the lease reply decide when a hidden page lets go of its stream and when it opens it again

The page already renews its selected session's lease every 30 s whether or
not it holds the event stream. Its reply now says what is under way there,
read from the wrapper alone (never starting one, never resetting its idle
timer): running (isRunning(): a prompt, a queued one, a `!` command, a
compaction), awaitingInput (an extension dialog waiting for an answer) and
subagentsActive (hasUnsettledSubagentRun()).

That one reply drives both ends of the release:
- At the end of the hidden page's grace, the release check is a lease
  request (shared with the renewal tick when one is in flight) instead of
  GET /api/agent/[id]; the stream closes only if the reply shows nothing
  under way. No reply within SESSION_LEASE_TIMEOUT_MS (5 s) still releases.
  GET /api/agent/[id] is back to main's.
- While released and still hidden, a renewal reply that shows activity
  opens the stream again and reads the session, so a run another tab, a
  device or an extension's timer starts there, and an extension dialog,
  again reach the hidden page with their chime and browser notifications.
  The reopen arms the grace again, so the stream is let go a grace after
  the activity ends.

A run that starts and settles between two renewals is still missed on the
hidden page; web push covers its completion when subscribed.

Refs #1283

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

---------

Co-authored-by: Claude Opus 5.5 <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.

2 participants