switchroom 0.19.41 → 0.19.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/dist/agent-scheduler/index.js +22 -2
  2. package/dist/auth-broker/index.js +22 -2
  3. package/dist/cli/notion-write-pretool.mjs +22 -2
  4. package/dist/cli/switchroom.js +772 -234
  5. package/dist/host-control/main.js +23 -3
  6. package/dist/vault/approvals/kernel-server.js +22 -2
  7. package/dist/vault/broker/server.js +22 -2
  8. package/package.json +1 -1
  9. package/telegram-plugin/dist/gateway/gateway.js +97 -83
  10. package/telegram-plugin/gateway/approval-callback-consume-record.test.ts +132 -0
  11. package/telegram-plugin/gateway/gateway.ts +7 -7
  12. package/telegram-plugin/gateway/liveness-wiring.ts +12 -0
  13. package/telegram-plugin/gateway/model-command.ts +21 -109
  14. package/telegram-plugin/gateway/stream-render.ts +57 -0
  15. package/telegram-plugin/tests/framework-fallback-drains-parked.test.ts +134 -0
  16. package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +3 -0
  17. package/vendor/hindsight-memory/CHANGELOG.md +28 -0
  18. package/vendor/hindsight-memory/docs/measurements/subagent-volume-gate-3994.md +84 -0
  19. package/vendor/hindsight-memory/scripts/backfill_transcripts.py +254 -40
  20. package/vendor/hindsight-memory/scripts/lib/content.py +11 -2
  21. package/vendor/hindsight-memory/scripts/recall.py +18 -3
  22. package/vendor/hindsight-memory/scripts/subagent_retain.py +59 -38
  23. package/vendor/hindsight-memory/scripts/tests/data/replay_volume_gate_3994.py +295 -0
  24. package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +109 -0
  25. package/vendor/hindsight-memory/scripts/tests/test_overlap_tokens.py +135 -0
  26. package/vendor/hindsight-memory/scripts/tests/test_recall_no_lexical_gate.py +20 -23
  27. package/vendor/hindsight-memory/scripts/tests/test_recall_query_shaping.py +48 -0
  28. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +94 -1
  29. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain_learnings.py +98 -42
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Regression test for #4069 — the "kernel record failed" false toast.
3
+ *
4
+ * The kernel's approval_consume_record reply carries `decision_id`, but
5
+ * the client decodes every reply through ResponseSchema (a plain
6
+ * z.union, first-match wins, unknown keys stripped). With
7
+ * OkApprovalConsumeResponseSchema ordered before
8
+ * OkApprovalConsumeRecordResponseSchema, the consume_record reply
9
+ * matched the prefix schema and `decision_id` was silently stripped —
10
+ * so handleApprovalCallback hit its defensive `!result.decision_id`
11
+ * branch, toasted "kernel record failed", and returned before editing
12
+ * the card to its granted state. Deterministic on EVERY `apv:` approval
13
+ * tap. The audit row was never lost — the kernel committed it — this
14
+ * was purely the client's mangled view of the reply.
15
+ *
16
+ * This test drives handleApprovalCallback end-to-end against a fake
17
+ * kernel serving a REAL wire frame through the REAL
18
+ * rpcRaw/decodeResponse path (the kernel suites parse the wire with raw
19
+ * JSON.parse and bypass decodeResponse — which is exactly why they
20
+ * missed this). It asserts the tap lands: no "kernel record failed"
21
+ * toast, and the card advances to its granted body carrying the
22
+ * decision_id revoke hint.
23
+ */
24
+
25
+ import { afterEach, describe, expect, it } from "vitest";
26
+ import * as net from "node:net";
27
+ import * as os from "node:os";
28
+ import * as fs from "node:fs";
29
+ import * as path from "node:path";
30
+ import type { Context } from "grammy";
31
+ import { handleApprovalCallback } from "./approval-callback.js";
32
+
33
+ const REQUEST_ID = "0123456789abcdef0123456789abcdef";
34
+ const DECISION_ID = "dec-4069";
35
+
36
+ /** One-shot fake kernel: replies to the first request line with `reply`. */
37
+ function startFakeKernel(
38
+ socketPath: string,
39
+ reply: Record<string, unknown>,
40
+ seen: string[],
41
+ ): Promise<net.Server> {
42
+ return new Promise((resolve, reject) => {
43
+ const server = net.createServer((conn) => {
44
+ let buf = "";
45
+ conn.on("data", (chunk) => {
46
+ buf += chunk.toString("utf8");
47
+ const idx = buf.indexOf("\n");
48
+ if (idx === -1) return;
49
+ seen.push(buf.slice(0, idx));
50
+ conn.write(JSON.stringify(reply) + "\n");
51
+ });
52
+ });
53
+ server.on("error", reject);
54
+ server.listen(socketPath, () => resolve(server));
55
+ });
56
+ }
57
+
58
+ function makeFakeCtx(): {
59
+ ctx: Context;
60
+ toasts: string[];
61
+ edits: Array<{ markdown: string }>;
62
+ } {
63
+ const toasts: string[] = [];
64
+ const edits: Array<{ markdown: string }> = [];
65
+ const ctx = {
66
+ from: { id: 42 },
67
+ answerCallbackQuery: async (args?: { text?: string }) => {
68
+ toasts.push(args?.text ?? "");
69
+ return true;
70
+ },
71
+ editMessageText: async (body: { markdown: string }) => {
72
+ edits.push(body);
73
+ return true;
74
+ },
75
+ } as unknown as Context;
76
+ return { ctx, toasts, edits };
77
+ }
78
+
79
+ describe("handleApprovalCallback × real decodeResponse (#4069)", () => {
80
+ let server: net.Server | undefined;
81
+ let tmpDir: string | undefined;
82
+ const savedEnv = process.env.SWITCHROOM_KERNEL_SOCKET;
83
+
84
+ afterEach(async () => {
85
+ if (server) await new Promise((r) => server!.close(r));
86
+ server = undefined;
87
+ if (savedEnv === undefined) delete process.env.SWITCHROOM_KERNEL_SOCKET;
88
+ else process.env.SWITCHROOM_KERNEL_SOCKET = savedEnv;
89
+ if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
90
+ tmpDir = undefined;
91
+ });
92
+
93
+ it("approve tap records + advances the card — no false 'kernel record failed'", async () => {
94
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "sr4069-"));
95
+ const socketPath = path.join(tmpDir, "kernel.sock");
96
+ const seen: string[] = [];
97
+ server = await startFakeKernel(
98
+ socketPath,
99
+ {
100
+ ok: true,
101
+ consumed: true,
102
+ decision_id: DECISION_ID,
103
+ agent_unit: "clerk",
104
+ scope: "doc:gdrive:D1",
105
+ action: "write",
106
+ why: null,
107
+ },
108
+ seen,
109
+ );
110
+ process.env.SWITCHROOM_KERNEL_SOCKET = socketPath;
111
+
112
+ const { ctx, toasts, edits } = makeFakeCtx();
113
+ await handleApprovalCallback(ctx, `apv:${REQUEST_ID}:once`);
114
+
115
+ // The fake kernel really received the atomic consume+record op.
116
+ expect(seen.length).toBe(1);
117
+ expect(JSON.parse(seen[0]!)).toMatchObject({
118
+ op: "approval_consume_record",
119
+ request_id: REQUEST_ID,
120
+ decision: "allow_once",
121
+ });
122
+
123
+ // Outcome: the tap must land as Approved, never the false-failure
124
+ // toast, and the card must advance to its granted body carrying the
125
+ // decision_id revoke hint.
126
+ expect(toasts).not.toContain("kernel record failed");
127
+ expect(toasts).toContain("Approved");
128
+ expect(edits.length).toBe(1);
129
+ expect(edits[0]!.markdown).toContain(DECISION_ID);
130
+ expect(edits[0]!.markdown).toContain("granted once");
131
+ });
132
+ });
@@ -457,7 +457,7 @@ import {
457
457
  type SendReplyGatewayDeps,
458
458
  type DeliverCapturedProseDeps,
459
459
  } from './outbound-send-path.js'
