switchroom 0.19.48 → 0.20.0
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 +18 -1
- package/dist/auth-broker/index.js +19 -2
- package/dist/buzz-gateway/index.js +9207 -0
- package/dist/cli/notion-write-pretool.mjs +18 -1
- package/dist/cli/switchroom.js +63 -4
- package/dist/host-control/main.js +20 -3
- package/dist/vault/approvals/kernel-server.js +19 -2
- package/dist/vault/broker/server.js +19 -2
- package/package.json +4 -3
- package/profiles/_base/start.sh.hbs +78 -1
- package/profiles/default/CLAUDE.md.hbs +1 -1
- package/skills/dev-protocol/SKILL.md +30 -1
- package/skills/switchroom-architecture/SKILL.md +5 -0
- package/skills/switchroom-cli/SKILL.md +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +7 -4
- package/telegram-plugin/dist/gateway/gateway.js +1149 -247
- package/telegram-plugin/dist/server.js +7 -4
- package/telegram-plugin/gateway/boot-briefing-builder.ts +458 -0
- package/telegram-plugin/gateway/boot-briefing-wiring.ts +170 -0
- package/telegram-plugin/gateway/buzz-mirror.ts +329 -0
- package/telegram-plugin/gateway/buzz-type-guards.ts +34 -0
- package/telegram-plugin/gateway/channel-route.ts +272 -0
- package/telegram-plugin/gateway/gateway.ts +73 -81
- package/telegram-plugin/gateway/inbound-spool.ts +33 -1
- package/telegram-plugin/gateway/ipc-protocol.ts +81 -2
- package/telegram-plugin/gateway/ipc-server.ts +197 -2
- package/telegram-plugin/gateway/outbound-send-path.ts +37 -1
- package/telegram-plugin/gateway/pending-turn-env.ts +61 -0
- package/telegram-plugin/gateway/stream-render.ts +21 -0
- package/telegram-plugin/gateway/subagent-handback-marker.ts +12 -0
- package/telegram-plugin/gateway/user-failure-notices.ts +172 -0
- package/telegram-plugin/history.ts +15 -0
- package/telegram-plugin/llm-error-present.ts +9 -4
- package/telegram-plugin/model-unavailable.ts +4 -0
- package/telegram-plugin/operator-events.fixtures.json +12 -12
- package/telegram-plugin/operator-events.ts +81 -9
- package/telegram-plugin/session-tail.ts +7 -1
- package/telegram-plugin/tests/boot-briefing-builder.test.ts +604 -0
- package/telegram-plugin/tests/buzz-mirror.test.ts +242 -0
- package/telegram-plugin/tests/buzz-origin-stamp-gate.test.ts +159 -0
- package/telegram-plugin/tests/channel-route.test.ts +306 -0
- package/telegram-plugin/tests/inbound-spool.test.ts +47 -0
- package/telegram-plugin/tests/ipc-server-buzz-dedup.test.ts +124 -0
- package/telegram-plugin/tests/ipc-server-buzz-peer.test.ts +269 -0
- package/telegram-plugin/tests/operator-events-session-tail.test.ts +63 -0
- package/telegram-plugin/tests/operator-events.test.ts +71 -7
- package/telegram-plugin/tests/user-failure-notices.test.ts +165 -0
- package/telegram-plugin/voice-normalize-text.ts +5 -0
- package/vendor/hindsight-memory/scripts/directive_verify.py +4 -0
- package/vendor/hindsight-memory/scripts/recall.py +7 -2
|
@@ -23,6 +23,9 @@ import type {
|
|
|
23
23
|
SessionEventForward,
|
|
24
24
|
ToolCallMessage,
|
|
25
25
|
ToolCallResult,
|
|
26
|
+
HelloBuzzPeerMessage,
|
|
27
|
+
OutboundToBuzzMessage,
|
|
28
|
+
BuzzPublishResultMessage,
|
|
26
29
|
} from "./ipc-protocol.js";
|
|
27
30
|
import { RICH_MESSAGE_MAX_CHARS } from "../format.js";
|
|
28
31
|
import { OPERATOR_EVENT_KINDS } from "../operator-events.js";
|
|
@@ -160,6 +163,16 @@ export interface IpcServerOptions {
|
|
|
160
163
|
client: IpcClient,
|
|
161
164
|
msg: RolloutStatusEditMessage,
|
|
162
165
|
) => void | Promise<void>;
|
|
166
|
+
/**
|
|
167
|
+
* Buzz co-channel Phase 2b — the duplex Buzz peer's advisory publish outcome
|
|
168
|
+
* (`buzz_publish_result`). Handler feeds the buzz-mirror hub's correlation
|
|
169
|
+
* map: it frees the pending slot and, on success, records the published
|
|
170
|
+
* eventId so a later `correction` can target it. Fire-and-forget — under
|
|
171
|
+
* `both` mode the Telegram copy is the guaranteed delivery, so a failed
|
|
172
|
+
* publish never fails or retries the answer. Optional; a gateway without Buzz
|
|
173
|
+
* wired simply drops the message.
|
|
174
|
+
*/
|
|
175
|
+
onBuzzPublishResult?: (client: IpcClient, msg: BuzzPublishResultMessage) => void;
|
|
163
176
|
log?: (msg: string) => void;
|
|
164
177
|
/**
|
|
165
178
|
* How long (in ms) to wait without a heartbeat before force-closing the
|
|
@@ -178,6 +191,15 @@ export interface IpcClient {
|
|
|
178
191
|
id: string;
|
|
179
192
|
agentName: string | null;
|
|
180
193
|
topicId: number | null;
|
|
194
|
+
/**
|
|
195
|
+
* Buzz co-channel Phase 2b (S7). True IFF this connection announced itself
|
|
196
|
+
* as the duplex Buzz publish peer via `hello_buzz_peer`. A peer NEVER holds
|
|
197
|
+
* an `agentName`/`agentIndex` slot; the flag makes the peer and agent-bridge
|
|
198
|
+
* roles mutually exclusive (a `register` is refused on a peer connection and
|
|
199
|
+
* vice-versa) and is what keeps the peer connection watchdog-exempt (it rides
|
|
200
|
+
* the existing `agentName === null` exemption — the peer has no heartbeat).
|
|
201
|
+
*/
|
|
202
|
+
isBuzzPeer: boolean;
|
|
181
203
|
send(msg: GatewayToClient): void;
|
|
182
204
|
close(): void;
|
|
183
205
|
isAlive(): boolean;
|
|
@@ -190,6 +212,14 @@ export interface IpcServer {
|
|
|
190
212
|
broadcast(msg: GatewayToClient): void;
|
|
191
213
|
getClient(agentName: string): IpcClient | undefined;
|
|
192
214
|
clientCount(): number;
|
|
215
|
+
/**
|
|
216
|
+
* Buzz co-channel Phase 2b. Send an `outbound_to_buzz` request to the single
|
|
217
|
+
* duplex Buzz peer, if one is currently connected. Returns false (no send)
|
|
218
|
+
* when no peer has announced itself — the caller (buzz-mirror hub) treats a
|
|
219
|
+
* false return as "Buzz unreachable" and simply drops the mirror; the
|
|
220
|
+
* guaranteed Telegram copy already went out, so nothing fails or retries.
|
|
221
|
+
*/
|
|
222
|
+
sendToBuzzPeer(msg: OutboundToBuzzMessage): boolean;
|
|
193
223
|
close(): Promise<void>;
|
|
194
224
|
}
|
|
195
225
|
|
|
@@ -493,6 +523,25 @@ export function validateClientMessage(msg: unknown): msg is ClientToGateway {
|
|
|
493
523
|
|| (m.text as string).length > RICH_MESSAGE_MAX_CHARS) return false;
|
|
494
524
|
return true;
|
|
495
525
|
}
|
|
526
|
+
case "hello_buzz_peer": {
|
|
527
|
+
// Buzz co-channel Phase 2b — the sidecar's one-time duplex-peer
|
|
528
|
+
// announcement. Wire shape only; the handler parks it in the dedicated
|
|
529
|
+
// buzzPeerClient slot and enforces role-disjointness with `register`.
|
|
530
|
+
return typeof m.agentName === "string"
|
|
531
|
+
&& AGENT_NAME_RE.test(m.agentName as string);
|
|
532
|
+
}
|
|
533
|
+
case "buzz_publish_result": {
|
|
534
|
+
// Buzz co-channel Phase 2b — the sidecar's advisory publish outcome.
|
|
535
|
+
if (typeof m.correlationId !== "string"
|
|
536
|
+
|| (m.correlationId as string).length === 0
|
|
537
|
+
|| (m.correlationId as string).length > 64) return false;
|
|
538
|
+
if (typeof m.ok !== "boolean") return false;
|
|
539
|
+
if (m.eventId !== undefined
|
|
540
|
+
&& (typeof m.eventId !== "string" || (m.eventId as string).length > 128)) return false;
|
|
541
|
+
if (m.error !== undefined
|
|
542
|
+
&& (typeof m.error !== "string" || (m.error as string).length > 500)) return false;
|
|
543
|
+
return true;
|
|
544
|
+
}
|
|
496
545
|
default:
|
|
497
546
|
return false;
|
|
498
547
|
}
|
|
@@ -522,10 +571,45 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
|
|
|
522
571
|
onRequestConfigFinalize,
|
|
523
572
|
onRolloutStatusPost,
|
|
524
573
|
onRolloutStatusEdit,
|
|
574
|
+
onBuzzPublishResult,
|
|
525
575
|
log = () => {},
|
|
526
576
|
heartbeatTimeoutMs = 30_000,
|
|
527
577
|
} = options;
|
|
528
578
|
|
|
579
|
+
// Buzz co-channel Phase 2b (S7) — the single duplex Buzz publish peer's
|
|
580
|
+
// connection, or null when none is connected. Parked here (never in
|
|
581
|
+
// agentIndex) so `outbound_to_buzz` addresses exactly one peer and the peer
|
|
582
|
+
// never shadows an agent-bridge slot. Nulled in removeClient on disconnect.
|
|
583
|
+
let buzzPeerClient: IpcClientImpl | null = null;
|
|
584
|
+
|
|
585
|
+
// Hub-side dedup ring for Buzz injects (fable MAJOR-2). The Buzz sidecar's
|
|
586
|
+
// durable journal covers the normal case, but a crash AFTER the gateway
|
|
587
|
+
// injects but BEFORE the sidecar records dedup would re-fire the turn on
|
|
588
|
+
// restart. This bounded in-memory ring drops a re-injected duplicate at the
|
|
589
|
+
// hub, keyed on the stable Buzz event id.
|
|
590
|
+
//
|
|
591
|
+
// SCOPE — buzz ONLY. This is the shared inject hot path (cron, reactions,
|
|
592
|
+
// resume, etc.). The check below fires exclusively for injects whose
|
|
593
|
+
// `inbound.meta.source === "buzz"` AND that carry a `buzz_event_id`; every
|
|
594
|
+
// other inject source flows through untouched, byte-identical to before. A
|
|
595
|
+
// buzz inject without a stable id (should not happen — inbound-map always
|
|
596
|
+
// stamps one) also flows through untouched rather than being dropped blind.
|
|
597
|
+
const BUZZ_INJECT_RING_MAX = 1024;
|
|
598
|
+
const buzzInjectSeen = new Set<string>();
|
|
599
|
+
const buzzInjectOrder: string[] = [];
|
|
600
|
+
/** Return true if this buzz event id was already injected (drop it); else
|
|
601
|
+
* record it and return false. Bounded FIFO eviction at RING_MAX entries. */
|
|
602
|
+
const buzzInjectIsDuplicate = (eventId: string): boolean => {
|
|
603
|
+
if (buzzInjectSeen.has(eventId)) return true;
|
|
604
|
+
buzzInjectSeen.add(eventId);
|
|
605
|
+
buzzInjectOrder.push(eventId);
|
|
606
|
+
if (buzzInjectOrder.length > BUZZ_INJECT_RING_MAX) {
|
|
607
|
+
const evicted = buzzInjectOrder.shift();
|
|
608
|
+
if (evicted !== undefined) buzzInjectSeen.delete(evicted);
|
|
609
|
+
}
|
|
610
|
+
return false;
|
|
611
|
+
};
|
|
612
|
+
|
|
529
613
|
// Race-safe cleanup: rename the live socket to a .bak sidecar rather than
|
|
530
614
|
// unlinking it. If the old gateway's delayed shutdown-cleanup later tries to
|
|
531
615
|
// rename again, it targets .bak (already-moved) not the freshly-bound file.
|
|
@@ -565,6 +649,10 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
|
|
|
565
649
|
if (client.topicId != null && topicIndex.get(client.topicId) === client) {
|
|
566
650
|
topicIndex.delete(client.topicId);
|
|
567
651
|
}
|
|
652
|
+
// Buzz co-channel Phase 2b — release the duplex peer slot if this was it,
|
|
653
|
+
// identity-checked (a fast peer reconnect may have already installed a new
|
|
654
|
+
// peer before this old connection's close runs).
|
|
655
|
+
if (buzzPeerClient === client) buzzPeerClient = null;
|
|
568
656
|
loggedLegacyUpdatePlaceholder.delete(client.id);
|
|
569
657
|
onClientDisconnected(client);
|
|
570
658
|
log(`client disconnected: ${client.id} (agent=${client.agentName})`);
|
|
@@ -623,9 +711,22 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
|
|
|
623
711
|
case "pty_partial":
|
|
624
712
|
if (onPtyPartial) onPtyPartial(client, msg as PtyPartialForward);
|
|
625
713
|
break;
|
|
626
|
-
case "inject_inbound":
|
|
627
|
-
|
|
714
|
+
case "inject_inbound": {
|
|
715
|
+
const injectMsg = msg as InjectInboundMessage;
|
|
716
|
+
// Hub-side Buzz dedup ring (fable MAJOR-2) — scoped strictly to
|
|
717
|
+
// meta.source==="buzz". A duplicate buzz event id (a re-inject after a
|
|
718
|
+
// crash between inject and the sidecar's dedup record) is dropped here;
|
|
719
|
+
// every non-buzz inject is unaffected.
|
|
720
|
+
const injMeta = (injectMsg.inbound as { meta?: Record<string, unknown> } | undefined)?.meta;
|
|
721
|
+
if (injMeta && injMeta.source === "buzz" && typeof injMeta.buzz_event_id === "string") {
|
|
722
|
+
if (buzzInjectIsDuplicate(injMeta.buzz_event_id)) {
|
|
723
|
+
log(`inject_inbound: dropped duplicate buzz event ${injMeta.buzz_event_id.slice(0, 12)} (hub dedup ring)`);
|
|
724
|
+
break;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
if (onInjectInbound) onInjectInbound(client, injectMsg);
|
|
628
728
|
break;
|
|
729
|
+
}
|
|
629
730
|
case "send_outbound":
|
|
630
731
|
if (onSendOutbound) onSendOutbound(client, msg as SendOutboundMessage);
|
|
631
732
|
break;
|
|
@@ -831,6 +932,28 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
|
|
|
831
932
|
// The handler replies `rollout_status_edited` (#4065) when it can; an
|
|
832
933
|
// unwired gateway sends nothing and hostd's bounded wait expires.
|
|
833
934
|
break;
|
|
935
|
+
case "hello_buzz_peer":
|
|
936
|
+
handleHelloBuzzPeer(client, msg as HelloBuzzPeerMessage);
|
|
937
|
+
break;
|
|
938
|
+
case "buzz_publish_result":
|
|
939
|
+
// Confused-deputy close (MAJOR-1a): ONLY the registered duplex Buzz peer
|
|
940
|
+
// may report a publish outcome. Without this gate any client (an agent
|
|
941
|
+
// MCP bridge, or a fresh anonymous connection) could forge a
|
|
942
|
+
// `buzz_publish_result` carrying a valid-looking correlationId and a
|
|
943
|
+
// foreign eventId, poisoning the hub's correlation map so a later
|
|
944
|
+
// correction signs against an arbitrary Nostr event. The peer→agent
|
|
945
|
+
// direction is already fenced (register-after-hello refused above); this
|
|
946
|
+
// mirrors that rigor on the agent→peer surface.
|
|
947
|
+
if (!client.isBuzzPeer) {
|
|
948
|
+
log(
|
|
949
|
+
`SECURITY: rejecting buzz_publish_result from non-peer connection ` +
|
|
950
|
+
`(agent=${client.agentName ?? "anonymous"} id=${client.id}) — only the ` +
|
|
951
|
+
`registered Buzz publish peer may report publish outcomes; dropped`,
|
|
952
|
+
);
|
|
953
|
+
break;
|
|
954
|
+
}
|
|
955
|
+
if (onBuzzPublishResult) onBuzzPublishResult(client, msg as BuzzPublishResultMessage);
|
|
956
|
+
break;
|
|
834
957
|
case "update_placeholder":
|
|
835
958
|
// Legacy recall.py IPC — placeholder UX was removed in #553 PR 5.
|
|
836
959
|
// Soft-accepted so recall.py keeps working without modifying
|
|
@@ -846,7 +969,72 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
|
|
|
846
969
|
}
|
|
847
970
|
}
|
|
848
971
|
|
|
972
|
+
/**
|
|
973
|
+
* Buzz co-channel Phase 2b (S7). Accept a `hello_buzz_peer` announcement,
|
|
974
|
+
* parking the connection as the single duplex Buzz publish peer. Enforces
|
|
975
|
+
* role-disjointness as a CODE mechanism, not sidecar self-discipline:
|
|
976
|
+
* - a connection that already `register`ed (agentName set) or already
|
|
977
|
+
* announced as a peer is refused (close+drop);
|
|
978
|
+
* - the reciprocal refusal — a `register` on a peer connection — lives in
|
|
979
|
+
* handleRegister below.
|
|
980
|
+
* The peer NEVER touches agentIndex/topicIndex and carries no heartbeat, so
|
|
981
|
+
* it rides the watchdog's existing `agentName === null` exemption untouched.
|
|
982
|
+
*/
|
|
983
|
+
function handleHelloBuzzPeer(client: IpcClientImpl, msg: HelloBuzzPeerMessage) {
|
|
984
|
+
if (client.agentName !== null || client.isBuzzPeer) {
|
|
985
|
+
log(
|
|
986
|
+
`rejecting hello_buzz_peer: connection already has a role ` +
|
|
987
|
+
`(agent=${client.agentName ?? "none"} isBuzzPeer=${client.isBuzzPeer}) — close+drop client=${client.id}`,
|
|
988
|
+
);
|
|
989
|
+
try { client.close(); } catch { /* nothing to do */ }
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
// Impersonation guard (MAJOR-1b): a fresh connection must NOT be able to
|
|
993
|
+
// DISPLACE a LIVE Buzz peer. Without this an anonymous client could send
|
|
994
|
+
// `hello_buzz_peer`, replace the real sidecar in the buzzPeerClient slot,
|
|
995
|
+
// then receive every `outbound_to_buzz` and answer with forged
|
|
996
|
+
// `{ok:true, eventId:<foreign>}` results — poisoning `msgToBuzz` so a later
|
|
997
|
+
// edit_message signs a correction targeting an arbitrary foreign event.
|
|
998
|
+
//
|
|
999
|
+
// A legitimate sidecar reconnect is still honored: it only ever happens
|
|
1000
|
+
// AFTER the prior socket dropped, at which point the socket `close` handler
|
|
1001
|
+
// has run removeClient (nulling buzzPeerClient), OR — in the brief window
|
|
1002
|
+
// before that fires — the prior client is already `close()`d so isAlive()
|
|
1003
|
+
// is false. So we refuse displacement ONLY while the existing peer
|
|
1004
|
+
// connection is still alive; a dead/closed prior peer is freely replaceable.
|
|
1005
|
+
if (buzzPeerClient && buzzPeerClient !== client && buzzPeerClient.isAlive()) {
|
|
1006
|
+
log(
|
|
1007
|
+
`SECURITY: rejecting hello_buzz_peer — a LIVE Buzz peer is already ` +
|
|
1008
|
+
`connected (live_id=${buzzPeerClient.id} rejected_id=${client.id}); ` +
|
|
1009
|
+
`refusing displacement, close+drop`,
|
|
1010
|
+
);
|
|
1011
|
+
try { client.close(); } catch { /* nothing to do */ }
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
// Replace a dead/closed prior peer connection (e.g. a sidecar reconnect
|
|
1015
|
+
// whose predecessor's socket already dropped) cleanly.
|
|
1016
|
+
if (buzzPeerClient && buzzPeerClient !== client) {
|
|
1017
|
+
log(`hello_buzz_peer: replacing prior (dead) buzz peer (prior_id=${buzzPeerClient.id} new_id=${client.id})`);
|
|
1018
|
+
try { buzzPeerClient.close(); } catch { /* nothing to do */ }
|
|
1019
|
+
}
|
|
1020
|
+
client.isBuzzPeer = true;
|
|
1021
|
+
buzzPeerClient = client;
|
|
1022
|
+
log(`registered buzz publish peer for agent=${msg.agentName} id=${client.id}`);
|
|
1023
|
+
}
|
|
1024
|
+
|
|
849
1025
|
function handleRegister(client: IpcClientImpl, msg: RegisterMessage) {
|
|
1026
|
+
// Buzz co-channel Phase 2b (S7) — reciprocal role-disjointness: a
|
|
1027
|
+
// connection that announced itself as the duplex Buzz peer must never be
|
|
1028
|
+
// allowed to claim an agentIndex slot. Refuse server-side (a code check,
|
|
1029
|
+
// not sidecar self-discipline).
|
|
1030
|
+
if (client.isBuzzPeer) {
|
|
1031
|
+
log(
|
|
1032
|
+
`rejecting register: connection is the Buzz publish peer, not an agent bridge ` +
|
|
1033
|
+
`(close+drop client=${client.id})`,
|
|
1034
|
+
);
|
|
1035
|
+
try { client.close(); } catch { /* nothing to do */ }
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
850
1038
|
// Defence in depth for #430. The bridge refuses to register
|
|
851
1039
|
// without SWITCHROOM_AGENT_NAME (set in start.sh per agent), but
|
|
852
1040
|
// an older bridge or a third-party caller could still send the
|
|
@@ -919,6 +1107,7 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
|
|
|
919
1107
|
id: string;
|
|
920
1108
|
agentName: string | null = null;
|
|
921
1109
|
topicId: number | null = null;
|
|
1110
|
+
isBuzzPeer = false;
|
|
922
1111
|
lastHeartbeat: number = Date.now();
|
|
923
1112
|
_socket: import("bun").Socket<SocketData>;
|
|
924
1113
|
private _closed = false;
|
|
@@ -1055,6 +1244,12 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
|
|
|
1055
1244
|
return clients.size;
|
|
1056
1245
|
},
|
|
1057
1246
|
|
|
1247
|
+
sendToBuzzPeer(msg: OutboundToBuzzMessage): boolean {
|
|
1248
|
+
if (!buzzPeerClient || !buzzPeerClient.isAlive()) return false;
|
|
1249
|
+
buzzPeerClient.send(msg);
|
|
1250
|
+
return true;
|
|
1251
|
+
},
|
|
1252
|
+
|
|
1058
1253
|
async close(): Promise<void> {
|
|
1059
1254
|
// Stop the heartbeat watchdog before closing clients so it doesn't
|
|
1060
1255
|
// log spurious evictions during planned shutdown.
|
|
@@ -69,6 +69,7 @@ import {
|
|
|
69
69
|
} from '../hooks/audience-classify.mjs'
|
|
70
70
|
import { queueFloodBlockedReply } from './flood-reply-queue.js'
|
|
71
71
|
import { resolveChatIdFallback } from './chat-id-fallback.js'
|
|
72
|
+
import { getBuzzMirror } from './buzz-mirror.js'
|
|
72
73
|
import { isFinalAnswerReply, isSubstantiveFinalReply, shouldJournalReplySiteDelivery } from '../final-answer-detect.js'
|
|
73
74
|
import { decideOverPing, type OverPingDecision } from '../over-ping-safety-net.js'
|
|
74
75
|
import { decideSilentReplyAnchor } from '../silent-reply-anchor.js'
|
|
@@ -834,6 +835,10 @@ export interface SendReplyGatewayDeps {
|
|
|
834
835
|
resolveReplyOwnerTurn(liveTurn: CurrentTurn | null, chatId: string, args: Record<string, unknown>): { turn: CurrentTurn | null; tier: ReplyOwnerTier; candidates: ReplyOwnerCandidates }
|
|
835
836
|
findTurnByOriginId(originTurnId: string | null | undefined): CurrentTurn | null
|
|
836
837
|
findTurnByQuotedMessageId(chatId: string, replyTo: unknown): CurrentTurn | null
|
|
838
|
+
/** Buzz co-channel Phase 2b (S1) — the same chat-wide latest-ended lookup the
|
|
839
|
+
* owner-resolution wiring uses. Read here ONLY to compute the S1 owner-guard
|
|
840
|
+
* input `hasRecentDifferentOriginTurn`; a no-op when Buzz mirroring is off. */
|
|
841
|
+
findLatestTurnForChat(chatId: string, opts: { endedOnly: boolean }): CurrentTurn | null
|
|
837
842
|
resolveAnswerThreadWithLog(
|
|
838
843
|
chatId: string,
|
|
839
844
|
explicitThreadId: number | undefined,
|
|
@@ -913,7 +918,7 @@ export async function sendReply(
|
|
|
913
918
|
lockedBot, robustApiCall, swallowingApiCall,
|
|
914
919
|
loadAccess, redactOutboundText, assertAllowedChat, assertSendable,
|
|
915
920
|
statusKey, streamKey,
|
|
916
|
-
resolveReplyOwnerTurn, findTurnByOriginId, findTurnByQuotedMessageId,
|
|
921
|
+
resolveReplyOwnerTurn, findTurnByOriginId, findTurnByQuotedMessageId, findLatestTurnForChat,
|
|
917
922
|
resolveAnswerThreadWithLog, resolveThreadId,
|
|
918
923
|
getLatestInboundMessageId, getLastSubagentHandbackAt, subagentReplyAuthority, recordOutbound,
|
|
919
924
|
emissionAuthorityFor, clearActivitySummary,
|
|
@@ -2585,6 +2590,37 @@ export async function sendReply(
|
|
|
2585
2590
|
if (shouldJournalReplySiteDelivery({ text: rawText, disableNotification: modelDisableNotification })) {
|
|
2586
2591
|
journalExternalDelivery({ turnNonce: t?.turnId ?? null, text, tgMessageId: sentIds[sentIds.length - 1], replyAlreadyDeliveredThisTurn: true })
|
|
2587
2592
|
}
|
|
2593
|
+
// ── Buzz co-channel Phase 2b mirror hook (S1/S4) ─────────────────────────
|
|
2594
|
+
// STRICTLY downstream of the guaranteed Telegram delivery above: this runs
|
|
2595
|
+
// only inside `sentIds.length > 0` (a Telegram copy landed) and is a
|
|
2596
|
+
// byte-identical no-op when Buzz is disabled (`getBuzzMirror()` is null).
|
|
2597
|
+
// Never throws — the hub swallows its own errors — so a Buzz mirror can
|
|
2598
|
+
// never fail, delay, or alter the Telegram answer (the core invariant), and
|
|
2599
|
+
// the tool result the model sees reflects the Telegram copy only (S4).
|
|
2600
|
+
const buzzMirror = getBuzzMirror()
|
|
2601
|
+
if (buzzMirror !== null) {
|
|
2602
|
+
const { turn: mOwnerTurn, tier: mOwnerTier } = resolveReplyOwnerTurn(turn, chat_id, args)
|
|
2603
|
+
const ownerOrigin = mOwnerTurn?.originChannel ?? 'telegram'
|
|
2604
|
+
const ownerTurnId = mOwnerTurn?.turnId ?? null
|
|
2605
|
+
// S1 inputs. ownerEchoed: the reply positively echoed the owner turn's id
|
|
2606
|
+
// (the `origin` tier is the only one that binds by `origin_turn_id`).
|
|
2607
|
+
const ownerEchoed = mOwnerTier === 'origin'
|
|
2608
|
+
// hasRecentDifferentOriginTurn: is there a recent turn of a DIFFERENT
|
|
2609
|
+
// origin than the resolved owner (the live turn, or the chat's latest
|
|
2610
|
+
// ended turn) that this reply could otherwise have belonged to? Deterministic.
|
|
2611
|
+
const latestEnded = findLatestTurnForChat(chat_id, { endedOnly: true })
|
|
2612
|
+
const hasRecentDifferentOriginTurn = [turn, latestEnded].some(
|
|
2613
|
+
(c) => c != null && c.turnId !== ownerTurnId && c.originChannel !== ownerOrigin,
|
|
2614
|
+
)
|
|
2615
|
+
buzzMirror.mirrorReplyDelivered({
|
|
2616
|
+
scrubbedText: text,
|
|
2617
|
+
ownerOriginChannel: ownerOrigin,
|
|
2618
|
+
ownerBuzzCoords: mOwnerTurn?.buzzCoords,
|
|
2619
|
+
ownerEchoed,
|
|
2620
|
+
hasRecentDifferentOriginTurn,
|
|
2621
|
+
telegramMessageKeys: sentIds.map((id) => `${chat_id}:${id}`),
|
|
2622
|
+
})
|
|
2623
|
+
}
|
|
2588
2624
|
}
|
|
2589
2625
|
return { content: [{ type: 'text', text: result }] }
|
|
2590
2626
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Writer for the one-shot `.pending-turn.env` diagnostic file (Stage 4 of
|
|
3
|
+
* simplify-restart, #250) — extracted verbatim from gateway.ts (#2996
|
|
4
|
+
* ratchet discipline: new inline code must not land in gateway.ts).
|
|
5
|
+
*
|
|
6
|
+
* The gateway writes `<agentDir>/.pending-turn.env` at boot when the
|
|
7
|
+
* previous shutdown left an interrupted turn; start.sh sources and then
|
|
8
|
+
* consumes it. These vars are PASSIVE forensic context for the wake-audit
|
|
9
|
+
* / "why did you restart" protocols — the real wake signal is the
|
|
10
|
+
* synthesized resume inbound, not this file.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { existsSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
14
|
+
import { join } from 'node:path'
|
|
15
|
+
import type { Turn } from '../registry/turns-schema.js'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Write (or clear) the pending-turn env file. Atomic tmp+rename: a crash
|
|
19
|
+
* mid-write must never leave a truncated file that start.sh `source`s —
|
|
20
|
+
* partial SWITCHROOM_PENDING_* vars or a malformed line would break shell
|
|
21
|
+
* parsing inside the source. Never throws (logs via `log`).
|
|
22
|
+
*/
|
|
23
|
+
export function writePendingTurnEnv(
|
|
24
|
+
agentDir: string,
|
|
25
|
+
pending: Turn | null,
|
|
26
|
+
log: (line: string) => void = (l) => process.stderr.write(l),
|
|
27
|
+
): void {
|
|
28
|
+
const pendingEnvPath = join(agentDir, '.pending-turn.env')
|
|
29
|
+
try {
|
|
30
|
+
if (pending != null) {
|
|
31
|
+
const lines = [
|
|
32
|
+
`SWITCHROOM_PENDING_TURN=true`,
|
|
33
|
+
`SWITCHROOM_PENDING_TURN_KEY=${pending.turn_key}`,
|
|
34
|
+
`SWITCHROOM_PENDING_CHAT_ID=${pending.chat_id}`,
|
|
35
|
+
pending.thread_id != null
|
|
36
|
+
? `SWITCHROOM_PENDING_THREAD_ID=${pending.thread_id}`
|
|
37
|
+
: `SWITCHROOM_PENDING_THREAD_ID=`,
|
|
38
|
+
pending.last_user_msg_id != null
|
|
39
|
+
? `SWITCHROOM_PENDING_USER_MSG_ID=${pending.last_user_msg_id}`
|
|
40
|
+
: `SWITCHROOM_PENDING_USER_MSG_ID=`,
|
|
41
|
+
`SWITCHROOM_PENDING_ENDED_VIA=${pending.ended_via ?? 'unknown'}`,
|
|
42
|
+
`SWITCHROOM_PENDING_STARTED_AT=${pending.started_at}`,
|
|
43
|
+
pending.interrupt_reason != null
|
|
44
|
+
? `SWITCHROOM_PENDING_INTERRUPT_REASON=${pending.interrupt_reason}`
|
|
45
|
+
: `SWITCHROOM_PENDING_INTERRUPT_REASON=`,
|
|
46
|
+
]
|
|
47
|
+
const pendingEnvTmp = `${pendingEnvPath}.tmp-${process.pid}`
|
|
48
|
+
writeFileSync(pendingEnvTmp, lines.join('\n') + '\n', { mode: 0o600 })
|
|
49
|
+
renameSync(pendingEnvTmp, pendingEnvPath)
|
|
50
|
+
log(
|
|
51
|
+
`telegram gateway: pending-turn env written to ${pendingEnvPath} ` +
|
|
52
|
+
`turnKey=${pending.turn_key} endedVia=${pending.ended_via ?? 'open'}\n`,
|
|
53
|
+
)
|
|
54
|
+
} else if (existsSync(pendingEnvPath)) {
|
|
55
|
+
rmSync(pendingEnvPath, { force: true })
|
|
56
|
+
log(`telegram gateway: pending-turn env cleared (clean previous shutdown)\n`)
|
|
57
|
+
}
|
|
58
|
+
} catch (err) {
|
|
59
|
+
log(`telegram gateway: pending-turn env write failed (${(err as Error).message})\n`)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -77,6 +77,7 @@ import { FlushCompletionTracker } from '../flushed-turn-supersede.js'
|
|
|
77
77
|
import { subagentReplyAuthority } from './subagent-reply-authority.js'
|
|
78
78
|
import { sessionConsumeSignal } from './session-consume-signal.js'
|
|
79
79
|
import { decideTerminalReason, deriveTurnRole } from '../turn-liveness-floor.js'
|
|
80
|
+
import { parseChannelOrigin, isBuzzTurnRoutingEnabled } from './channel-route.js'
|
|
80
81
|
import { chatKey, chatKeyWithSuffix } from './chat-key.js'
|
|
81
82
|
import { deriveTurnId } from './derive-turn-id.js'
|
|
82
83
|
import { EMISSION_AUTHORITY_ENABLED, EmissionAuthority } from './emission-authority.js'
|
|
@@ -151,6 +152,17 @@ const QUEUED_CARD_HTML = '⏳ Queued — waiting for the current task to finish
|
|
|
151
152
|
const QUEUED_CARD_FOLDED_HTML = '✅ Folded into the current task.'
|
|
152
153
|
const QUEUED_CARD_EXPIRED_HTML = '⚠️ This queued message timed out before it could start.'
|
|
153
154
|
|
|
155
|
+
// Buzz co-channel — Phase 2a origin stamp gate (Finding 10). The per-turn
|
|
156
|
+
// `parseChannelOrigin` call is invoked ONLY when BOTH hold:
|
|
157
|
+
// · BUZZ_ENABLED — the per-agent projection `src/agents/compose.ts` sets in
|
|
158
|
+
// the container env ONLY when `channels.buzz.enabled === true`; absent for
|
|
159
|
+
// every Telegram-only agent.
|
|
160
|
+
// · SWITCHROOM_BUZZ_TURN_ROUTING !== '0' — the Phase 2a kill switch.
|
|
161
|
+
// When either is off, the ctor stamps a plain Telegram origin without ever
|
|
162
|
+
// calling the parser, so the Telegram-only hot path is byte-for-byte unchanged.
|
|
163
|
+
const BUZZ_ENABLED = process.env.BUZZ_ENABLED === '1' || process.env.BUZZ_ENABLED === 'true'
|
|
164
|
+
const BUZZ_ORIGIN_STAMP_ACTIVE = BUZZ_ENABLED && isBuzzTurnRoutingEnabled()
|
|
165
|
+
|
|
154
166
|
/** The `enqueue` envelope fields `beginTurn` needs — the SessionEvent minus its
|
|
155
167
|
* discriminant. A parked entry additionally carries `parkedAt` for the TTL. */
|
|
156
168
|
export interface TurnStartEnvelope {
|
|
@@ -576,6 +588,15 @@ function beginTurn(deps: StreamRenderDeps, ev: TurnStartEnvelope): void {
|
|
|
576
588
|
gatewayReceiveAt: startedAt,
|
|
577
589
|
// #2527 — stamp the loop role once, from the enqueue envelope.
|
|
578
590
|
role: deriveTurnRole(ev.rawContent),
|
|
591
|
+
// Buzz co-channel — Phase 2a. Stamp the immutable origin provenance once,
|
|
592
|
+
// from the same enqueue envelope. Gated (Finding 10): when Buzz is off for
|
|
593
|
+
// this agent (or the kill switch is set) the parser is never called and
|
|
594
|
+
// the turn defaults to a plain Telegram origin — no coords, hot path
|
|
595
|
+
// untouched. When active, `parseChannelOrigin` reads the outer channel
|
|
596
|
+
// tag's meta-hoisted `source="buzz"` + coords (see `channel-route.ts`).
|
|
597
|
+
...(BUZZ_ORIGIN_STAMP_ACTIVE
|
|
598
|
+
? parseChannelOrigin(ev.rawContent)
|
|
599
|
+
: { originChannel: 'telegram' as const }),
|
|
579
600
|
// PR1 (cross-turn stale-card guard, §9 lever 4 / race C/D). Only a
|
|
580
601
|
// synthetic represent/owed-reply turn carries this; a foreground turn
|
|
581
602
|
// leaves it undefined and the cross-turn card-OPEN gate is inert.
|
|
@@ -176,6 +176,18 @@ export const INBOUND_SOURCE_CLASSIFICATION: Record<string, { decoupledCompletion
|
|
|
176
176
|
mental_model_proposal_failed: { decoupledCompletion: false },
|
|
177
177
|
webhook: { decoupledCompletion: false },
|
|
178
178
|
linear: { decoupledCompletion: false },
|
|
179
|
+
// Buzz co-channel (Phase 1): a Nostr kind:9 group message the buzz sidecar
|
|
180
|
+
// injects onto the gateway IPC queue as its OWN live inbound turn (anonymous
|
|
181
|
+
// inject, `meta.source="buzz"`) — never a decoupled completion resolving a
|
|
182
|
+
// different ended turn, so it must NOT stamp.
|
|
183
|
+
buzz: { decoupledCompletion: false },
|
|
184
|
+
// Gateway boot briefing (session_continuity.briefing: gateway): a synthetic
|
|
185
|
+
// FIRST user turn the gateway assembles from durable history and injects over
|
|
186
|
+
// the spool (`<channel source="boot_briefing">`, boot-briefing-builder.ts).
|
|
187
|
+
// Like the resume_* synthetics it lands as its OWN live inbound turn — its
|
|
188
|
+
// briefing reply resolves the live tier for its own turnId and cannot
|
|
189
|
+
// supersede a different ended turn's record — so it must NOT stamp.
|
|
190
|
+
boot_briefing: { decoupledCompletion: false },
|
|
179
191
|
}
|
|
180
192
|
|
|
181
193
|
/**
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* user-failure-notices.ts — gateway-side handling of the plain-language
|
|
3
|
+
* "couldn't complete that" user notice, plus the `transport-transient`
|
|
4
|
+
* classifier kind (a mid-response stream abort reaching Anthropic).
|
|
5
|
+
*
|
|
6
|
+
* Two concerns, one subsystem (the plain user-failure-notice send side):
|
|
7
|
+
*
|
|
8
|
+
* 1. `emitTransportTransientEvent` — the calm path for a `transport-transient`
|
|
9
|
+
* operator event. Claude Code emits `error: "server_error"` with NO HTTP
|
|
10
|
+
* status on a mid-response stream abort; that is a transport failure, not an
|
|
11
|
+
* account/auth/quota fault, and Claude retries it internally. So there is NO
|
|
12
|
+
* broadcast card (a Reauth card would be the wrong remedy and reach every
|
|
13
|
+
* chat). Instead: record for `/status` history, and DEFER one plain user
|
|
14
|
+
* notice to the turn-end gate (`pending-user-notice.ts`) so a genuinely
|
|
15
|
+
* reply-less dead turn still gets one calm line while a recovered retry
|
|
16
|
+
* sends nothing. A BURST — >=3 terminal transport-transient events for one
|
|
17
|
+
* agent within ~10min — escalates to exactly ONE operator-only, Dismiss-only
|
|
18
|
+
* card ("repeated stream failures reaching Anthropic"), so honest
|
|
19
|
+
* suppression never hides a genuinely degraded pipe.
|
|
20
|
+
*
|
|
21
|
+
* 2. `flushDeferredUserNotices` — the turn-end send loop for notices the gate
|
|
22
|
+
* released (moved here from the gateway; see #3293 finding 1).
|
|
23
|
+
*
|
|
24
|
+
* Pure orchestration: every side effect (record, schedule, resolve, send, log,
|
|
25
|
+
* clock) crosses in through `UserFailureNoticeDeps`, so the outcomes are
|
|
26
|
+
* unit-testable with fakes and no gateway/grammy/IPC coupling leaks in.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { escapeMarkdown } from '../format.js'
|
|
30
|
+
import type { InlineKeyboardMarkup, OperatorEvent } from '../operator-events.js'
|
|
31
|
+
import type { PendingUserNotice } from '../pending-user-notice.js'
|
|
32
|
+
|
|
33
|
+
// ─── transport-transient escalation counter (pure) ───────────────────────────
|
|
34
|
+
|
|
35
|
+
const ESCALATION_WINDOW_MS = 10 * 60_000
|
|
36
|
+
const ESCALATION_THRESHOLD = 3
|
|
37
|
+
|
|
38
|
+
/** Per-agent sliding window of terminal transport-transient event timestamps. */
|
|
39
|
+
const transportEscalation = new Map<string, number[]>()
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Record one terminal transport-transient event for `agent` at `now` and decide
|
|
43
|
+
* whether it crosses the escalation threshold. Returns `true` at most once per
|
|
44
|
+
* burst: crossing the threshold RESETS the window so a sustained outage emits
|
|
45
|
+
* one operator card per ~`windowMs`, not one per event.
|
|
46
|
+
*/
|
|
47
|
+
export function noteTransportTransientAndShouldEscalate(
|
|
48
|
+
agent: string,
|
|
49
|
+
now: number,
|
|
50
|
+
opts?: { windowMs?: number; threshold?: number },
|
|
51
|
+
): boolean {
|
|
52
|
+
const windowMs = opts?.windowMs ?? ESCALATION_WINDOW_MS
|
|
53
|
+
const threshold = opts?.threshold ?? ESCALATION_THRESHOLD
|
|
54
|
+
const recent = (transportEscalation.get(agent) ?? []).filter((t) => now - t < windowMs)
|
|
55
|
+
recent.push(now)
|
|
56
|
+
if (recent.length >= threshold) {
|
|
57
|
+
transportEscalation.set(agent, [])
|
|
58
|
+
return true
|
|
59
|
+
}
|
|
60
|
+
transportEscalation.set(agent, recent)
|
|
61
|
+
return false
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Test/reauth-recovery helper: forget the escalation window (all or one agent). */
|
|
65
|
+
export function resetTransportTransientEscalation(agent?: string): void {
|
|
66
|
+
if (agent == null) transportEscalation.clear()
|
|
67
|
+
else transportEscalation.delete(agent)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The ONE operator-only card a transport-transient BURST escalates to. Dismiss-
|
|
72
|
+
* only — no Reauth (a dropped stream is not a credential fault), no failover
|
|
73
|
+
* (every account hits the same degraded pipe).
|
|
74
|
+
*/
|
|
75
|
+
export function renderTransportEscalationCard(agent: string): {
|
|
76
|
+
text: string
|
|
77
|
+
keyboard: InlineKeyboardMarkup
|
|
78
|
+
} {
|
|
79
|
+
const a = escapeMarkdown(agent)
|
|
80
|
+
return {
|
|
81
|
+
text: [
|
|
82
|
+
`🔌 **Repeated stream failures** reaching Anthropic for **${a}**.`,
|
|
83
|
+
`3+ mid-response aborts within ~10 min. Turns retry automatically; if this persists, Anthropic's API may be degraded.`,
|
|
84
|
+
].join('\n'),
|
|
85
|
+
keyboard: {
|
|
86
|
+
inline_keyboard: [
|
|
87
|
+
[{ text: '❌ Dismiss', callback_data: `op:dismiss:${encodeURIComponent(agent)}` }],
|
|
88
|
+
],
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ─── Deps + orchestration ────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
export interface UserFailureNoticeDeps {
|
|
96
|
+
/** Injectable clock. */
|
|
97
|
+
now(): number
|
|
98
|
+
/** Allowlist chats (broadcast audience). */
|
|
99
|
+
allowFrom(): readonly string[]
|
|
100
|
+
/** Topic key of the live turn, or undefined (agent-level / between turns). */
|
|
101
|
+
liveTurnKey(): string | undefined
|
|
102
|
+
/** Persist to /status history (best-effort — must swallow its own errors). */
|
|
103
|
+
record(event: OperatorEvent): void
|
|
104
|
+
/** Schedule the turn-end-gated plain user notice for `chatIds`. */
|
|
105
|
+
scheduleUserNotice(input: {
|
|
106
|
+
chatIds: string[]
|
|
107
|
+
agent: string
|
|
108
|
+
kind: string
|
|
109
|
+
key: string | undefined
|
|
110
|
+
atMs: number
|
|
111
|
+
}): void
|
|
112
|
+
/** Release the notices resolved by this turn end (may be empty). */
|
|
113
|
+
resolveNotices(turnDeliveredReply: boolean, turnKey: string): PendingUserNotice[]
|
|
114
|
+
/** Topic-aware raw send (fire-and-forget). `keyboard` omitted → plain message. */
|
|
115
|
+
send(chatId: string, text: string, keyboard?: InlineKeyboardMarkup): void
|
|
116
|
+
/** stderr line (the gateway prefixes "telegram gateway: "). */
|
|
117
|
+
log(msg: string): void
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Handle a `transport-transient` operator event: record it, defer the plain
|
|
122
|
+
* user notice to turn-end, and — on a burst — post exactly one operator-only
|
|
123
|
+
* escalation card. Never sends a broadcast card.
|
|
124
|
+
*/
|
|
125
|
+
export function emitTransportTransientEvent(
|
|
126
|
+
event: OperatorEvent,
|
|
127
|
+
deps: UserFailureNoticeDeps,
|
|
128
|
+
): void {
|
|
129
|
+
const now = deps.now()
|
|
130
|
+
deps.record(event)
|
|
131
|
+
const escalate = noteTransportTransientAndShouldEscalate(event.agent, now)
|
|
132
|
+
deps.log(
|
|
133
|
+
`transport-transient agent=${event.agent} escalate=${escalate} ` +
|
|
134
|
+
`(no broadcast card; user-notice deferred to turn-end)`,
|
|
135
|
+
)
|
|
136
|
+
const allowFrom = deps.allowFrom()
|
|
137
|
+
if (allowFrom.length === 0) return
|
|
138
|
+
deps.scheduleUserNotice({
|
|
139
|
+
chatIds: [...allowFrom],
|
|
140
|
+
agent: event.agent,
|
|
141
|
+
kind: event.kind,
|
|
142
|
+
key: deps.liveTurnKey(),
|
|
143
|
+
atMs: now,
|
|
144
|
+
})
|
|
145
|
+
if (escalate) {
|
|
146
|
+
const card = renderTransportEscalationCard(event.agent)
|
|
147
|
+
deps.send(allowFrom[0], card.text, card.keyboard)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Turn-end resolution of deferred user failure notices (#3293 finding 1, moved
|
|
153
|
+
* out of gateway.ts). `turnDeliveredReply` is `finalAnswerDelivered ||
|
|
154
|
+
* replyCalled` — a delivered reply DROPS the notices (turn recovered); a
|
|
155
|
+
* reply-less end flushes them (turn genuinely died). `turnKey` scopes resolution
|
|
156
|
+
* to the ending turn's topic under keyed liveness.
|
|
157
|
+
*/
|
|
158
|
+
export function flushDeferredUserNotices(
|
|
159
|
+
turnDeliveredReply: boolean,
|
|
160
|
+
turnKey: string,
|
|
161
|
+
deps: UserFailureNoticeDeps,
|
|
162
|
+
): void {
|
|
163
|
+
const notices = deps.resolveNotices(turnDeliveredReply, turnKey)
|
|
164
|
+
if (notices.length === 0) return
|
|
165
|
+
for (const notice of notices) {
|
|
166
|
+
deps.log(
|
|
167
|
+
`user-notice flush (turn died reply-less) agent=${notice.agent} ` +
|
|
168
|
+
`kind=${notice.kind} chats=${notice.chatIds.length}`,
|
|
169
|
+
)
|
|
170
|
+
for (const chatId of notice.chatIds) deps.send(chatId, notice.text)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -373,6 +373,21 @@ export function verifyHistoryWritable(): { ok: boolean; error?: string } {
|
|
|
373
373
|
}
|
|
374
374
|
}
|
|
375
375
|
|
|
376
|
+
/**
|
|
377
|
+
* Read-only handle for the gateway boot-briefing builder
|
|
378
|
+
* (`gateway/boot-briefing-wiring.ts`). Exposes only the `prepare(...).all`
|
|
379
|
+
* subset the builder's `BriefingDb` seam needs; returns null before
|
|
380
|
+
* `initHistory` (or when history is disabled) so the briefing degrades to
|
|
381
|
+
* empty instead of throwing. Do NOT use for writes — every write path goes
|
|
382
|
+
* through the record* functions above so redaction and validity checks
|
|
383
|
+
* cannot be bypassed.
|
|
384
|
+
*/
|
|
385
|
+
export function getHistoryDbForBriefing(): {
|
|
386
|
+
prepare(sql: string): { all(...params: unknown[]): unknown[] }
|
|
387
|
+
} | null {
|
|
388
|
+
return db
|
|
389
|
+
}
|
|
390
|
+
|
|
376
391
|
/**
|
|
377
392
|
* For tests — close the singleton and forget it. Production code never
|
|
378
393
|
* needs this; the DB is held open for the lifetime of the process.
|