switchroom 0.19.42 β 0.19.44
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 +4 -1
- package/dist/auth-broker/index.js +284 -124
- package/dist/cli/notion-write-pretool.mjs +4 -1
- package/dist/cli/switchroom.js +4094 -2711
- package/dist/host-control/main.js +472 -155
- package/dist/vault/approvals/kernel-server.js +231 -71
- package/dist/vault/broker/server.js +293 -133
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +93 -8
- package/telegram-plugin/bridge/bridge.ts +1 -1
- package/telegram-plugin/card-layout.ts +63 -1
- package/telegram-plugin/dist/bridge/bridge.js +4 -1
- package/telegram-plugin/dist/gateway/gateway.js +1682 -936
- package/telegram-plugin/dist/server.js +5 -2
- package/telegram-plugin/flood-429-ledger.ts +5 -3
- package/telegram-plugin/flood-circuit-breaker.ts +11 -3
- package/telegram-plugin/format.ts +223 -13
- package/telegram-plugin/gateway/boot-card.ts +7 -2
- package/telegram-plugin/gateway/flood-reply-queue.ts +5 -0
- package/telegram-plugin/gateway/gateway.ts +49 -51
- package/telegram-plugin/gateway/handback-orphan-recovery.ts +69 -0
- package/telegram-plugin/gateway/handback-preturn-signal.ts +170 -32
- package/telegram-plugin/gateway/ipc-protocol.ts +31 -3
- package/telegram-plugin/gateway/ipc-server.ts +13 -4
- package/telegram-plugin/gateway/obligation-store.ts +55 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +127 -11
- package/telegram-plugin/gateway/outbox-sweep.ts +371 -61
- package/telegram-plugin/gateway/rollout-status-edit.ts +175 -0
- package/telegram-plugin/gateway/stream-render.ts +5 -0
- package/telegram-plugin/gateway/update-announce.ts +11 -4
- package/telegram-plugin/hooks/audience-classify.d.mts +50 -0
- package/telegram-plugin/hooks/audience-classify.mjs +364 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +86 -3
- package/telegram-plugin/hooks/silent-end-scan.mjs +80 -4
- package/telegram-plugin/outbox.ts +103 -2
- package/telegram-plugin/render/rich-render.ts +10 -2
- package/telegram-plugin/reply-quote.ts +150 -0
- package/telegram-plugin/retry-api-call.ts +254 -11
- package/telegram-plugin/shared/bot-runtime.ts +69 -34
- package/telegram-plugin/shared/gw-trace-gate.ts +14 -2
- package/telegram-plugin/silent-end.ts +34 -2
- package/telegram-plugin/status-no-truncate.ts +31 -16
- package/telegram-plugin/stream-controller.ts +44 -9
- package/telegram-plugin/stream-reply-handler.ts +5 -3
- package/telegram-plugin/tests/boot-card-render.test.ts +2 -1
- package/telegram-plugin/tests/card-budget-never-overflows.test.ts +165 -0
- package/telegram-plugin/tests/card-hard-truncate-entity-safe.test.ts +153 -0
- package/telegram-plugin/tests/card-variants.golden.txt +9 -9
- package/telegram-plugin/tests/flood-reply-queue.test.ts +89 -4
- package/telegram-plugin/tests/format-consistency.test.ts +160 -14
- package/telegram-plugin/tests/handback-orphan-recovery.test.ts +131 -0
- package/telegram-plugin/tests/handback-preturn-signal.test.ts +212 -17
- package/telegram-plugin/tests/nested-indent-idiom.test.ts +162 -0
- package/telegram-plugin/tests/nested-prefix-indent.test.ts +115 -0
- package/telegram-plugin/tests/no-robust-api-call-factory.test.ts +62 -0
- package/telegram-plugin/tests/obligation-store.test.ts +51 -0
- package/telegram-plugin/tests/outbox-audience-3865.test.ts +579 -0
- package/telegram-plugin/tests/outbox-provenance-4141.test.ts +1126 -0
- package/telegram-plugin/tests/outbox-sweep-single-flight.test.ts +138 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +12 -10
- package/telegram-plugin/tests/plain-fallback-4096-cap.test.ts +509 -0
- package/telegram-plugin/tests/reply-quote-wire.test.ts +433 -0
- package/telegram-plugin/tests/retry-grammy-http-error.test.ts +404 -0
- package/telegram-plugin/tests/rollout-narration-edit-socket.test.ts +170 -0
- package/telegram-plugin/tests/rollout-status-edit-retry-policy.test.ts +366 -0
- package/telegram-plugin/tests/rollout-status-edit.test.ts +140 -0
- package/telegram-plugin/tests/rollout-status-wiring.test.ts +40 -7
- package/telegram-plugin/tests/tg-post-logger-error-shape.test.ts +4 -0
- package/telegram-plugin/tests/tg-post-retry-attribution.test.ts +196 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +23 -17
- package/telegram-plugin/tool-activity-summary.ts +58 -30
- package/telegram-plugin/uat/scenarios/jtbd-foreground-subagent-activity-dm.test.ts +3 -2
- package/vendor/hindsight-memory/scripts/lib/config.py +15 -0
- package/vendor/hindsight-memory/scripts/tests/test_retain_provenance_tag.py +135 -0
- package/vendor/hindsight-memory/settings.json +1 -1
|
@@ -16996,7 +16996,10 @@ var init_plugin_logger = __esm(() => {
|
|
|
16996
16996
|
});
|
|
16997
16997
|
|
|
16998
16998
|
// format.ts
|
|
16999
|
-
var
|
|
16999
|
+
var DELIMITER_RUN_CHARS;
|
|
17000
|
+
var init_format = __esm(() => {
|
|
17001
|
+
DELIMITER_RUN_CHARS = new Set(["*", "_", "`", "~", "|"]);
|
|
17002
|
+
});
|
|
17000
17003
|
|
|
17001
17004
|
// raw-error-scrub.ts
|
|
17002
17005
|
function extractRequestId(raw) {
|
|
@@ -24849,7 +24852,7 @@ var init_bridge = __esm(async () => {
|
|
|
24849
24852
|
format: { type: "string", enum: ["html", "markdownv2", "text"], description: "Rendering mode. Default renders your text as rich GFM markdown (Bot API 10.1). 'text' sends plain text verbatim. 'html' and 'markdownv2' are legacy aliases that route through the same single rich GFM path \u2014 no separate HTML engine, no auto-escaping." },
|
|
24850
24853
|
disable_web_page_preview: { type: "boolean", description: "Disable link preview thumbnails. Default: true." },
|
|
24851
24854
|
protect_content: { type: "boolean", description: "When true, Telegram prevents the message from being forwarded or saved." },
|
|
24852
|
-
quote_text: { type: "string", description: "Surgical quote: specific text to highlight from the reply_to message. Requires reply_to." },
|
|
24855
|
+
quote_text: { type: "string", description: "Surgical quote: specific text to highlight from the reply_to message. Must be an EXACT substring of that message (copy it verbatim, do not paraphrase; max 1024 chars) \u2014 a quote Telegram cannot find is dropped and the reply lands unquoted. Requires reply_to." },
|
|
24853
24856
|
disable_notification: { type: "boolean", description: 'When true, Telegram delivers the message silently \u2014 no device ping for this user. Default false (pings). Use true for mid-turn updates ("still working through X") so only the final answer pings. Always omit (or pass false) on the final answer of a turn.' },
|
|
24854
24857
|
inline_keyboard: {
|
|
24855
24858
|
type: "array",
|
|
@@ -362,9 +362,11 @@ export function writeFlood429Ledger(
|
|
|
362
362
|
* call at the ~5s cadence a long ban produces, because the fold collapses
|
|
363
363
|
* those into a single episode instead of growing the file.
|
|
364
364
|
*
|
|
365
|
-
* Read-fold-write is not atomic across processes
|
|
366
|
-
*
|
|
367
|
-
*
|
|
365
|
+
* Read-fold-write is not atomic across processes. Today every writer lives in
|
|
366
|
+
* the gateway process (its two `createRetryApiCall` wirings plus the outbox
|
|
367
|
+
* sweep's, all on one event loop), but the ledger is keyed by state DIR, so a
|
|
368
|
+
* second process wiring the breaker could drop one episode on a simultaneous
|
|
369
|
+
* 429. That is deliberate β the alternative is a lock
|
|
368
370
|
* on the send path's error handler, and the cost of losing an episode is
|
|
369
371
|
* nil: a penalty stays on the ledger for seven days and is re-recorded by the
|
|
370
372
|
* next 429, so no verdict tier turns on a single write landing.
|
|
@@ -235,9 +235,17 @@ export function writeFloodState(
|
|
|
235
235
|
* classifies it (`src/cli/doctor-flood-pressure.ts`).
|
|
236
236
|
*
|
|
237
237
|
* Recording here rather than at the gateway callsites is deliberate: this is
|
|
238
|
-
* the one function EVERY `onFloodWait` wiring goes through
|
|
239
|
-
*
|
|
240
|
-
*
|
|
238
|
+
* the one function EVERY `onFloodWait` wiring goes through, so no future
|
|
239
|
+
* callsite can record a window without also recording its history. The live
|
|
240
|
+
* wirings are `gateway/gateway.ts:5715` (`robustApiCall`) and `:5849`
|
|
241
|
+
* (`nonEssentialApiCall`), plus `gateway/outbox-sweep.ts:582` β and
|
|
242
|
+
* `scripts/check-retry-flood-hooks.mjs` fails the build if a new
|
|
243
|
+
* `createRetryApiCall` site omits either breaker hook.
|
|
244
|
+
*
|
|
245
|
+
* (Until #3863 this docblock also cited `shared/bot-runtime.ts`'s
|
|
246
|
+
* `createRobustApiCall` as a live wiring. It had zero production callers and
|
|
247
|
+
* its breaker hooks were OPTIONAL, so it was deleted rather than counted.
|
|
248
|
+
* Cite only wirings the lint gate can see.)
|
|
241
249
|
*/
|
|
242
250
|
export function makeFloodWaitRecorder(
|
|
243
251
|
path: string,
|
|
@@ -30,6 +30,20 @@
|
|
|
30
30
|
*/
|
|
31
31
|
export const RICH_MESSAGE_MAX_CHARS = 32768
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* The legacy plain-text `sendMessage` / `editMessageText` wire cap (4096
|
|
35
|
+
* UTF-16 units). It does NOT apply to the rich path (`sendRichMessage`, up to
|
|
36
|
+
* {@link RICH_MESSAGE_MAX_CHARS}), but it DOES apply the moment a send path
|
|
37
|
+
* DEGRADES to the plain endpoint β the parse-reject fallbacks that resend a
|
|
38
|
+
* rich chunk as plain text, and `renderSafe`'s `mode: "plain"` results.
|
|
39
|
+
*
|
|
40
|
+
* Lives here (next to the rich cap and the splitters) rather than in
|
|
41
|
+
* `render/rich-render.ts` so every plain-degradation site can re-cap without
|
|
42
|
+
* importing the renderer; `render/rich-render.ts` re-exports it for its
|
|
43
|
+
* existing importers.
|
|
44
|
+
*/
|
|
45
|
+
export const PLAIN_TEXT_MAX_CHARS = 4096
|
|
46
|
+
|
|
33
47
|
/**
|
|
34
48
|
* Escape the GFM-markdown special characters so a dynamic value
|
|
35
49
|
* (a filename, an id, arbitrary user text) renders LITERALLY inside a
|
|
@@ -782,8 +796,16 @@ function isFullyBolded(fragment: string): boolean {
|
|
|
782
796
|
* treated as a legitimate pseudo-heading (`**Section**`) rather than an
|
|
783
797
|
* over-bolded paragraph. Single source of truth for BOTH the per-block rule
|
|
784
798
|
* and the global-ratio heading exemption.
|
|
799
|
+
*
|
|
800
|
+
* 64, not 48: at 48 a legitimate long section label
|
|
801
|
+
* (`**Deployment status across all three regions:**` β 50 chars with markers)
|
|
802
|
+
* was read as an over-bolded paragraph and flattened. 64 still bounds the
|
|
803
|
+
* exemption to something that reads as a label on a phone; the alternative of
|
|
804
|
+
* exempting any fully-bold line ending in `:` regardless of length was
|
|
805
|
+
* rejected because an arbitrarily long bolded paragraph that happens to end
|
|
806
|
+
* in a colon would then escape the tripwire entirely.
|
|
785
807
|
*/
|
|
786
|
-
const PSEUDO_HEADING_MAX_CHARS =
|
|
808
|
+
const PSEUDO_HEADING_MAX_CHARS = 64
|
|
787
809
|
|
|
788
810
|
/**
|
|
789
811
|
* True when a blank-line-delimited block is a single-line pseudo-heading: one
|
|
@@ -828,7 +850,12 @@ function unbold(fragment: string): string {
|
|
|
828
850
|
* encourages) and is NOT stripped β neither by the per-block rule NOR by
|
|
829
851
|
* the global-ratio rule (it still counts toward the global ratio, so a
|
|
830
852
|
* genuinely over-bolded message still trips, but its section headings
|
|
831
|
-
* survive instead of the whole reply going plain).
|
|
853
|
+
* survive instead of the whole reply going plain). The one exception:
|
|
854
|
+
* when EVERY block of the message is such a heading there is no body to
|
|
855
|
+
* preserve contrast against, so the global rule strips them all rather
|
|
856
|
+
* than leaving a 100%-bold message untouched. A block that is nothing but
|
|
857
|
+
* masked code (a bare fence, a lone inline span) does NOT count as body
|
|
858
|
+
* for that test β see the note at the call site.
|
|
832
859
|
* - A list with any non-fully-bolded item is left alone.
|
|
833
860
|
*
|
|
834
861
|
* Code spans/fences are masked (maskCodeRegions) and never counted or
|
|
@@ -862,8 +889,33 @@ export function stripExcessBold(
|
|
|
862
889
|
// Clearly over-bolded β strip every bold span, keeping the text, EXCEPT
|
|
863
890
|
// standalone short pseudo-heading blocks (`**Section**`), which stay bold
|
|
864
891
|
// so a bold-dense digest keeps its section headings.
|
|
892
|
+
//
|
|
893
|
+
// The exemption only makes sense when there is body text for the headings
|
|
894
|
+
// to stand out FROM. A message whose every block is a pseudo-heading has
|
|
895
|
+
// no body: exempting all of them leaves a 100%-bold message untouched and
|
|
896
|
+
// unlogged. In that case the exemption is dropped and the whole message is
|
|
897
|
+
// stripped, which is what the ratio rule says.
|
|
898
|
+
//
|
|
899
|
+
// "Body" here means VISIBLE (non-code) text, so a block that is nothing but
|
|
900
|
+
// masked code is skipped rather than counted as body (#4114). Masked code
|
|
901
|
+
// is invisible to every other measurement in this function β it is stripped
|
|
902
|
+
// out of `visible` before the ratio is taken β so counting a bare fence as
|
|
903
|
+
// body would be the one place code influences the bold decision, and it
|
|
904
|
+
// would do so by RESTORING the exact #4017 symptom: a headings-plus-one-
|
|
905
|
+
// snippet digest (a very ordinary agent status report) stayed 100% bold
|
|
906
|
+
// with no onStrip line, because the code block broke `every(...)`. The
|
|
907
|
+
// contrast a code block provides is real but partial, and it does not make
|
|
908
|
+
// every remaining visible character being bold the right render.
|
|
909
|
+
//
|
|
910
|
+
// Filtering with `stripPlaceholders` (the masker's own "remove EVERY mask"
|
|
911
|
+
// primitive) rather than a fence-specific test means all mask kinds β the
|
|
912
|
+
// FENCE and INLINE prefixes today, anything maskCodeRegions adds later β
|
|
913
|
+
// are handled by construction instead of by enumeration.
|
|
914
|
+
const contentBlocks = blocks.filter((b) => stripPlaceholders(b).trim() !== '')
|
|
915
|
+
const allHeadings =
|
|
916
|
+
contentBlocks.length > 0 && contentBlocks.every(isPseudoHeadingBlock)
|
|
865
917
|
const rebuilt = blocks.map((block) =>
|
|
866
|
-
isPseudoHeadingBlock(block) ? block : unbold(block),
|
|
918
|
+
!allHeadings && isPseudoHeadingBlock(block) ? block : unbold(block),
|
|
867
919
|
)
|
|
868
920
|
const out = rejoinBlocks(masked, rebuilt)
|
|
869
921
|
if (out !== masked) onStrip?.({ rule: 'global', ratio })
|
|
@@ -1160,6 +1212,41 @@ export function hardSliceToCap(text: string, cap = RICH_MESSAGE_MAX_CHARS): stri
|
|
|
1160
1212
|
return out
|
|
1161
1213
|
}
|
|
1162
1214
|
|
|
1215
|
+
/**
|
|
1216
|
+
* Re-cap a body that is about to be sent through the PLAIN `sendMessage` /
|
|
1217
|
+
* `editMessageText` endpoint (switchroom #4043).
|
|
1218
|
+
*
|
|
1219
|
+
* Every plain-text FALLBACK in the send path inherits a chunk that was split
|
|
1220
|
+
* for the RICH cap ({@link RICH_MESSAGE_MAX_CHARS} = 32768). Handing such a
|
|
1221
|
+
* chunk to the plain endpoint (which caps at {@link PLAIN_TEXT_MAX_CHARS} =
|
|
1222
|
+
* 4096) makes Telegram reject it with `message is too long` β so the fallback
|
|
1223
|
+
* that exists precisely to rescue a failed send fails too, and the user never
|
|
1224
|
+
* receives the message (in the outbox sweep it retries forever).
|
|
1225
|
+
*
|
|
1226
|
+
* Cuts at `splitMarkdownChunks`' safe boundaries first (never bisecting a
|
|
1227
|
+
* fenced block or a table row) and hard-slices any residual indivisible region,
|
|
1228
|
+
* so EVERY returned piece is guaranteed `<= cap`. Returns the input as a
|
|
1229
|
+
* single-element array when it already fits (the overwhelmingly common case,
|
|
1230
|
+
* byte-identical to the pre-fix behaviour).
|
|
1231
|
+
*/
|
|
1232
|
+
export function splitPlainTextToCap(text: string, cap = PLAIN_TEXT_MAX_CHARS): string[] {
|
|
1233
|
+
if (cap <= 0) return [text]
|
|
1234
|
+
if (text.length <= cap) return [text]
|
|
1235
|
+
const out: string[] = []
|
|
1236
|
+
for (const piece of splitMarkdownChunks(text, cap)) {
|
|
1237
|
+
if (piece.length <= cap) {
|
|
1238
|
+
if (piece.length > 0) out.push(piece)
|
|
1239
|
+
continue
|
|
1240
|
+
}
|
|
1241
|
+
// `splitMarkdownChunks` emits an unsplittable region whole; hard-cut it so
|
|
1242
|
+
// the plain endpoint can actually accept it.
|
|
1243
|
+
for (const slice of hardSliceToCap(piece, cap)) {
|
|
1244
|
+
if (slice.length > 0) out.push(slice)
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
return out.length > 0 ? out : [text]
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1163
1250
|
// ---------------------------------------------------------------------------
|
|
1164
1251
|
// Markdown-aware chunking β never bisects a code fence or a table row
|
|
1165
1252
|
// ---------------------------------------------------------------------------
|
|
@@ -1208,16 +1295,10 @@ export function splitMarkdownChunks(text: string, maxLen = RICH_MESSAGE_MAX_CHAR
|
|
|
1208
1295
|
cut = spaceIdx
|
|
1209
1296
|
}
|
|
1210
1297
|
|
|
1211
|
-
// Back off
|
|
1212
|
-
|
|
1213
|
-
//
|
|
1214
|
-
cut =
|
|
1215
|
-
// Back off so the cut doesn't bisect an inline entity (`**bold**`,
|
|
1216
|
-
// `` `code` ``, `_italic_`, `[label](href)`), which would leave an unclosed
|
|
1217
|
-
// delimiter in the emitted chunk (Telegram then parse-rejects it to
|
|
1218
|
-
// plaintext, or mis-renders the continuation). Runs LAST so it also cleans
|
|
1219
|
-
// up a boundary the fence/table back-offs landed on.
|
|
1220
|
-
cut = backOffOpenInline(rest, cut)
|
|
1298
|
+
// Back the cut off any markdown entity it would bisect β fenced block,
|
|
1299
|
+
// table row, inline span. One shared mechanism (`safeMarkdownCut`), also
|
|
1300
|
+
// used by the card fitter's last-resort clip (#4116).
|
|
1301
|
+
cut = safeMarkdownCut(rest, cut)
|
|
1221
1302
|
|
|
1222
1303
|
if (cut <= 0) {
|
|
1223
1304
|
// Could not find a safe boundary below maxLen β the region is one
|
|
@@ -1334,8 +1415,137 @@ const INLINE_SPAN_PATTERNS: readonly RegExp[] = [
|
|
|
1334
1415
|
/__[^_\n]+__/g, // underline
|
|
1335
1416
|
/(?<![\w*])_[^_\n]+_(?![\w*])/g, // italic (snake_case-guarded)
|
|
1336
1417
|
/\[[^\]\n]*\]\([^)\n]*\)/g, // link [label](href)
|
|
1418
|
+
/~~[^~\n]+~~/g, // strikethrough
|
|
1419
|
+
/\|\|[^|\n]+\|\|/g, // spoiler
|
|
1337
1420
|
]
|
|
1338
1421
|
|
|
1422
|
+
/** Characters that form multi-character markdown delimiter runs (`**`, `~~`, `||`, ` ``` `). */
|
|
1423
|
+
const DELIMITER_RUN_CHARS = new Set(['*', '_', '`', '~', '|'])
|
|
1424
|
+
|
|
1425
|
+
/**
|
|
1426
|
+
* The opening delimiter of every entity kind {@link safeMarkdownCut} can
|
|
1427
|
+
* retreat past, anchored at the head of a string. Used ONLY by
|
|
1428
|
+
* {@link truncateMarkdownSafe}'s repair step β see the doc there for why
|
|
1429
|
+
* dropping an opener is the right repair. Kept adjacent to
|
|
1430
|
+
* `INLINE_SPAN_PATTERNS` so the two marker vocabularies stay in step.
|
|
1431
|
+
*/
|
|
1432
|
+
const FENCE_OPENER_AT_HEAD = /^(\n?)(?:`{3,}|~{3,})[^\n]*/
|
|
1433
|
+
const INLINE_OPENER_AT_HEAD = /^(?:\*{1,3}|_{1,3}|`+|~~|\|\||\[)/
|
|
1434
|
+
const TABLE_ROW_OPENER_AT_HEAD = /^(\n?[ \t]*)\|/
|
|
1435
|
+
|
|
1436
|
+
/**
|
|
1437
|
+
* Bound on {@link truncateMarkdownSafe}'s opener-drop repair loop. Each pass
|
|
1438
|
+
* removes at least one character, so this only caps a pathologically nested
|
|
1439
|
+
* head (`***` + `` ` `` + `[` β¦); eight is far past anything a card produces.
|
|
1440
|
+
*/
|
|
1441
|
+
const MAX_OPENER_REPAIRS = 8
|
|
1442
|
+
|
|
1443
|
+
/**
|
|
1444
|
+
* The largest index `<= cut` at which `text` can be split without bisecting a
|
|
1445
|
+
* markdown entity β the ONE place "where is it safe to cut markdown?" is
|
|
1446
|
+
* answered.
|
|
1447
|
+
*
|
|
1448
|
+
* Composes the three back-offs in the order the chunker has always applied
|
|
1449
|
+
* them (fenced block β table row β inline span; the inline pass runs last so
|
|
1450
|
+
* it also cleans a boundary the first two landed on), then refuses to split a
|
|
1451
|
+
* surrogate pair so the result is always valid UTF-16 as well as valid
|
|
1452
|
+
* markdown.
|
|
1453
|
+
*
|
|
1454
|
+
* Returns `0` when NO safe boundary exists at or below `cut` β i.e. an entity
|
|
1455
|
+
* that starts at index 0 swallows the whole window. Callers must handle that
|
|
1456
|
+
* case; see {@link truncateMarkdownSafe}.
|
|
1457
|
+
*/
|
|
1458
|
+
export function safeMarkdownCut(text: string, cut: number): number {
|
|
1459
|
+
if (cut <= 0) return 0
|
|
1460
|
+
if (cut >= text.length) return text.length
|
|
1461
|
+
let safe = backOffOpenFence(text, cut)
|
|
1462
|
+
safe = backOffTableRow(text, safe)
|
|
1463
|
+
safe = backOffOpenInline(text, safe)
|
|
1464
|
+
// Never cut INSIDE a delimiter run: `**bold**` cut between the two closing
|
|
1465
|
+
// stars emits one lone `*` (odd marker count β parse-reject), and the span
|
|
1466
|
+
// back-off above cannot see it because the run's first half is a complete
|
|
1467
|
+
// span's tail, not a straddle. Retreat to the run's start.
|
|
1468
|
+
while (safe > 0 && DELIMITER_RUN_CHARS.has(text[safe]) && text[safe - 1] === text[safe]) safe--
|
|
1469
|
+
if (safe <= 0) return 0
|
|
1470
|
+
// Never leave a lone high surrogate by cutting through an astral character.
|
|
1471
|
+
const prev = text.charCodeAt(safe - 1)
|
|
1472
|
+
const here = text.charCodeAt(safe)
|
|
1473
|
+
if (prev >= 0xd800 && prev <= 0xdbff && here >= 0xdc00 && here <= 0xdfff) safe -= 1
|
|
1474
|
+
return safe
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
/**
|
|
1478
|
+
* Truncate `text` to at most `budget` characters such that the RESULT IS
|
|
1479
|
+
* ALWAYS PARSEABLE MARKDOWN β no half-open `**`/`_`/`` ` ``/`~~`/`||` run, no
|
|
1480
|
+
* unclosed fence, no bisected link or table row, no split surrogate pair.
|
|
1481
|
+
*
|
|
1482
|
+
* Why this exists (#4116): the card fitter's last-resort clip used to slice
|
|
1483
|
+
* the raw markdown by character count. A cut landing between a `**` pair
|
|
1484
|
+
* yields a body Telegram parse-REJECTS β so the code path that exists
|
|
1485
|
+
* specifically to guarantee delivery within the char budget could itself make
|
|
1486
|
+
* the send fail. A clipped card beats no card, but only if it can be sent.
|
|
1487
|
+
*
|
|
1488
|
+
* Two steps, in order:
|
|
1489
|
+
*
|
|
1490
|
+
* 1. **Back off** to the nearest safe boundary ({@link safeMarkdownCut}).
|
|
1491
|
+
* This is the normal case and it preserves formatting exactly: an entity
|
|
1492
|
+
* that straddles the limit simply doesn't make the cut.
|
|
1493
|
+
*
|
|
1494
|
+
* 2. **Drop the opener** when backing off would keep less than HALF the
|
|
1495
|
+
* window. That happens when a single entity is longer than the window β
|
|
1496
|
+
* `**<40k-char description>**`, the #3682 repro β and a back-off would
|
|
1497
|
+
* return a near-empty card, which is no better than the dropped card the
|
|
1498
|
+
* clip exists to prevent. The entity cannot render anyway (its closing
|
|
1499
|
+
* delimiter is past the window), so we drop its OPENING delimiter and ask
|
|
1500
|
+
* again: the text survives as literal prose, the markdown stays balanced,
|
|
1501
|
+
* and the card is delivered.
|
|
1502
|
+
*
|
|
1503
|
+
* The window END IS HELD FIXED IN SOURCE TERMS across a repair (`end` shrinks
|
|
1504
|
+
* by exactly the characters removed). Letting the window slide forward by the
|
|
1505
|
+
* dropped opener's width would pull the entity's CLOSING delimiter into the
|
|
1506
|
+
* output β an orphan closer, unbalanced again. That is a real defect this
|
|
1507
|
+
* function had before its property test swept every offset.
|
|
1508
|
+
*
|
|
1509
|
+
* The repair loop is bounded (each pass removes at least one character; at
|
|
1510
|
+
* most `MAX_OPENER_REPAIRS` passes) so a pathological body cannot spin.
|
|
1511
|
+
*/
|
|
1512
|
+
export function truncateMarkdownSafe(text: string, budget: number): string {
|
|
1513
|
+
if (budget <= 0) return ''
|
|
1514
|
+
if (text.length <= budget) return text
|
|
1515
|
+
let t = text
|
|
1516
|
+
let end = budget
|
|
1517
|
+
for (let pass = 0; pass <= MAX_OPENER_REPAIRS; pass++) {
|
|
1518
|
+
const safe = safeMarkdownCut(t, end)
|
|
1519
|
+
// A back-off that keeps at least half the window is the good outcome: the
|
|
1520
|
+
// straddling entity is dropped whole and everything before it renders as
|
|
1521
|
+
// authored.
|
|
1522
|
+
if (safe * 2 >= end) return t.slice(0, safe)
|
|
1523
|
+
const repaired = dropLeadingOpener(t.slice(safe))
|
|
1524
|
+
// No opener to drop (unreachable in principle β every back-off above
|
|
1525
|
+
// retreats TO one). Prefer the short-but-valid back-off over a slice that
|
|
1526
|
+
// cannot be parsed.
|
|
1527
|
+
if (repaired == null) return t.slice(0, safe)
|
|
1528
|
+
end -= t.length - safe - repaired.length
|
|
1529
|
+
t = t.slice(0, safe) + repaired
|
|
1530
|
+
}
|
|
1531
|
+
return t.slice(0, Math.max(0, safeMarkdownCut(t, end)))
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
/**
|
|
1535
|
+
* Remove the opening delimiter of the entity that begins at the head of
|
|
1536
|
+
* `rest`, preserving any leading newline so line structure survives. Returns
|
|
1537
|
+
* `null` when the head is not an entity opener.
|
|
1538
|
+
*/
|
|
1539
|
+
function dropLeadingOpener(rest: string): string | null {
|
|
1540
|
+
const fence = FENCE_OPENER_AT_HEAD.exec(rest)
|
|
1541
|
+
if (fence != null) return fence[1] + rest.slice(fence[0].length)
|
|
1542
|
+
const inline = INLINE_OPENER_AT_HEAD.exec(rest)
|
|
1543
|
+
if (inline != null) return rest.slice(inline[0].length)
|
|
1544
|
+
const row = TABLE_ROW_OPENER_AT_HEAD.exec(rest)
|
|
1545
|
+
if (row != null) return row[1] + rest.slice(row[0].length)
|
|
1546
|
+
return null
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1339
1549
|
function backOffOpenInline(text: string, cut: number): number {
|
|
1340
1550
|
if (cut <= 0 || cut >= text.length) return cut
|
|
1341
1551
|
let earliest = cut
|
|
@@ -53,6 +53,7 @@ import {
|
|
|
53
53
|
AGENT_LIVE_POLL_INTERVAL_MS,
|
|
54
54
|
} from './boot-probes.js'
|
|
55
55
|
import { escapeMarkdown, stackCardLines } from '../card-format.js'
|
|
56
|
+
import { NESTED_PREFIX } from '../status-no-truncate.js'
|
|
56
57
|
import {
|
|
57
58
|
loadCache as loadBootIssueCache,
|
|
58
59
|
diffProbes as diffBootProbes,
|
|
@@ -540,7 +541,11 @@ export function renderBootCard(opts: RenderBootCardOpts): string {
|
|
|
540
541
|
const tailCmd = process.env.SWITCHROOM_RUNTIME === 'docker'
|
|
541
542
|
? `docker logs --tail 100 switchroom-${agentSlug}`
|
|
542
543
|
: `journalctl --user -u switchroom-${agentSlug} -n 100`
|
|
543
|
-
|
|
544
|
+
// One indent idiom, repo-wide (#4115): NESTED_PREFIX, not ASCII spaces.
|
|
545
|
+
// Telegram left-trims a leading ASCII-whitespace run off a content line,
|
|
546
|
+
// so the ASCII-space indent this row used to carry rendered FLAT on a
|
|
547
|
+
// phone β the #3668 failure, live-verified 2026-07-26.
|
|
548
|
+
degradedRows.push(`${NESTED_PREFIX}Tail logs: \`${tailCmd}\``)
|
|
544
549
|
}
|
|
545
550
|
|
|
546
551
|
// Probe rows β only those that surfaced as degraded/fail. Healthy
|
|
@@ -569,7 +574,7 @@ export function renderBootCard(opts: RenderBootCardOpts): string {
|
|
|
569
574
|
// user does or doesn't see across consecutive boots.
|
|
570
575
|
degradedRows.push(`${dot} **${PROBE_LABELS[key]}** ${escapeMarkdown(r.detail)}`)
|
|
571
576
|
if (r.nextStep) {
|
|
572
|
-
degradedRows.push(
|
|
577
|
+
degradedRows.push(`${NESTED_PREFIX}${renderNextStep(r.nextStep)}`)
|
|
573
578
|
}
|
|
574
579
|
}
|
|
575
580
|
}
|
|
@@ -145,6 +145,11 @@ export function queueFloodBlockedReply(input: FloodQueueInput): FloodQueueResult
|
|
|
145
145
|
textSha256: sha256Hex(text),
|
|
146
146
|
createdAt,
|
|
147
147
|
source: FLOOD_QUEUED_SOURCE,
|
|
148
|
+
// W1-d (#3865): a flood-deferred record is a reply the agent ALREADY chose
|
|
149
|
+
// to send to this chat β its audience is never in question. Stamped
|
|
150
|
+
// explicitly rather than relying on the legacy default, so this producer
|
|
151
|
+
// stays correct if that default is ever revisited.
|
|
152
|
+
audience: 'user',
|
|
148
153
|
...(input.originChatId != null ? { originChatId: input.originChatId } : {}),
|
|
149
154
|
...(input.originThreadId != null ? { originThreadId: input.originThreadId } : {}),
|
|
150
155
|
}
|
|
@@ -280,10 +280,8 @@ import { REPLY_TOOLS } from '../narrative-dedup.js'
|
|
|
280
280
|
import { NarrativeFlushController, PENDING_NARRATIVE_FLUSH_MS } from '../narrative-flush.js'
|
|
281
281
|
import { createTypingWrapper } from '../typing-wrap.js'
|
|
282
282
|
import { createTurnTypingLoop } from './turn-typing-loop.js'
|
|
283
|
-
import {
|
|
284
|
-
|
|
285
|
-
type PreTurnCardRecord,
|
|
286
|
-
} from './handback-preturn-signal.js'
|
|
283
|
+
import { createHandbackPreturnSignal } from './handback-preturn-signal.js'
|
|
284
|
+
import { createHandbackOrphanRecovery } from './handback-orphan-recovery.js'
|
|
287
285
|
import { deriveTurnId } from './derive-turn-id.js'
|
|
288
286
|
import { createTypingEmitter, TYPING_REFRESH_MS } from '../typing-emitter.js'
|
|
289
287
|
import { type DraftStreamHandle } from '../draft-stream.js'
|
|
@@ -654,6 +652,7 @@ import { startWebhookIngestServer } from './webhook-ingest-server.js'
|
|
|
654
652
|
import { recordWebhookEvent } from '../../src/web/webhook-gateway-record.js'
|
|
655
653
|
|
|
656
654
|
import { createIpcServer, type IpcClient, type IpcServer } from './ipc-server.js'
|
|
655
|
+
import { handleRolloutStatusEdit } from './rollout-status-edit.js'
|
|
657
656
|
import { handleRequestDriveApproval } from './drive-write-approval.js'
|
|
658
657
|
import { handleRequestMs365Approval } from './ms365-write-approval.js'
|
|
659
658
|
import { buildDiffPreviewCard } from './diff-preview-card.js'
|
|
@@ -667,7 +666,7 @@ import {
|
|
|
667
666
|
} from './obligation-ledger.js'
|
|
668
667
|
// (#2996 P8 PR-C2: buildObligationRepresentInbound is now imported by
|
|
669
668
|
// obligation-wiring.ts, where the sweep lives.)
|
|
670
|
-
import { loadObligations, persistObligations } from './obligation-store.js'
|
|
669
|
+
import { loadObligations, persistObligations, removeUnmaintainedSnapshot } from './obligation-store.js'
|
|
671
670
|
import { buildCapturedDeliverySnapshot, createCapturedResumeDispatcher } from './captured-answer-resume.js'
|
|
672
671
|
import {
|
|
673
672
|
loadStatusPins,
|
|
@@ -2804,7 +2803,7 @@ const obligationStoreFs = {
|
|
|
2804
2803
|
writeFileSync: (p: string, d: string) => writeFileSync(p, d),
|
|
2805
2804
|
renameSync: (a: string, b: string) => renameSync(a, b),
|
|
2806
2805
|
existsSync: (p: string) => existsSync(p),
|
|
2807
|
-
fsyncFileSync: fsyncPathSync, fsyncDirSync: fsyncPathSync,
|
|
2806
|
+
fsyncFileSync: fsyncPathSync, fsyncDirSync: fsyncPathSync, unlinkSync,
|
|
2808
2807
|
}
|
|
2809
2808
|
const obligationLedger = new ObligationLedger(OBLIGATION_REPRESENT_MAX, {
|
|
2810
2809
|
onChange:
|
|
@@ -2822,7 +2821,7 @@ if (isGatewayMain && !STATIC && OBLIGATION_LEDGER_ENABLED) {
|
|
|
2822
2821
|
`telegram gateway: obligation-ledger hydrated ${restored.length} open obligation(s) from ${OBLIGATION_STORE_PATH}\n`,
|
|
2823
2822
|
)
|
|
2824
2823
|
}
|
|
2825
|
-
}
|
|
2824
|
+
} else if (isGatewayMain) removeUnmaintainedSnapshot(OBLIGATION_STORE_PATH, obligationStoreFs, `static=${STATIC} enabled=${OBLIGATION_LEDGER_ENABLED}`) // #4146: persistence is off, so a leftover snapshot must not read as "nobody is waiting" to the W1-d audience classifier (logic in obligation-store.ts)
|
|
2826
2825
|
// Origin ids with an escalation send IN FLIGHT β prevents the 5s sweep from
|
|
2827
2826
|
// firing a second concurrent send for the same obligation while the first is
|
|
2828
2827
|
// still awaiting (an escalation that takes >5s, or repeated failures).
|
|
@@ -10052,7 +10051,7 @@ async function deliverCapturedProse(args: {
|
|
|
10052
10051
|
originTurnId: string
|
|
10053
10052
|
text: string
|
|
10054
10053
|
/** Turn elapsed for the honest "(waited Ns)" apology clause; optional. */
|
|
10055
|
-
turnDurationMs?: number
|
|
10054
|
+
turnDurationMs?: number; replyToolThrewThisTurn?: boolean // #4141: label prose recovered from a turn whose reply tool threw (see outbound-send-path.ts)
|
|
10056
10055
|
}): Promise<void> {
|
|
10057
10056
|
// #2996 P2: body moved VERBATIM to outbound-send-path.ts (single tested
|
|
10058
10057
|
// send module). Injects the ONE `outboundDedup` instance (Amendment 1/9).
|
|
@@ -11447,28 +11446,31 @@ if (isGatewayMain) ipcServer = createIpcServer({
|
|
|
11447
11446
|
}
|
|
11448
11447
|
},
|
|
11449
11448
|
|
|
11450
|
-
// #2726 Part 2 β edit the previously-posted rollout status message in
|
|
11451
|
-
// later phases arrive
|
|
11452
|
-
//
|
|
11453
|
-
//
|
|
11454
|
-
onRolloutStatusEdit(
|
|
11455
|
-
|
|
11456
|
-
|
|
11457
|
-
|
|
11458
|
-
|
|
11459
|
-
|
|
11460
|
-
|
|
11461
|
-
|
|
11462
|
-
|
|
11463
|
-
|
|
11464
|
-
|
|
11465
|
-
|
|
11466
|
-
|
|
11467
|
-
|
|
11468
|
-
|
|
11469
|
-
|
|
11470
|
-
|
|
11471
|
-
|
|
11449
|
+
// #2726 Part 2 / #4065 β edit the previously-posted rollout status message in
|
|
11450
|
+
// place as later phases arrive, and REPLY with the outcome so hostd can tell
|
|
11451
|
+
// an applied edit from an edit into a deleted card (see rollout-status-edit.ts).
|
|
11452
|
+
// Still fire-and-forget toward the roll: hostd never blocks on the reply.
|
|
11453
|
+
onRolloutStatusEdit(client: IpcClient, msg: RolloutStatusEditMessage) {
|
|
11454
|
+
return handleRolloutStatusEdit(
|
|
11455
|
+
{
|
|
11456
|
+
selfAgentName: process.env.SWITCHROOM_AGENT_NAME,
|
|
11457
|
+
operatorChatId: () => loadAccess().allowFrom[0],
|
|
11458
|
+
editMessage: (chatId, messageId, text) =>
|
|
11459
|
+
robustApiCall(
|
|
11460
|
+
() =>
|
|
11461
|
+
// allow-raw-bot-api: in-place edit of the ordinary status message.
|
|
11462
|
+
bot.api.editMessageText(chatId, messageId, richMessage(text), {}),
|
|
11463
|
+
// BOTH opt-outs are load-bearing β see "TWO swallows" in
|
|
11464
|
+
// rollout-status-edit.ts. A SHED edit and a SWALLOWED benign
|
|
11465
|
+
// `400 message to edit not found` each resolve WITHOUT throwing,
|
|
11466
|
+
// and the handler reads success from the absence of a throw.
|
|
11467
|
+
{ chat_id: String(chatId), verb: 'rollout-status-edit',
|
|
11468
|
+
priorityClass: 'critical', rethrowBenign400: true },
|
|
11469
|
+
),
|
|
11470
|
+
log: (m) => process.stderr.write(`telegram gateway: ${m}\n`),
|
|
11471
|
+
},
|
|
11472
|
+
client,
|
|
11473
|
+
msg,
|
|
11472
11474
|
)
|
|
11473
11475
|
},
|
|
11474
11476
|
|
|
@@ -13743,8 +13745,6 @@ if (isGatewayMain && !STATIC && FEED_HEARTBEAT_ENABLED) {
|
|
|
13743
13745
|
// closes. Kill switch: SWITCHROOM_HANDBACK_PRETURN=0.
|
|
13744
13746
|
const HANDBACK_PRETURN_ENABLED = !STATIC && process.env.SWITCHROOM_HANDBACK_PRETURN !== '0'
|
|
13745
13747
|
const HANDBACK_PRETURN_HTML = 'π€ Reading the workerβs resultsβ¦'
|
|
13746
|
-
const HANDBACK_PRETURN_ORPHAN_HTML =
|
|
13747
|
-
'π€ A background worker finished, but the handback never started β it may need a nudge.'
|
|
13748
13748
|
|
|
13749
13749
|
async function openHandbackPreTurnCard(
|
|
13750
13750
|
chatId: string,
|
|
@@ -13774,24 +13774,20 @@ async function openHandbackPreTurnCard(
|
|
|
13774
13774
|
}
|
|
13775
13775
|
}
|
|
13776
13776
|
|
|
13777
|
-
|
|
13778
|
-
|
|
13779
|
-
|
|
13780
|
-
|
|
13781
|
-
|
|
13782
|
-
|
|
13783
|
-
|
|
13784
|
-
|
|
13785
|
-
),
|
|
13786
|
-
|
|
13787
|
-
|
|
13788
|
-
|
|
13789
|
-
|
|
13790
|
-
|
|
13791
|
-
)
|
|
13792
|
-
.then(() => undefined)
|
|
13793
|
-
.catch(() => undefined)
|
|
13794
|
-
}
|
|
13777
|
+
// Deterministic recovery for a genuinely-orphaned handback: delete the frozen
|
|
13778
|
+
// pre-turn card, re-inject the handback through the pending-inbound buffer, and
|
|
13779
|
+
// past the retry cap emit fleet-health telemetry β never an operator-facing
|
|
13780
|
+
// "needs a nudge" card. See handback-orphan-recovery.ts.
|
|
13781
|
+
const handbackOrphanRecovery = createHandbackOrphanRecovery({
|
|
13782
|
+
deleteMessage: (chatId, messageId, threadId) =>
|
|
13783
|
+
robustApiCall(() => bot.api.deleteMessage(chatId, messageId), {
|
|
13784
|
+
chat_id: chatId,
|
|
13785
|
+
...(threadId != null ? { threadId } : {}),
|
|
13786
|
+
verb: 'handback-preturn.orphan-delete',
|
|
13787
|
+
}),
|
|
13788
|
+
pushInbound: (inbound) =>
|
|
13789
|
+
pendingInboundBuffer.push(process.env.SWITCHROOM_AGENT_NAME ?? '', inbound),
|
|
13790
|
+
})
|
|
13795
13791
|
|
|
13796
13792
|
const handbackPreturnSignal = createHandbackPreturnSignal({
|
|
13797
13793
|
chatKey: (chatId, threadId) => chatKey(chatId, threadId) as string,
|
|
@@ -13799,7 +13795,9 @@ const handbackPreturnSignal = createHandbackPreturnSignal({
|
|
|
13799
13795
|
startTypingLoop: (chatId, threadId) => startTurnTypingLoop(chatId, threadId),
|
|
13800
13796
|
stopTypingLoop: (chatId, threadId) => stopTurnTypingLoop(chatId, threadId),
|
|
13801
13797
|
openCard: openHandbackPreTurnCard,
|
|
13802
|
-
|
|
13798
|
+
deleteCard: handbackOrphanRecovery.deleteCard,
|
|
13799
|
+
reinjectHandback: handbackOrphanRecovery.reinjectHandback,
|
|
13800
|
+
escalateOrphan: handbackOrphanRecovery.escalateOrphan,
|
|
13803
13801
|
writeCardRecord: (record) => {
|
|
13804
13802
|
if (!activityCardPersistEnabled) return
|
|
13805
13803
|
writeActivityCardRecord(ACTIVITY_CARD_STORE_PATH, activityCardStoreFs, record)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* handback-orphan-recovery.ts β the gateway-side EFFECTS for deterministic
|
|
3
|
+
* recovery of a genuinely-orphaned sub-agent handback.
|
|
4
|
+
*
|
|
5
|
+
* The decision ("is this an orphan, should it be re-injected, has it exhausted
|
|
6
|
+
* its retries") lives in `handback-preturn-signal.ts`. This module owns only the
|
|
7
|
+
* three side effects that decision drives, so they live in a module instead of
|
|
8
|
+
* inline in gateway.ts (switchroom#2996 anti-inflation ratchet β new logic goes
|
|
9
|
+
* into a seam, not into the 24k-line gateway):
|
|
10
|
+
*
|
|
11
|
+
* 1. `deleteCard` β DELETE (never edit) the frozen pre-turn card. The
|
|
12
|
+
* old behaviour rewrote it to an operator-facing string asking a human to
|
|
13
|
+
* manually nudge a system that must recover itself. That string is gone
|
|
14
|
+
* from the codebase; the card is removed outright.
|
|
15
|
+
* 2. `reinjectHandback` β push the handback back through the pending-inbound
|
|
16
|
+
* buffer (the same path live synthesis + boot replay use) so the idle drain
|
|
17
|
+
* re-delivers it and the machine re-processes it. The seam stamps the retry
|
|
18
|
+
* counter on `inbound.meta` before calling this, so the cap survives the
|
|
19
|
+
* round trip.
|
|
20
|
+
* 3. `escalateOrphan` β retries exhausted β fleet-health TELEMETRY, never a
|
|
21
|
+
* chat card. A structured line on the gateway log that the nightly
|
|
22
|
+
* fleet-health sensor (`src/fleet-health/scan.ts`) reads; the
|
|
23
|
+
* `handback orphan escalation` marker is greppable and carries the join key.
|
|
24
|
+
*
|
|
25
|
+
* Every effect is injected, so the contract is asserted by a unit test with spy
|
|
26
|
+
* transports (`tests/handback-orphan-recovery.test.ts`) rather than by reading
|
|
27
|
+
* gateway.ts.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import type { InboundMessage } from './ipc-protocol.js'
|
|
31
|
+
import type { HandbackOrphanEscalation, PreTurnCardRecord } from './handback-preturn-signal.js'
|
|
32
|
+
|
|
33
|
+
export interface HandbackOrphanRecoveryDeps {
|
|
34
|
+
/** Delete a message. The gateway supplies its `robustApiCall`-wrapped
|
|
35
|
+
* `bot.api.deleteMessage`; rejections are swallowed here (best-effort). */
|
|
36
|
+
deleteMessage: (chatId: string, messageId: number, threadId: number | null) => Promise<unknown>
|
|
37
|
+
/** The gateway's `pendingInboundBuffer.push`, pre-bound to this agent. */
|
|
38
|
+
pushInbound: (inbound: InboundMessage) => void
|
|
39
|
+
/** Telemetry sink. Defaults to the gateway log (stderr). */
|
|
40
|
+
writeLog?: (line: string) => void
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Structured, greppable telemetry line for an exhausted-retry orphan. Exported
|
|
44
|
+
* so the test asserts the exact marker + join keys the fleet-health sensor
|
|
45
|
+
* greps for, not just that "something was logged". */
|
|
46
|
+
export function formatOrphanEscalation(esc: HandbackOrphanEscalation): string {
|
|
47
|
+
return (
|
|
48
|
+
`telegram gateway: handback orphan escalation key=${esc.statusKey} ` +
|
|
49
|
+
`turnId=${esc.adoptTurnId} reinjects=${esc.reinjectCount} ageMs=${esc.ageMs} ` +
|
|
50
|
+
`β deterministic recovery exhausted, no operator nudge issued\n`
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function createHandbackOrphanRecovery(deps: HandbackOrphanRecoveryDeps): {
|
|
55
|
+
deleteCard: (record: PreTurnCardRecord) => Promise<void>
|
|
56
|
+
reinjectHandback: (inbound: InboundMessage) => void
|
|
57
|
+
escalateOrphan: (escalation: HandbackOrphanEscalation) => void
|
|
58
|
+
} {
|
|
59
|
+
const writeLog = deps.writeLog ?? ((line: string) => void process.stderr.write(line))
|
|
60
|
+
return {
|
|
61
|
+
deleteCard: (record) =>
|
|
62
|
+
deps
|
|
63
|
+
.deleteMessage(record.chatId, record.activityMessageId, record.threadId)
|
|
64
|
+
.then(() => undefined)
|
|
65
|
+
.catch(() => undefined),
|
|
66
|
+
reinjectHandback: (inbound) => deps.pushInbound(inbound),
|
|
67
|
+
escalateOrphan: (esc) => writeLog(formatOrphanEscalation(esc)),
|
|
68
|
+
}
|
|
69
|
+
}
|