# Side-Effects Review — Compaction-recovery proxy-filter fix

**Version / slug:** `0.28.52`
**Date:** `2026-04-17`
**Author:** `echo`
**Second-pass reviewer:** `(this is a classifier-dedup fix with no Guard
surface — second pass not required per skill Phase 5)`

## Summary of the change

Closes the topic-6795 compaction stall: `recoverCompactedSession` was
deciding "is there pending work?" by looking at the last message in the
topic without filtering out PresenceProxy standby messages or
server-emitted delivery/lifecycle acks. Those are `fromUser: false` but
they are NOT real agent responses — treating them as "agent answered" is
what let the compaction-recovery safety net decline three consecutive
re-inject attempts while the user sat with an unanswered question.

The fix hoists the classifier that `PresenceProxy.isSystemMessage()` and
`checkLogForAgentResponse()` already used into a shared module, adds a
thin `findLastRealMessage(history)` walk-back helper on top, and routes
`recoverCompactedSession` through it. Three scattered copies of the
prefix list are now one.

Files touched:

- `src/messaging/shared/isSystemOrProxyMessage.ts` — new. Exports
  `isSystemOrProxyMessage(text)` (the classifier) and `findLastRealMessage(history)`
  (the walk-back). Thorough header comment documenting which subsystems
  consume it and the regression anchor.
- `src/commands/server.ts` — `recoverCompactedSession` now uses
  `findLastRealMessage` instead of `history[history.length - 1]`. History
  window widened 5 → 20. `checkLogForAgentResponse` now delegates to
  `isSystemOrProxyMessage` instead of its inlined prefix list.
- `src/monitoring/PresenceProxy.ts` — `isSystemMessage()` is now a thin
  wrapper over `isSystemOrProxyMessage` (instance-method signature
  preserved so existing callsites are unchanged).
- `tests/unit/isSystemOrProxyMessage.test.ts` — new, 25 tests.

## Decision-point inventory

- `recoverCompactedSession` unanswered-message check — **modify** — the
  authority that decides whether a compaction re-injection fires. The
  decision predicate changed from "look at last message only" to "walk
  backward skipping system/proxy, check first real message". Role
  (authority) unchanged; correctness of the predicate improved.
