instar 1.3.67 → 1.3.69

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,100 @@
1
+ # Side-Effects Review — ContextWedge recovery: disk-backed topic resolution
2
+
3
+ **Version / slug:** `context-wedge-null-topic-recovery`
4
+ **Date:** `2026-05-28`
5
+ **Author:** `echo`
6
+ **Spec:** `docs/specs/context-wedge-sentinel.md` (Follow-up section)
7
+ **Second-pass reviewer:** `not required` (see §"Phase 5 trigger check")
8
+
9
+ ## Summary of the change
10
+
11
+ The ContextWedgeSentinel (shipped v1.3.62, PR #485) auto-recovers a thinking-block-400
12
+ session wedge via a fresh respawn through `SessionRefresh`. In production it
13
+ DETECTED a wedge on the long-lived Codey collaboration session (Telegram topic
14
+ 13435) but failed to recover it — escalating "respawn attempt did not clear it."
15
+
16
+ Root cause: `SessionRefresh` resolves a session's topic via
17
+ `TelegramAdapter.getTopicForSession`, which reads the **in-memory** `sessionToTopic`
18
+ map. Echo's server runs `--no-telegram`, so that map is only a boot-time snapshot
19
+ of the registry, while the lifeline keeps writing new topic↔session bindings to
20
+ `topic-session-registry.json` on disk. A session bound AFTER the server booted
21
+ resolves to `null` in-memory → `refreshSession` returned `not_telegram_bound` →
22
+ the dead session stayed dead.
23
+
24
+ This change adds a disk-backed fallback to the topic resolution used by recovery:
25
+
26
+ - `TelegramAdapter.resolveTopicForSessionFromDisk(sessionName)` — a fresh read of
27
+ the persisted registry returning the bound topic, or null. **Pure read** — does
28
+ not mutate the in-memory maps.
29
+ - `SessionRefresh.refreshSession` — tries `getTopicForSession` (in-memory) first;
30
+ on a `null`, falls back to `resolveTopicForSessionFromDisk` before bailing. An
31
+ in-memory hit short-circuits, so the disk read is a fallback, never a default.
32
+ Genuinely unbound sessions (no binding on disk either) still return
33
+ `not_telegram_bound` exactly as before.
34
+
35
+ **Files touched:**
36
+ - `src/messaging/TelegramAdapter.ts` (+38 lines: `resolveTopicForSessionFromDisk` + doc).
37
+ - `src/core/SessionRefresh.ts` (in-memory→disk resolution; updated header + failure message).
38
+ - `tests/unit/SessionRefresh.test.ts` (+3 cases + a `diskTopicId` mock override).
39
+ - `tests/unit/telegram-registry-log.test.ts` (+4 cases for the disk-read method).
40
+ - `tests/integration/session-refresh-disk-topic-fallback.test.ts` (new — REAL SessionRefresh × REAL TelegramAdapter).
41
+ - `tests/e2e/context-wedge-sentinel-lifecycle.test.ts` (fixed a pre-existing cleanup bug — see below).
42
+ - `docs/specs/context-wedge-sentinel.md` (Follow-up section).
43
+
44
+ ## Decision-point inventory
45
+
46
+ - **Topic resolution (in-memory vs disk)** — *modify*. Was in-memory-only; is now
47
+ in-memory-then-disk-fallback. The selection is a pure presence check
48
+ (`inMemory ?? diskFallback ?? bail`), no judgment. Both branches are exercised
49
+ by tests; the in-memory branch is unchanged for the common case.
50
+ - **Disk read of the registry** — *read-only, additive*. Reads the existing
51
+ `topic-session-registry.json` that `TelegramAdapter.saveRegistry` already writes.
52
+ No new file, no new format, no write. Wrapped in try/catch → returns null on a
53
+ missing/corrupt file (test-covered) so it can never throw into recovery.
54
+ - **Respawn (kill + fresh spawn)** — *unchanged*. Already gated by `SessionRefresh`'s
55
+ rate guard + in-flight guard + the sentinel's confirm window + the `autoRecovery`
56
+ opt-in flag. This change only makes the topic *resolvable*; it does not loosen any
57
+ guard or change when a respawn fires.
58
+
59
+ ## Blast radius
60
+
61
+ - **No new authority, no new gate, no new detector.** A topic-resolution fallback
62
+ behind an existing, already-guarded recovery primitive.
63
+ - **Hot paths unchanged.** `getTopicForSession` is untouched; its many callers
64
+ (live-tail, SessionReaper topic-binding) behave identically. Only `SessionRefresh`
65
+ consults the disk fallback, and only on an in-memory miss.
66
+ - **No agent-installed files changed** — no `.claude/settings.json` hooks, no
67
+ `.instar/config.json` defaults, no CLAUDE.md template, no hook scripts, no skills.
68
+ So **no PostUpdateMigrator entry is required**: the fix is pure `src/` logic that
69
+ reaches every agent through the normal dist update. (`autoRecovery` config already
70
+ exists from PR #485.)
71
+ - **Idempotent / side-effect-free fallback.** Pure read; safe to call repeatedly.
72
+
73
+ ## Pre-existing-bug fix (bundled)
74
+
75
+ While running the e2e (`context-wedge-sentinel-lifecycle.test.ts`) the "live"
76
+ case failed at setup with `mkdtemp ENOENT`. The test's `cleanup()` did
77
+ `fs.rmSync(path.dirname(logsDir), …)` where `logsDir = path.join(stateDir, '..', 'logs')`
78
+ — so `path.dirname(logsDir)` resolved to `os.tmpdir()` ITSELF, and cleanup rm-rf'd
79
+ the shared tmpdir base, intermittently breaking the next test's `mkdtemp`. It only
80
+ "passed" before when the OS base happened to survive (permissions / timing). Fixed
81
+ by nesting logs INSIDE the per-test `stateDir` and cleaning up exactly `stateDir`.
82
+ Fixed here (not deferred) per the Zero-Failure Standard since it sits in the same
83
+ feature I'm shipping.
84
+
85
+ ## Phase 5 trigger check (second-pass reviewer)
86
+
87
+ Second pass **not required**: no new authority, no destructive operation introduced
88
+ (the only rm-rf change *removes* an over-broad delete), no external surface, no
89
+ migration. The change is an additive, read-only fallback behind an existing
90
+ rate-guarded recovery primitive, with full three-tier test coverage (114 tests
91
+ green across the related files).
92
+
93
+ ## Verification
94
+
95
+ - `tests/unit/SessionRefresh.test.ts` — 20 pass (incl. 3 new disk-fallback cases).
96
+ - `tests/unit/telegram-registry-log.test.ts` — 20 pass (incl. 4 new disk-read cases).
97
+ - `tests/integration/session-refresh-disk-topic-fallback.test.ts` — 2 pass
98
+ (disk-only binding recovers; truly-unbound still bails).
99
+ - `tests/e2e/context-wedge-sentinel-lifecycle.test.ts` — 7 pass (flake fixed).
100
+ - Full related set: 114 pass across 8 files.
@@ -362,3 +362,71 @@ SleepWakeDetector cumulative (6, new) + Phase-2 wiring (14) green; typecheck cle
362
362
 
363
363
  **Rollback:** revert this commit. `getCumulativeSleepMsBetween` becomes unused
364
364
  but harmless.
365
+
366
+ ## Phase 3 — Commit #7: quota soft-check (bounded, force-kill via ReapAuthority)
367
+
368
+ **Files:** `src/monitoring/SessionMigrator.ts`, `src/monitoring/QuotaManager.ts`,
369
+ `tests/unit/session-lifecycle-phase-3-wiring.test.ts` (new).
370
+
371
+ The quota migrator's force-kill path now goes through the single ReapAuthority,
372
+ and grants a bounded extra-grace round to working sessions — without ever
373
+ letting that grace push real usage to 100%/lockout.
374
+
375
+ 1. **Bounded soft-check (SE-9).** New `MigrationThresholds`:
376
+ `softCheckEnabled` (default true), `softCheckMaxUsagePercent` (default 95),
377
+ `softCheckExtraGraceMs` (default = `gracePeriodMs`). The kill loop computes
378
+ `softCheckActive = softEnabled && currentUsagePct ≤ softCeilingPct`. **Above**
379
+ the ceiling the soft check is **disabled** — quota's final authority cannot
380
+ be undermined when usage is already near 100%. When unknown,
381
+ `quotaUsagePercent` falls back to **100** (fail-closed posture: no grace
382
+ without proof we're below the ceiling).
383
+ 2. **One extra Ctrl+C grace round.** When the soft check is active AND
384
+ `isBuildOrAutonomousActive()` returns true, the session gets ONE more C-c +
385
+ `softCheckExtraGraceMs` wait before force-kill. The current implementation
386
+ uses a coarse signal — `state/build/build-state.json` fresh OR any
387
+ `autonomous/*.local.md` fresh under 30 min — which trades per-topic
388
+ precision for simplicity (the ceiling backstop bounds the worst case).
389
+ 3. **Route through ReapAuthority.** The force-kill goes through
390
+ `deps.terminateSession(id, 'quota-shed', { disposition: 'terminal',
391
+ finalStatus: 'killed' })` so the §P3 notifier surfaces "your session was
392
+ shut down — quota-shed" and the reap-log records it. Falls back to
393
+ `deps.killSession` only if `terminateSession` is unwired (older agents).
394
+ 4. **Tier-1 supervision seam.** A new `quota-force-kill-decision` event is
395
+ emitted with `{ tmuxSession, sessionId, jobSlug, currentUsagePct,
396
+ softCheckActive, workingSoftCheckFired }` so a future Haiku-wrapping
397
+ supervisor can validate the policy decision. The event is observability,
398
+ not a gate — the kill still happens.
399
+
400
+ **Tests:** session-migrator existing (37) + Phase-3 wiring (6) green; typecheck clean.
401
+
402
+ **Rollback:** revert this commit. The new thresholds default to safe values and
403
+ the migrator dep stays optional, so older callers are unaffected.
404
+
405
+ ## Phase 3 — Commit (bonus): session label follows topic rename
406
+
407
+ **Files:** `src/core/SessionManager.ts`, `src/messaging/TelegramAdapter.ts`,
408
+ `src/commands/server.ts`, `tests/unit/session-rename-by-tmux.test.ts` (new),
409
+ `tests/unit/session-lifecycle-phase-3-wiring.test.ts`.
410
+
411
+ When the user renames a Telegram forum topic, the bound session's DISPLAY name
412
+ follows the rename — but the operational identity (`tmuxSession` key + `id`
413
+ UUID) stays intact, as the spec requires.
414
+
415
+ 1. **`SessionManager.renameSessionByTmux(tmuxSession, newName)`** — updates
416
+ `session.name` ONLY. NEVER touches `tmuxSession` (the tmux key + every
417
+ internal lookup) or `id` (state lookups). Idempotent; safe on unknown
418
+ tmuxSession; rejects empty / whitespace-only / non-string names.
419
+ 2. **`TelegramAdapter.setTopicRenamedHandler`** — wires a fire-and-forget
420
+ callback fired ONLY on a true rename (`forum_topic_edited` present AND the
421
+ name changed). Initial-capture and topic-creation cases do not fire.
422
+ 3. **server.ts wiring** — `setTopicRenamedHandler((topicId, newName) => {
423
+ sessionManager.renameSessionByTmux(telegram.getSessionForTopic(topicId), newName); })`.
424
+
425
+ Because the renamed value is user-controlled, it flows through the same §P3
426
+ sanitization (literal code spans) wherever it surfaces in user-facing notices.
427
+
428
+ **Tests:** SessionManager rename (5) + Phase-3 wiring (9, including the bonus
429
+ contracts) green; typecheck clean.
430
+
431
+ **Rollback:** revert this commit; the handler stays optional, so older callers
432
+ are unaffected.