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
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* activity-card-store.ts — durable handle for the mid-turn activity card, so a
|
|
3
|
+
* gateway restart mid-turn can finalize the orphaned card instead of leaving it
|
|
4
|
+
* frozen forever.
|
|
5
|
+
*
|
|
6
|
+
* Why this exists (reference/rfcs/deterministic-turn-liveness.md Known Gap 1):
|
|
7
|
+
* `CurrentTurn.activityMessageId` / `startedAt` and the live `currentTurn`
|
|
8
|
+
* itself live ONLY in gateway memory. If the gateway restarts while a turn's
|
|
9
|
+
* activity card is open, the new process has no handle to the old card — it
|
|
10
|
+
* is never edited again and sits frozen on its last pre-restart text forever.
|
|
11
|
+
* The resumed turn (if any) opens a FRESH card; the old one is orphaned.
|
|
12
|
+
*
|
|
13
|
+
* The fix mirrors `status-pin-store.ts` byte-for-byte in shape: a tiny durable
|
|
14
|
+
* snapshot on the per-agent state volume (STATE_DIR), written whenever a card
|
|
15
|
+
* OPENS, cleared the moment it closes/finalizes normally. On boot, AFTER this
|
|
16
|
+
* gateway wins the startup mutex (same ordering constraint as the status-pin
|
|
17
|
+
* cleanup — this is a shared per-agent file, so a losing double-boot must
|
|
18
|
+
* never touch it), a one-shot reaper reads any leftover record and finalizes
|
|
19
|
+
* the orphaned card with a SINGLE honest edit (never a new message — edits do
|
|
20
|
+
* not notify, so no ping). We do NOT attempt to resume climbing the old card
|
|
21
|
+
* across the restart; the resumed turn (if any) owns a fresh card. Honest
|
|
22
|
+
* finalization — not resumption — is the goal.
|
|
23
|
+
*
|
|
24
|
+
* Crash-safety: write-tmp + atomic rename (identical convention to
|
|
25
|
+
* status-pin-store.ts / obligation-store.ts). A corrupt or unreadable file
|
|
26
|
+
* fails open to `[]` — never crashes boot, worst case an orphan isn't cleaned
|
|
27
|
+
* up this boot, no worse than pre-fix behaviour.
|
|
28
|
+
*
|
|
29
|
+
* DELIVERED GUARANTEE — AT-MOST-ONCE finalization (be honest about it):
|
|
30
|
+
* the reaper deletes a record from the on-disk store BEFORE attempting its
|
|
31
|
+
* finalizing edit (not after) and swallows every Telegram failure with no
|
|
32
|
+
* retry. That ordering buys strict idempotency — a crash mid-reap, or a second
|
|
33
|
+
* boot re-reading the file, can never double-edit or double-unpin the same
|
|
34
|
+
* message — at the cost of durability: if the single edit fails (flood-wait
|
|
35
|
+
* exhausted, transient 5xx, chat momentarily unreachable), the orphaned card is
|
|
36
|
+
* FORFEIT and stays frozen, because its record is already gone. So the promise
|
|
37
|
+
* is NOT "finalized on the very next boot" — it is "finalized AT MOST ONCE, on
|
|
38
|
+
* the next boot, best-effort". We deliberately chose at-most-once over
|
|
39
|
+
* at-least-once: a retry-until-success design cannot preserve the
|
|
40
|
+
* second-boot-zero-edits property across a crash between a successful edit and
|
|
41
|
+
* its post-edit delete (that window would re-edit an already-finalized card),
|
|
42
|
+
* and a stale re-finalize on a chat the user has since moved on from is a worse
|
|
43
|
+
* failure than one frozen orphan. A benign-400 (message already
|
|
44
|
+
* deleted/vanished, or "not modified") is NOT counted as a finalize — nothing
|
|
45
|
+
* was delivered — see the `vanished` tally in `runActivityCardBootReaper`.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
export interface ActivityCardStoreFsSeam {
|
|
49
|
+
readFileSync: (path: string) => string
|
|
50
|
+
writeFileSync: (path: string, data: string) => void
|
|
51
|
+
/** Atomic same-dir replace (POSIX rename) so a crash mid-write can't tear
|
|
52
|
+
* the snapshot. */
|
|
53
|
+
renameSync: (from: string, to: string) => void
|
|
54
|
+
existsSync: (path: string) => boolean
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** One persisted card handle: enough to identify and finalize the orphaned
|
|
58
|
+
* Telegram message on a future boot, without any live turn state. */
|
|
59
|
+
export interface ActivityCardRecord {
|
|
60
|
+
/** Stable per-topic key (`statusKey(chatId, threadId)` shape) — also the
|
|
61
|
+
* upsert key: a fresh OPEN for the same topic replaces any stale row. */
|
|
62
|
+
turnKey: string
|
|
63
|
+
chatId: string
|
|
64
|
+
threadId: number | null
|
|
65
|
+
activityMessageId: number
|
|
66
|
+
/** Wall-clock ms the turn started — carried through so the finalize text
|
|
67
|
+
* can be honest about elapsed time even after a restart. */
|
|
68
|
+
startedAt: number
|
|
69
|
+
/** Whether this card was silently pinned when it opened (the gateway pins
|
|
70
|
+
* every fresh activity card via `reconcileStatusPin('fg:'+turnKey, …)`).
|
|
71
|
+
* The boot reaper must unpin it too — a frozen card that's ALSO still
|
|
72
|
+
* pinned is a worse orphan (it stays glued to the top of the chat
|
|
73
|
+
* forever). Optional so a v1-shape record (pre-unpin-tracking) still
|
|
74
|
+
* loads and degrades to "don't attempt an unpin", never a crash. */
|
|
75
|
+
pinned?: boolean
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface SnapshotEnvelope {
|
|
79
|
+
v: 1
|
|
80
|
+
cards: ActivityCardRecord[]
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isCardRow(x: unknown): x is ActivityCardRecord {
|
|
84
|
+
if (x == null || typeof x !== 'object') return false
|
|
85
|
+
const o = x as Record<string, unknown>
|
|
86
|
+
return (
|
|
87
|
+
typeof o.turnKey === 'string' &&
|
|
88
|
+
o.turnKey.length > 0 &&
|
|
89
|
+
typeof o.chatId === 'string' &&
|
|
90
|
+
o.chatId.length > 0 &&
|
|
91
|
+
(o.threadId === null || typeof o.threadId === 'number') &&
|
|
92
|
+
typeof o.activityMessageId === 'number' &&
|
|
93
|
+
typeof o.startedAt === 'number' &&
|
|
94
|
+
(o.pinned === undefined || typeof o.pinned === 'boolean')
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Load the persisted card-handle set. Returns [] on a missing, unreadable, or
|
|
100
|
+
* malformed file — fail-open, never throws.
|
|
101
|
+
*/
|
|
102
|
+
export function loadActivityCards(
|
|
103
|
+
path: string,
|
|
104
|
+
fs: ActivityCardStoreFsSeam,
|
|
105
|
+
): ActivityCardRecord[] {
|
|
106
|
+
if (!fs.existsSync(path)) return []
|
|
107
|
+
let raw = ''
|
|
108
|
+
try {
|
|
109
|
+
raw = fs.readFileSync(path)
|
|
110
|
+
} catch {
|
|
111
|
+
return []
|
|
112
|
+
}
|
|
113
|
+
let parsed: unknown
|
|
114
|
+
try {
|
|
115
|
+
parsed = JSON.parse(raw)
|
|
116
|
+
} catch {
|
|
117
|
+
return []
|
|
118
|
+
}
|
|
119
|
+
if (parsed == null || typeof parsed !== 'object') return []
|
|
120
|
+
const env = parsed as Record<string, unknown>
|
|
121
|
+
if (env.v !== 1 || !Array.isArray(env.cards)) return []
|
|
122
|
+
return env.cards.filter(isCardRow)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Persist the card-handle set atomically (write sibling tmp → rename). Never
|
|
127
|
+
* throws — a write failure is logged and the store degrades to in-memory-only
|
|
128
|
+
* for this process (same contract as `status-pin-store.ts`).
|
|
129
|
+
*/
|
|
130
|
+
export function persistActivityCards(
|
|
131
|
+
path: string,
|
|
132
|
+
fs: ActivityCardStoreFsSeam,
|
|
133
|
+
snapshot: readonly ActivityCardRecord[],
|
|
134
|
+
log: (line: string) => void = (l) => process.stderr.write(l),
|
|
135
|
+
): void {
|
|
136
|
+
const env: SnapshotEnvelope = { v: 1, cards: [...snapshot] }
|
|
137
|
+
const tmp = path + '.tmp'
|
|
138
|
+
try {
|
|
139
|
+
fs.writeFileSync(tmp, JSON.stringify(env))
|
|
140
|
+
fs.renameSync(tmp, path)
|
|
141
|
+
} catch (err) {
|
|
142
|
+
log(
|
|
143
|
+
`activity-card-store: persist FAILED path=${path}: ${(err as Error).message} — ` +
|
|
144
|
+
`durability degraded to in-memory\n`,
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Upsert the card-handle row for `turnKey` (replacing any stale row for the
|
|
151
|
+
* same topic — e.g. a prior turn's card that was never cleared). Called the
|
|
152
|
+
* moment a card OPENs (activityMessageId transitions null → set).
|
|
153
|
+
*/
|
|
154
|
+
export function writeActivityCardRecord(
|
|
155
|
+
path: string,
|
|
156
|
+
fs: ActivityCardStoreFsSeam,
|
|
157
|
+
record: ActivityCardRecord,
|
|
158
|
+
log: (line: string) => void = (l) => process.stderr.write(l),
|
|
159
|
+
): void {
|
|
160
|
+
const current = loadActivityCards(path, fs)
|
|
161
|
+
const others = current.filter((c) => c.turnKey !== record.turnKey)
|
|
162
|
+
persistActivityCards(path, fs, [...others, record], log)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Remove the card-handle row for `turnKey`, if present. Called the moment a
|
|
167
|
+
* card closes/finalizes normally (clearActivitySummary), and by the boot
|
|
168
|
+
* reaper (BEFORE attempting the finalizing edit — the idempotency guard).
|
|
169
|
+
*
|
|
170
|
+
* REAP-RACE guard: when `activityMessageId` is supplied, only a row matching
|
|
171
|
+
* BOTH `turnKey` AND `activityMessageId` is removed. During a slow multi-record
|
|
172
|
+
* reap (e.g. a 429 flood-wait between records), a fresh live turn can upsert a
|
|
173
|
+
* NEW record under the same `turnKey` (its own, different `activityMessageId`).
|
|
174
|
+
* A turnKey-only clear would then delete the LIVE card's durable protection.
|
|
175
|
+
* Scoping the clear to the exact message id the reaper (or the closing turn) is
|
|
176
|
+
* handling leaves the live card's record intact. Omit `activityMessageId` only
|
|
177
|
+
* when you genuinely mean "clear whatever row exists for this topic".
|
|
178
|
+
*/
|
|
179
|
+
export function clearActivityCardRecord(
|
|
180
|
+
path: string,
|
|
181
|
+
fs: ActivityCardStoreFsSeam,
|
|
182
|
+
turnKey: string,
|
|
183
|
+
activityMessageId?: number,
|
|
184
|
+
log: (line: string) => void = (l) => process.stderr.write(l),
|
|
185
|
+
): void {
|
|
186
|
+
const current = loadActivityCards(path, fs)
|
|
187
|
+
if (current.length === 0) return
|
|
188
|
+
const next = current.filter(
|
|
189
|
+
(c) =>
|
|
190
|
+
c.turnKey !== turnKey ||
|
|
191
|
+
(activityMessageId !== undefined && c.activityMessageId !== activityMessageId),
|
|
192
|
+
)
|
|
193
|
+
if (next.length === current.length) return
|
|
194
|
+
persistActivityCards(path, fs, next, log)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Boot-time orphan reaper, extracted as a pure routine over injected seams
|
|
199
|
+
* (mirrors `runStatusPinBootCleanup`). The gateway's thin wrapper binds the
|
|
200
|
+
* live fs, a Telegram edit call, and the logger.
|
|
201
|
+
*
|
|
202
|
+
* CRITICAL ordering, identical constraint to the status-pin cleanup: the
|
|
203
|
+
* caller MUST only invoke this AFTER winning the startup mutex. The store is
|
|
204
|
+
* a shared per-agent file; a losing double-boot running this would finalize
|
|
205
|
+
* cards the still-alive holder's in-flight turn still legitimately owns.
|
|
206
|
+
*
|
|
207
|
+
* Deletes each record from disk BEFORE attempting its edit/unpin (not
|
|
208
|
+
* after), so a crash mid-reap, or a second boot re-reading the file, can
|
|
209
|
+
* never re-finalize (and thus never double-edit, never double-unpin) the
|
|
210
|
+
* same message. `finalizeCard` / `unpinCard` failures (message already
|
|
211
|
+
* deleted, chat unreachable, permissions changed, already-unpinned, etc.)
|
|
212
|
+
* are swallowed — non-fatal, logged, and never re-attempted since the
|
|
213
|
+
* record is already gone.
|
|
214
|
+
*
|
|
215
|
+
* Unpin runs AFTER the finalizing edit, only for a record with `pinned:
|
|
216
|
+
* true` (a v1-shape record with no `pinned` field is left un-unpinned —
|
|
217
|
+
* conservative degrade, not a crash), and its failure is independent of the
|
|
218
|
+
* edit's outcome (an edit failure — e.g. message already deleted — must not
|
|
219
|
+
* skip the unpin attempt, since Telegram allows unpinning a deleted
|
|
220
|
+
* message's id to no-op harmlessly, and conversely an unpin failure must
|
|
221
|
+
* never re-attempt the edit).
|
|
222
|
+
*/
|
|
223
|
+
export async function runActivityCardBootReaper(args: {
|
|
224
|
+
path: string
|
|
225
|
+
fs: ActivityCardStoreFsSeam
|
|
226
|
+
/** Returns the Telegram result: a truthy value (the edited Message) on a
|
|
227
|
+
* real delivered edit, or `null`/`undefined` when the underlying
|
|
228
|
+
* `robustApiCall` swallowed a benign 400 (message already deleted /
|
|
229
|
+
* vanished / "not modified") — i.e. nothing was actually finalized. */
|
|
230
|
+
finalizeCard: (record: ActivityCardRecord) => Promise<unknown>
|
|
231
|
+
unpinCard: (record: ActivityCardRecord) => Promise<unknown>
|
|
232
|
+
log?: (line: string) => void
|
|
233
|
+
}): Promise<{ finalized: number; vanished: number; unpinned: number; total: number }> {
|
|
234
|
+
const log = args.log ?? ((l: string) => process.stderr.write(l))
|
|
235
|
+
const persisted = loadActivityCards(args.path, args.fs)
|
|
236
|
+
if (persisted.length === 0) return { finalized: 0, vanished: 0, unpinned: 0, total: 0 }
|
|
237
|
+
let finalized = 0
|
|
238
|
+
let vanished = 0
|
|
239
|
+
let unpinned = 0
|
|
240
|
+
for (const record of persisted) {
|
|
241
|
+
// Delete BEFORE attempting the edit/unpin — the idempotency guard. If the
|
|
242
|
+
// gateway crashes here, or a second boot re-reads the file, this record
|
|
243
|
+
// no longer exists on disk, so it can never be finalized or unpinned
|
|
244
|
+
// twice. Scoped to the exact activityMessageId (reap-race guard): a live
|
|
245
|
+
// turn that upserted a fresh card under the same turnKey mid-reap keeps
|
|
246
|
+
// its own (different-id) record.
|
|
247
|
+
clearActivityCardRecord(args.path, args.fs, record.turnKey, record.activityMessageId, log)
|
|
248
|
+
try {
|
|
249
|
+
// Count a finalize ONLY when the edit actually landed. robustApiCall
|
|
250
|
+
// resolves to undefined on a benign-400 (the card was already deleted or
|
|
251
|
+
// the chat is gone) — that delivered nothing, so it is a `vanished`
|
|
252
|
+
// orphan, not a `finalized` one. Counting it as finalized (the pre-fix
|
|
253
|
+
// behaviour) over-reported the guarantee in the boot log.
|
|
254
|
+
const res = await args.finalizeCard(record)
|
|
255
|
+
if (res != null) finalized++
|
|
256
|
+
else vanished++
|
|
257
|
+
} catch (err) {
|
|
258
|
+
log(
|
|
259
|
+
`activity-card-store: boot reaper finalize failed ` +
|
|
260
|
+
`(chat=${record.chatId} msg=${record.activityMessageId}): ` +
|
|
261
|
+
`${(err as Error).message}\n`,
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
if (record.pinned) {
|
|
265
|
+
try {
|
|
266
|
+
await args.unpinCard(record)
|
|
267
|
+
unpinned++
|
|
268
|
+
} catch (err) {
|
|
269
|
+
log(
|
|
270
|
+
`activity-card-store: boot reaper unpin failed ` +
|
|
271
|
+
`(chat=${record.chatId} msg=${record.activityMessageId}): ` +
|
|
272
|
+
`${(err as Error).message}\n`,
|
|
273
|
+
)
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return { finalized, vanished, unpinned, total: persisted.length }
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Mid-session periodic orphan reaper (#2918). The boot reaper above only runs
|
|
282
|
+
* once at startup, so a card whose owning turn's process dies MID-session (SDK
|
|
283
|
+
* subprocess SIGKILL / OOM / crash) without a clean finalize stays frozen on
|
|
284
|
+
* its last "working…" frame until the NEXT gateway boot — hours later. This
|
|
285
|
+
* variant runs inside the LIVE gateway on a timer and finalizes ONLY records
|
|
286
|
+
* that are demonstrably orphaned:
|
|
287
|
+
*
|
|
288
|
+
* - `isLive(record)` — the caller's liveness predicate (gateway:
|
|
289
|
+
* `currentTurnMap.byKey.has(record.turnKey)` OR the singleton `currentTurn`
|
|
290
|
+
* mirror owns this topic). A record whose topic is still live is NEVER
|
|
291
|
+
* touched, so the currently-spinning card of an in-flight turn is safe.
|
|
292
|
+
* - age gate — `now - startedAt >= ttlMs`, a secondary guard so a card that
|
|
293
|
+
* JUST opened (before its turn populated the live map) is protected.
|
|
294
|
+
*
|
|
295
|
+
* Same AT-MOST-ONCE contract as the boot reaper: each record is deleted from
|
|
296
|
+
* the store BEFORE its finalize/unpin is attempted, and failures are swallowed
|
|
297
|
+
* without retry. The two reapers share `runOneCardFinalize` semantics via the
|
|
298
|
+
* identical delete-first ordering — a boot reaper and a mid-session sweep can
|
|
299
|
+
* never double-finalize the same message (whichever deletes the record first
|
|
300
|
+
* wins; the other sees an empty/short list).
|
|
301
|
+
*/
|
|
302
|
+
export async function runActivityCardMidSessionReaper(args: {
|
|
303
|
+
path: string
|
|
304
|
+
fs: ActivityCardStoreFsSeam
|
|
305
|
+
/** True IFF the record's topic is still owned by a live in-flight turn. */
|
|
306
|
+
isLive: (record: ActivityCardRecord) => boolean
|
|
307
|
+
ttlMs: number
|
|
308
|
+
now?: number
|
|
309
|
+
finalizeCard: (record: ActivityCardRecord) => Promise<unknown>
|
|
310
|
+
unpinCard: (record: ActivityCardRecord) => Promise<unknown>
|
|
311
|
+
log?: (line: string) => void
|
|
312
|
+
}): Promise<{ finalized: number; vanished: number; unpinned: number; total: number }> {
|
|
313
|
+
const log = args.log ?? ((l: string) => process.stderr.write(l))
|
|
314
|
+
const now = args.now ?? Date.now()
|
|
315
|
+
const cutoff = now - args.ttlMs
|
|
316
|
+
const persisted = loadActivityCards(args.path, args.fs)
|
|
317
|
+
// Only ownerless cards aged past the TTL. Liveness does the real work; the
|
|
318
|
+
// age gate only closes the just-opened-not-yet-tracked race window.
|
|
319
|
+
const stale = persisted.filter(
|
|
320
|
+
(r) => !args.isLive(r) && r.startedAt <= cutoff,
|
|
321
|
+
)
|
|
322
|
+
if (stale.length === 0) return { finalized: 0, vanished: 0, unpinned: 0, total: 0 }
|
|
323
|
+
let finalized = 0
|
|
324
|
+
let vanished = 0
|
|
325
|
+
let unpinned = 0
|
|
326
|
+
for (const record of stale) {
|
|
327
|
+
// Delete BEFORE the edit — the same idempotency guard the boot reaper uses.
|
|
328
|
+
clearActivityCardRecord(args.path, args.fs, record.turnKey, record.activityMessageId, log)
|
|
329
|
+
try {
|
|
330
|
+
const res = await args.finalizeCard(record)
|
|
331
|
+
if (res != null) finalized++
|
|
332
|
+
else vanished++
|
|
333
|
+
} catch (err) {
|
|
334
|
+
log(
|
|
335
|
+
`activity-card-store: mid-session reaper finalize failed ` +
|
|
336
|
+
`(chat=${record.chatId} msg=${record.activityMessageId}): ` +
|
|
337
|
+
`${(err as Error).message}\n`,
|
|
338
|
+
)
|
|
339
|
+
}
|
|
340
|
+
if (record.pinned) {
|
|
341
|
+
try {
|
|
342
|
+
await args.unpinCard(record)
|
|
343
|
+
unpinned++
|
|
344
|
+
} catch (err) {
|
|
345
|
+
log(
|
|
346
|
+
`activity-card-store: mid-session reaper unpin failed ` +
|
|
347
|
+
`(chat=${record.chatId} msg=${record.activityMessageId}): ` +
|
|
348
|
+
`${(err as Error).message}\n`,
|
|
349
|
+
)
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return { finalized, vanished, unpinned, total: stale.length }
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Honest finalize text for an orphaned card — the single edit the boot
|
|
358
|
+
* reaper applies. Mirrors `silentEndFallbackText`'s honesty-about-elapsed
|
|
359
|
+
* convention (`telegram-plugin/silent-end.ts`): degenerate/unknown durations
|
|
360
|
+
* omit the elapsed clause rather than printing a nonsensical "(0s)".
|
|
361
|
+
*/
|
|
362
|
+
export function restartOrphanCardFinalizeText(startedAt: number): string {
|
|
363
|
+
const elapsedMs = Date.now() - startedAt
|
|
364
|
+
const elapsed =
|
|
365
|
+
Number.isFinite(elapsedMs) && elapsedMs > 0
|
|
366
|
+
? ` (was running ${Math.round(elapsedMs / 1000)}s)`
|
|
367
|
+
: ''
|
|
368
|
+
return `⚠️ Interrupted by a gateway restart${elapsed} — this turn did not finish.`
|
|
369
|
+
}
|