- `checkLogForAgentResponse` — **modify** — signal producer ("has the
  agent responded since X?"). Logic unchanged, just dedup against shared
  classifier.
- `PresenceProxy.isSystemMessage()` — **modify** — signal producer for
  the race guard. Logic unchanged, just dedup against shared classifier.
- `isSystemOrProxyMessage` / `findLastRealMessage` — **new helpers** —
  pure functions, no state, no side effects, no blocking authority of
  their own. They are detectors; callers decide what to do with the
  verdict.

---

## 1. Over-block

**What legitimate inputs does this change reject that it shouldn't?**

None new. The classifier's prefix list is identical to what
`PresenceProxy.isSystemMessage` and the pre-fix inlined copy in
`checkLogForAgentResponse` already used. Unit tests
(`does NOT classify a checkmark used in narrative as a delivery ack`,
`does NOT classify a message merely CONTAINING 🔭 later as proxy`) pin
down the leading-prefix contract — an agent reply that *mentions* 🔭 or
uses ✓ later in a sentence is correctly treated as a real response.

The only behavior change affecting "the agent answered" detection is that
`recoverCompactedSession` now walks PAST system/proxy entries instead of
tripping on them. The pre-fix behavior was strictly more trigger-happy
with declines; post-fix is strictly more trigger-happy with re-injections.
A re-injection on an already-answered topic costs one
`COMPACTION_RESUME_PROMPT` message plus a small session-wake — the same
path that fires on legitimate recoveries, and well-tested. Not comparable
to the silent 15-minute user-facing stall from the old behavior.

---

## 2. Under-block

**What failure modes does this still miss?**

1. **Pre-change under-block (the one this fixes):** recoverCompactedSession
   accepting standby-as-answer and declining recovery. Closed.
2. **History horizon:** `telegram.getTopicHistory(topicId, 20)` reads the
   last 20 topic entries. If the user's unanswered question is 21+ entries
   back AND every message in between is system/proxy, the walk returns
   null and declines. Pre-fix used a window of 5, so this is a
   strict improvement; but it's still finite. In practice, 20 messages of
   pure standby/ack traffic without a single real agent reply would itself
   be a distinct pathology (agent is completely wedged, not just
   compacting), and the right response is escalation to stall triage —
   not a bigger history window. Documented in the module header.
3. **New from-agent message formats:** if a future subsystem starts
   emitting a new kind of "not really a response" message (say, a new
   telemetry prefix), the classifier won't know about it and recovery
   will decline. Mitigation: the classifier is now in one place, so
   adding the new prefix is one edit and gets all three callsites for
   free — versus the pre-fix state where you'd need to update three
   files and remember which ones.
4. **Trimmed-whitespace input:** classifier trims before matching, so
   indentation/CRLF variations are covered. Pinned by
   `trim handling` tests.

---

## 3. Level-of-abstraction fit

The classifier sits at the *data-shape* layer: "given a line of text,
is it a real agent response?" It has no knowledge of topics, sessions,
timestamps, or recovery policy. Callers (recoverCompactedSession,
PresenceProxy, checkLogForAgentResponse) own the *policy* — what to do
when a message is or isn't a real response.

The walk-back helper `findLastRealMessage` sits one layer up: "given a
chronological history, find the latest real entry." Still no policy. Its
only knowledge is that histories are chronologically ordered and that the
classifier distinguishes real from not-real.

This is the same shape as other shared classifiers in the repo
(`detectContextExhaustion`, `isHttpIdempotent`). Appropriate fit.
No authority is being packed into a detector, no detector is being
smeared across three files.

Alternatives considered and rejected:

- **Inline the walk-back in `recoverCompactedSession` only** — would
  re-introduce the three-copy duplication problem on the next change.
  Rejected.
- **Make the classifier itself decide "should recovery fire?"** — would
  pack policy into the detector. Callers need different behaviors
  (PresenceProxy cares about self-cancellation, recoverFn cares about
  user turns, checkLog cares about any real agent message). Rejected.
- **Move the walk-back into TelegramAdapter as `getLastRealMessage`** —
  would couple a generic message-shape filter to the Telegram adapter.
  The same logic needs to apply to Slack histories later. Rejected in
  favor of the transport-agnostic helper.

---

## 4. Signal vs authority compliance

**Required reference:** [docs/signal-vs-authority.md](../../docs/signal-vs-authority.md)

**Does this change hold blocking authority with brittle logic?**

- [x] No — this change moves in the correct direction on the principle.

Narrative breakdown:

- `isSystemOrProxyMessage` is a **signal producer** — a detector on
  textual shape. No blocking authority, no side effects, no state. It
  emits a classification; callers choose what to do.
- `findLastRealMessage` is also a signal producer — it transforms a
  history into "which entry is the latest real one?" It doesn't decide
  whether anything should fire.
- The blocking/non-blocking authority lives in `recoverCompactedSession`
  (which decides to inject or not) and in the CompactionSentinel
  (which decides to retry or finalize). Neither gained new authority;
  the existing authority now consumes a less-brittle signal.

Signal strength: the classifier is prefix-based, which is fragile for
general-purpose intent classification — but here the "signal" is a
structural marker emitted by known server-code paths (PresenceProxy
emits `🔭`, the server emits `✓ Delivered` via a specific code path, etc.).
It's not inferring intent from free-text user input. Prefix matching is
the right tool for this specific job.

No brittle check is acquiring blocking authority. One authority
(recoverCompactedSession) is migrating from a brittle predicate ("last
message is from user?") to a robust one ("last REAL message is from
user?"). Principle held.

---

## 5. Interactions

- **With CompactionSentinel:** the Sentinel observes `recoverFn`'s
  accept/reject verdict unchanged. Dedupe, retry, verify-window, and
  finalize semantics are untouched. The fix changes WHEN recoverFn says
  `true`, not the Sentinel's reaction to it.
- **With PresenceProxy race guard:** PresenceProxy already used the
  (now shared) classifier to decide whether to self-cancel on a sibling
  standby. That logic is byte-equivalent pre and post — only the source
  of the prefix list changed. Verified by running
  `presence-proxy-cancel-race.test.ts` (5 tests) green.
- **With checkLogForAgentResponse:** delegates the classifier call.
  Function-level behavior unchanged. No new callers; same call sites
  (stall-triage idle check, PresenceProxy "has the agent responded since
  X"). Verified by running the broader suite green.
- **History-window widening (5 → 20):** only affects
  `recoverCompactedSession`. `telegram.getTopicHistory` is already
  safe up to 50+ entries in other call sites. No performance concern —
  reading 20 log entries is ~1-2ms.
- **Races:** the walk is over a snapshot of the history read once per
  call. No multi-step read, no TOCTOU window. Sentinel-level deduplication
  already prevents concurrent recoverFn calls for the same session.
- **Feedback loops:** recovery inject → session wakes → agent emits real
  response → future `checkLogForAgentResponse`/walk-back sees the real
  response, not the proxy standby that preceded it. Correctly breaks the
  loop that previously kept the session "stuck-looking."

---

## 6. External surfaces

- **Other agents on the same machine:** none. The classifier lives in
  the per-agent server process.
- **Other users of the install base:** on upgrade, agents whose
  compaction recovery was declining due to this bug will begin
  successfully recovering. This is the intended, silent, good behavior
  change. Users should not notice anything except that compaction stalls
  become shorter.
- **External systems:** none. No new egress.
- **Persistent state:** none. No schema changes. No agent-state migration.
- **Log format:** `[CompactionResume]` log lines are unchanged.
  `[Sentinel]` lifecycle lines are unchanged. A re-injection that
  previously would have logged `recoverFn declined (no pending work or
  session gone)` will now log the normal
  `direct re-inject OK for topic <N>` path.
- **Timing/runtime:** walking 20 log entries through a string-prefix
  classifier adds <1ms to the recoverFn hot path. Sentinel timers
  unchanged.

---

## 7. Rollback cost

Pure code change. Revert the commit, ship a patch. No persistent state
migration. No agent state repair. On rollback, compaction recovery
returns to the pre-fix behavior — declining when the last message is a
PresenceProxy standby. No user-visible regression beyond re-exposing the
original bug.

Estimated rollback effort: one revert commit + one release bump, <10 minutes.

---

## Conclusion

Small-surface bug fix. The change:

1. Closes a concrete user-visible failure (topic-6795 compaction stall,
   repro-ed three times).
2. Dedups three copies of a prefix list that were drifting.
3. Adds regression coverage (25 unit tests including the exact
   repro sequence).
4. Moves `recoverCompactedSession` from a brittle last-message-only
   predicate to a walk-back over filtered history.
5. Holds to signal-vs-authority: no detector gains blocking power; one
   authority now consumes a robust signal instead of a brittle one.

Clear to ship.

---

## Evidence pointers

- Reproduction: `.instar/shared-state.jsonl` entries around topic 6795 at
  16:08:14 / 16:13:xx / 16:18:xx logged the three
  `recoverFn declined (no pending work or session gone)` lines; topic
  history in `.instar/telegram-messages.jsonl` at each decline point
  shows the preceding message was a `🔭 Echo is currently updating the
  ledger spec…` PresenceProxy standby.
- Pre-fix behavior: `git show <pre-fix>:src/commands/server.ts` around
  the `recoverCompactedSession` definition shows
  `const lastMsg = history[history.length - 1]; if (lastMsg?.fromUser) { ... }`.
- Post-fix behavior: `findLastRealMessage(history)` returns the last
  non-system/non-proxy entry; the decision predicate sees the real user
  turn.
- Tests: `tests/unit/isSystemOrProxyMessage.test.ts` — 25 passing tests.
  Explicit `topic-6795 repro` case asserts the helper finds the user
  question past trailing proxy + ack entries.
