switchroom 0.17.10 → 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 +27 -1
- package/dist/auth-broker/index.js +6161 -151
- package/dist/cli/notion-write-pretool.mjs +29 -2
- package/dist/cli/switchroom.js +578 -454
- package/dist/host-control/main.js +6182 -172
- package/dist/vault/approvals/kernel-server.js +5891 -164
- package/dist/vault/broker/server.js +6597 -881
- package/package.json +1 -1
- package/profiles/_base/settings.json.hbs +2 -2
- package/profiles/_base/start.sh.hbs +170 -21
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md +2 -2
- 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/telegram-plugin/auth-snapshot-format.ts +22 -24
- package/telegram-plugin/context-exhaustion.ts +124 -0
- package/telegram-plugin/dist/gateway/gateway.js +24086 -8727
- package/telegram-plugin/gateway/activity-card-store.ts +76 -0
- package/telegram-plugin/gateway/gateway.ts +480 -85
- package/telegram-plugin/gateway/inbound-delivery-gate.ts +26 -0
- package/telegram-plugin/gateway/model-command.ts +70 -10
- 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/stream-controller.ts +14 -3
- package/telegram-plugin/tests/activity-card-store.test.ts +94 -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/feed-heartbeat-liveness-open.test.ts +11 -0
- package/telegram-plugin/tests/feed-survival.test.ts +39 -0
- 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/liveness-tracker.test.ts +228 -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/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/telegram-activity-visibility-integration.test.ts +155 -1
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +98 -3
- package/telegram-plugin/turn-liveness-floor.ts +35 -1
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +99 -7
- package/telegram-plugin/worktree-watch-cwds.ts +92 -17
- package/vendor/hindsight-memory/scripts/lib/client.py +11 -1
- package/vendor/hindsight-memory/scripts/lib/config.py +9 -2
- package/vendor/hindsight-memory/scripts/recall.py +64 -6
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +1 -0
- package/vendor/hindsight-memory/tests/test_client.py +43 -0
- package/vendor/hindsight-memory/tests/test_recall_precision.py +114 -0
|
@@ -116,3 +116,29 @@ export function decideInboundDelivery(
|
|
|
116
116
|
if (input.turnInFlight) return 'buffer-until-idle'
|
|
117
117
|
return 'deliver'
|
|
118
118
|
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* #2917 — atomic check-and-reserve for per-chat outbound FIFO.
|
|
122
|
+
*
|
|
123
|
+
* `decideInboundDelivery` decides deliver-vs-buffer, but on the concurrent
|
|
124
|
+
* `handleInbound` path that decision and the busy-mark that records "a turn is
|
|
125
|
+
* now in flight for this chat" are separated by an `await` (attachment
|
|
126
|
+
* download, composer-clear). Two same-chat inbounds can therefore each read
|
|
127
|
+
* "idle" during the other's async lead-in and both deliver — the replies then
|
|
128
|
+
* come back reordered. This helper couples the decision with a `reserve` flag:
|
|
129
|
+
* a FRESH-TURN deliver must reserve the chat's busy key SYNCHRONOUSLY (before
|
|
130
|
+
* any await) so the next same-chat inbound sees it and buffers behind it.
|
|
131
|
+
*
|
|
132
|
+
* `reserve` is true ONLY for a fresh-turn deliver. Steering / interrupt
|
|
133
|
+
* inbounds deliver mid-turn WITHOUT starting a turn, so they must not reserve
|
|
134
|
+
* (reserving would wedge the running turn's key). A buffered decision never
|
|
135
|
+
* reserves.
|
|
136
|
+
*/
|
|
137
|
+
export function reserveInboundDelivery(
|
|
138
|
+
input: InboundDeliveryGateInput,
|
|
139
|
+
): { decision: InboundDeliveryDecision; reserve: boolean } {
|
|
140
|
+
const decision = decideInboundDelivery(input)
|
|
141
|
+
const reserve =
|
|
142
|
+
decision === 'deliver' && !input.isSteering && input.isInterrupt !== true
|
|
143
|
+
return { decision, reserve }
|
|
144
|
+
}
|
|
@@ -52,11 +52,14 @@ export const MODEL_ALIASES = ['opus', 'sonnet', 'haiku', 'fable', 'default'] as
|
|
|
52
52
|
* Shape gate for the model argument. This string is typed literally
|
|
53
53
|
* into the agent's tmux pane, so the gate is strict by construction:
|
|
54
54
|
* one token, alphanumeric start, then alphanumerics plus the chars
|
|
55
|
-
* that appear in real model ids (`.` `_` `-` and the `[1m]`-style
|
|
56
|
-
* variant brackets).
|
|
55
|
+
* that appear in real model ids (`.` `_` `-` `/` and the `[1m]`-style
|
|
56
|
+
* variant brackets). `/` is allowed for OpenRouter-style
|
|
57
|
+
* `sr-vendor/model` ids; it is not a shell metachar inside the
|
|
58
|
+
* double-quoted `claude --model "$_EFFECTIVE_MODEL"` usage, so it can't
|
|
59
|
+
* break the launch. No whitespace means no second token can ride
|
|
57
60
|
* along; no control characters means no newline/Enter smuggling.
|
|
58
61
|
*/
|
|
59
|
-
const MODEL_ARG_RE = /^[A-Za-z0-9][A-Za-z0-9._
|
|
62
|
+
const MODEL_ARG_RE = /^[A-Za-z0-9][A-Za-z0-9._/\[\]-]{0,99}$/
|
|
60
63
|
|
|
61
64
|
export function isValidModelArg(arg: string): boolean {
|
|
62
65
|
return MODEL_ARG_RE.test(arg)
|
|
@@ -133,6 +136,18 @@ export interface ModelCommandDeps {
|
|
|
133
136
|
* mechanism as the `/restart` command (hostd-first, SIGTERM fallback).
|
|
134
137
|
*/
|
|
135
138
|
scheduleRestart: (reason: string) => Promise<void>
|
|
139
|
+
/**
|
|
140
|
+
* Schedule a session-only switch TO a non-Claude (`sr-*` LiteLLM/OpenRouter)
|
|
141
|
+
* model. claude's in-REPL `/model` picker rejects unknown `sr-*` ids, so an
|
|
142
|
+
* inject can't set them. Instead the gateway writes the chosen token to the
|
|
143
|
+
* `.session-model-override` carrier file and gracefully restarts the agent;
|
|
144
|
+
* the next boot launches `claude --model <token>` directly (LiteLLM routes
|
|
145
|
+
* it, no picker validation). Session-only: reverts to the configured default
|
|
146
|
+
* on the following restart. Wired to the same restart dispatch as
|
|
147
|
+
* `scheduleRestart`, plus the carrier write. `model` is the full `sr-*` id
|
|
148
|
+
* (already alias-expanded); `reason` is stamped as the restart reason.
|
|
149
|
+
*/
|
|
150
|
+
scheduleModelRelaunch: (model: string, reason: string) => Promise<void>
|
|
136
151
|
}
|
|
137
152
|
|
|
138
153
|
export interface ModelCommandReply {
|
|
@@ -152,6 +167,7 @@ function helpText(deps: ModelCommandDeps, reason?: string): ModelCommandReply {
|
|
|
152
167
|
'\`/model\` — show the configured model',
|
|
153
168
|
`\`/model <name>\` — switch the live session (${MODEL_ALIASES.map(a => `\`${a}\``).join(' · ')} or a full model id)`,
|
|
154
169
|
`_OpenRouter shortcuts:_ ${srAliasExamples}`,
|
|
170
|
+
'_OpenRouter (sr-\\*) switches restart the session (~30s); Claude switches apply instantly._',
|
|
155
171
|
PERSIST_NOTE,
|
|
156
172
|
)
|
|
157
173
|
return { text: lines.join('\n'), html: true }
|
|
@@ -214,6 +230,30 @@ export async function handleModelCommand(
|
|
|
214
230
|
}
|
|
215
231
|
}
|
|
216
232
|
|
|
233
|
+
// Claude → sr-*: an in-place inject can't set a non-Anthropic model — claude's
|
|
234
|
+
// native `/model` picker rejects the unknown `sr-*` id ("Model not found").
|
|
235
|
+
// Carry the token across a graceful restart and relaunch `claude --model
|
|
236
|
+
// sr-*` directly (LiteLLM routes it). Session-only: reverts to the configured
|
|
237
|
+
// default on the next restart. The sr-* → Claude direction is handled above.
|
|
238
|
+
if (isSrModel(model)) {
|
|
239
|
+
try {
|
|
240
|
+
await deps.scheduleModelRelaunch(model, `user: /model ${model} (session-only relaunch)`)
|
|
241
|
+
} catch (err) {
|
|
242
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
243
|
+
return {
|
|
244
|
+
text: `❌ Could not schedule model switch: ${deps.escapeHtml(msg)}`,
|
|
245
|
+
html: true,
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return {
|
|
249
|
+
text: [
|
|
250
|
+
`Switching to \`${deps.escapeHtml(model)}\` — restarting session (~30s).`,
|
|
251
|
+
'_Session-only — reverts to the configured default on the next restart._',
|
|
252
|
+
].join('\n'),
|
|
253
|
+
html: true,
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
217
257
|
const verbHtml = `\`/model ${deps.escapeHtml(model)}\``
|
|
218
258
|
let result: InjectResult
|
|
219
259
|
try {
|
|
@@ -358,16 +398,31 @@ export const EXTRA_CLAUDE_ALIASES: ReadonlyArray<{ alias: string; label: string
|
|
|
358
398
|
* Friendly display names for sr-* synthetic model names. An sr-* model in
|
|
359
399
|
* LiteLLM has no entry in `model_group_settings.*.forward_client_headers_to_llm_api`
|
|
360
400
|
* so the Anthropic OAuth credential is NEVER forwarded — safe to route to
|
|
361
|
-
* OpenRouter. Names here are display-only
|
|
362
|
-
*
|
|
401
|
+
* OpenRouter. Names here are display-only (used by srFriendlyLabel for /status
|
|
402
|
+
* and switch confirmations); the raw `sr-*` id is what gets injected into the
|
|
403
|
+
* agent's session. This table is a SUPERSET of the menu-reachable set
|
|
404
|
+
* (SR_MODEL_ALIASES): it also labels models that are only reachable by typing
|
|
405
|
+
* the full `/model sr-<name>` (manual passthrough), so those still get a
|
|
406
|
+
* friendly name without appearing as a keyboard button.
|
|
407
|
+
* See reference/rfcs/litellm-max-subscription-invariants.md § I6.
|
|
363
408
|
*/
|
|
364
409
|
export const SR_MODEL_LABELS: Record<string, string> = {
|
|
365
410
|
'sr-gemini-2.5-pro': 'Gemini 2.5 Pro',
|
|
366
411
|
'sr-gemini-2.5-flash': 'Gemini 2.5 Flash',
|
|
367
412
|
'sr-deepseek-r1': 'DeepSeek R1',
|
|
368
413
|
'sr-deepseek-v3': 'DeepSeek V3',
|
|
369
|
-
|
|
414
|
+
// sr-glm-5 now targets glm-5.2 in the live litellm config — label bumped to match.
|
|
415
|
+
'sr-glm-5': 'GLM-5.2',
|
|
370
416
|
'sr-codex-5.5': 'Codex 5.5',
|
|
417
|
+
// OpenRouter coverage (pairs with the live litellm sr-* model_name additions).
|
|
418
|
+
'sr-gpt-oss-20b': 'GPT-OSS 20B',
|
|
419
|
+
'sr-gpt-oss-120b': 'GPT-OSS 120B',
|
|
420
|
+
'sr-gpt-5.5': 'GPT-5.5',
|
|
421
|
+
'sr-gpt-5-codex': 'GPT-5 Codex',
|
|
422
|
+
'sr-gpt-5.2-codex': 'GPT-5.2 Codex',
|
|
423
|
+
'sr-gemini-flash-lite': 'Gemini 3.1 Flash Lite',
|
|
424
|
+
'sr-minimax-m3': 'MiniMax M3',
|
|
425
|
+
'sr-deepseek-v4-flash': 'DeepSeek V4 Flash',
|
|
371
426
|
}
|
|
372
427
|
|
|
373
428
|
/**
|
|
@@ -448,10 +503,15 @@ function headerRow(label: string): ModelMenuKeyboardButton[] {
|
|
|
448
503
|
* requires ANTHROPIC_CUSTOM_HEADERS (a litellm key) to be set on the gateway
|
|
449
504
|
* process. switchroom never sets that env on the gateway, so in production
|
|
450
505
|
* discoverSrModels() always returns [] and the external group was silently
|
|
451
|
-
* empty. The six SR_MODEL_ALIASES targets are the sr-*
|
|
452
|
-
*
|
|
453
|
-
*
|
|
454
|
-
*
|
|
506
|
+
* empty. The six SR_MODEL_ALIASES targets are the CURATED main sr-* set, so
|
|
507
|
+
* seeding from them makes the group reliable without the missing env — while
|
|
508
|
+
* still merging any live results on hosts that do configure discovery.
|
|
509
|
+
*
|
|
510
|
+
* Deliberately a SUBSET: the litellm config exposes more sr-* models than this
|
|
511
|
+
* (the full OpenRouter catalogue). Those extras are display-labelled in
|
|
512
|
+
* SR_MODEL_LABELS and remain typeable via `/model <full-sr-name>` (manual
|
|
513
|
+
* passthrough — the set path is a shape gate, no whitelist), but they are kept
|
|
514
|
+
* OUT of SR_MODEL_ALIASES on purpose so the keyboard stays small and curated.
|
|
455
515
|
*
|
|
456
516
|
* Subscription-honest: ONLY the curated sr-* aliases surface as buttons. Raw
|
|
457
517
|
* gpt-4o / openrouter/* dupes / voyage-* embeddings never do.
|
|
@@ -33,8 +33,14 @@
|
|
|
33
33
|
"@secretlint/types": "^12.2.0",
|
|
34
34
|
"@xterm/headless": "^6.0.0",
|
|
35
35
|
"grammy": "^1.44",
|
|
36
|
+
"mdast-util-from-markdown": "^2.0.2",
|
|
37
|
+
"mdast-util-gfm": "^3.0.0",
|
|
38
|
+
"micromark-extension-gfm": "^3.0.0",
|
|
36
39
|
"posthog-node": "^5.29.2"
|
|
37
40
|
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/mdast": "^4.0.4"
|
|
43
|
+
},
|
|
38
44
|
"engines": {
|
|
39
45
|
"node": ">=20.11.0"
|
|
40
46
|
},
|
|
@@ -97,9 +97,10 @@ export function emptyAccountState(): QuotaWatchAccountState {
|
|
|
97
97
|
* SWITCHROOM_QUOTA_WATCH_FLEET_DEDUP "0" disables the broker claim
|
|
98
98
|
* (every agent sends, pre-incident
|
|
99
99
|
* behaviour)
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
100
|
+
*
|
|
101
|
+
* When the pre-send validation probe fails, the alert is unconditionally
|
|
102
|
+
* suppressed (a quota notification must never carry numbers we could not
|
|
103
|
+
* verify live); the transition re-evaluates on the next poll tick.
|
|
103
104
|
*/
|
|
104
105
|
export interface QuotaWatchTuning {
|
|
105
106
|
/** Cached snapshots older than this are treated as unknown (no opinion). 0 = off. */
|
|
@@ -108,8 +109,6 @@ export interface QuotaWatchTuning {
|
|
|
108
109
|
lateRecoveryMs: number;
|
|
109
110
|
/** Route sends through the broker's claim-notification dedup. */
|
|
110
111
|
fleetDedup: boolean;
|
|
111
|
-
/** Legacy: send from cached data when the validation probe fails. */
|
|
112
|
-
sendOnProbeFail: boolean;
|
|
113
112
|
}
|
|
114
113
|
|
|
115
114
|
export const DEFAULT_QUOTA_WATCH_MAX_STALE_MS = 60 * 60_000;
|
|
@@ -133,7 +132,6 @@ export function resolveQuotaWatchTuning(
|
|
|
133
132
|
maxStaleMs: num(env.SWITCHROOM_QUOTA_WATCH_MAX_STALE_MS, DEFAULT_QUOTA_WATCH_MAX_STALE_MS),
|
|
134
133
|
lateRecoveryMs: num(env.SWITCHROOM_QUOTA_WATCH_LATE_RECOVERY_MS, DEFAULT_QUOTA_WATCH_LATE_RECOVERY_MS),
|
|
135
134
|
fleetDedup: env.SWITCHROOM_QUOTA_WATCH_FLEET_DEDUP !== "0",
|
|
136
|
-
sendOnProbeFail: env.SWITCHROOM_QUOTA_WATCH_SEND_ON_PROBE_FAIL === "1",
|
|
137
135
|
};
|
|
138
136
|
}
|
|
139
137
|
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
recordTurnEnd,
|
|
22
22
|
findRecentTurnsForChat,
|
|
23
23
|
getTurnByKey,
|
|
24
|
+
reapStaleOpenTurns,
|
|
24
25
|
} from './turns-schema.js'
|
|
25
26
|
|
|
26
27
|
// ---------------------------------------------------------------------------
|
|
@@ -157,3 +158,99 @@ describe('getTurnByKey', () => {
|
|
|
157
158
|
db.close()
|
|
158
159
|
})
|
|
159
160
|
})
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// reapStaleOpenTurns — mid-session periodic orphan sweep (#2918)
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
describe('reapStaleOpenTurns (#2918 mid-session sweep)', () => {
|
|
167
|
+
// Helper: force a row's started_at into the past so the TTL gate is met.
|
|
168
|
+
function ageRow(db: ReturnType<typeof openTurnsDbInMemory>, turnKey: string, startedAt: number): void {
|
|
169
|
+
db.prepare('UPDATE turns SET started_at = ? WHERE turn_key = ?').run(startedAt, turnKey)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
it('stamps an ownerless open row aged past the TTL as restart', () => {
|
|
173
|
+
const db = openTurnsDbInMemory()
|
|
174
|
+
const now = 1_000_000_000_000
|
|
175
|
+
recordTurnStart(db, { turnKey: 'dm:dead', chatId: '111' })
|
|
176
|
+
ageRow(db, 'dm:dead', now - 30 * 60_000) // 30 min old
|
|
177
|
+
const res = reapStaleOpenTurns(db, {
|
|
178
|
+
activeTurnKeys: new Set<string>(), // process gone → no live owner
|
|
179
|
+
ttlMs: 15 * 60_000,
|
|
180
|
+
now,
|
|
181
|
+
})
|
|
182
|
+
expect(res.reaped).toBe(1)
|
|
183
|
+
expect(res.reapedTurnKeys).toEqual(['dm:dead'])
|
|
184
|
+
const turn = getTurnByKey(db, 'dm:dead')
|
|
185
|
+
expect(turn?.ended_at).toBe(now)
|
|
186
|
+
expect(turn?.ended_via).toBe('restart')
|
|
187
|
+
db.close()
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('NEVER touches a healthy in-flight turn (turn_key in activeTurnKeys)', () => {
|
|
191
|
+
const db = openTurnsDbInMemory()
|
|
192
|
+
const now = 1_000_000_000_000
|
|
193
|
+
recordTurnStart(db, { turnKey: 'dm:live', chatId: '222' })
|
|
194
|
+
// Even though it is aged well past the TTL, a live owner protects it.
|
|
195
|
+
ageRow(db, 'dm:live', now - 6 * 60 * 60_000) // 6h "long-running" turn
|
|
196
|
+
const res = reapStaleOpenTurns(db, {
|
|
197
|
+
activeTurnKeys: new Set(['dm:live']),
|
|
198
|
+
ttlMs: 15 * 60_000,
|
|
199
|
+
now,
|
|
200
|
+
})
|
|
201
|
+
expect(res.reaped).toBe(0)
|
|
202
|
+
const turn = getTurnByKey(db, 'dm:live')
|
|
203
|
+
expect(turn?.ended_at).toBeNull()
|
|
204
|
+
expect(turn?.ended_via).toBeNull()
|
|
205
|
+
db.close()
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('does not reap an ownerless row younger than the TTL (race guard)', () => {
|
|
209
|
+
const db = openTurnsDbInMemory()
|
|
210
|
+
const now = 1_000_000_000_000
|
|
211
|
+
recordTurnStart(db, { turnKey: 'dm:fresh', chatId: '333' })
|
|
212
|
+
ageRow(db, 'dm:fresh', now - 60_000) // 1 min old, ownerless
|
|
213
|
+
const res = reapStaleOpenTurns(db, {
|
|
214
|
+
activeTurnKeys: new Set<string>(),
|
|
215
|
+
ttlMs: 15 * 60_000,
|
|
216
|
+
now,
|
|
217
|
+
})
|
|
218
|
+
expect(res.reaped).toBe(0)
|
|
219
|
+
expect(getTurnByKey(db, 'dm:fresh')?.ended_at).toBeNull()
|
|
220
|
+
db.close()
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('reaps the dead orphan while sparing a concurrently-live turn', () => {
|
|
224
|
+
const db = openTurnsDbInMemory()
|
|
225
|
+
const now = 1_000_000_000_000
|
|
226
|
+
recordTurnStart(db, { turnKey: 'dm:dead', chatId: '111' })
|
|
227
|
+
recordTurnStart(db, { turnKey: 'dm:live', chatId: '222' })
|
|
228
|
+
ageRow(db, 'dm:dead', now - 30 * 60_000)
|
|
229
|
+
ageRow(db, 'dm:live', now - 30 * 60_000)
|
|
230
|
+
const res = reapStaleOpenTurns(db, {
|
|
231
|
+
activeTurnKeys: new Set(['dm:live']),
|
|
232
|
+
ttlMs: 15 * 60_000,
|
|
233
|
+
now,
|
|
234
|
+
})
|
|
235
|
+
expect(res.reapedTurnKeys).toEqual(['dm:dead'])
|
|
236
|
+
expect(getTurnByKey(db, 'dm:dead')?.ended_via).toBe('restart')
|
|
237
|
+
expect(getTurnByKey(db, 'dm:live')?.ended_at).toBeNull()
|
|
238
|
+
db.close()
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('leaves already-ended rows alone (idempotent)', () => {
|
|
242
|
+
const db = openTurnsDbInMemory()
|
|
243
|
+
const now = 1_000_000_000_000
|
|
244
|
+
recordTurnStart(db, { turnKey: 'dm:done', chatId: '444' })
|
|
245
|
+
ageRow(db, 'dm:done', now - 30 * 60_000)
|
|
246
|
+
recordTurnEnd(db, { turnKey: 'dm:done', endedVia: 'stop' })
|
|
247
|
+
const res = reapStaleOpenTurns(db, {
|
|
248
|
+
activeTurnKeys: new Set<string>(),
|
|
249
|
+
ttlMs: 15 * 60_000,
|
|
250
|
+
now,
|
|
251
|
+
})
|
|
252
|
+
expect(res.reaped).toBe(0)
|
|
253
|
+
expect(getTurnByKey(db, 'dm:done')?.ended_via).toBe('stop')
|
|
254
|
+
db.close()
|
|
255
|
+
})
|
|
256
|
+
})
|
|
@@ -497,6 +497,84 @@ export function markOrphanedWithTimeoutClassification(
|
|
|
497
497
|
return { reaped: (timeoutTurnKey ? 1 : 0) + rest.changes, timeoutTurnKey }
|
|
498
498
|
}
|
|
499
499
|
|
|
500
|
+
export interface ReapStaleOpenTurnsOpts {
|
|
501
|
+
/**
|
|
502
|
+
* The set of turn_keys that belong to a turn still LIVE in this process's
|
|
503
|
+
* memory (the gateway's `currentTurnMap` registry keys plus the singleton
|
|
504
|
+
* `currentTurn` mirror). A row whose turn_key is in this set is NEVER
|
|
505
|
+
* reaped — it is a genuinely in-flight turn whose spinner must keep
|
|
506
|
+
* spinning, however long it runs. This is the load-bearing liveness
|
|
507
|
+
* predicate: age alone must never reap; only an open row with NO live
|
|
508
|
+
* owner qualifies.
|
|
509
|
+
*/
|
|
510
|
+
activeTurnKeys: ReadonlySet<string>
|
|
511
|
+
/**
|
|
512
|
+
* Minimum age (ms, measured from `started_at`) before an ownerless open row
|
|
513
|
+
* is swept. A secondary guard against races — a turn that has JUST started
|
|
514
|
+
* but not yet populated the live set (or a row recorded microseconds ago) is
|
|
515
|
+
* protected until it ages past this. Liveness (activeTurnKeys) does the real
|
|
516
|
+
* work; the TTL only closes the "recorded-but-not-yet-tracked" window.
|
|
517
|
+
*/
|
|
518
|
+
ttlMs: number
|
|
519
|
+
/** Injectable clock for tests. */
|
|
520
|
+
now?: number
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
export interface ReapStaleOpenTurnsResult {
|
|
524
|
+
/** Rows stamped `ended_via='restart'` by this sweep. */
|
|
525
|
+
reaped: number
|
|
526
|
+
/** The turn_keys that were stamped, for logging / card finalization. */
|
|
527
|
+
reapedTurnKeys: string[]
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Mid-session periodic reaper (#2918). The boot-time
|
|
532
|
+
* `markOrphanedWithTimeoutClassification` only runs once, right after
|
|
533
|
+
* `openTurnsDb` — so a turn whose owning process dies MID-session (the SDK
|
|
534
|
+
* subprocess is SIGKILLed / OOMs / crashes) without a clean `recordTurnEnd`
|
|
535
|
+
* leaves its row `ended_at IS NULL`, and its activity card keeps spinning
|
|
536
|
+
* until the NEXT gateway boot (often many hours later). This sweep runs on a
|
|
537
|
+
* periodic timer inside the live gateway and stamps those ownerless open rows
|
|
538
|
+
* `ended_via='restart'` (the same clean-interrupt classification the boot
|
|
539
|
+
* reaper uses for a non-hung orphan) so the stale card can be finalized
|
|
540
|
+
* without waiting for a restart.
|
|
541
|
+
*
|
|
542
|
+
* CORRECTNESS: only rows that are BOTH (a) not owned by any live turn
|
|
543
|
+
* (`turn_key ∉ activeTurnKeys`) AND (b) older than `ttlMs` are swept. A
|
|
544
|
+
* healthy in-flight turn — however long it runs — is always in
|
|
545
|
+
* `activeTurnKeys` and is never touched. Never invents a new state; reuses
|
|
546
|
+
* `'restart'` so the existing resume/report policy applies unchanged.
|
|
547
|
+
*/
|
|
548
|
+
export function reapStaleOpenTurns(
|
|
549
|
+
db: SqliteDatabase,
|
|
550
|
+
opts: ReapStaleOpenTurnsOpts,
|
|
551
|
+
): ReapStaleOpenTurnsResult {
|
|
552
|
+
const now = opts.now ?? Date.now()
|
|
553
|
+
const cutoff = now - opts.ttlMs
|
|
554
|
+
// Candidate = open row aged past the TTL. Liveness is filtered in JS against
|
|
555
|
+
// the injected active set (avoids brittle SQL IN-list binding).
|
|
556
|
+
const candidates = db.prepare(`
|
|
557
|
+
SELECT turn_key FROM turns
|
|
558
|
+
WHERE ended_at IS NULL AND started_at <= ?
|
|
559
|
+
`).all(cutoff) as { turn_key: string }[]
|
|
560
|
+
|
|
561
|
+
const stamp = db.prepare(`
|
|
562
|
+
UPDATE turns
|
|
563
|
+
SET ended_at = ?,
|
|
564
|
+
ended_via = 'restart',
|
|
565
|
+
updated_at = ?
|
|
566
|
+
WHERE turn_key = ? AND ended_at IS NULL
|
|
567
|
+
`)
|
|
568
|
+
|
|
569
|
+
const reapedTurnKeys: string[] = []
|
|
570
|
+
for (const { turn_key } of candidates) {
|
|
571
|
+
if (opts.activeTurnKeys.has(turn_key)) continue // live — never reap
|
|
572
|
+
const r = stamp.run(now, now, turn_key) as { changes: number }
|
|
573
|
+
if (r.changes > 0) reapedTurnKeys.push(turn_key)
|
|
574
|
+
}
|
|
575
|
+
return { reaped: reapedTurnKeys.length, reapedTurnKeys }
|
|
576
|
+
}
|
|
577
|
+
|
|
500
578
|
/**
|
|
501
579
|
* Return the most recent N turns for `chatId` (any state — running or ended),
|
|
502
580
|
* ordered by started_at DESC. Used by the idle-footer renderer to decide
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// Typed intermediate representation (IR) for the Telegram HTML render engine.
|
|
2
|
+
//
|
|
3
|
+
// This is the parser <-> renderer contract. `parse()` (parse.ts) folds an
|
|
4
|
+
// mdast tree into this shape; a later increment's renderer walks it and emits
|
|
5
|
+
// Telegram Bot API HTML. Increment 1 lands ONLY the parser + this IR — there
|
|
6
|
+
// is no renderer yet.
|
|
7
|
+
//
|
|
8
|
+
// Every node carries `{ start, end }` UTF-16 source offsets copied verbatim
|
|
9
|
+
// from mdast `position.start.offset` / `position.end.offset`. They are UTF-16
|
|
10
|
+
// code-unit indices into the original markdown string, so
|
|
11
|
+
// `source.slice(node.start, node.end)` round-trips to the node's source text.
|
|
12
|
+
//
|
|
13
|
+
// Telegram HTML tag mapping (for the next increment — NOT implemented here):
|
|
14
|
+
//
|
|
15
|
+
// Inline
|
|
16
|
+
// plain -> (raw text, HTML-escaped)
|
|
17
|
+
// bold -> <b>…</b> (markdown `**…**`)
|
|
18
|
+
// italic -> <i>…</i> (markdown `*…*`)
|
|
19
|
+
// underline -> <u>…</u> (markdown `__…__`, Bot API 10.1)
|
|
20
|
+
// strike -> <s>…</s> (markdown `~~…~~`)
|
|
21
|
+
// spoiler -> <tg-spoiler>…</tg-spoiler> (markdown `||…||`)
|
|
22
|
+
// highlight -> <mark>…</mark> (markdown `==…==`, Bot API 10.1)
|
|
23
|
+
// code -> <code>…</code>
|
|
24
|
+
// link -> <a href="…">…</a>
|
|
25
|
+
//
|
|
26
|
+
// Block
|
|
27
|
+
// paragraph -> children joined; blocks separated by "\n\n"
|
|
28
|
+
// heading -> <b>…</b> (Telegram HTML has no <h1>…<h6>; bold + newlines)
|
|
29
|
+
// blockquote -> <blockquote>…</blockquote>
|
|
30
|
+
// (expandable === true -> <blockquote expandable>)
|
|
31
|
+
// code-block -> <pre><code class="language-…">…</code></pre>
|
|
32
|
+
// list -> rendered line-per-item with "•"/"1." bullets
|
|
33
|
+
// (Telegram HTML has no <ul>/<ol>)
|
|
34
|
+
// thematic-break -> a horizontal-rule text line (e.g. "───")
|
|
35
|
+
// table -> monospaced <pre> table (Telegram HTML has no <table>)
|
|
36
|
+
|
|
37
|
+
export interface Pos {
|
|
38
|
+
/** UTF-16 code-unit offset of the node's first char (mdast position.start.offset). */
|
|
39
|
+
start: number;
|
|
40
|
+
/** UTF-16 code-unit offset just past the node's last char (mdast position.end.offset). */
|
|
41
|
+
end: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Inline nodes
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
export interface PlainNode extends Pos {
|
|
49
|
+
type: "plain";
|
|
50
|
+
text: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface BoldNode extends Pos {
|
|
54
|
+
type: "bold";
|
|
55
|
+
children: Inline[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ItalicNode extends Pos {
|
|
59
|
+
type: "italic";
|
|
60
|
+
children: Inline[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Telegram underline (<u>…</u>). In Bot API 10.1 rich markdown the `__…__`
|
|
64
|
+
* double-underscore run is UNDERLINE — distinct from `**…**` bold, even though
|
|
65
|
+
* GFM/micromark folds both into a single `strong` mdast node. `parse.ts`
|
|
66
|
+
* disambiguates the two by looking at the run's source delimiter. */
|
|
67
|
+
export interface UnderlineNode extends Pos {
|
|
68
|
+
type: "underline";
|
|
69
|
+
children: Inline[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface StrikeNode extends Pos {
|
|
73
|
+
type: "strike";
|
|
74
|
+
children: Inline[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Telegram spoiler (<tg-spoiler>…</tg-spoiler>), markdown `||…||`. GFM has no
|
|
78
|
+
* spoiler syntax, so `parse.ts` recognises the `||…||` delimiter in a
|
|
79
|
+
* post-parse pass over `plain` text. */
|
|
80
|
+
export interface SpoilerNode extends Pos {
|
|
81
|
+
type: "spoiler";
|
|
82
|
+
children: Inline[];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Telegram highlight / marked text (<mark>…</mark>), markdown `==…==` (Bot API
|
|
86
|
+
* 10.1). Like spoiler, recognised by `parse.ts` in a post-parse pass over
|
|
87
|
+
* `plain` text (GFM has no highlight syntax). */
|
|
88
|
+
export interface HighlightNode extends Pos {
|
|
89
|
+
type: "highlight";
|
|
90
|
+
children: Inline[];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface CodeNode extends Pos {
|
|
94
|
+
type: "code";
|
|
95
|
+
text: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface LinkNode extends Pos {
|
|
99
|
+
type: "link";
|
|
100
|
+
href: string;
|
|
101
|
+
children: Inline[];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export type Inline =
|
|
105
|
+
| PlainNode
|
|
106
|
+
| BoldNode
|
|
107
|
+
| ItalicNode
|
|
108
|
+
| UnderlineNode
|
|
109
|
+
| StrikeNode
|
|
110
|
+
| SpoilerNode
|
|
111
|
+
| HighlightNode
|
|
112
|
+
| CodeNode
|
|
113
|
+
| LinkNode;
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// Block nodes
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
export interface ParagraphNode extends Pos {
|
|
120
|
+
type: "paragraph";
|
|
121
|
+
children: Inline[];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface HeadingNode extends Pos {
|
|
125
|
+
type: "heading";
|
|
126
|
+
/** 1..6 */
|
|
127
|
+
level: number;
|
|
128
|
+
children: Inline[];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface BlockquoteNode extends Pos {
|
|
132
|
+
type: "blockquote";
|
|
133
|
+
children: Block[];
|
|
134
|
+
/** Telegram <blockquote expandable>. Always false in Increment 1 — see parse.ts. */
|
|
135
|
+
expandable: boolean;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface CodeBlockNode extends Pos {
|
|
139
|
+
type: "code-block";
|
|
140
|
+
text: string;
|
|
141
|
+
language: string | null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface ListNode extends Pos {
|
|
145
|
+
type: "list";
|
|
146
|
+
ordered: boolean;
|
|
147
|
+
// NOTE: the spec names this ordinal `start`, but every node already carries
|
|
148
|
+
// `start`/`end` UTF-16 offsets (load-bearing for round-trip slicing). To
|
|
149
|
+
// avoid the collision the ordered-list ordinal is `startNumber` here; its
|
|
150
|
+
// semantics match the spec's `list.start` exactly (mdast `list.start`).
|
|
151
|
+
/** First number of an ordered list (mdast `start`); null for unordered. */
|
|
152
|
+
startNumber: number | null;
|
|
153
|
+
/** Loose vs tight (mdast `list.spread`). A LOOSE list separates its items
|
|
154
|
+
* with a blank line in the source; a TIGHT list keeps them on adjacent
|
|
155
|
+
* lines. The renderer preserves this: loose lists join items with a blank
|
|
156
|
+
* line, tight lists (including nested sub-lists) stay on single newlines so
|
|
157
|
+
* no spurious blank line is injected between a tight item and its sub-list. */
|
|
158
|
+
spread: boolean;
|
|
159
|
+
items: ListItem[];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface ThematicBreakNode extends Pos {
|
|
163
|
+
type: "thematic-break";
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface TableNode extends Pos {
|
|
167
|
+
type: "table";
|
|
168
|
+
header: TableRow;
|
|
169
|
+
rows: TableRow[];
|
|
170
|
+
/** Per-column alignment, parallel to the cells. */
|
|
171
|
+
align: ("left" | "center" | "right" | null)[];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export type Block =
|
|
175
|
+
| ParagraphNode
|
|
176
|
+
| HeadingNode
|
|
177
|
+
| BlockquoteNode
|
|
178
|
+
| CodeBlockNode
|
|
179
|
+
| ListNode
|
|
180
|
+
| ThematicBreakNode
|
|
181
|
+
| TableNode;
|
|
182
|
+
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
// Composite / container shapes
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
export interface ListItem extends Pos {
|
|
188
|
+
children: Block[];
|
|
189
|
+
/** GFM task-list state: true (checked), false (unchecked), null (not a task item). */
|
|
190
|
+
checked: boolean | null;
|
|
191
|
+
/** Loose vs tight at the ITEM level (mdast `listItem.spread`): whether this
|
|
192
|
+
* item's own block children are separated by a blank line in the source.
|
|
193
|
+
* A tight item (spread=false) — e.g. a paragraph followed by a nested
|
|
194
|
+
* sub-list — keeps its children on single newlines, so no blank line is
|
|
195
|
+
* injected before the sub-list. */
|
|
196
|
+
spread: boolean;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export interface TableRow extends Pos {
|
|
200
|
+
cells: TableCell[];
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export interface TableCell extends Pos {
|
|
204
|
+
children: Inline[];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export interface Document {
|
|
208
|
+
blocks: Block[];
|
|
209
|
+
}
|