instar 0.28.87 → 0.28.89

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,382 @@
1
+ # Side-Effects Review — Session Self-Refresh
2
+
3
+ **Version / slug:** `session-self-refresh`
4
+ **Date:** `2026-05-11`
5
+ **Author:** Echo (instar developer)
6
+ **Second-pass reviewer:** Required (touches session lifecycle: spawn, kill, recovery)
7
+
8
+ ## Summary of the change
9
+
10
+ Adds an agent-initiated session refresh capability. The agent calls
11
+ `POST /sessions/refresh`, which 202-acks immediately and then (after ~500ms
12
+ for response flush) kills the current tmux session and respawns it with
13
+ `claude --resume <uuid>` so newly installed MCPs/skills attach while the
14
+ conversation is preserved.
15
+
16
+ The lifecycle is owned by a new module `src/core/SessionRefresh.ts` (~180
17
+ lines) that the existing Telegram `/restart` handler now also delegates to,
18
+ consolidating kill+resume logic in one place. The module enforces a rolling
19
+ rate-counter (default: 5 refreshes / 10-minute window / session) to prevent
20
+ infinite respawn loops.
21
+
22
+ **Files touched:**
23
+ - `src/core/SessionRefresh.ts` (new — lifecycle owner + rate guard)
24
+ - `src/server/routes.ts` (new POST /sessions/refresh + `sessionRefresh` on RouteContext)
25
+ - `src/server/AgentServer.ts` (option pass-through to RouteContext)
26
+ - `src/commands/server.ts` (module-scope `_sessionRefresh`, constructor wiring, `onRestartSession` delegation)
27
+ - `tests/unit/SessionRefresh.test.ts` (new — 11 cases)
28
+ - `tests/unit/sessions-refresh-route.test.ts` (new — 6 cases)
29
+ - `upgrades/NEXT.md` (release note: bump minor, new endpoint)
30
+
31
+ **Decision points touched:**
32
+ - Rate-guard authority on session respawn (new — see Q4 below).
33
+ - Telegram `/restart` kill+resume path (existing — refactored to delegate, no behavior change).
34
+
35
+ ## Decision-point inventory
36
+
37
+ - **Respawn rate guard** (`SessionRefresh.checkRateLimit`) — **add** — refuses
38
+ refresh calls beyond N/window per session. Structural rate-counter, not
39
+ a judgment call.
40
+ - **Telegram `/restart` kill+resume** (`server.ts:642` `onRestartSession`) —
41
+ **modify** — now delegates to `SessionRefresh.refreshSession`. Legacy
42
+ inline path preserved as fallback for early-boot ordering.
43
+ - **POST /sessions/refresh validation** (`routes.ts`) — **add** — hard-invariant
44
+ input validation at the API boundary (per signal-vs-authority "When this
45
+ principle does NOT apply" carve-out for boundary structural validators).
46
+
47
+ ---
48
+
49
+ ## 1. Over-block
50
+
51
+ **What legitimate inputs does this change reject that it shouldn't?**
52
+
53
+ - **Rate guard at 5/10min.** A legitimate sequence of "install MCP A, refresh,
54
+ install MCP B, refresh, install MCP C, refresh, ..." could legitimately
55
+ hit the cap if it happens 6+ times in 10 minutes. Operationally improbable
56
+ — most refresh use cases are one-off after a tool install. If a user
57
+ genuinely needs more, the cap is constructor-injectable (tests use 2/3
58
+ limits) and could be raised in config.
59
+ - **`not_telegram_bound` refusal.** Non-Telegram-bound sessions (Slack,
60
+ iMessage, headless) cannot self-refresh in v1. They are not over-blocked
61
+ per se — they're un-implemented. The refusal is explicit (`code:
62
+ 'not_telegram_bound'`), not a silent drop. Tracked as v2 follow-up
63
+ (TODO comment at `SessionRefresh.ts` `refreshSession` body).
64
+ - **Input validators**:
65
+ - `SESSION_NAME_RE` rejects names containing spaces, dots, special chars.
66
+ This matches existing `/sessions/spawn` and `/sessions/:id` validation
67
+ — consistent with the rest of the route surface.
68
+ - `followUpPrompt` capped at 500KB (matches `/sessions/spawn` `prompt`
69
+ limit).
70
+ - `reason` capped at 1000 chars — short observability tag, not free-form
71
+ content. Unlikely to over-block.
72
+
73
+ ## 2. Under-block
74
+
75
+ **What failure modes does this still miss?**
76
+
77
+ - **Bursty calls from different agent processes on the same machine.** The
78
+ rate guard is keyed by `sessionName`. Two distinct sessions can each
79
+ consume the full budget independently. Acceptable: the failure mode the
80
+ guard exists to prevent (infinite-loop per session) is per-session by
81
+ construction.
82
+ - **Cross-process rate-guard bypass.** The counter lives in-memory in the
83
+ `SessionRefresh` instance. If the *server* itself respawns (which the
84
+ /restart-server path can do via update flows), the counter resets. A
85
+ bad actor could theoretically exploit this, but the threat model here is
86
+ "an agent's own buggy loop respawns itself," not adversarial — the
87
+ realistic failure is a logic bug looping every few seconds, which the
88
+ in-memory counter catches.
89
+ - **Respawner callback failure.** If `respawnSessionForTopic` throws,
90
+ `refreshSession` propagates the throw — the route handler catches it in
91
+ the `.catch()` block and logs. The agent's tmux session may have been
92
+ killed without a successful respawn. Mitigation: existing
93
+ `respawnSessionForTopic` is robust (used by SessionRecovery,
94
+ Telegram /restart, and idle-respawn paths); failure here is the same
95
+ failure those existing paths could hit. No new failure mode.
96
+ - **No "the session is actively busy" check.** A refresh fires regardless
97
+ of whether the agent is mid-tool-call. Acceptable for v1: agent caller
98
+ is responsible for choosing the moment (e.g. right after an MCP install
99
+ finishes). A "session-busy" signal could be added later but isn't
100
+ required for the use case driving this change.
101
+
102
+ ## 3. Level-of-abstraction fit
103
+
104
+ **Is this at the right layer?**
105
+
106
+ The lifecycle owner (`SessionRefresh`) is at the **core/** layer — the
107
+ same layer as `SessionManager`, `TopicResumeMap`, etc. It composes those
108
+ primitives without reinventing them. ✓
109
+
110
+ The respawn callback (`respawnSessionForTopic`) lives in
111
+ `commands/server.ts` and is closure-captured via the `respawner` constructor
112
+ dep. This avoids cyclic imports while keeping the lifecycle owner pure of
113
+ top-level server orchestration. ✓
114
+
115
+ The rate guard is a structural rate-counter living *inside* `SessionRefresh`
116
+ — not parallel-to a higher-level gate. Per signal-vs-authority's "When this
117
+ does NOT apply" carve-out, safety guards on potentially irreversible /
118
+ resource-exhaustion actions (infinite respawn → DoS) are allowed as
119
+ brittle blockers when the cost of false-pass is large and false-block is
120
+ cheap (an agent gets a clear "rate_limited" response and can wait). ✓
121
+
122
+ The HTTP endpoint (`routes.ts`) is a thin entry point: validate → 202 →
123
+ schedule. It does not own any judgment. ✓
124
+
125
+ The Telegram `/restart` refactor consolidates duplicate kill+resume logic.
126
+ The inline path was reasonable, but having two places to update was a code
127
+ smell — now both feed one orchestrator. ✓
128
+
129
+ ## 4. Signal vs authority compliance
130
+
131
+ **Required reference:** [docs/signal-vs-authority.md](../../docs/signal-vs-authority.md)
132
+
133
+ **Does this change hold blocking authority with brittle logic?**
134
+
135
+ - [ ] No — this change produces a signal consumed by an existing smart gate.
136
+ - [ ] No — this change has no block/allow surface.
137
+ - [x] **Yes — but the logic is a hard-invariant safety guard, not a judgment call.**
138
+ - [ ] ⚠️ Yes, with brittle logic — STOP. Reshape the design.
139
+
140
+ The rate guard *does* hold blocking authority (it can refuse a refresh).
141
+ Per signal-vs-authority "When this principle does NOT apply":
142
+
143
+ > **Safety guards on irreversible actions.** `rm -rf /`, force-pushing to
144
+ > main, deleting the database — these can and should be hard-blocked by
145
+ > brittle pattern matchers, because the cost of a false pass is catastrophic
146
+ > and the cost of a false block is merely "try again with the right arguments."
147
+
148
+ Infinite-respawn loops fall into this category: a runaway agent calling
149
+ `/sessions/refresh` in a tight loop would tmux-kill itself dozens of times
150
+ per minute, each kill consuming claude-CLI startup cycles, potentially
151
+ exhausting tmux slots, log volume, and CPU. The cost of a false pass
152
+ (letting an infinite loop through) is large; the cost of a false block
153
+ (agent has to wait 10 minutes or fix its bug) is small and recoverable.
154
+
155
+ The guard:
156
+ - **Is structural, not judgmental.** It counts timestamps in a window. It
157
+ does not interpret what a message means or what the agent's intent is.
158
+ - **Logs structured decisions.** `console.warn` with all relevant fields
159
+ (sessionName, window, cap, reason) — so over-blocks are detectable in
160
+ ops per the signal-vs-authority logging requirement.
161
+ - **Has no LLM-backed authority that would be a better consumer.** Session
162
+ lifecycle is a deterministic resource-management domain. Inventing an
163
+ LLM gate "should this agent be allowed to respawn right now" would be
164
+ over-engineering and would introduce a far worse failure mode (gate
165
+ latency + LLM cost on the kill path).
166
+
167
+ The validation in the HTTP route (`sessionName` regex, length caps) is
168
+ boundary structural validation, also explicitly allowed by the principle.
169
+
170
+ **Verdict: compliant.** Brittle blocking authority on a hard-invariant
171
+ safety guard — exactly the carve-out the principle specifies.
172
+
173
+ ## 5. Interactions
174
+
175
+ **Does this interact with existing checks, recovery paths, or infrastructure?**
176
+
177
+ - **Shadowing the existing Telegram `/restart` handler:** The refactor
178
+ routes `onRestartSession` through `SessionRefresh.refreshSession` instead
179
+ of inline kill+respawn. Behavior is equivalent (same UUID save → kill →
180
+ respawn → topicMemory pass-through). The legacy inline path is preserved
181
+ as a fallback when `_sessionRefresh` is null (early boot, no Telegram).
182
+ Tested: existing `/restart` Telegram tests would catch a behavior
183
+ divergence; none of the unit tests for restart-window or session-summary
184
+ tracked changes here.
185
+
186
+ - **Double-fire with idle-respawn / SessionRecovery:** Both code paths can
187
+ kill+respawn the same session. The guard's per-session counter sees
188
+ every refresh attempt, but NOT respawns triggered by
189
+ `SessionRecovery.respawnSession` or the idle-prompt-kill path (those
190
+ bypass `SessionRefresh`). This is intentional: a context-exhaustion
191
+ recovery is a different concern than a self-refresh and shouldn't
192
+ compete for the same rate budget. Risk: if both fire near-simultaneously
193
+ on the same session, you get a double kill. The first kill wins (the
194
+ second's `kill-session -t =name` no-ops on already-dead sessions) and
195
+ the second respawn may attach to a topic that already has a new
196
+ session. Mitigation: the existing `respawnSessionForTopic` registers
197
+ the new session name via `telegram.registerTopicSession`, which
198
+ overwrites — last writer wins. This race exists for `/restart` today
199
+ and was not introduced by this change.
200
+
201
+ - **TopicResumeMap interactions:** Two writers (`SessionRefresh` and the
202
+ legacy `onRestartSession` fallback) both call `topicResumeMap.save()`.
203
+ They are mutually exclusive — fallback only runs when `_sessionRefresh`
204
+ is null. No race within this PR. Existing writes from
205
+ `respawnSessionForTopic`, beforeSessionKill emitters, etc. continue to
206
+ work — `save()` is idempotent (last-write-wins on `{topicId, uuid,
207
+ sessionName}` tuple).
208
+
209
+ - **Async kill timing:** The route schedules the kill via `setTimeout(...,
210
+ 500ms)`. If the server is shutting down within that 500ms window, the
211
+ refresh won't execute and the response was already sent. Acceptable —
212
+ agent will retry on next attempt; cluster of dropped refreshes during
213
+ graceful shutdown is fine.
214
+
215
+ - **Feedback loops:** A successful refresh kills the requester and spawns
216
+ a fresh process. The new process has no concept of "I just refreshed,"
217
+ so if its first action after resume is to call `/sessions/refresh`
218
+ again, it counts as a new attempt against the in-memory rate counter
219
+ (because the counter survives in the server process across the kill+
220
+ spawn — only the agent's process dies). The cap protects against this.
221
+ ✓
222
+
223
+ ## 6. External surfaces
224
+
225
+ **Does this change anything visible outside the immediate code path?**
226
+
227
+ - **HTTP surface:** New endpoint `POST /sessions/refresh`. No removal,
228
+ no contract change to existing endpoints. New 503 path for "no Telegram
229
+ adapter wired."
230
+ - **Other agents on the same machine:** No effect. The endpoint is
231
+ per-agent-server (each agent runs its own server on its own port).
232
+ - **Other users of the install base:** Feature is additive and
233
+ backwards-compatible. Agents that never call the endpoint are
234
+ unaffected. Telegram `/restart` behavior is unchanged for users
235
+ (same kill+respawn, just with a slightly different log line).
236
+ - **External systems:** None. No new outbound calls to Telegram, Slack,
237
+ GitHub, etc.
238
+ - **Persistent state:** No new persistent state. `TopicResumeMap` writes
239
+ are unchanged. The rate-counter is in-memory only by design (loops
240
+ detected in-process; no need to persist across restarts).
241
+ - **Timing / runtime conditions:** 500ms async-kill delay is the only
242
+ new timing surface. Bounded, well below user-visible latency.
243
+
244
+ ## 7. Rollback cost
245
+
246
+ **If this turns out wrong in production, what's the back-out?**
247
+
248
+ - **Hot-fix release:** Revert the four src/* commits + tests. Pure code
249
+ change, no schema migration, no persistent state.
250
+ - **Agent state repair:** None needed. The rate-counter is in-memory; it
251
+ resets on server restart. No agent will be in a "stuck refreshed" state
252
+ that needs cleanup.
253
+ - **User visibility during rollback:** Agents that have started calling
254
+ `/sessions/refresh` will see 404 after rollback (route disappears).
255
+ They'd fall back to "tell user to send another message" — the pre-PR
256
+ behavior. No regression for existing users.
257
+ - **Telegram /restart rollback risk:** The refactor of `onRestartSession`
258
+ is the only behavior-touching change for existing users. If the
259
+ `SessionRefresh` delegation has a latent bug, `/restart` could fail in
260
+ ways the inline path didn't. Mitigation: legacy fallback path retained;
261
+ if `_sessionRefresh` is null, the old code runs. If a bug emerges, a
262
+ follow-up patch can null out `_sessionRefresh` to force fallback while
263
+ the root cause is fixed. Estimated rollback time: 5 minutes.
264
+
265
+ ## Conclusion
266
+
267
+ The change is contained, additive, and reuses existing primitives
268
+ (`respawnSessionForTopic`, `TopicResumeMap`, `SessionManager.killSession`).
269
+ The single decision-point introduced (the rate guard) falls cleanly under
270
+ the signal-vs-authority "safety guard on irreversible action" carve-out
271
+ and is implemented as a structural rate-counter, not a judgment.
272
+
273
+ No design changes required from the review. The non-Telegram-bound case is
274
+ explicitly scoped out as a v2 follow-up; the refusal is structured and
275
+ detectable in logs.
276
+
277
+ **Status:** Clear to ship pending second-pass review (required because
278
+ this touches session lifecycle).
279
+
280
+ ---
281
+
282
+ ## Second-pass review (if required)
283
+
284
+ **Reviewer:** Phase-5 dedicated reviewer subagent
285
+ **Independent read of the artifact: concern → resolved**
286
+
287
+ The reviewer found two **blocker** issues that the first cut missed (the unit
288
+ tests passed only because the respawner was mocked entirely):
289
+
290
+ 1. **UUID discovery silently no-op'd.** `SessionRefresh` called
291
+ `topicResumeMap.findUuidForSession(sessionName)` without the mandatory
292
+ `claudeSessionId` second argument. Per `TopicResumeMap.findUuidForSession`
293
+ (`src/core/TopicResumeMap.ts:109-117`), the mtime fallback was deliberately
294
+ removed, so the method returns `null` unless given an authoritative
295
+ `claudeSessionId`. The pre-save would never persist a UUID, so the respawn
296
+ went through WITHOUT `--resume`, dropping the live conversation —
297
+ defeating the whole feature.
298
+
299
+ 2. **The old tmux session was never killed.** `respawnSessionForTopic`
300
+ (`src/commands/server.ts:564-613`) does NOT kill the target session; it
301
+ only spawns a new one and re-registers the topic mapping. The pre-PR
302
+ inline `/restart` path killed via `execFileSync(tmux kill-session)`
303
+ BEFORE calling respawnSessionForTopic. The first-cut refactor lost that
304
+ kill entirely — both the new endpoint and the delegated `/restart` would
305
+ spawn a parallel session while leaving the old one running.
306
+
307
+ Both fixes landed in the same change set: SessionRefresh now routes the kill
308
+ through `sessionManager.killSession(stateSession.id)`, which (a) actually
309
+ kills the tmux session and (b) fires `beforeSessionKill`, whose existing
310
+ listener at `src/commands/server.ts:3406-3419` persists the UUID using
311
+ `session.claudeSessionId` — the correct source.
312
+
313
+ Additional changes from the second-pass:
314
+
315
+ 3. **In-flight guard added.** A `Set<string>` of session names currently
316
+ being refreshed prevents the race where a second call fires before the
317
+ first's kill+spawn completes. Returns `{ ok: false, code:
318
+ 'refresh_in_progress' }`. The `finally` block ensures the flag is
319
+ cleared even on respawner throw.
320
+ 4. **Tests rewritten to assert kill ordering.** The new
321
+ `SessionRefresh.test.ts` includes a `callOrder` array that asserts
322
+ `killSession` fires BEFORE `respawner`, plus an explicit assertion that
323
+ `findUuidForSession` is NOT called on the SessionRefresh side (the
324
+ listener-driven path is authoritative).
325
+ 5. **Rate-guard pruning test strengthened.** The "prunes stale entries"
326
+ test now walks the clock past the boundary and asserts the FULL budget
327
+ is restored (not just one extra call) — so a dead `checkRateLimit`
328
+ that always returned `true` would fail because the post-boundary cap
329
+ wouldn't kick back in.
330
+ 6. **Legacy fallback removed.** The `_sessionRefresh === null` fallback in
331
+ `onRestartSession` previously did inline tmux kill + respawn, but the
332
+ inline path had the same latent UUID-loss bug as #1 (existed pre-PR).
333
+ Replaced with a warning log + no-op. This isn't a regression — the
334
+ fallback only fires in a very narrow early-boot window, and the
335
+ pre-existing path never actually preserved resume context anyway.
336
+ 7. **`Session_not_found` failure mode added.** SessionRefresh now looks up
337
+ the state session by tmux name and returns a structured refusal if no
338
+ running session matches. Previously it would have crashed when trying
339
+ to kill a non-existent session.
340
+
341
+ Reviewer concerns NOT addressed in this PR (logged as follow-ups):
342
+
343
+ - **Finding 5 (Telegram-during-window race):** Pre-existing issue —
344
+ TelegramAdapter polling can deliver a message to a tmux session that has
345
+ been killed but not yet replaced. Not introduced by this change; the new
346
+ HTTP endpoint widens the surface. Tracked as a v2 follow-up — would
347
+ require a "topic respawning" flag the inject path consults to buffer
348
+ briefly. Filing under deferred work.
349
+ - **Finding 6 (counter resets on server restart):** Rate-counter is
350
+ in-memory only. A buggy loop that includes `/server/restart` could reset
351
+ its own cap. Reviewer agreed not a blocker; trivial follow-up (persist to
352
+ `.instar/state/session-refresh-counter.json` with read-time pruning).
353
+ - **Finding 10 (structured logging):** Rate-limit refusals log via
354
+ `console.warn` only. For ops dashboards, emitting via DegradationReporter
355
+ or operations log would be cleaner. Polish item; not gating.
356
+
357
+ **Status:** Blockers resolved; reviewer's verdict updated to **concur**
358
+ pending re-test. Full test suite is re-running on the fixed code.
359
+
360
+ ---
361
+
362
+ ## Evidence pointers
363
+
364
+ - **Reproduction of the problem this fixes:**
365
+ - On qalatra (Echo's primary), `claude mcp add fathom -- npx mcp-remote@latest https://api.fathom.ai/mcp` was run mid-session. `claude mcp list` showed `fathom: ... ✓ Connected`. Within the same running Claude Code session, `ToolSearch` query "fathom" returned "No matching deferred tools found" — confirming the new MCP tools were NOT loaded into the running session.
366
+ - User's only available path today: send another Telegram message to trigger a fresh CONTINUATION spawn via `respawnSessionForTopic`. There was no agent-initiated path.
367
+ - With this PR: agent calls `POST /sessions/refresh` → tmux kill + `claude --resume <uuid>` → new process picks up Fathom tools and continues conversation.
368
+ - **Test output:** 21 new tests pass (15 SessionRefresh unit + 6 route integration; corrected from earlier 17 count after the second-pass rework added in-flight guard + ordering assertions). Full suite: 564/564 locally + on CI after the test-anchor fix noted below.
369
+
370
+ ## Post-push CI fix (2026-05-11)
371
+
372
+ CI shard 2 (Unit Tests, node 20 + 22) caught a real test-fragility regression that the local push gate did not surface: `tests/unit/slack-context-exhaustion-recovery.test.ts:78` uses `source.indexOf('beforeSessionKill')` as its anchor for slicing the listener body. The original commit's `onRestartSession` explainer contained the literal word "beforeSessionKill" earlier in the file than the real listener, so the test sliced the wrong block and the `contextExhaustionKills` assertion failed.
373
+
374
+ Fix: reworded the comment in `src/commands/server.ts:645–657` from "beforeSessionKill listener" to "kill hook" — comment intent preserved, the literal anchor string now appears only at the real listener registration. Verified the suspect test passes locally after the reword (12/12 in 278ms). The test-anchor fragility itself is filed as a follow-up (test hygiene change, separate PR) — anchoring on `sessionManager.on('beforeSessionKill'` would be more robust.
375
+
376
+ This is exactly the memory-noted pattern "Refactors break tests that assert on inlined content" — my `npm run test:push` gate happened to pass locally (564/564) but the same test failed on CI shard 2. Root cause confirmed and shipped the fix in the same PR rather than splitting.
377
+
378
+ ## Post-rebase manifest regeneration (2026-05-12)
379
+
380
+ Recovery session picked the PR back up after the previous session hit context death. Rebase against main (which had landed v0.28.87 → v0.28.88 plus PRs #150/#155) surfaced a conflict in `src/data/builtin-manifest.json`. Resolved by taking main's manifest as the base and re-running `node scripts/generate-builtin-manifest.cjs` to capture the updated `contentHash` values for `route-group:sessions` and adjacent groups whose content shifted due to my new `/sessions/refresh` route registration in `src/server/routes.ts`. Entry count unchanged (188 in, 188 out — no new entries, just hash refresh).
381
+
382
+ Also bumped `package.json` from `0.28.88` → `0.28.89` to satisfy the pre-push version-increment gate after the rebase pulled main's release-cut bump in. The npm publish workflow derives the actual next version from npm at release time, so this bump is just a placeholder that keeps the local gate happy — the workflow may pick a different next-version (likely also 0.28.89) when it runs. No code/test changes in this commit.