460
- import { handleSessionEvent as handleSessionEventCore } from './stream-render.js'
460
+ import { handleSessionEvent as handleSessionEventCore, drainParkedTurnStartsForChat } from './stream-render.js'
461
461
  import { createNarrativeLane } from './narrative-lane.js'
462
462
  import {
463
463
  parseAgentCallback,
@@ -9669,14 +9669,14 @@ function gatewayLivenessWiringDeps() {
9669
9669
  turnLiveForItsTopic,
9670
9670
  endCurrentTurnForKey,
9671
9671
  // Getter, not an eager read: `pendingInboundBuffer` (const) is declared
9672
- // LATER in this module (~9863) than the module-load `startTimer` call that
9673
- // invokes this builder (isGatewayMain path). A by-value capture here is a
9674
- // temporal-dead-zone ReferenceError that crash-loops every gateway boot
9675
- // (#3392 regression). Deferring to a getter matching getInboundSpool /
9676
- // getTurnsDb / ipcServer — means the binding is only read when the
9677
- // fallback actually fires, well after module eval.
9672
+ // LATER than the module-load `startTimer` call that invokes this builder,
9673
+ // so a by-value capture is a temporal-dead-zone ReferenceError that
9674
+ // crash-loops boot (#3392). A getter (matching getInboundSpool / getTurnsDb
9675
+ // / ipcServer) is read only when the fallback fires, well after module eval.
9678
9676
  getPendingInboundBuffer: () => pendingInboundBuffer,
9679
9677
  trackRedeliveredInbound,
9678
+ // Two-state desync unwedge (#3927): drain the parked store the fallback else leaves latched — see liveness-wiring + stream-render drainParkedTurnStartsForChat.
9679
+ drainParkedTurnStarts: (chatId: string, threadId: number | null): number => drainParkedTurnStartsForChat(gatewayStreamRenderDeps(), chatId, threadId != null ? String(threadId) : null).length,
9680
9680
  closeActivityLane,
9681
9681
  closeProgressLane,
9682
9682
  // Stage B: escalate a mid-tool + marker-stale fallback to a real restart.
@@ -62,6 +62,7 @@ export function buildSilencePokeOptions(deps: LivenessWiringDeps): Parameters<ty
62
62
  endCurrentTurnForKey,
63
63
  getPendingInboundBuffer,
64
64
  trackRedeliveredInbound,
65
+ drainParkedTurnStarts,
65
66
  closeActivityLane,
66
67
  closeProgressLane,
67
68
  hangRestart,
@@ -593,6 +594,16 @@ export function buildSilencePokeOptions(deps: LivenessWiringDeps): Parameters<ty
593
594
  getInboundSpool(),
594
595
  trackRedeliveredInbound,
595
596
  )
597
+ // Two-state desync unwedge. `pendingInboundBuffer` (above) is NOT where a
598
+ // wedge-behind-a-dead-lock message sits: an already-`enqueue`d message lives
599
+ // in stream-render's `parkedTurnStarts`, and the busy park gate stays latched
600
+ // while that store is non-empty. A hung REPL never emits the `dequeue`/
601
+ // `remove` that drain it, so drain it HERE, scoped to the wedged chat/thread,
602
+ // routing each envelope back through the real `dequeue` turn-start path. The
603
+ // count joins the `drained_buffered` field below so the log stops reading a
604
+ // misleading `0/0` while real user messages were stranded. Guarded getter:
605
+ // absent (older deps) → 0, so the fallback degrades to its prior behaviour.
606
+ const fbDrainedParked = drainParkedTurnStarts?.(fbChatId, fbThreadId ?? null) ?? 0
596
607
  process.stderr.write(
597
608
  `telegram gateway: silence-poke framework-fallback ended wedged turn ` +
598
609
  `chat=${fbChatId} thread=${ctx.threadId ?? '-'} silence_ms=${ctx.silenceMs} ` +
@@ -601,6 +612,7 @@ export function buildSilencePokeOptions(deps: LivenessWiringDeps): Parameters<ty
601
612
  // keyed liveness check skipped the teardown, so the field lied.
602
613
  `currentTurn_nulled=${tearsDownLiveTurn} ` +
603
614
  `drained_buffered=${fbRedeliver.redelivered}/${fbRedeliver.drained}` +
615
+ `${fbDrainedParked > 0 ? ` drained_parked=${fbDrainedParked}` : ''}` +
604
616
  `${fbRedeliver.rebuffered > 0 ? ` rebuffered=${fbRedeliver.rebuffered}` : ''}` +
605
617
  `${fbExtraPurge.purged.length > 0 ? ` extra_keys_purged=${fbExtraPurge.purged.length}` : ''}\n`,
606
618
  )
@@ -34,6 +34,27 @@ import {
34
34
  type ModelPickerOption,
35
35
  } from '../../src/agents/model-picker.js'
36
36
  import { assessThinkingEffortRisk } from '../../src/config/thinking-effort-risk.js'
37
+ // The model-alias tables + expansion helpers live in the shared
38
+ // `src/agents/model-aliases.ts` module so the gateway `/model` path and
39
+ // scaffold-time config-default resolution expand from ONE table (#3998).
40
+ // Imported for this module's internal use and re-exported below to preserve the
41
+ // existing `model-command.js` public API (every importer keeps its entrypoint).
42
+ import {
43
+ CLAUDE_MODEL_ALIASES,
44
+ SR_MODEL_ALIASES,
45
+ expandClaudeAlias,
46
+ expandSrAlias,
47
+ canonicalModelToken,
48
+ expandModelAlias,
49
+ } from '../../src/agents/model-aliases.js'
50
+ export {
51
+ CLAUDE_MODEL_ALIASES,
52
+ SR_MODEL_ALIASES,
53
+ expandClaudeAlias,
54
+ expandSrAlias,
55
+ canonicalModelToken,
56
+ expandModelAlias,
57
+ }
37
58
 
38
59
  /**
39
60
  * Aliases the claude CLI resolves natively (`claude --help`: "an alias for
@@ -51,34 +72,6 @@ import { assessThinkingEffortRisk } from '../../src/config/thinking-effort-risk.
51
72
  */
52
73
  export const MODEL_ALIASES = ['opus', 'sonnet', 'haiku', 'fable', 'default'] as const
53
74
 
54
- /**
55
- * Short SWITCHROOM-side spellings for pinned **Anthropic** model ids. These are
56
- * expanded to the full `claude-*` id on the `/model` path BEFORE any
57
- * Claude-vs-external classification runs, so what reaches the `.session-model`
58
- * carrier (and therefore `claude --model`) is always the canonical id — the CLI
59
- * never sees the short spelling.
60
- *
61
- * Deliberately NOT `SR_MODEL_ALIASES`: that map means "external / OpenRouter /
62
- * the Anthropic OAuth header is NOT forwarded". These targets are Anthropic
63
- * OAuth-passthrough models, so putting them there would flip the
64
- * header-forwarding + external-billing classification and surface them under the
65
- * "🌐 External models" keyboard page with an `srFriendlyLabel`.
66
- *
67
- * Also NOT `MODEL_ALIASES`: that list is the aliases the claude CLI resolves
68
- * *itself*, and its members double as FAMILY tokens in `modelFamilyToken` /
69
- * `canonicalClaudeToken` / `servedModelMatchesRequested`. A pinned-id shortcut
70
- * is neither.
71
- *
72
- * NB the repo prefers family aliases over pinned ids (an alias tracks the
73
- * current flagship, a pinned id goes stale) — these exist because an operator
74
- * sometimes wants a specific pinned Opus, and typing `claude-opus-4-8` on a
75
- * phone is hostile. Keys are matched case-insensitively.
76
- */
77
- export const CLAUDE_MODEL_ALIASES: Record<string, string> = {
78
- opus48: 'claude-opus-4-8',
79
- 'opus-4-8': 'claude-opus-4-8',
80
- }
81
-
82
75
  /**
83
76
  * Shape gate for the model argument. This string is typed literally
84
77
  * into the agent's tmux pane, so the gate is strict by construction:
@@ -1166,35 +1159,6 @@ export const SR_MODEL_LABELS: Record<string, string> = {
1166
1159
  'sr-gpt-5.6-luna': 'GPT-5.6 Luna',
1167
1160
  }
1168
1161
 
1169
- /**
1170
- * Short text-command aliases for sr-* models. These let the operator type
1171
- * `/model flash`, `/model codex`, etc. instead of the full `sr-*` id.
1172
- * Expanded in handleModelCommand before injection; the full sr-* id is what
1173
- * reaches the agent session and LiteLLM.
1174
- */
1175
- export const SR_MODEL_ALIASES: Record<string, string> = {
1176
- flash: 'sr-gemini-2.5-flash',
1177
- gemini: 'sr-gemini-2.5-pro',
1178
- deepseek: 'sr-deepseek-v3',
1179
- r1: 'sr-deepseek-r1',
1180
- glm: 'sr-glm-5',
1181
- codex: 'sr-codex-5.5',
1182
- // Flagship keyboard buttons for the three providers added 2026-07-19. Each
1183
- // points at that provider's top tier; the cheaper tiers (grok-4.3,
1184
- // kimi-k2.7-code, gpt-5.6-terra/luna) stay typeable via `/model sr-<id>` and
1185
- // keep a friendly label above, matching the "labels are a superset of buttons"
1186
- // design (SR_MODEL_LABELS doc comment).
1187
- grok: 'sr-grok-4.5',
1188
- kimi: 'sr-kimi-k3',
1189
- gpt: 'sr-gpt-5.6-sol',
1190
- // Per-tier buttons for the GPT-5.6 line so each is a one-word shortcut
1191
- // (`gpt` stays the flagship-Sol default). Added 2026-07-19.
1192
- sol: 'sr-gpt-5.6-sol',
1193
- terra: 'sr-gpt-5.6-terra',
1194
- luna: 'sr-gpt-5.6-luna',
1195
- }
1196
-
1197
- /** Expand a short alias (case-insensitive) to its full sr-* id, or return the original. */
1198
1162
  /**
1199
1163
  * #3042 review blocker 2a: can `token` be trusted for a boot-applied
1200
1164
  * `.session-model` carrier persist WITHOUT a live confirmation from claude?
@@ -1230,58 +1194,6 @@ export function isOfflineTrustedModelToken(token: string): boolean {
1230
1194
  return Object.values(CLAUDE_MODEL_ALIASES).includes(lower)
1231
1195
  }
1232
1196
 
1233
- export function expandSrAlias(arg: string): string {
1234
- return SR_MODEL_ALIASES[arg.toLowerCase()] ?? arg
1235
- }
1236
-
1237
- /**
1238
- * The ONE canonical form of a model token — what `claude --model` is handed and
1239
- * what every downstream gate compares against.
1240
- *
1241
- * Two normalizations, both narrow:
1242
- * - **trim** — unconditional. `unvalidatedIdCaveat` already trimmed while
1243
- * expansion did not, so the two disagreed on a padded token.
1244
- * - **lowercase, `claude-*` ONLY.** Every real Anthropic model id is lowercase
1245
- * and a phone autocapitalizes, so `/model Claude-Opus-4-8` used to be launched
1246
- * VERBATIM as an unvalidated id while the caveat exemption (which lowercases)
1247
- * suppressed the warning — a clean green ack for a token the caveat was
1248
- * written for. Non-`claude-` tokens are left alone: `sr-*` ids are matched
1249
- * case-insensitively by their own maps, and rewriting an arbitrary external
1250
- * id's case is not ours to do.
1251
- *
1252
- * Applied by `expandModelAlias`, so canonicalization happens on every `/model`
1253
- * path (typed apply, mid-turn queue, menu tap, queued-across-restart persist)
1254
- * whether or not the token hit a shortcut map — one canonical form flows to
1255
- * `claude --model`, and `unvalidatedIdCaveat` sees exactly that form.
1256
- */
1257
- export function canonicalModelToken(arg: string): string {
1258
- const trimmed = arg.trim()
1259
- const lower = trimmed.toLowerCase()
1260
- return lower.startsWith('claude-') ? lower : trimmed
1261
- }
1262
-
1263
- /** Expand a short pinned-Claude spelling (case-insensitive) to its full `claude-*` id. */
1264
- export function expandClaudeAlias(arg: string): string {
1265
- return CLAUDE_MODEL_ALIASES[arg.trim().toLowerCase()] ?? arg
1266
- }
1267
-
1268
- /**
1269
- * The ONE expansion every `/model` argument goes through, on every path that
1270
- * consumes a user-supplied token (typed apply, mid-turn queue, menu tap,
1271
- * queued-across-restart persist). Claude shortcuts resolve first, then sr-*
1272
- * shortcuts; the two key sets are disjoint by construction (a Claude shortcut
1273
- * never expands to an `sr-*` id and vice versa), so order only documents intent.
1274
- * The result is `canonicalModelToken`-normalized, so a miss can no longer emit a
1275
- * verbatim mixed-case `claude-*` id that the caveat exemption then mis-matches.
1276
- *
1277
- * Expanding HERE — before `isClaudeModel` / `isSrModel` / `externalModelNames`
1278
- * ever see the token — is what keeps a pinned-Claude shortcut on the Anthropic
1279
- * OAuth passthrough instead of being misread as an external route.
1280
- */
1281
- export function expandModelAlias(arg: string): string {
1282
- return canonicalModelToken(expandSrAlias(expandClaudeAlias(arg)))
1283
- }
1284
-
1285
1197
  export function srFriendlyLabel(srName: string): string {
1286
1198
  return SR_MODEL_LABELS[srName] ?? srName.replace(/^sr-/, '').replace(/-/g, ' ')
1287
1199
  }
@@ -232,6 +232,63 @@ export function __parkedTurnStartCountForTest(): number {
232
232
  return parkedTurnStarts.length
233
233
  }
234
234
 
235
+ /**
236
+ * Silence-fallback unwedge for the parked store (the two-state desync fix).
237
+ *
238
+ * The park gate in `case 'enqueue'` treats the session as busy while EITHER a
239
+ * live `currentTurn` exists OR `parkedTurnStarts` is non-empty. The 300 s
240
+ * framework-fallback (`liveness-wiring.ts` onFrameworkFallback) that recovers a
241
+ * hung turn nulls `currentTurn` and redelivers `pendingInboundBuffer` — but
242
+ * before this it NEVER touched `parkedTurnStarts`. When the claude REPL hangs
243
+ * mid-turn the CLI's own `dequeue` / `remove` events (the only real drains of
244
+ * this store) never arrive, so a leftover parked envelope latches the busy gate
245
+ * permanently and every later inbound parks unseen until a manual container
246
+ * restart (gymbro parked msgs 4502→4504 behind a dead lock until SIGTERM). The
247
+ * `drained_buffered=0/0` fallback log was honest-but-misleading: it counted the
248
+ * empty `pendingInboundBuffer`, not this store, where the real messages sat.
249
+ *
250
+ * This drains the parked envelopes for ONE chat/thread — scoped, never global,
251
+ * so a fallback fired for chat A cannot release chat B's queued messages — and
252
+ * hands each back into turn processing through the EXACT `beginTurn` path a real
253
+ * `dequeue` uses. Envelopes are re-begun in arrival order (oldest first) so the
254
+ * NEWEST, the message the user is actually waiting on, ends up the live turn and
255
+ * the older ones are cleanly superseded (never silently dropped). The store is
256
+ * kept module-encapsulated: callers get a function, not the array. Returns the
257
+ * drained envelopes so the caller can log an honest count. Idempotent w.r.t. a
258
+ * late CLI event: once drained, a subsequent `dequeue` finds an empty store and
259
+ * `takeParkedTurnStart` returns null, so a message can never be double-started.
260
+ */
261
+ export function drainParkedTurnStartsForChat(
262
+ deps: StreamRenderDeps,
263
+ chatId: string | null,
264
+ threadId: string | null,
265
+ ): TurnStartEnvelope[] {
266
+ const wantThread = envThreadIdNum(threadId)
267
+ const drained: ParkedTurnStart[] = []
268
+ // Splice out the matching entries, preserving arrival order (oldest first).
269
+ for (let i = 0; i < parkedTurnStarts.length; ) {
270
+ const entry = parkedTurnStarts[i]!
271
+ if (entry.chatId === chatId && envThreadIdNum(entry.threadId) === wantThread) {
272
+ drained.push(entry)
273
+ parkedTurnStarts.splice(i, 1)
274
+ } else {
275
+ i++
276
+ }
277
+ }
278
+ for (const env of drained) {
279
+ process.stderr.write(
280
+ `telegram gateway: parked-turn-start drained on silence-fallback ` +
281
+ `chat=${env.chatId ?? '-'} thread=${envThreadIdNum(env.threadId) ?? '-'} ` +
282
+ `msg=${env.messageId ?? '-'}\n`,
283
+ )
284
+ // Reuse the dequeue turn-start path verbatim — beginTurn adopts the parked
285
+ // envelope's queued card (Part B) so the frozen "⏳ Queued" surface becomes
286
+ // the live progress card rather than lingering.
287
+ beginTurn(deps, env)
288
+ }
289
+ return drained
290
+ }
291
+
235
292
  // ─── Queued-turn card send / finalize (Part B) ───────────────────────────────
236
293
  // These use the ALREADY-injected `bot` + `robustApiCall` deps, so the whole
237
294
  // feature lives in stream-render.ts with zero new wiring in gateway.ts. No
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Two-state desync wedge — the silence-fallback must drain `parkedTurnStarts`.
3
+ *
4
+ * The gateway carries TWO independent "turn in flight" states. The park gate in
5
+ * `stream-render.ts` (`case 'enqueue'`) treats the session as busy if EITHER a
6
+ * live `currentTurn` exists OR `parkedTurnStarts` is non-empty. The 300 s
7
+ * framework-fallback (`liveness-wiring.ts` onFrameworkFallback) that recovers a
8
+ * hung turn nulls `currentTurn` and redelivers `pendingInboundBuffer` — but
9
+ * before the fix it NEVER touched `parkedTurnStarts`. That store's only real
10
+ * drains are the CLI's own `dequeue` / `remove` stream events, so when the
11
+ * claude REPL hangs mid-turn those never arrive, a leftover parked envelope
12
+ * latches the busy gate permanently, and every later inbound parks unseen until
13
+ * a manual container restart (gymbro parked msgs 4502→4504 behind a dead lock
14
+ * until SIGTERM). #3927 introduced the parked store but never wired it into
15
+ * fallback recovery.
16
+ *
17
+ * This drives the REAL `handleSessionEvent` (the extracted-module golden
18
+ * harness, same standard as `turn-mint-defers-until-dequeue.test.ts`) and the
19
+ * REAL `onFrameworkFallback` (via `buildSilencePokeOptions`, same standard as
20
+ * `silence-poke-teardown-notice.test.ts`), sharing ONE world: the fallback's
21
+ * `drainParkedTurnStarts` dep routes to the real store + `beginTurn` over the
22
+ * harness deps. It asserts the OUTCOME, not a code path: after the fallback the
23
+ * parked store is emptied AND the parked message was handed back into turn
24
+ * processing (its turn begun, its card opened) rather than silently dropped.
25
+ */
26
+ import { describe, it, expect, beforeEach } from 'vitest'
27
+ import {
28
+ handleSessionEvent,
29
+ drainParkedTurnStartsForChat,
30
+ __resetParkedTurnStartsForTest,
31
+ __parkedTurnStartCountForTest,
32
+ } from '../gateway/stream-render.js'
33
+ import { buildSilencePokeOptions } from '../gateway/liveness-wiring.js'
34
+ import { makeLivenessFixture, makeTurn, statusKeyForTests } from './helpers/liveness-wiring-fixture.js'
35
+ import { CHAT, enqueue, makeHarness } from './turn-mint-harness.js'
36
+
37
+ beforeEach(() => {
38
+ __resetParkedTurnStartsForTest()
39
+ })
40
+
41
+ describe('silence-fallback drains parkedTurnStarts so a hung REPL cannot wedge the park gate', () => {
42
+ it('empties the parked store AND redelivers the parked message into turn processing', async () => {
43
+ const h = makeHarness()
44
+
45
+ // ── turn A mints on the idle enqueue and its ms-later dequeue ─────────────
46
+ handleSessionEvent(h.deps, enqueue('501'))
47
+ handleSessionEvent(h.deps, { kind: 'dequeue' })
48
+ const turnA = h.current()!
49
+ expect(turnA.sourceMessageId).toBe(501)
50
+
51
+ // ── the operator sends B mid-turn; the CLI queues it (enqueue) but the
52
+ // REPL hangs, so the paired `dequeue` never comes and B stays parked ──
53
+ handleSessionEvent(h.deps, enqueue('502', 'still there?'))
54
+ expect(__parkedTurnStartCountForTest()).toBe(1)
55
+
56
+ // ── the REAL 300 s framework-fallback fires, wired to the REAL parked
57
+ // drain over the harness deps so both states share one world ──────────
58
+ const KEY = statusKeyForTests(CHAT, null)
59
+ const fx = makeLivenessFixture({
60
+ drainParkedTurnStarts: (chatId: string, threadId: number | null) =>
61
+ drainParkedTurnStartsForChat(
62
+ h.deps,
63
+ chatId,
64
+ threadId != null ? String(threadId) : null,
65
+ ).length,
66
+ })
67
+ // The wedged turn the fallback tears down — same chat/thread as parked B.
68
+ fx.activeTurnStartedAt.set(KEY, 0)
69
+ fx.setCurrentTurn(makeTurn({ sessionChatId: CHAT, sessionThreadId: undefined, turnId: `${KEY}#42` }))
70
+
71
+ const opts = buildSilencePokeOptions(fx.deps)
72
+ await opts.onFrameworkFallback({
73
+ key: KEY,
74
+ chatId: CHAT,
75
+ threadId: null,
76
+ fallbackKind: 'working',
77
+ silenceMs: 302_000,
78
+ inFlightTools: [],
79
+ })
80
+
81
+ // The fallback nulled the wedged turn (its one load-bearing job).
82
+ expect(fx.endedKeys).toContain(KEY)
83
+
84
+ // OUTCOME 1 — the parked store is emptied, so the busy park gate unlatches
85
+ // and the NEXT inbound can mint its own turn instead of parking unseen.
86
+ expect(__parkedTurnStartCountForTest()).toBe(0)
87
+
88
+ // OUTCOME 2 — B was actually dispatched back into turn processing, not
89
+ // dropped: its turn was begun (it is now the live turn) and its progress
90
+ // card was opened. Pre-fix, B stayed parked and this never happened.
91
+ expect(h.current()!.sourceMessageId).toBe(502)
92
+ expect(h.cardsOpened.some((c) => c.sourceMessageId === 502)).toBe(true)
93
+ })
94
+
95
+ it('is scoped to the wedged chat — a fallback for chat A leaves chat B parked', () => {
96
+ const h = makeHarness()
97
+
98
+ // A live turn on CHAT, plus a parked entry on CHAT.
99
+ handleSessionEvent(h.deps, enqueue('601'))
100
+ handleSessionEvent(h.deps, { kind: 'dequeue' })
101
+ handleSessionEvent(h.deps, enqueue('602'))
102
+ expect(__parkedTurnStartCountForTest()).toBe(1)
103
+
104
+ // A fallback for a DIFFERENT chat must not touch CHAT's parked entry.
105
+ const drainedOther = drainParkedTurnStartsForChat(h.deps, '9999', null)
106
+ expect(drainedOther).toHaveLength(0)
107
+ expect(__parkedTurnStartCountForTest()).toBe(1)
108
+
109
+ // The correctly-scoped drain releases it.
110
+ const drainedHere = drainParkedTurnStartsForChat(h.deps, CHAT, null)
111
+ expect(drainedHere).toHaveLength(1)
112
+ expect(drainedHere[0]!.messageId).toBe('602')
113
+ expect(__parkedTurnStartCountForTest()).toBe(0)
114
+ })
115
+
116
+ it('is idempotent w.r.t. a late CLI dequeue — no double-start', () => {
117
+ const h = makeHarness()
118
+ handleSessionEvent(h.deps, enqueue('701'))
119
+ handleSessionEvent(h.deps, { kind: 'dequeue' })
120
+ handleSessionEvent(h.deps, enqueue('702'))
121
+ expect(__parkedTurnStartCountForTest()).toBe(1)
122
+
123
+ // Fallback drains + begins 702.
124
+ expect(drainParkedTurnStartsForChat(h.deps, CHAT, null)).toHaveLength(1)
125
+ expect(h.current()!.sourceMessageId).toBe(702)
126
+ const cardsAfterDrain = h.cardsOpened.length
127
+
128
+ // The REPL finally un-hangs and emits its late `dequeue`. The store is empty
129
+ // so `takeParkedTurnStart` returns null and no second turn/card is minted.
130
+ handleSessionEvent(h.deps, { kind: 'dequeue' })
131
+ expect(h.cardsOpened).toHaveLength(cardsAfterDrain)
132
+ expect(h.current()!.sourceMessageId).toBe(702)
133
+ })
134
+ })
@@ -154,6 +154,9 @@ export function makeLivenessFixture(
154
154
  purgeReactionTracking: () => {},
155
155
  getPendingInboundBuffer: () => ({ drain: () => [] }),
156
156
  trackRedeliveredInbound: () => {},
157
+ // Default: no parked store wired (returns 0). Tests exercising the parked
158
+ // drain override this with the real `drainParkedTurnStartsForChat`.
159
+ drainParkedTurnStarts: (_chatId: string, _threadId: number | null) => 0,
157
160
  closeActivityLane: () => {},
158
161
  closeProgressLane: () => {},
159
162
  hangRestart: null,
@@ -54,6 +54,34 @@
54
54
 
55
55
  ### Changed (switchroom divergence)
56
56
 
57
+ - **Recall/retain hygiene guard-rail batch** (`scripts/recall.py`,
58
+ `scripts/subagent_retain.py`, `scripts/lib/content.py`, `scripts/tests/**`).
59
+ Closes guard-rail debt in the fork's hook scripts before extending them:
60
+ - **#3766** — `shape_recall_query` no longer drops a single-char SUBJECT
61
+ ('C', 'R', a single-digit version) before the stopword fallback runs. The
62
+ `len(t) > 1` guard moved into the fallback, so a single-char content word
63
+ survives while single-char stopwords are still removed.
64
+ - **#3578** — `_overlap_tokens` (the #3369 transcript-fallback tokenizer) now
65
+ accumulates alphanumeric runs (`isalnum`), so issue numbers, ports and
66
+ versions ('3993', ':9077', 'v0') carry signal and match symmetrically on
67
+ both the query and transcript sides. A lone digit is still dropped as noise.
68
+ - **#3994** — the sidechain volume gate counts ONLY the chars the text-only
69
+ retain path keeps (`retained_text_char_count` → `_extract_text_content`),
70
+ not the `tool_use` inputs the payload drops. Gate and payload now measure
71
+ the same char set, so a tool-heavy / prose-light fork that used to clear the
72
+ gate and retain a near-empty document is now correctly skipped. The
73
+ 2,000-char floor was re-measured against the corrected metric —
74
+ `docs/measurements/subagent-volume-gate-3994.md`, replay harness
75
+ `scripts/tests/data/replay_volume_gate_3994.py`.
76
+ - **#4001** — the "window formatted to empty despite clearing the gate" skip
77
+ now emits an unconditional stderr line, not a debug-only log that was silent
78
+ in shipped settings.
79
+ - **#3999 / #3777** — rewrote the vacuous sidechain config-mutation test to
80
+ assert on the config object the code actually receives (RED when the copy
81
+ fix is deleted), and restored direct characterisation of `_overlap_tokens`
82
+ (`scripts/tests/test_overlap_tokens.py`) that was deleted with the removed
83
+ lexical gate while the tokenizer stayed load-bearing.
84
+
57
85
  - **`MAX_DIRECTIVES` 15 → 30, and truncation is no longer SILENT**
58
86
  (`scripts/lib/directives.py`). Live fleet active-directive counts were 24
59
87
  (assistant), 17 (klanker), 15 (carrie) against a client-side cap of 15, so the