Skip to content

fix(sessions): the gate is the universal turn heartbeat (v0.326.0) - #596

Merged
vikasprogrammer merged 1 commit into
mainfrom
feat/gate-heartbeat
Aug 8, 2026
Merged

fix(sessions): the gate is the universal turn heartbeat (v0.326.0)#596
vikasprogrammer merged 1 commit into
mainfrom
feat/gate-heartbeat

Conversation

@vikasprogrammer

Copy link
Copy Markdown
Owner

Reported: a session actively doing work shows ready.

Yes — because it was launched before the lifecycle hook shipped. But it's a real hole either way: #593 made isWorking self-heal against a stale flag, and left the opposite direction with no backstop at all — nothing could set the flag back.

Hook settings are written into the agent folder at launch, so a session launched before UserPromptSubmit was wired never fires it. Once its busy_since was cleared it could never regain it.

Confirmed on the reported session ses_c63f1492dc12ce06:

pane:        ✽ Germinating… (1m 2s · ↓ 2.6k tokens)
gate events: every few seconds
busy_since:  NULL          ← nothing left that could set it
console:     ready

Fix — put the heartbeat on the one hook that cannot be missing

The gate hook is wired into every session that exists; it is the invariant. A tool call is proof a turn is running, so it stamps busy_since too.

Three properties that make it safe:

  • answered: false — a tool call is not a human answering, so it never retires a "waiting on you" card. (A session blocked on an approval reads needs you regardless — that outranks working.)
  • it re-stamps a flag older than MID_TURN_MAX_MS, turning that ceiling from a dumb timer into an honest activity test: a long turn still calling tools keeps its spinner, a wedged one emits nothing and ages out. Without this arm a real 2h+ turn would silently read ready — the same class of bug in a new place.
  • throttled to one write per 30s per session (in-memory, dropped by clearTurnBusy so the first tool call of the next turn is never swallowed). The gate is a hot path and node:sqlite is synchronous — a lock-taking write there is the event-loop blocking that has made a busy box feel unresponsive before, and the statement is a no-op mid-turn anyway.

Verification

9 new assertions in scripts/turn-lifecycle-test.cjs (49 total): an old session with no UserPromptSubmit goes working on a tool call; a tool call doesn't clear a waiting card; the throttle never swallows the first call of a turn; a 3h-old stamp is wedged until a tool call revives it. Full suite green.

Deploy note: server restart required. Works on already-running sessions — that's the point.

🤖 Generated with Claude Code

https://claude.ai/code/session_015ZUfffxY4hKv7M6wMCcaTz

A session that was visibly generating could read `ready` — the inverse of the
v0.324.0 bug, and the half that had no backstop. isWorking self-healed against a
STALE flag, but nothing could set the flag back.

Hook settings are written into the agent folder at LAUNCH, so a session launched
before UserPromptSubmit was wired never fires it: once its busy_since was
cleared, it could never regain it. Observed live on ses_c63f1492dc12ce06 — pane
showing "Germinating… (1m 2s)", gate events every few seconds, busy_since NULL,
console reading `ready`.

The heartbeat now lives on the gate hook, which is wired into every session that
exists and is the one thing that cannot be missing — it IS the invariant. A tool
call is proof a turn is running, so it stamps busy_since too:

- with `answered: false`, so a tool call never retires a "waiting on you" card
  (only a human submitting a prompt does). A session blocked on an approval reads
  `needs you` regardless, since that outranks `working`.
- re-stamping a flag older than MID_TURN_MAX_MS, which turns that ceiling from a
  dumb timer into an honest ACTIVITY test: a long turn still calling tools keeps
  its spinner, a wedged one emits nothing and ages out. Without this arm a real
  2h+ turn would silently read `ready`.
- throttled to one write per 30s per session (in-memory, dropped by
  clearTurnBusy so the first tool call of the next turn is never swallowed). The
  gate is a hot path and node:sqlite is synchronous — a lock-taking write there
  is the event-loop blocking that has made a busy box feel unresponsive before,
  and the statement is a no-op mid-turn anyway.

9 new assertions in scripts/turn-lifecycle-test.cjs (49 total).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015ZUfffxY4hKv7M6wMCcaTz
@vikasprogrammer
vikasprogrammer merged commit 29658a6 into main Aug 8, 2026
1 check passed
@vikasprogrammer
vikasprogrammer deleted the feat/gate-heartbeat branch August 8, 2026 17:43
vikasprogrammer added a commit that referenced this pull request Aug 11, 2026
)

A session that was visibly generating could read `ready` — the inverse of the
v0.324.0 bug, and the half that had no backstop. isWorking self-healed against a
STALE flag, but nothing could set the flag back.

Hook settings are written into the agent folder at LAUNCH, so a session launched
before UserPromptSubmit was wired never fires it: once its busy_since was
cleared, it could never regain it. Observed live on ses_c63f1492dc12ce06 — pane
showing "Germinating… (1m 2s)", gate events every few seconds, busy_since NULL,
console reading `ready`.

The heartbeat now lives on the gate hook, which is wired into every session that
exists and is the one thing that cannot be missing — it IS the invariant. A tool
call is proof a turn is running, so it stamps busy_since too:

- with `answered: false`, so a tool call never retires a "waiting on you" card
  (only a human submitting a prompt does). A session blocked on an approval reads
  `needs you` regardless, since that outranks `working`.
- re-stamping a flag older than MID_TURN_MAX_MS, which turns that ceiling from a
  dumb timer into an honest ACTIVITY test: a long turn still calling tools keeps
  its spinner, a wedged one emits nothing and ages out. Without this arm a real
  2h+ turn would silently read `ready`.
- throttled to one write per 30s per session (in-memory, dropped by
  clearTurnBusy so the first tool call of the next turn is never swallowed). The
  gate is a hot path and node:sqlite is synchronous — a lock-taking write there
  is the event-loop blocking that has made a busy box feel unresponsive before,
  and the statement is a no-op mid-turn anyway.

9 new assertions in scripts/turn-lifecycle-test.cjs (49 total).


Claude-Session: https://claude.ai/code/session_015ZUfffxY4hKv7M6wMCcaTz

Co-authored-by: Claude Opus 5 (1M context) <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