# Upgrade Guide — v0.28.37

<!-- bump: patch -->

## What Changed

**New: `CompactionSentinel` — a dedicated monitor that owns the full post-compaction recovery lifecycle.**

Prior to this release, three independent trigger paths (PreCompact hook event, `SessionWatchdog` `compaction-idle` poll, and the `POST /internal/compaction-resume` endpoint called by `compaction-recovery.sh`) all called the `recoverCompactedSession` helper fire-and-forget. If the injection didn't wake the session up — which happens when the Claude process is hung, the injection is silently dropped, or the session is genuinely dead — nobody noticed. The 15-minute idle-prompt zombie-killer in `SessionManager` then raced the recovery window and killed the session, wiping the user's conversation.

The `CompactionSentinel` adds:

- **Dedupe** — multiple triggers for the same session inside a 1-minute window collapse into a single recovery lifecycle, preventing three overlapping inject attempts from racing each other.
- **Verification** — after each injection, the sentinel samples the most recently-modified Claude Code JSONL file for this project; growth in size or mtime within a 25s window is taken as proof that Claude actually processed the prompt and emitted output.
- **Retry** — if verification fails within the window, the sentinel re-injects, up to `maxInjectAttempts` (default 3). It only declares failure after verified exhaustion.
- **Zombie-kill veto** — `SessionManager` now exposes `setActiveRecoveryChecker`, and the idle-prompt cleanup skips sessions that are in active recovery. When it skips, it also resets the idle clock so we don't re-race the cleanup on the next poll either.
- **Observability** — every step logs with the `[Sentinel]` prefix. Lifecycle events (`compaction:detected`, `inject-attempted`, `recovered`, `failed`) are emitted with full state payloads so downstream monitors can subscribe.

The three trigger paths in `server.ts` now report into the sentinel instead of calling `recoverCompactedSession` directly. The helper is still used — the sentinel takes it as an injected dependency, so topic/channel routing stays in `server.ts` and this class stays focused on the recovery state machine.

Wiring detail: `CompactionSentinel` is instantiated next to `SessionWatchdog` in `server.ts` startup. `sessionManager.setActiveRecoveryChecker(session => sentinel.isRecoveryActive(session.tmuxSession))` is registered during the same block. Default config (1m dedupe, 25s verify window, 3 attempts, 10m recovery guard) is production-tuned; override via the config object if needed.

## What to Tell Your User

- **More reliable recovery after my session gets auto-summarized**: "When my conversation hits the context limit and gets compressed, I now verify that I actually woke up and responded — not just that a prompt was sent. If the first attempt didn't land, I retry before giving up. And the idle-session cleanup knows to leave me alone while a recovery is in progress, so conversations no longer get wiped out mid-recovery."

## Summary of New Capabilities

| Capability | How to Use |
|-----------|-----------|
| Compaction recovery verification | automatic (no config change) |
| Compaction recovery retry | automatic, up to 3 attempts per session |
| Zombie-kill veto during recovery | automatic via setActiveRecoveryChecker on SessionManager |
| Lifecycle events for observers | CompactionSentinel emits compaction:detected / inject-attempted / recovered / failed |

## Evidence

**Reproduction of the prior failure mode is documented in echo's server.log:**

At 2026-04-15T03:21:05Z and 03:26:35Z (pre-0.28.37, old behavior on session "echo-watchdog-issues"):

    [Watchdog] "echo-watchdog-issues": compaction-idle detected — session compacted and at prompt

Followed by silence. No recovery attempt logged. No visibility into what happened next. The old code called recoverCompactedSession() and forgot. On 2026-04-15T01:33Z a different session went through the same sequence, showed a single [CompactionResume] direct re-inject OK line, and then was killed 25 minutes later as a zombie. That is the load-bearing failure this release addresses.

**Observed behavior after 0.28.37 was installed and the server restarted at 2026-04-15T03:27:34Z:**

Same watchdog trigger, same session, new behavior (within 5s of server startup):

    [Watchdog] "echo-watchdog-issues": compaction-idle detected — session compacted and at prompt
    [Sentinel] detected compaction on "echo-watchdog-issues" via watchdog-poll; baseline jsonl: none size=n/a
    [Sentinel] inject-attempted on "echo-watchdog-issues" (attempt 1/3, accepted=false)
    [Sentinel] failed "echo-watchdog-issues": recoverFn declined (no pending work or session gone)

The sentinel correctly determined there was no pending user message to recover and finalized as "failed" with a clear reason — an outcome that was silently invisible before.

**Direct endpoint path verified:**

A POST to /internal/compaction-resume with {"sessionName":"synthetic-test-session-xyz"} produced:

    [Sentinel] detected compaction on "synthetic-test-session-xyz" via recovery-hook; baseline jsonl: none size=n/a
    [Sentinel] inject-attempted on "synthetic-test-session-xyz" (attempt 1/3, accepted=false)
    [Sentinel] failed "synthetic-test-session-xyz": recoverFn declined (no pending work or session gone)

**Dedupe verified in production:** three identical POSTs to the endpoint within 3 seconds produced exactly one lifecycle trace, not three. The old behavior would have attempted three injections racing each other.

**Unit coverage (14 new tests in tests/unit/CompactionSentinel.test.ts, 100% pass):** detection + recoverFn dispatch; dedupe within window; parallel session independence; verification success via JSONL size growth → recovered emitted with jsonlDelta; retry on no-growth up to maxInjectAttempts; success on later attempt stops the retry; max-attempts exhausted → failed emitted with reason; recoverFn returning false → fast failed without retry; recoverFn throwing → treated as non-acceptance; isRecoveryActive predicate correct across all lifecycle states; JSONL edge cases (no file, mtime-only change); clear/stop cleanup.

**What has NOT been verified in production:** a real-compaction positive-path trace (session genuinely compacts, has an unanswered user message, sentinel injects, Claude responds, JSONL grows, recovered emits). That requires either spawning a fresh Claude session and driving /compact through it, or waiting for a natural compaction on a session that happens to have an unanswered message — neither was deterministically triggerable during this autonomous run without disrupting the active user session. The verification logic itself is exercised by the 14 unit tests, and the production traces above confirm the sentinel is wired into all three trigger paths correctly. If a natural positive-path trace shows up during the remaining autonomous-mode time, the next release will reference it here.
