switchroom 0.17.6 → 0.18.3

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.
Files changed (197) hide show
  1. package/bin/workspace-dynamic-hook.sh +12 -13
  2. package/dist/agent-scheduler/index.js +65 -5
  3. package/dist/auth-broker/index.js +6623 -514
  4. package/dist/cli/notion-write-pretool.mjs +64 -4
  5. package/dist/cli/switchroom.js +1888 -1162
  6. package/dist/host-control/main.js +6306 -162
  7. package/dist/vault/approvals/kernel-server.js +6014 -202
  8. package/dist/vault/broker/server.js +6741 -940
  9. package/package.json +1 -1
  10. package/profiles/_base/settings.json.hbs +2 -2
  11. package/profiles/_base/start.sh.hbs +218 -25
  12. package/profiles/coding/CLAUDE.md.hbs +1 -1
  13. package/profiles/default/CLAUDE.md +116 -0
  14. package/profiles/default/CLAUDE.md.hbs +2 -2
  15. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  16. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  17. package/skills/mental-model-curator/SKILL.md +162 -0
  18. package/telegram-plugin/auth-snapshot-format.ts +22 -24
  19. package/telegram-plugin/bridge/bridge.ts +80 -1
  20. package/telegram-plugin/bridge/ipc-client.ts +19 -0
  21. package/telegram-plugin/bridge/permission-ledger.ts +61 -0
  22. package/telegram-plugin/consolidation-legibility.ts +279 -0
  23. package/telegram-plugin/context-exhaustion.ts +124 -0
  24. package/telegram-plugin/dist/bridge/bridge.js +85 -1
  25. package/telegram-plugin/dist/gateway/gateway.js +25802 -8488
  26. package/telegram-plugin/dist/server.js +86 -2
  27. package/telegram-plugin/feed-heartbeat-climb.ts +206 -0
  28. package/telegram-plugin/gateway/activity-card-store.ts +369 -0
  29. package/telegram-plugin/gateway/gateway.ts +1861 -172
  30. package/telegram-plugin/gateway/inbound-delivery-gate.ts +26 -0
  31. package/telegram-plugin/gateway/inbound-spool.ts +22 -0
  32. package/telegram-plugin/gateway/mental-model-propose-card.ts +69 -0
  33. package/telegram-plugin/gateway/mental-model-propose-diff.ts +171 -0
  34. package/telegram-plugin/gateway/mental-model-propose-inbound-builders.ts +147 -0
  35. package/telegram-plugin/gateway/mental-model-propose-resolve.ts +201 -0
  36. package/telegram-plugin/gateway/missed-approvals-card.ts +161 -0
  37. package/telegram-plugin/gateway/missed-approvals-store.ts +167 -0
  38. package/telegram-plugin/gateway/model-command.ts +70 -10
  39. package/telegram-plugin/gateway/permission-rearm.ts +115 -0
  40. package/telegram-plugin/gateway/scoped-grant-store.ts +89 -0
  41. package/telegram-plugin/memory-legibility.ts +217 -0
  42. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  43. package/telegram-plugin/package.json +6 -0
  44. package/telegram-plugin/quota-watch.ts +4 -6
  45. package/telegram-plugin/registry/turns-schema.test.ts +97 -0
  46. package/telegram-plugin/registry/turns-schema.ts +78 -0
  47. package/telegram-plugin/render/ir.ts +209 -0
  48. package/telegram-plugin/render/parse.ts +363 -0
  49. package/telegram-plugin/render/render.ts +440 -0
  50. package/telegram-plugin/render/rich-render.ts +72 -0
  51. package/telegram-plugin/scoped-approval.ts +59 -0
  52. package/telegram-plugin/silent-end.ts +78 -0
  53. package/telegram-plugin/stream-controller.ts +14 -3
  54. package/telegram-plugin/subagent-watcher.ts +60 -6
  55. package/telegram-plugin/tests/activity-card-store.test.ts +530 -0
  56. package/telegram-plugin/tests/activity-card-wiring.test.ts +88 -0
  57. package/telegram-plugin/tests/auth-command-format2.test.ts +1 -1
  58. package/telegram-plugin/tests/auth-snapshot-format.test.ts +30 -16
  59. package/telegram-plugin/tests/claude-code-event-contract.test.ts +48 -0
  60. package/telegram-plugin/tests/consolidation-legibility.test.ts +224 -0
  61. package/telegram-plugin/tests/emission-authority-facade.test.ts +25 -10
  62. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +44 -9
  63. package/telegram-plugin/tests/feed-survival.test.ts +39 -0
  64. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  65. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +81 -0
  66. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +82 -0
  67. package/telegram-plugin/tests/inbound-spool.test.ts +105 -0
  68. package/telegram-plugin/tests/liveness-tracker.test.ts +228 -0
  69. package/telegram-plugin/tests/memory-legibility.test.ts +216 -0
  70. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +67 -0
  71. package/telegram-plugin/tests/mental-model-propose-card.test.ts +56 -0
  72. package/telegram-plugin/tests/mental-model-propose-diff.test.ts +201 -0
  73. package/telegram-plugin/tests/mental-model-propose-inbound-builders.test.ts +68 -0
  74. package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +157 -0
  75. package/telegram-plugin/tests/missed-approvals-card.test.ts +145 -0
  76. package/telegram-plugin/tests/missed-approvals-store.test.ts +147 -0
  77. package/telegram-plugin/tests/missed-approvals-wiring.test.ts +89 -0
  78. package/telegram-plugin/tests/model-command.test.ts +193 -16
  79. package/telegram-plugin/tests/narrative-render.test.ts +125 -0
  80. package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +123 -163
  81. package/telegram-plugin/tests/permission-ledger.test.ts +166 -0
  82. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +1 -1
  83. package/telegram-plugin/tests/permission-rearm-wiring.test.ts +175 -0
  84. package/telegram-plugin/tests/permission-rearm.test.ts +126 -0
  85. package/telegram-plugin/tests/quota-watch.test.ts +1 -4
  86. package/telegram-plugin/tests/rapid-fire-delivery-ordering.test.ts +149 -0
  87. package/telegram-plugin/tests/render/parse-torture.test.ts +136 -0
  88. package/telegram-plugin/tests/render/parse.test.ts +393 -0
  89. package/telegram-plugin/tests/render/render.test.ts +436 -0
  90. package/telegram-plugin/tests/render/rich-render.test.ts +85 -0
  91. package/telegram-plugin/tests/scoped-grant-persist.test.ts +223 -0
  92. package/telegram-plugin/tests/silent-end-transport.test.ts +290 -0
  93. package/telegram-plugin/tests/silent-turn-climb-transport.test.ts +337 -0
  94. package/telegram-plugin/tests/subagent-watcher.test.ts +139 -0
  95. package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +155 -1
  96. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +198 -0
  97. package/telegram-plugin/turn-liveness-floor.ts +35 -1
  98. package/telegram-plugin/uat/assertions.ts +88 -4
  99. package/telegram-plugin/uat/feed-matcher.test.ts +69 -0
  100. package/telegram-plugin/uat/scenarios/fuzz-liveness-climb-dm.test.ts +155 -0
  101. package/telegram-plugin/uat/scenarios/jtbd-directive-capture-nudge-dm.test.ts +185 -0
  102. package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-channel.test.ts +192 -0
  103. package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-dm.test.ts +220 -0
  104. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +137 -0
  105. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +148 -0
  106. package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-channel.test.ts +66 -0
  107. package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-dm.test.ts +61 -0
  108. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +99 -7
  109. package/telegram-plugin/uat/scenarios/silent-end-recovery-channel.test.ts +136 -0
  110. package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +24 -2
  111. package/telegram-plugin/worktree-watch-cwds.ts +135 -0
  112. package/vendor/hindsight-memory/hooks/hooks.json +9 -0
  113. package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
  114. package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
  115. package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
  116. package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
  117. package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
  118. package/vendor/hindsight-memory/scripts/directive_verify.py +445 -0
  119. package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  120. package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
  121. package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
  122. package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
  123. package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
  124. package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
  125. package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
  126. package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
  127. package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
  128. package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
  129. package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
  130. package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
  131. package/vendor/hindsight-memory/scripts/lib/client.py +11 -1
  132. package/vendor/hindsight-memory/scripts/lib/config.py +46 -2
  133. package/vendor/hindsight-memory/scripts/lib/directives.py +88 -0
  134. package/vendor/hindsight-memory/scripts/lib/switchroom_envelope.py +77 -0
  135. package/vendor/hindsight-memory/scripts/recall.py +217 -10
  136. package/vendor/hindsight-memory/scripts/retain.py +17 -0
  137. package/vendor/hindsight-memory/scripts/setup_hooks.py +9 -0
  138. package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
  139. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
  140. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
  141. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
  142. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
  143. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
  144. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
  145. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
  146. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
  147. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
  148. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
  149. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
  150. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
  151. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
  152. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
  153. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
  154. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
  155. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
  156. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
  157. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
  158. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
  159. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
  160. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
  161. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
  162. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
  163. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
  164. package/vendor/hindsight-memory/scripts/tests/test_directive_capture_nudge.py +185 -0
  165. package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +516 -0
  166. package/vendor/hindsight-memory/scripts/tests/test_directives.py +49 -0
  167. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +1 -0
  168. package/vendor/hindsight-memory/scripts/tests/test_retain_window.py +66 -1
  169. package/vendor/hindsight-memory/scripts/tests/test_switchroom_envelope.py +69 -0
  170. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  171. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
  172. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
  173. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
  174. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
  175. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
  176. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
  177. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
  178. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
  179. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
  180. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
  181. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  182. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
  183. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
  184. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
  185. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
  186. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
  187. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  188. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
  189. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
  190. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
  191. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  192. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
  193. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
  194. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
  195. package/vendor/hindsight-memory/tests/test_client.py +43 -0
  196. package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +49 -2
  197. package/vendor/hindsight-memory/tests/test_recall_precision.py +114 -0
