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.
- package/dist/agent-scheduler/index.js +22 -2
- package/dist/auth-broker/index.js +22 -2
- package/dist/cli/notion-write-pretool.mjs +22 -2
- package/dist/cli/switchroom.js +772 -234
- package/dist/host-control/main.js +23 -3
- package/dist/vault/approvals/kernel-server.js +22 -2
- package/dist/vault/broker/server.js +22 -2
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +97 -83
- package/telegram-plugin/gateway/approval-callback-consume-record.test.ts +132 -0
- package/telegram-plugin/gateway/gateway.ts +7 -7
- package/telegram-plugin/gateway/liveness-wiring.ts +12 -0
- package/telegram-plugin/gateway/model-command.ts +21 -109
- package/telegram-plugin/gateway/stream-render.ts +57 -0
- package/telegram-plugin/tests/framework-fallback-drains-parked.test.ts +134 -0
- package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +3 -0
- package/vendor/hindsight-memory/CHANGELOG.md +28 -0
- package/vendor/hindsight-memory/docs/measurements/subagent-volume-gate-3994.md +84 -0
- package/vendor/hindsight-memory/scripts/backfill_transcripts.py +254 -40
- package/vendor/hindsight-memory/scripts/lib/content.py +11 -2
- package/vendor/hindsight-memory/scripts/recall.py +18 -3
- package/vendor/hindsight-memory/scripts/subagent_retain.py +59 -38
- package/vendor/hindsight-memory/scripts/tests/data/replay_volume_gate_3994.py +295 -0
- package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +109 -0
- package/vendor/hindsight-memory/scripts/tests/test_overlap_tokens.py +135 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_no_lexical_gate.py +20 -23
- package/vendor/hindsight-memory/scripts/tests/test_recall_query_shaping.py +48 -0
- package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +94 -1
- 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
|
|
9673
|
-
//
|
|
9674
|
-
//
|
|
9675
|
-
//
|
|
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
|