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.
- package/bin/workspace-dynamic-hook.sh +12 -13
- package/dist/agent-scheduler/index.js +65 -5
- package/dist/auth-broker/index.js +6623 -514
- package/dist/cli/notion-write-pretool.mjs +64 -4
- package/dist/cli/switchroom.js +1888 -1162
- package/dist/host-control/main.js +6306 -162
- package/dist/vault/approvals/kernel-server.js +6014 -202
- package/dist/vault/broker/server.js +6741 -940
- package/package.json +1 -1
- package/profiles/_base/settings.json.hbs +2 -2
- package/profiles/_base/start.sh.hbs +218 -25
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md +116 -0
- package/profiles/default/CLAUDE.md.hbs +2 -2
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/skills/mental-model-curator/SKILL.md +162 -0
- package/telegram-plugin/auth-snapshot-format.ts +22 -24
- package/telegram-plugin/bridge/bridge.ts +80 -1
- package/telegram-plugin/bridge/ipc-client.ts +19 -0
- package/telegram-plugin/bridge/permission-ledger.ts +61 -0
- package/telegram-plugin/consolidation-legibility.ts +279 -0
- package/telegram-plugin/context-exhaustion.ts +124 -0
- package/telegram-plugin/dist/bridge/bridge.js +85 -1
- package/telegram-plugin/dist/gateway/gateway.js +25802 -8488
- package/telegram-plugin/dist/server.js +86 -2
- package/telegram-plugin/feed-heartbeat-climb.ts +206 -0
- package/telegram-plugin/gateway/activity-card-store.ts +369 -0
- package/telegram-plugin/gateway/gateway.ts +1861 -172
- package/telegram-plugin/gateway/inbound-delivery-gate.ts +26 -0
- package/telegram-plugin/gateway/inbound-spool.ts +22 -0
- package/telegram-plugin/gateway/mental-model-propose-card.ts +69 -0
- package/telegram-plugin/gateway/mental-model-propose-diff.ts +171 -0
- package/telegram-plugin/gateway/mental-model-propose-inbound-builders.ts +147 -0
- package/telegram-plugin/gateway/mental-model-propose-resolve.ts +201 -0
- package/telegram-plugin/gateway/missed-approvals-card.ts +161 -0
- package/telegram-plugin/gateway/missed-approvals-store.ts +167 -0
- package/telegram-plugin/gateway/model-command.ts +70 -10
- package/telegram-plugin/gateway/permission-rearm.ts +115 -0
- package/telegram-plugin/gateway/scoped-grant-store.ts +89 -0
- package/telegram-plugin/memory-legibility.ts +217 -0
- package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/telegram-plugin/package.json +6 -0
- package/telegram-plugin/quota-watch.ts +4 -6
- package/telegram-plugin/registry/turns-schema.test.ts +97 -0
- package/telegram-plugin/registry/turns-schema.ts +78 -0
- package/telegram-plugin/render/ir.ts +209 -0
- package/telegram-plugin/render/parse.ts +363 -0
- package/telegram-plugin/render/render.ts +440 -0
- package/telegram-plugin/render/rich-render.ts +72 -0
- package/telegram-plugin/scoped-approval.ts +59 -0
- package/telegram-plugin/silent-end.ts +78 -0
- package/telegram-plugin/stream-controller.ts +14 -3
- package/telegram-plugin/subagent-watcher.ts +60 -6
- package/telegram-plugin/tests/activity-card-store.test.ts +530 -0
- package/telegram-plugin/tests/activity-card-wiring.test.ts +88 -0
- package/telegram-plugin/tests/auth-command-format2.test.ts +1 -1
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +30 -16
- package/telegram-plugin/tests/claude-code-event-contract.test.ts +48 -0
- package/telegram-plugin/tests/consolidation-legibility.test.ts +224 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +25 -10
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +44 -9
- package/telegram-plugin/tests/feed-survival.test.ts +39 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +81 -0
- package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +82 -0
- package/telegram-plugin/tests/inbound-spool.test.ts +105 -0
- package/telegram-plugin/tests/liveness-tracker.test.ts +228 -0
- package/telegram-plugin/tests/memory-legibility.test.ts +216 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +67 -0
- package/telegram-plugin/tests/mental-model-propose-card.test.ts +56 -0
- package/telegram-plugin/tests/mental-model-propose-diff.test.ts +201 -0
- package/telegram-plugin/tests/mental-model-propose-inbound-builders.test.ts +68 -0
- package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +157 -0
- package/telegram-plugin/tests/missed-approvals-card.test.ts +145 -0
- package/telegram-plugin/tests/missed-approvals-store.test.ts +147 -0
- package/telegram-plugin/tests/missed-approvals-wiring.test.ts +89 -0
- package/telegram-plugin/tests/model-command.test.ts +193 -16
- package/telegram-plugin/tests/narrative-render.test.ts +125 -0
- package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +123 -163
- package/telegram-plugin/tests/permission-ledger.test.ts +166 -0
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +1 -1
- package/telegram-plugin/tests/permission-rearm-wiring.test.ts +175 -0
- package/telegram-plugin/tests/permission-rearm.test.ts +126 -0
- package/telegram-plugin/tests/quota-watch.test.ts +1 -4
- package/telegram-plugin/tests/rapid-fire-delivery-ordering.test.ts +149 -0
- package/telegram-plugin/tests/render/parse-torture.test.ts +136 -0
- package/telegram-plugin/tests/render/parse.test.ts +393 -0
- package/telegram-plugin/tests/render/render.test.ts +436 -0
- package/telegram-plugin/tests/render/rich-render.test.ts +85 -0
- package/telegram-plugin/tests/scoped-grant-persist.test.ts +223 -0
- package/telegram-plugin/tests/silent-end-transport.test.ts +290 -0
- package/telegram-plugin/tests/silent-turn-climb-transport.test.ts +337 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +139 -0
- package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +155 -1
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +198 -0
- package/telegram-plugin/turn-liveness-floor.ts +35 -1
- package/telegram-plugin/uat/assertions.ts +88 -4
- package/telegram-plugin/uat/feed-matcher.test.ts +69 -0
- package/telegram-plugin/uat/scenarios/fuzz-liveness-climb-dm.test.ts +155 -0
- package/telegram-plugin/uat/scenarios/jtbd-directive-capture-nudge-dm.test.ts +185 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-channel.test.ts +192 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-dm.test.ts +220 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +137 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +148 -0
- package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-channel.test.ts +66 -0
- package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-dm.test.ts +61 -0
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +99 -7
- package/telegram-plugin/uat/scenarios/silent-end-recovery-channel.test.ts +136 -0
- package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +24 -2
- package/telegram-plugin/worktree-watch-cwds.ts +135 -0
- package/vendor/hindsight-memory/hooks/hooks.json +9 -0
- package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/directive_verify.py +445 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/client.py +11 -1
- package/vendor/hindsight-memory/scripts/lib/config.py +46 -2
- package/vendor/hindsight-memory/scripts/lib/directives.py +88 -0
- package/vendor/hindsight-memory/scripts/lib/switchroom_envelope.py +77 -0
- package/vendor/hindsight-memory/scripts/recall.py +217 -10
- package/vendor/hindsight-memory/scripts/retain.py +17 -0
- package/vendor/hindsight-memory/scripts/setup_hooks.py +9 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/test_directive_capture_nudge.py +185 -0
- package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +516 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +49 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +1 -0
- package/vendor/hindsight-memory/scripts/tests/test_retain_window.py +66 -1
- package/vendor/hindsight-memory/scripts/tests/test_switchroom_envelope.py +69 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/test_client.py +43 -0
- package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +49 -2
- 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
|
|
1711
|
-
//
|
|
1712
|
-
//
|
|
1713
|
-
|
|
1714
|
-
|
|
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
|
-
|
|
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
|
+
})
|