@@ -21,6 +21,7 @@
21
21
 
22
22
  import { createDraftStream, type DraftStreamHandle } from './draft-stream.js'
23
23
  import { richMessage, isParseEntitiesError } from './rich-send.js'
24
+ import { maybeRenderOutbound } from './render/rich-render.js'
24
25
 
25
26
  /**
26
27
  * Minimal bot.api surface the controller needs. Real callers pass grammy's
@@ -224,12 +225,22 @@ export function createStreamController(cfg: StreamControllerConfig): DraftStream
224
225
  // control previews via entity detection), so strip it for the rich path.
225
226
  const doSend = (text: string, opts: StreamSendOpts) => {
226
227
  if (literalText) return bot.api.sendMessage(chatId, text, opts)
228
+ // Flag-gated rich render (`SWITCHROOM_RICH_RENDER`, default OFF — returns
229
+ // the text untouched as markdown when off, so this is a no-op for every
230
+ // agent until an operator opts in). A `plain` result (oversized/unsafe
231
+ // content renderSafe declined to emit as rich) sends WITHOUT the wrapper.
232
+ const rendered = maybeRenderOutbound(text)
233
+ if (rendered.mode === 'plain') return bot.api.sendMessage(chatId, rendered.text, opts)
227
234
  const richOpts = { ...opts }
228
235
  delete richOpts.link_preview_options
229
- return bot.api.sendRichMessage(chatId, richMessage(text), richOpts)
236
+ return bot.api.sendRichMessage(chatId, richMessage(rendered.text), richOpts)
237
+ }
238
+ const doEdit = (id: number, text: string, opts: StreamSendOpts) => {
239
+ if (literalText) return bot.api.editMessageText(chatId, id, text, opts)
240
+ const rendered = maybeRenderOutbound(text)
241
+ if (rendered.mode === 'plain') return bot.api.editMessageText(chatId, id, rendered.text, opts)
242
+ return bot.api.editMessageText(chatId, id, richMessage(rendered.text), opts)
230
243
  }
231
- const doEdit = (id: number, text: string, opts: StreamSendOpts) =>
232
- bot.api.editMessageText(chatId, id, literalText ? text : richMessage(text), opts)
233
244
 
234
245
  return createDraftStream(
235
246
  async (text) => {
@@ -211,6 +211,24 @@ export interface SubagentWatcherConfig {
211
211
  * an agent's home pollutes the watcher with phantom registrations).
212
212
  */
