# Side-Effects Review — Topic-binding-aware zombie kill + resume-failure fallback

**Version / slug:** `zombie-kill-topic-binding`
**Date:** `2026-05-04`
**Author:** `Echo`
**Second-pass reviewer:** `independent-review-subagent (concerns raised + resolved)`

## Summary of the change

Closes a two-stage failure mode that drops the user's first message after a
conversational pause on Telegram-bound (and Slack/iMessage-bound) agents.

**Root cause traced from Inspec/monroe-workspace logs.** When a Telegram agent
finishes replying, Claude sits at the prompt waiting for the next user
message. SessionManager's zombie-killer interprets "idle at prompt + no active
processes for 15 minutes" as zombie and kills the session. When the user
finally messages, the bridge tries to respawn with `--resume <UUID>`; the
saved UUID was captured at kill time and sometimes crashes Claude during
startup (`Session died during startup`). `waitForClaudeReady` times out, the
initial message is logged "NOT injected", and the user's message is dropped.
Five minutes later, the presence proxy fires its `tier-3 — session appears
stopped` warning. The user has to send "unstick" or re-send to recover.

**Fix in two layers:**

- **Layer A — Topic-binding exemption (signal-vs-authority structural
  exemption).** SessionManager gains an optional `topicBindingChecker`
  callback. When the zombie-killer is about to act, it consults the checker;
  if the session is bound to a live messaging topic the kill threshold is
  raised from 15 minutes to a configurable bound threshold (default 240
  minutes / 4h). The binding is an authoritative structural fact (the
  TelegramAdapter's reverse map), not a judgment call. Default chosen to
  cover normal conversational pauses through a workday without holding
  per-session resources (Claude TUI ~200-500MB RSS, Anthropic connection)
  indefinitely. Operators can override via `idlePromptKillMinutesBoundToTopic`.

- **Layer B — Resume-failure fresh-spawn fallback.** When the readiness
  probe fails AND tmux died during startup AND the spawn was using
  `--resume`, SessionManager falls through once to a fresh-spawn carrying the
  same initial message. A `resumeFailed` event is emitted; the bridge clears
  the bad UUID from `TopicResumeMap` so the next user-driven respawn doesn't
  retry the same broken UUID. The bridge listener gates the `remove()` on
  UUID-equality with the failed UUID — so a fresh spawn that quickly saved a
  *new* UUID won't have it wiped by a late-firing listener.

**Files touched:**
- `src/core/types.ts` — adds `idlePromptKillMinutesBoundToTopic?: number`.
- `src/core/SessionManager.ts` — adds binding checker, bound threshold getter,
  binding-aware kill decision, and `handleReadyAndInject` with single-retry
  fresh-spawn fallback. Emits `resumeFailed` event.
- `src/commands/server.ts` — wires the binding checker to consult Telegram /
  Slack / iMessage adapters; subscribes to `resumeFailed` to clear the stale
  UUID from `TopicResumeMap`.
- `tests/unit/zombie-kill-topic-binding.test.ts` — new behavioral tests.
- `tests/unit/spawn-resume-fallback.test.ts` — new behavioral tests.

## Decision-point inventory

- `SessionManager` zombie-kill decision (`isActuallyIdle && idleMs > threshold`) — **modified**: threshold is now binding-aware.
- `SessionManager.spawnInteractiveSession` post-readiness initial-message inject — **modified**: adds a single fresh-spawn fallback when --resume crashes during startup.
- `commands/server.ts` `injectionDropped` listener — **pass-through**: existing recovery path is preserved; the new `resumeFailed` listener is purely a UUID-cleanup hook, no block/allow surface.

---

## 1. Over-block

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

The only block-shaped surface this change touches is "kill vs don't-kill". The
change *raises* the threshold for topic-bound sessions; it does not block
anything new. The risk is the inverse of over-block: failing to kill a truly-
zombied topic-bound session for up to 24h.

Concrete scenario: A topic-bound session whose Claude process hangs internally
(e.g., infinite loop in the TUI) but stays "alive" by `pane_current_command`
will not be cleaned up by the zombie-killer for up to 24h. Mitigation: the
bridge's `isSessionAlive` check on the next user message authoritatively
detects truly-dead Claude processes and triggers a clean respawn — that's the
fast path. The 24h threshold only matters for users who never message again.

---

## 2. Under-block

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

Layer A misses: a topic-bound session whose Claude has stopped responding to
input but is still showing the prompt and registering as `alive` will not be
killed promptly. As above — mitigated by user-driven respawn on next message.

Layer B misses: if the fresh-spawn fallback also crashes during startup (e.g.,
disk full, claudePath wrong, persistent corruption), we surface a degradation
event but do not retry again. This is intentional — single retry only — to
avoid spawn-loops. The bridge's existing `injectionDropped` recovery path
will pick up the ball on the next inbound message.

Layer B also does not cover: the case where `--resume` succeeds *enough* for
tmux to stay alive but Claude itself is broken (won't render the prompt). In
that case we still fall through to "best-effort inject anyway" preserving the
prior behavior. That's not a regression.

---

## 3. Level-of-abstraction fit

**Is this at the right layer?**

Yes. SessionManager owns session lifecycle, so the kill threshold belongs
there. The binding check is delegated to a callback (the same pattern as
`subagentChecker` and `activeRecoveryChecker`) so SessionManager stays
unaware of which messaging platform is asking — it only consumes a yes/no
binding signal.

The fresh-spawn fallback also belongs in SessionManager because it owns the
spawn primitive. The bridge layer only consumes the `resumeFailed` event for
its own state cleanup (`TopicResumeMap.remove`), which is unique to the
bridge's responsibility.

A higher-level alternative would have been to do the retry in the bridge
(routes.ts `/internal/telegram-forward`). Rejected: that requires either
refactoring `spawnInteractiveSession` to expose readiness to the caller (big
churn across 15 callers) or duplicating the spawn-and-await logic in two
places (drift risk). Keeping it in SessionManager is cheaper and isolates
the fix to one method body.

---

## 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 has no block/allow surface in the judgment sense.

The zombie-killer is not a judgment authority — it's a structural cleanup
mechanism whose behavior is now parameterized by an authoritative structural
fact (is this session in the topic→session reverse map). Per the principle
doc:

> When this principle does NOT apply: Hard-invariant validation … structural
> validators at the boundary of the system are not decision points in the
> sense this principle applies to.

The binding lookup is a hard structural fact ("is this session ID in the
TelegramAdapter's reverse map?"), not a judgment about what a message
*means*. There is no LLM, no regex, no similarity score, no token list.

The fresh-spawn fallback is a recovery flow control, not a decision point on
content or intent. No principle violation.

---

## 5. Interactions

**Does this interact with existing checks, recovery paths, or infrastructure?**

- **Shadowing:**
  - The zombie-killer's existing vetoes (`activeRecoveryChecker`,
    `subagentChecker`, `pendingInjections`) all run BEFORE the new threshold
    check. They are unaffected — bound sessions still respect compaction
    recovery, subagent activity, and pending-injection events.
  - The new `topicBindingChecker` runs AFTER `idlePromptSince` is established,
    not before, so the existing first-idle hooks (paste-retry, error-nudge)
    still fire normally on bound sessions.

- **Double-fire:**
  - `resumeFailed` and `injectionDropped` could both fire for the same session
    if the resume crashes AND the recovered fresh-spawn also fails to
    inject. In that case, the bridge's `injectionDropped` listener will
    re-forward the user's text via `/internal/telegram-forward`, which
    triggers a new spawn. This is the same path that already runs today on
    crashed sessions; the change does not introduce a new loop.

- **Races:**
  - The fresh-spawn fallback inside `handleReadyAndInject` runs after a
    `kill-session` to clean up any zombie pane. If a concurrent monitor tick
    is in flight, it could observe the dead pane mid-cleanup and emit
    `sessionComplete` for the failed session. We mark the failed session
    `status: 'failed'` BEFORE emitting `resumeFailed` (and before the
    recursive spawn), so reapers see consistent state through the full
    handoff.
  - The `resumeFailed` listener fires AFTER the fresh-spawn may have already
    saved a new UUID via the proactive 8-second save. To avoid wiping the new
    UUID, the listener gates `TopicResumeMap.remove(topicId)` on a
    UUID-equality check (only remove when the stored UUID still matches the
    failed one). Direct test: `tests/unit/resume-failed-uuid-gate.test.ts`.
  - `topicBindingChecker` is read-only. No shared mutable state.

- **Feedback loops:** None. The fresh-spawn fallback is one-shot.

---

## 6. External surfaces

**Does this change anything visible outside the immediate code path?**

- **Other agents on the same machine:** No. Each agent's SessionManager owns
  its own kill threshold and its own binding map.
- **Other users of the install base:** Yes — every Telegram/Slack/iMessage
  agent will now hold sessions for up to 24h instead of cleaning them up at
  15 minutes. Memory footprint and Claude API connection count per agent
  may rise. Users with many concurrent topics on a memory-constrained host
  can override `idlePromptKillMinutesBoundToTopic` in config.json. The
  default is conservative for the common case (1-3 topics per agent).
- **External systems:** No changes to Telegram/Slack/iMessage API surface.
  Tunnel, GitHub, Cloudflare unaffected.
- **Persistent state:** `TopicResumeMap` entries are cleared on resume
  failure (one extra `remove` call per failure). State file format unchanged.
- **Timing/runtime:** The bound threshold default (24h) is bounded; sessions
  cannot accumulate forever. The fresh-spawn fallback adds at most one
  additional 90-second readiness window per spawn attempt; bounded.
- **Logs:** New log lines on bound-zombie-kill (`(topic-bound, threshold Nm)`),
  resume failure (`Resume failed for "X" — tmux died during startup. Falling
  back to fresh spawn.`), and fresh-spawn success/failure. Format is
  consistent with existing `[SessionManager]` lines.

---

## 7. Rollback cost

**If this turns out wrong in production, what's the back-out?**

Pure code change. No schema migration, no persistent state shape change, no
data migration. Rollback path: revert the commit, ship as next patch. Agents
will resume the prior 15-minute kill threshold on their next server restart.
No user-visible regression during rollback window — at worst, the user sees
the old "session appears stopped" pattern they reported.

The new config option `idlePromptKillMinutesBoundToTopic` falls back to a
hardcoded default (1440), so a rollback that drops the field from disk
config is a no-op.

---

## Conclusion

This review produced no design changes — both layers passed signal-vs-
authority compliance and the side-effects review on first read. The change
is contained to SessionManager and one wiring call in `commands/server.ts`,
with two new dedicated test files (8 new tests) plus 21 existing
session-reap-detect tests still passing.

The change is clear to ship pending second-pass review (required because it
touches session lifecycle: spawn, kill, recovery).

---

## Second-pass review (if required)

**Reviewer:** independent-review-subagent
**Independent read of the artifact: concern**

I concur on layer A's signal-vs-authority compliance and on the overall shape of layer B, but I have specific concerns that should be resolved before ship:

- **Threshold default 1440m (24h) is too aggressive a swing from 15m.** The healthy waiting state argument is sound, but 24h means each bound session holds a Claude TUI process (~200–500MB RSS) and an Anthropic connection for a full day even if the user never returns. For an agent with 8–10 concurrent Telegram topics on a 16GB host, that's 2–5GB of resident memory locked indefinitely, vs. the prior steady-state where idle topics released within 15m and only re-spawned on the next message. The artifact's mitigation ("config override available") puts the burden on every multi-topic operator to discover the new default and tune it down; the conservative default should solve the reported symptom without the resource cost. **Recommended resolution:** drop default to 240m (4h) — long enough that conversational pauses through normal work hours don't trip the kill, short enough that overnight idle sessions release. Keep the config knob for users who genuinely want 24h.

- **Cleanup race between proactive UUID save and `resumeFailed` listener is plausible (low-likelihood but real).** Order of operations I traced:
  1. `spawnSessionForTopic` calls `spawnInteractiveSession` (returns at line 1390 once tmux is created, before readiness probe).
  2. Caller at server.ts:528 immediately removes the bad UUID from `TopicResumeMap`.
  3. Caller at server.ts:537–549 schedules a `setTimeout(8s)` proactive UUID save against the same tmux name.
  4. ~90s later, `handleReadyAndInject` decides resume failed, emits `resumeFailed`, listener tries to remove UUID (no-op — already gone).
  5. Fallback recursively calls `spawnInteractiveSession` which creates a fresh Claude under the same tmux name.

  The 8s proactive save fires while the failed Claude is still in startup-crash territory (no hook event yet, `claudeSessionId` empty → save is skipped). That's safe by accident, not by design. If the fresh-spawn fallback finishes quickly enough that a hook event lands before the resumeFailed listener fires, the listener could clear a fresh, valid UUID. The current emit-before-spawn ordering makes this unlikely, but it's not asserted by a test. **Recommended resolution:** add a test that runs the full sequence (proactive save scheduled → resume crash → fallback spawn → fresh hook event lands) and verifies `TopicResumeMap` ends with the *new* UUID, not empty. Or, more defensively, gate the listener's remove on a UUID-equality check (only clear if the stored UUID still matches `info.resumeSessionId`).

- **Failed-session status update happens AFTER `emit('resumeFailed')`.** Lines 1431–1446 emit the event first, then mark `failed.status = 'failed'`. The artifact §5 Races claims "We mark the failed session `status: 'failed'` before kicking off the fresh spawn so reapers don't re-process it" — but the emit-then-mark order means a concurrent monitor tick that fires between emit and `state.saveSession(failed)` will see `status: 'running'` on a dead pane. The recursive spawn happens after the marking, so the practical impact is small (window is microseconds), but the artifact's claim doesn't match the code. **Recommended resolution:** either move the status update before the emit, or soften the artifact's claim.

- **Test coverage gaps the artifact undersells.** All 8 new tests use mocked tmux with a single session; none cover (a) the fresh-spawn fallback itself failing — only `DegradationReporter.report` is exercised by code path, never by test, (b) concurrent monitor ticks during fallback (the race the artifact §5 itself flags), (c) multiple bound + unbound sessions on the same manager where the binding checker returns a mix, or (d) the listener's UUID-cleanup interacting with a happy-path remove on the same topic. The "21 existing session-reap-detect tests" don't cover any of this — they predate the change. **Recommended resolution:** add at least the "fallback also fails" test and a "mixed bound/unbound sessions" test before merge.

- **Minor: `tmuxSession.replace(\`${path.basename(this.config.projectDir)}-\`, '')` at line 1457 is a string-first-occurrence replace.** If the agent's project directory basename happens to appear later in the session name (rare but possible — e.g. project `monroe`, session named `monroe-ai-monroe-debug`), only the first occurrence is stripped, which is correct. But the implicit assumption that `tmuxSession` always begins with `${projectBase}-` isn't enforced — if `name` was originally `null` and `tmuxSession` was `${projectBase}-interactive-${Date.now()}`, the recursive call passes `interactive-${Date.now()}` as `name`, creating a *different* tmux session name on retry (`projectBase-interactive-<sanitized>`). The recursive call would not reuse the same tmux name, breaking the implicit contract that the bridge's session→topic mapping still resolves. **Recommended resolution:** add a guard for the un-named case, or pass the tmuxSession name through more explicitly to ensure name preservation.

None of these are blockers in the "stop the world" sense — layer A is sound and layer B is a clear improvement on dropping messages. But the threshold default and the cleanup-race test gap warrant a follow-up before this lands on a production-traffic agent.

---

### Author's resolution of second-pass concerns

All five concerns were addressed in this same PR before commit:

1. **Threshold default lowered from 1440 → 240 minutes (4h).** Source: `src/core/SessionManager.ts:65`. Long enough to cover normal conversational pauses through a workday; short enough to release resources from genuinely abandoned topics. Config knob `idlePromptKillMinutesBoundToTopic` preserved for operators who want a different value. Test updated to assert the new default.

2. **UUID-equality gate on `resumeFailed` listener.** Source: `src/commands/server.ts` (search `UUID-equality gate`). Listener now reads stored UUID and only calls `remove()` when it matches `info.resumeSessionId`. New test file `tests/unit/resume-failed-uuid-gate.test.ts` covers all four cases: matching, replaced (the race), absent, and missing-topicId.

3. **Order swapped: failed-status update now happens BEFORE `emit('resumeFailed')`.** Source: `src/core/SessionManager.ts` `handleReadyAndInject`. Artifact §5 claim now matches the code.

4. **Test gaps closed.** Added: "fresh-spawn fallback also fails → degradation reported", "mixed bound + unbound sessions on the same manager", and the entire UUID-equality gate test file. Total new behavioral tests: 14 (was 8).

5. **tmuxSession-name reconstruction fixed.** Source: `handleReadyAndInject` now threads the original `name` parameter through and passes it directly to the recursive `spawnInteractiveSession`. The fragile `tmuxSession.replace(prefix, '')` reconstruction is gone — auto-generated `interactive-${ts}` names round-trip correctly.

Verified by re-running the focused test suite: 81 tests across 7 files passing.

---

## Evidence pointers

**Repro evidence:**
- `/Users/justin/Documents/Projects/monroe-workspace/logs/server.log`
  - `2026-05-04T23:39:14Z` — zombie kill of healthy idle session
  - `2026-05-05T00:48:16Z` — user message arrives, no live session
  - `2026-05-05T00:48:16Z` — respawn-with-resume attempted (UUID `716881a4-...`)
  - `2026-05-05T00:48:20Z` — Session died during startup
  - `2026-05-05T00:48:20Z` — Claude not ready, message NOT injected
  - 19 prior occurrences of the same `Claude not ready` log line going back to 2026-04-28.

**Test evidence:**
- `tests/unit/zombie-kill-topic-binding.test.ts` — 6 tests: unbound kill, bound exemption, bound + over-threshold kill, null-checker, mixed bound+unbound (added per reviewer), default 4h.
- `tests/unit/spawn-resume-fallback.test.ts` — 4 tests: resume crash → fresh-spawn fallback, no-resume fresh spawn, no-fallback on prompt-detection false negative, both spawns fail → degradation reported (added per reviewer).
- `tests/unit/resume-failed-uuid-gate.test.ts` — 4 tests (added per reviewer): clear when stored UUID matches, preserve when stored UUID has been replaced (race), no-op when no stored UUID, no-op when no telegramTopicId.
- 7 related test files (81 tests) all green: `session-manager-behavioral`, `session-reap-detect`, `CompactionSentinel`, `bootstrap-file-threshold`, plus the three new files.