213
213
  agentCwd?: string
214
+ /**
215
+ * Additional cwds (beyond `agentCwd`) whose Claude-Code project-dir slug
216
+ * should also be watched. Gap 2 of `deterministic-turn-liveness.md`: a
217
+ * sub-agent dispatched with worktree isolation (`switchroom worktree
218
+ * claim`) runs with a *different* cwd than the parent agent, so its
219
+ * `agent-*.jsonl` lands under a different `.claude/projects/<slug>/`
220
+ * tree than `expectedProjectSlug` — the #1116 foreign-slug filter then
221
+ * silently skips it forever (no activity stamp, no `🛠 Worker` feed).
222
+ *
223
+ * Called fresh on every rescan tick (cheap — reads a handful of small
224
+ * JSON records from the worktree registry) so a worktree claimed or
225
+ * released mid-run is picked up/dropped without a watcher restart.
226
+ * Deterministic guarantee this preserves: only cwds this agent's own
227
+ * sub-agents can legitimately run in are ever added — a worktree record
228
+ * not owned by this agent is never included, so this does not widen the
229
+ * #1116 foreign-project protection into a wildcard watch.
230
+ */
231
+ extraWatchCwdsProvider?: () => string[]
214
232
  /**
215
233
  * How often to re-scan for new subagent dirs (ms). Default 1000.
216
234
  */
@@ -1173,6 +1191,7 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
1173
1191
  const expectedProjectSlug = config.agentCwd != null
1174
1192
  ? sanitizeCwdToProjectName(config.agentCwd)
1175
1193
  : null
1194
+ const extraWatchCwdsProvider = config.extraWatchCwdsProvider ?? null
1176
1195
  // One-shot logging: warn the first time a foreign slug is observed
1177
1196
  // so silent regressions are visible without re-running with debug.
1178
1197
  const warnedForeignSlugs = new Set<string>()
@@ -1706,17 +1725,52 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
1706
1725
  projectDirs = fs.readdirSync(projectsRoot) as string[]
1707
1726
  } catch { return }
1708
1727
 
1728
+ // Gap 2 (deterministic-turn-liveness.md): re-derive the set of
1729
+ // worktree-isolated slugs this agent's own sub-agents may legitimately
1730
+ // run in, fresh on every tick. Best-effort — a registry read hiccup
1731
+ // (e.g. the worktree dir not existing on an agent that never uses
1732
+ // worktrees) must never break the tail loop, so it just yields no
1733
+ // extra slugs for this tick.
1734
+ let allowedSlugs: Set<string> | null = null
1735
+ // Did the extra-cwd provider run cleanly this tick? A transient failure
1736
+ // (registry read hiccup) must NOT permanently mislabel an owned worktree
1737
+ // slug as "foreign": we still skip it this tick (it isn't provably ours
1738
+ // right now), but we do NOT latch the one-shot warning, so the next clean
1739
+ // tick re-includes and re-derives it instead of staying silently excluded.
1740
+ let providerOk = true
1741
+ if (expectedProjectSlug != null) {
1742
+ allowedSlugs = new Set([expectedProjectSlug])
1743
+ if (extraWatchCwdsProvider != null) {
1744
+ try {
1745
+ for (const cwd of extraWatchCwdsProvider()) {
1746
+ allowedSlugs.add(sanitizeCwdToProjectName(cwd))
1747
+ }
1748
+ } catch (err) {
1749
+ providerOk = false
1750
+ log?.(`subagent-watcher: extraWatchCwdsProvider error: ${(err as Error).message}`)
1751
+ }
1752
+ }
1753
+ }
1754
+
1709
1755
  for (const pDir of projectDirs) {
1710
- // Issue #1116: filter to the agent's own slug. Skip foreign
1711
- // project dirs so their stale subagent JSONLs (which Claude
1712
- // Code reaps mid-session) don't pollute the watcher's registry.
1713
- if (expectedProjectSlug != null && pDir !== expectedProjectSlug) {
1714
- if (!warnedForeignSlugs.has(pDir)) {
1756
+ // Issue #1116: filter to the agent's own slug (plus, per Gap 2, any
1757
+ // worktree-isolated slugs this agent's own sub-agents may run in).
1758
+ // Skip foreign project dirs so their stale subagent JSONLs (which
1759
+ // Claude Code reaps mid-session) don't pollute the watcher's registry.
1760
+ if (allowedSlugs != null && !allowedSlugs.has(pDir)) {
1761
+ // A slug that is now allowed must clear any stale "foreign" latch, so a
1762
+ // slug that was transiently excluded (or later genuinely goes foreign)
1763
+ // re-warns rather than staying mislabeled forever.
1764
+ if (providerOk && !warnedForeignSlugs.has(pDir)) {
1715
1765
  warnedForeignSlugs.add(pDir)
1716
- log?.(`subagent-watcher: skipping foreign project dir ${pDir} (expected ${expectedProjectSlug})`)
1766
+ const allowed = [...allowedSlugs].join(', ')
1767
+ log?.(`subagent-watcher: skipping foreign project dir ${pDir} (allowed: ${allowed})`)
1717
1768
  }
1718
1769
  continue
1719
1770
  }
1771
+ // Owned/allowed this tick — drop any prior foreign latch so a future
1772
+ // genuine foreign appearance of the same slug re-warns.
1773
+ warnedForeignSlugs.delete(pDir)
1720
1774
  const projectPath = join(projectsRoot, pDir)
1721
1775
  let sessionDirs: string[]
1722
1776
  try {
@@ -0,0 +1,530 @@
1
+ /**
2
+ * activity-card-store.test.ts — outcome-based tests for the mid-turn
3
+ * activity-card restart-survivability fix (Known Gap 1,
4
+ * `reference/rfcs/deterministic-turn-liveness.md`).
5
+ *
6
+ * PRE-FIX PROOF: before this PR, no persistence existed for
7
+ * `CurrentTurn.activityMessageId` at all — `../gateway/activity-card-store.js`
8
+ * did not exist, so `import` itself failed at collection time:
9
+ *
10
+ * Error: Cannot find module '../gateway/activity-card-store.js'
11
+ * (or its corresponding .ts source) imported from
12
+ * telegram-plugin/tests/activity-card-store.test.ts
13
+ *
14
+ * i.e. every test below fails on today's (pre-fix) base — there was no seam
15
+ * to test. Recorded run against `origin/main` (this PR's parent commit)
16
+ * before the module was added:
17
+ *
18
+ * FAIL telegram-plugin/tests/activity-card-store.test.ts
19
+ * [vitest] Cannot find module '../gateway/activity-card-store.js'
20
+ *
21
+ * This file exercises the REAL exported store + boot-reaper functions (not a
22
+ * structural grep), mirroring `status-pin-store.test.ts`'s in-memory fs seam
23
+ * convention, plus a transport-boundary reaper test (mirrors
24
+ * `silent-end-transport.test.ts`'s spy-transport convention) proving the
25
+ * exact outcome contract: (a) card open persists a record; (b) clean close
26
+ * clears it; (c) a boot reaper run against a leftover record performs
27
+ * EXACTLY ONE finalizing edit + one unpin and deletes the record; (d) a
28
+ * second boot performs ZERO edits and ZERO unpins; (e) a corrupt state file
29
+ * never crashes boot and is dropped without an edit.
30
+ */
31
+ import { describe, it, expect } from 'vitest'
32
+ import {
33
+ loadActivityCards,
34
+ persistActivityCards,
35
+ writeActivityCardRecord,
36
+ clearActivityCardRecord,
37
+ runActivityCardBootReaper,
38
+ runActivityCardMidSessionReaper,
39
+ restartOrphanCardFinalizeText,
40
+ type ActivityCardRecord,
41
+ type ActivityCardStoreFsSeam,
42
+ } from '../gateway/activity-card-store.js'
43
+
44
+ /** In-memory fs seam with an atomic rename — same convention as
45
+ * status-pin-store.test.ts's memFs, so the tmp→rename crash-safety
46
+ * contract is exercised without touching real disk. */
47
+ function memFs(seed: Record<string, string> = {}) {
48
+ const files = new Map<string, string>(Object.entries(seed))
49
+ const calls: string[] = []
50
+ const fs: ActivityCardStoreFsSeam = {
51
+ readFileSync: (p) => {
52
+ if (!files.has(p)) throw new Error(`ENOENT ${p}`)
53
+ return files.get(p)!
54
+ },
55
+ writeFileSync: (p, d) => {
56
+ calls.push(`write:${p}`)
57
+ files.set(p, d)
58
+ },
59
+ renameSync: (a, b) => {
60
+ calls.push(`rename:${a}->${b}`)
61
+ if (!files.has(a)) throw new Error(`ENOENT ${a}`)
62
+ files.set(b, files.get(a)!)
63
+ files.delete(a)
64
+ },
65
+ existsSync: (p) => files.has(p),
66
+ }
67
+ return { fs, files, calls }
68
+ }
69
+
70
+ const PATH = '/state/agent/telegram/activity-cards-pending.json'
71
+
72
+ function card(over: Partial<ActivityCardRecord> = {}): ActivityCardRecord {
73
+ return {
74
+ turnKey: 'c:_',
75
+ chatId: '-100123',
76
+ threadId: null,
77
+ activityMessageId: 715,
78
+ startedAt: Date.now() - 60_000,
79
+ pinned: true,
80
+ ...over,
81
+ }
82
+ }
83
+
84
+ describe('activity-card-store — persistence primitives', () => {
85
+ it('loads [] when no file exists (fresh boot)', () => {
86
+ const { fs } = memFs()
87
+ expect(loadActivityCards(PATH, fs)).toEqual([])
88
+ })
89
+
90
+ it('(a) card open → state persisted (round-trips a written record)', () => {
91
+ const { fs } = memFs()
92
+ // Construct ONCE — card() stamps `startedAt: Date.now() - 60_000`, so two
93
+ // separate calls can straddle a millisecond and flake the deep-equal.
94
+ const record = card()
95
+ writeActivityCardRecord(PATH, fs, record)
96
+ const loaded = loadActivityCards(PATH, fs)
97
+ expect(loaded).toHaveLength(1)
98
+ expect(loaded[0]).toEqual(record)
99
+ })
100
+
101
+ it('a fresh OPEN for the same topic replaces (upserts) the stale row, not appends', () => {
102
+ const { fs } = memFs()
103
+ writeActivityCardRecord(PATH, fs, card({ activityMessageId: 715 }))
104
+ writeActivityCardRecord(PATH, fs, card({ activityMessageId: 900 }))
105
+ const loaded = loadActivityCards(PATH, fs)
106
+ expect(loaded).toHaveLength(1)
107
+ expect(loaded[0]!.activityMessageId).toBe(900)
108
+ })
109
+
110
+ it('(b) clean close → state cleared', () => {
111
+ const { fs } = memFs()
112
+ writeActivityCardRecord(PATH, fs, card())
113
+ expect(loadActivityCards(PATH, fs)).toHaveLength(1)
114
+ clearActivityCardRecord(PATH, fs, 'c:_')
115
+ expect(loadActivityCards(PATH, fs)).toEqual([])
116
+ })
117
+
118
+ it('clearing an absent/mismatched turnKey is a no-op (never throws)', () => {
119
+ const { fs } = memFs()
120
+ writeActivityCardRecord(PATH, fs, card({ turnKey: 'c:other' }))
121
+ clearActivityCardRecord(PATH, fs, 'c:_')
122
+ expect(loadActivityCards(PATH, fs)).toHaveLength(1)
123
+ })
124
+
125
+ it('writes via tmp + atomic rename (crash-safe)', () => {
126
+ const { fs, calls } = memFs()
127
+ persistActivityCards(PATH, fs, [card()])
128
+ expect(calls).toEqual([`write:${PATH}.tmp`, `rename:${PATH}.tmp->${PATH}`])
129
+ })
130
+
131
+ it('(e) corrupt state file: boot survives, loads as [] (fail-open, never throws)', () => {
132
+ const { fs } = memFs({ [PATH]: '{not json' })
133
+ expect(() => loadActivityCards(PATH, fs)).not.toThrow()
134
+ expect(loadActivityCards(PATH, fs)).toEqual([])
135
+ })
136
+
137
+ it('fail-open: wrong envelope version / malformed shape loads as []', () => {
138
+ const { fs: f1 } = memFs({ [PATH]: JSON.stringify({ v: 2, cards: [card()] }) })
139
+ expect(loadActivityCards(PATH, f1)).toEqual([])
140
+ const { fs: f2 } = memFs({ [PATH]: JSON.stringify({ v: 1, cards: 'nope' }) })
141
+ expect(loadActivityCards(PATH, f2)).toEqual([])
142
+ })
143
+
144
+ it('drops malformed rows but keeps valid ones', () => {
145
+ const { fs } = memFs({
146
+ [PATH]: JSON.stringify({
147
+ v: 1,
148
+ cards: [
149
+ card({ activityMessageId: 715 }),
150
+ { turnKey: '', chatId: 'c', threadId: null, activityMessageId: 1, startedAt: 1 },
151
+ { turnKey: 'k', chatId: 'c', threadId: null, startedAt: 1 }, // missing activityMessageId
152
+ ],
153
+ }),
154
+ })
155
+ const loaded = loadActivityCards(PATH, fs)
156
+ expect(loaded).toHaveLength(1)
157
+ expect(loaded[0]!.activityMessageId).toBe(715)
158
+ })
159
+
160
+ it('persist never throws on a failing fs (durability degrades, live path unaffected)', () => {
161
+ const throwingFs: ActivityCardStoreFsSeam = {
162
+ readFileSync: () => {
163
+ throw new Error('boom')
164
+ },
165
+ writeFileSync: () => {
166
+ throw new Error('disk full')
167
+ },
168
+ renameSync: () => {
169
+ throw new Error('nope')
170
+ },
171
+ existsSync: () => false,
172
+ }
173
+ const logs: string[] = []
174
+ expect(() => persistActivityCards(PATH, throwingFs, [card()], (l) => logs.push(l))).not.toThrow()
175
+ expect(logs.join('')).toContain('persist FAILED')
176
+ })
177
+ })
178
+
179
+ /**
180
+ * Boot reaper — transport-boundary outcome tests (mirrors
181
+ * silent-end-transport.test.ts's spy-transport convention): a spy stands in
182
+ * for `bot.api.editMessageText` / `bot.api.unpinChatMessage`, asserting an
183
+ * actual finalizing edit + unpin land, not a stubbed decision callback.
184
+ */
185
+ describe('runActivityCardBootReaper — transport-boundary outcome tests', () => {
186
+ it('(c) leftover record → exactly one finalizing edit + one unpin, record deleted', async () => {
187
+ const { fs } = memFs()
188
+ const startedAt = Date.now() - 90_000
189
+ writeActivityCardRecord(PATH, fs, card({ startedAt }))
190
+
191
+ const edits: Array<{ chatId: string; messageId: number; text: string }> = []
192
+ const unpins: Array<{ chatId: string; messageId: number }> = []
193
+
194
+ const res = await runActivityCardBootReaper({
195
+ path: PATH,
196
+ fs,
197
+ finalizeCard: async (record) => {
198
+ edits.push({
199
+ chatId: record.chatId,
200
+ messageId: record.activityMessageId,
201
+ text: restartOrphanCardFinalizeText(record.startedAt),
202
+ })
203
+ // A truthy result models a REAL delivered edit (grammy returns the
204
+ // edited Message); a benign-400 would resolve to undefined instead.
205
+ return { message_id: record.activityMessageId }
206
+ },
207
+ unpinCard: async (record) => {
208
+ unpins.push({ chatId: record.chatId, messageId: record.activityMessageId })
209
+ },
210
+ })
211
+
212
+ expect(edits).toHaveLength(1)
213
+ expect(edits[0]!.chatId).toBe('-100123')
214
+ expect(edits[0]!.messageId).toBe(715)
215
+ expect(edits[0]!.text).toContain('Interrupted by a gateway restart')
216
+ expect(edits[0]!.text).toContain('was running')
217
+
218
+ expect(unpins).toHaveLength(1)
219
+ expect(unpins[0]).toEqual({ chatId: '-100123', messageId: 715 })
220
+
221
+ expect(res).toEqual({ finalized: 1, vanished: 0, unpinned: 1, total: 1 })
222
+ // Record gone — nothing left for a future boot to re-finalize.
223
+ expect(loadActivityCards(PATH, fs)).toEqual([])
224
+ })
225
+
226
+ it('(d) second boot with an already-empty store → zero edits, zero unpins', async () => {
227
+ const { fs } = memFs()
228
+ writeActivityCardRecord(PATH, fs, card())
229
+
230
+ let edits = 0
231
+ let unpins = 0
232
+ // First boot reaps the one leftover record.
233
+ await runActivityCardBootReaper({
234
+ path: PATH,
235
+ fs,
236
+ finalizeCard: async () => {
237
+ edits++
238
+ return { ok: true }
239
+ },
240
+ unpinCard: async () => {
241
+ unpins++
242
+ },
243
+ })
244
+ expect(edits).toBe(1)
245
+ expect(unpins).toBe(1)
246
+
247
+ // Second boot re-reads the (now empty) file — the idempotency guarantee.
248
+ const res2 = await runActivityCardBootReaper({
249
+ path: PATH,
250
+ fs,
251
+ finalizeCard: async () => {
252
+ edits++
253
+ return { ok: true }
254
+ },
255
+ unpinCard: async () => {
256
+ unpins++
257
+ },
258
+ })
259
+ expect(edits).toBe(1) // unchanged
260
+ expect(unpins).toBe(1) // unchanged
261
+ expect(res2).toEqual({ finalized: 0, vanished: 0, unpinned: 0, total: 0 })
262
+ })
263
+
264
+ it('a record with pinned:false (or absent) is finalized but never unpinned', async () => {
265
+ const { fs } = memFs()
266
+ writeActivityCardRecord(PATH, fs, card({ pinned: false }))
267
+ let unpins = 0
268
+ const res = await runActivityCardBootReaper({
269
+ path: PATH,
270
+ fs,
271
+ finalizeCard: async () => ({ ok: true }),
272
+ unpinCard: async () => {
273
+ unpins++
274
+ },
275
+ })
276
+ expect(res.finalized).toBe(1)
277
+ expect(unpins).toBe(0)
278
+ })
279
+
280
+ it('no-op on a fresh boot with no persisted cards (zero edits, zero unpins)', async () => {
281
+ const { fs } = memFs()
282
+ let calls = 0
283
+ const res = await runActivityCardBootReaper({
284
+ path: PATH,
285
+ fs,
286
+ finalizeCard: async () => {
287
+ calls++
288
+ return { ok: true }
289
+ },
290
+ unpinCard: async () => {
291
+ calls++
292
+ },
293
+ })
294
+ expect(res).toEqual({ finalized: 0, vanished: 0, unpinned: 0, total: 0 })
295
+ expect(calls).toBe(0)
296
+ })
297
+
298
+ it('a failing finalize edit is non-fatal, still attempts the unpin, and the store is still emptied', async () => {
299
+ const { fs } = memFs()
300
+ writeActivityCardRecord(PATH, fs, card())
301
+ let unpinCalled = false
302
+ const res = await runActivityCardBootReaper({
303
+ path: PATH,
304
+ fs,
305
+ finalizeCard: async () => {
306
+ throw new Error('message to edit not found')
307
+ },
308
+ unpinCard: async () => {
309
+ unpinCalled = true
310
+ },
311
+ })
312
+ expect(res).toEqual({ finalized: 0, vanished: 0, unpinned: 1, total: 1 })
313
+ expect(unpinCalled).toBe(true)
314
+ expect(loadActivityCards(PATH, fs)).toEqual([])
315
+ })
316
+
317
+ it('a failing unpin (already unpinned / no rights) is non-fatal and does not re-run the edit', async () => {
318
+ const { fs } = memFs()
319
+ writeActivityCardRecord(PATH, fs, card())
320
+ let editCalls = 0
321
+ const res = await runActivityCardBootReaper({
322
+ path: PATH,
323
+ fs,
324
+ finalizeCard: async () => {
325
+ editCalls++
326
+ return { ok: true }
327
+ },
328
+ unpinCard: async () => {
329
+ throw new Error('message to unpin not found')
330
+ },
331
+ })
332
+ expect(res).toEqual({ finalized: 1, vanished: 0, unpinned: 0, total: 1 })
333
+ expect(editCalls).toBe(1)
334
+ expect(loadActivityCards(PATH, fs)).toEqual([])
335
+ })
336
+
337
+ it('reaps multiple leftover records from distinct topics independently', async () => {
338
+ const { fs } = memFs()
339
+ writeActivityCardRecord(PATH, fs, card({ turnKey: 'c:1', chatId: '-100111', activityMessageId: 1 }))
340
+ writeActivityCardRecord(PATH, fs, card({ turnKey: 'c:2', chatId: '-100222', activityMessageId: 2 }))
341
+ const edited: number[] = []
342
+ const unpinned: number[] = []
343
+ const res = await runActivityCardBootReaper({
344
+ path: PATH,
345
+ fs,
346
+ finalizeCard: async (r) => {
347
+ edited.push(r.activityMessageId)
348
+ return { message_id: r.activityMessageId }
349
+ },
350
+ unpinCard: async (r) => {
351
+ unpinned.push(r.activityMessageId)
352
+ },
353
+ })
354
+ expect(res).toEqual({ finalized: 2, vanished: 0, unpinned: 2, total: 2 })
355
+ expect(edited.sort()).toEqual([1, 2])
356
+ expect(unpinned.sort()).toEqual([1, 2])
357
+ expect(loadActivityCards(PATH, fs)).toEqual([])
358
+ })
359
+
360
+ it('idempotency guard: the record is removed from disk BEFORE the finalize/unpin calls run (crash-mid-reap safety)', async () => {
361
+ const { fs } = memFs()
362
+ writeActivityCardRecord(PATH, fs, card())
363
+ let onDiskDuringFinalize: ActivityCardRecord[] = []
364
+ await runActivityCardBootReaper({
365
+ path: PATH,
366
+ fs,
367
+ finalizeCard: async () => {
368
+ onDiskDuringFinalize = loadActivityCards(PATH, fs)
369
+ },
370
+ unpinCard: async () => {},
371
+ })
372
+ // At the moment finalizeCard ran, the record was ALREADY gone from disk —
373
+ // so a crash right here (or a concurrent second boot) can never re-reap it.
374
+ expect(onDiskDuringFinalize).toEqual([])
375
+ })
376
+
377
+ it('a benign-400 (finalizeCard resolves undefined) counts as vanished, not finalized', async () => {
378
+ const { fs } = memFs()
379
+ writeActivityCardRecord(PATH, fs, card({ pinned: false }))
380
+ // robustApiCall swallows "message to edit not found" / "not modified" and
381
+ // resolves to undefined — nothing was delivered, so it must not be counted
382
+ // as a finalize (the pre-fix boot log over-reported the guarantee).
383
+ const res = await runActivityCardBootReaper({
384
+ path: PATH,
385
+ fs,
386
+ finalizeCard: async () => undefined,
387
+ unpinCard: async () => {},
388
+ })
389
+ expect(res).toEqual({ finalized: 0, vanished: 1, unpinned: 0, total: 1 })
390
+ expect(loadActivityCards(PATH, fs)).toEqual([])
391
+ })
392
+
393
+ it('reap-race guard: an id-scoped clear leaves a live turn\'s fresh upsert (same turnKey) intact', async () => {
394
+ const { fs } = memFs()
395
+ // The reaper loads this OLD card (id=715) and finalizes it…
396
+ writeActivityCardRecord(PATH, fs, card({ turnKey: 'c:_', activityMessageId: 715 }))
397
+ await runActivityCardBootReaper({
398
+ path: PATH,
399
+ fs,
400
+ finalizeCard: async () => {
401
+ // …but MID-REAP (e.g. during a 429 flood-wait) a fresh live turn opens a
402
+ // NEW card under the same topic key (id=999). Its record must survive.
403
+ writeActivityCardRecord(PATH, fs, card({ turnKey: 'c:_', activityMessageId: 999 }))
404
+ return { ok: true }
405
+ },
406
+ unpinCard: async () => {},
407
+ })
408
+ // The id-scoped clear removed only the OLD (715) row; the live 999 row is
409
+ // still protected. A turnKey-only clear would have wiped it.
410
+ const remaining = loadActivityCards(PATH, fs)
411
+ expect(remaining).toHaveLength(1)
412
+ expect(remaining[0]!.activityMessageId).toBe(999)
413
+ })
414
+
415
+ it('clearActivityCardRecord with a mismatched id is a no-op (does not drop a same-turnKey different-id row)', () => {
416
+ const { fs } = memFs()
417
+ writeActivityCardRecord(PATH, fs, card({ turnKey: 'c:_', activityMessageId: 999 }))
418
+ clearActivityCardRecord(PATH, fs, 'c:_', 715) // wrong id
419
+ expect(loadActivityCards(PATH, fs)).toHaveLength(1)
420
+ clearActivityCardRecord(PATH, fs, 'c:_', 999) // matching id
421
+ expect(loadActivityCards(PATH, fs)).toEqual([])
422
+ })
423
+ })
424
+
425
+ describe('restartOrphanCardFinalizeText', () => {
426
+ it('is honest about elapsed time', () => {
427
+ const text = restartOrphanCardFinalizeText(Date.now() - 45_000)
428
+ expect(text).toContain('Interrupted by a gateway restart')
429
+ expect(text).toMatch(/was running \d+s/)
430
+ })
431
+
432
+ it('omits a nonsensical elapsed clause for a degenerate startedAt', () => {
433
+ const text = restartOrphanCardFinalizeText(Date.now() + 10_000) // future — negative elapsed
434
+ expect(text).toContain('Interrupted by a gateway restart')
435
+ expect(text).not.toContain('was running')
436
+ })
437
+ })
438
+
439
+ describe('runActivityCardMidSessionReaper (#2918 mid-session sweep)', () => {
440
+ const NOW = 2_000_000_000_000
441
+ const TTL = 15 * 60_000
442
+
443
+ it('finalizes + unpins an ownerless stale card, and deletes its record', async () => {
444
+ const dead = card({ turnKey: 'c:dead', activityMessageId: 715, startedAt: NOW - 30 * 60_000 })
445
+ const { fs, files } = memFs()
446
+ writeActivityCardRecord(PATH, fs, dead)
447
+ const finalized: number[] = []
448
+ const unpinned: number[] = []
449
+ const res = await runActivityCardMidSessionReaper({
450
+ path: PATH, fs,
451
+ isLive: () => false, // process gone, no live owner
452
+ ttlMs: TTL, now: NOW,
453
+ finalizeCard: async (r) => { finalized.push(r.activityMessageId); return { message_id: r.activityMessageId } },
454
+ unpinCard: async (r) => { unpinned.push(r.activityMessageId); return true },
455
+ })
456
+ expect(res).toEqual({ finalized: 1, vanished: 0, unpinned: 1, total: 1 })
457
+ expect(finalized).toEqual([715])
458
+ expect(unpinned).toEqual([715])
459
+ // Record deleted BEFORE the edit (at-most-once idempotency guard).
460
+ expect(loadActivityCards(PATH, fs)).toEqual([])
461
+ void files
462
+ })
463
+
464
+ it('NEVER touches a live card (isLive true) — spinner keeps spinning', async () => {
465
+ const live = card({ turnKey: 'c:live', activityMessageId: 900, startedAt: NOW - 6 * 60 * 60_000 })
466
+ const { fs } = memFs()
467
+ writeActivityCardRecord(PATH, fs, live)
468
+ let edits = 0
469
+ const res = await runActivityCardMidSessionReaper({
470
+ path: PATH, fs,
471
+ isLive: (r) => r.turnKey === 'c:live',
472
+ ttlMs: TTL, now: NOW,
473
+ finalizeCard: async () => { edits++; return {} },
474
+ unpinCard: async () => true,
475
+ })
476
+ expect(res.total).toBe(0)
477
+ expect(edits).toBe(0)
478
+ // Live record untouched on disk.
479
+ expect(loadActivityCards(PATH, fs)).toHaveLength(1)
480
+ })
481
+
482
+ it('does not reap an ownerless card younger than the TTL (race guard)', async () => {
483
+ const fresh = card({ turnKey: 'c:fresh', startedAt: NOW - 60_000 })
484
+ const { fs } = memFs()
485
+ writeActivityCardRecord(PATH, fs, fresh)
486
+ let edits = 0
487
+ const res = await runActivityCardMidSessionReaper({
488
+ path: PATH, fs,
489
+ isLive: () => false,
490
+ ttlMs: TTL, now: NOW,
491
+ finalizeCard: async () => { edits++; return {} },
492
+ unpinCard: async () => true,
493
+ })
494
+ expect(res.total).toBe(0)
495
+ expect(edits).toBe(0)
496
+ expect(loadActivityCards(PATH, fs)).toHaveLength(1)
497
+ })
498
+
499
+ it('reaps the dead card while sparing a concurrently-live one', async () => {
500
+ const { fs } = memFs()
501
+ writeActivityCardRecord(PATH, fs, card({ turnKey: 'c:dead', activityMessageId: 1, startedAt: NOW - 30 * 60_000 }))
502
+ writeActivityCardRecord(PATH, fs, card({ turnKey: 'c:live', activityMessageId: 2, startedAt: NOW - 30 * 60_000 }))
503
+ const finalized: number[] = []
504
+ const res = await runActivityCardMidSessionReaper({
505
+ path: PATH, fs,
506
+ isLive: (r) => r.turnKey === 'c:live',
507
+ ttlMs: TTL, now: NOW,
508
+ finalizeCard: async (r) => { finalized.push(r.activityMessageId); return {} },
509
+ unpinCard: async () => true,
510
+ })
511
+ expect(res.total).toBe(1)
512
+ expect(finalized).toEqual([1])
513
+ const remaining = loadActivityCards(PATH, fs)
514
+ expect(remaining).toHaveLength(1)
515
+ expect(remaining[0]!.turnKey).toBe('c:live')
516
+ })
517
+
518
+ it('counts a benign-400 (vanished) card as vanished, not finalized', async () => {
519
+ const { fs } = memFs()
520
+ writeActivityCardRecord(PATH, fs, card({ turnKey: 'c:gone', pinned: false, startedAt: NOW - 30 * 60_000 }))
521
+ const res = await runActivityCardMidSessionReaper({
522
+ path: PATH, fs,
523
+ isLive: () => false,
524
+ ttlMs: TTL, now: NOW,
525
+ finalizeCard: async () => undefined, // robustApiCall swallowed a benign 400
526
+ unpinCard: async () => true,
527
+ })
528
+ expect(res).toEqual({ finalized: 0, vanished: 1, unpinned: 0, total: 1 })
529
+ })
530
+ })