switchroom 0.20.6 → 0.20.7
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 +1 -1
- package/dist/auth-broker/index.js +3 -2
- package/dist/cli/notion-write-pretool.mjs +1 -1
- package/dist/cli/switchroom.js +4 -3
- package/dist/host-control/main.js +4 -3
- package/dist/vault/approvals/kernel-server.js +3 -2
- package/dist/vault/broker/server.js +3 -2
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +130 -23
- package/telegram-plugin/gateway/gateway.ts +23 -25
- package/telegram-plugin/gateway/obligation-ledger.ts +9 -0
- package/telegram-plugin/gateway/obligation-wiring.ts +19 -4
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +43 -3
- package/telegram-plugin/gateway/represent-delivery-guard.ts +157 -0
- package/telegram-plugin/gateway/stream-render.ts +13 -0
- package/telegram-plugin/tests/pending-inbound-buffer.test.ts +145 -9
- package/telegram-plugin/tests/represent-guard.test.ts +250 -0
|
@@ -21997,7 +21997,7 @@ var init_schema = __esm(() => {
|
|
|
21997
21997
|
reflect: HindsightPerOpLlmSchema.optional().describe("Per-op override for the `reflect` LLM op (synthesis / mental-model " + "refresh). Emits `HINDSIGHT_API_REFLECT_LLM_*`. Absent \u2192 uses global."),
|
|
21998
21998
|
consolidation: HindsightPerOpLlmSchema.optional().describe("Per-op override for the `consolidation` LLM op (background memory " + "merge). Emits `HINDSIGHT_API_CONSOLIDATION_LLM_*`. Absent \u2192 global.")
|
|
21999
21999
|
}).optional().describe("LLM knob for the hindsight container. The flat `provider`/`model` set " + "the global default (backward-compatible); optional `retain`/`reflect`/" + "`consolidation` blocks override individual ops. All fields optional; " + "unset fields fall back to the hard-coded defaults."),
|
|
22000
|
-
env: exports_external.record(exports_external.union([exports_external.string(), exports_external.number(), exports_external.boolean()])).optional().describe("Operator overrides for switchroom's capability-gated Hindsight " + "performance defaults. Only the keys switchroom actually manages are " + "honoured (`HINDSIGHT_PERF_ENV_KEYS` in " + "src/setup/hindsight-perf-defaults.ts: RERANKER_LOCAL_FP16, " + "RERANKER_LOCAL_BATCH_SIZE, LLM_MAX_CONCURRENT, " + "RETAIN/CONSOLIDATION_LLM_MAX_CONCURRENT, LLM_STRICT_SCHEMA, " + "LLM_MAX_RETRIES, CONSOLIDATION_LLM_PARALLELISM, " + "MAX_OBSERVATIONS_PER_SCOPE, " + "RECALL_MAX_CANDIDATES_PER_SOURCE, LINK_EXPANSION_PER_ENTITY_LIMIT, " + "LINK_EXPANSION_TIMEOUT, LLM_REASONING_EFFORT, " + "RERANKER_LOCAL_BUCKET_BATCHING, RERANKER_MAX_CANDIDATES, " + "RERANKER_LOCAL_MAX_CONCURRENT, RECALL_MAX_CONCURRENT, " + "REFLECT_WALL_TIMEOUT, WORKER_CONSOLIDATION_RESERVED_SLOTS, " + "WORKER_CONSOLIDATION_SLOT_LIMIT, " + "CONSOLIDATION_MAX_MEMORIES_PER_ROUND, GRAPH_SEED_MIN_SIMILARITY, " + "LLM_SUPPORTS_MAX_ITEMS, RECENCY_DECAY_FUNCTION, " + "RECENCY_DECAY_HALFLIFE_DAYS \u2014 switchroom defaults recall's recency " + "curve to `exponential` with a 30-day half-life so a fact retained " + "today outranks a stale one, instead of upstream's near-flat " + "linear/365-day window), the override-only keys " + "switchroom manages but ships NO default for " + "(`HINDSIGHT_PERF_OVERRIDE_ONLY_KEYS`: " + "HINDSIGHT_API_WORKER_CONSOLIDATION_BANK_PRIORITY \u2014 a per-deployment " + "`bank-pattern:priority,...` map; unset means upstream's flat " + "created_at FIFO across banks; and " + "HINDSIGHT_CE_DECISIVE_RELATIVE_GAP \u2014 the rollback knob for " + "switchroom's CE-saturation damping patch, a float; >= ~0.65 backs the " + "damping out entirely, unset means the patch's own derived gap; and " + "HINDSIGHT_API_RECENCY_DECAY_LINEAR_WINDOW_DAYS \u2014 only read when the " + "decay function is `linear`, so switchroom ships no default for it but " + "still honours an operator who flips the function back; and " + "HINDSIGHT_API_WORKER_MAX_SLOTS \u2014 the worker poller's TOTAL in-flight " + "task budget, the pool WORKER_CONSOLIDATION_RESERVED_SLOTS reserves out of; " + "unset means upstream's own default; and " + "HINDSIGHT_API_WORKER_RETAIN_RESERVED_SLOTS \u2014 the reserved slot FLOOR for the " + "retain (memory write) lane, carved from that same total; unset means " + "upstream's own 0, i.e. no floor and retain competes for the shared " + "pool; and " + "the pre-0.8.6 name HINDSIGHT_API_WORKER_<TYPE>_MAX_SLOTS is still " + "accepted for every operation type and normalised to " + "..._RESERVED_SLOTS on the way in, because setting both names for " + "one type is a hard boot failure in the engine and switchroom " + "therefore emits only the canonical name; and " + "HINDSIGHT_API_SEMANTIC_MIN_SIMILARITY \u2014 the cosine-similarity floor a " + "candidate must reach to be returned by the semantic retrieval arm at " + "all (0..1); unset means upstream's own 0.3, and where it should sit " + "depends on the bank's embedding model and phrasing diversity, so " + "switchroom ships no opinion; and " + "HINDSIGHT_MCP_RECALL_BUDGET_MODE \u2014 the rollback knob for switchroom's " + "mcp-recall-token-budget image patch; `legacy` restores upstream's " + "exact pre-patch recall returns, anything else or unset is the " + "honest-envelope mode; and " + "HINDSIGHT_API_LLM_TEMPERATURE_REFLECT \u2014 upstream's own per-op reflect " + "temperature knob, made live by switchroom's reflect-temperature image " + "patch; a float, or `none` to omit the kwarg (provider default, " + "upstream's accidental pre-patch behaviour); unset means the image's " + "baked default 0.1; and " + "HINDSIGHT_API_CONSOLIDATION_RECALL_MAX_CONCURRENT \u2014 the background " + "half of switchroom's recall-admission split (#3660): of the " + "RECALL_MAX_CONCURRENT admission slots, at most this many may be held " + "by background consolidation recalls at once, so foreground per-turn " + "recall always keeps the remainder; must be >= 1 and strictly less " + "than RECALL_MAX_CONCURRENT or the engine refuses to boot; unset " + "means the image's derived default min(2, RECALL_MAX_CONCURRENT - 1); " + "1 biases hard toward the interactive lane while a consolidation " + "backlog drains; and " + "HINDSIGHT_MM_REFRESH_MIN_INTERVAL_S \u2014 the rollback/tuning knob for " + "switchroom's MM-refresh-debounce image patch: the minimum seconds " + "between consolidation-triggered refreshes of one mental model; unset " + "means the image's baked 3600, 0 restores upstream's " + "refresh-every-round behaviour; explicit and cron-scheduled refreshes " + "are never debounced; and " + "HINDSIGHT_API_TEMPORAL_LANGUAGES \u2014 the language set dateparser is " + "restricted to during temporal query analysis, made live by switchroom's " + "temporal-language image patch (which ended a 200+-locale auto-detection " + "pass that blocked the shared asyncio loop on every recall); " + "comma-separated, unset means the image's baked `en`, set e.g. `en,es` to " + "restore i18n parsing), plus the " + "embedded-PostgreSQL (pg0) sizing keys switchroom manages in " + "src/setup/hindsight-pg-defaults.ts (`HINDSIGHT_PG_ENV_KEYS`: " + "SWITCHROOM_HINDSIGHT_PG_EFFECTIVE_CACHE_SIZE, " + "SWITCHROOM_HINDSIGHT_PG_SHARED_BUFFERS \u2014 a postgres size string such " + "as `4GB`, or the sentinel `off` to leave pg0's own default for that " + "one knob). A value set here " + "REPLACES switchroom's default and is emitted even when the gating " + "capability is absent, so an operator can always force a knob. Other " + "`HINDSIGHT_API_*` keys are deliberately IGNORED \u2014 a blanket " + "passthrough would collide with the vars startHindsight() derives " + "itself (HINDSIGHT_API_PORT, the retain token/deadline budget).")
|
|
22000
|
+
env: exports_external.record(exports_external.union([exports_external.string(), exports_external.number(), exports_external.boolean()])).optional().describe("Operator overrides for switchroom's capability-gated Hindsight " + "performance defaults. Only the keys switchroom actually manages are " + "honoured (`HINDSIGHT_PERF_ENV_KEYS` in " + "src/setup/hindsight-perf-defaults.ts: RERANKER_LOCAL_FP16, " + "RERANKER_LOCAL_BATCH_SIZE, LLM_MAX_CONCURRENT, " + "RETAIN/CONSOLIDATION_LLM_MAX_CONCURRENT, LLM_STRICT_SCHEMA, " + "LLM_MAX_RETRIES, CONSOLIDATION_LLM_PARALLELISM, " + "MAX_OBSERVATIONS_PER_SCOPE, " + "RECALL_MAX_CANDIDATES_PER_SOURCE, LINK_EXPANSION_PER_ENTITY_LIMIT, " + "LINK_EXPANSION_TIMEOUT, LLM_REASONING_EFFORT, " + "RERANKER_LOCAL_BUCKET_BATCHING, RERANKER_MAX_CANDIDATES, " + "RERANKER_LOCAL_MAX_CONCURRENT, RECALL_MAX_CONCURRENT, " + "REFLECT_WALL_TIMEOUT, WORKER_CONSOLIDATION_RESERVED_SLOTS, " + "WORKER_CONSOLIDATION_SLOT_LIMIT, " + "CONSOLIDATION_MAX_MEMORIES_PER_ROUND, GRAPH_SEED_MIN_SIMILARITY, " + "LLM_SUPPORTS_MAX_ITEMS, RECENCY_DECAY_FUNCTION, " + "RECENCY_DECAY_HALFLIFE_DAYS \u2014 switchroom defaults recall's recency " + "curve to `exponential` with a 30-day half-life so a fact retained " + "today outranks a stale one, instead of upstream's near-flat " + "linear/365-day window), the override-only keys " + "switchroom manages but ships NO default for " + "(`HINDSIGHT_PERF_OVERRIDE_ONLY_KEYS`: " + "HINDSIGHT_API_WORKER_CONSOLIDATION_BANK_PRIORITY \u2014 a per-deployment " + "`bank-pattern:priority,...` map; unset means upstream's flat " + "created_at FIFO across banks; and " + "HINDSIGHT_CE_DECISIVE_RELATIVE_GAP \u2014 the rollback knob for " + "switchroom's CE-saturation damping patch, a float; >= ~0.65 backs the " + "damping out entirely, unset means the patch's own derived gap; and " + "HINDSIGHT_API_RECENCY_DECAY_LINEAR_WINDOW_DAYS \u2014 only read when the " + "decay function is `linear`, so switchroom ships no default for it but " + "still honours an operator who flips the function back; and " + "HINDSIGHT_API_WORKER_MAX_SLOTS \u2014 the worker poller's TOTAL in-flight " + "task budget, the pool WORKER_CONSOLIDATION_RESERVED_SLOTS reserves out of; " + "unset means upstream's own default; and " + "HINDSIGHT_API_WORKER_RETAIN_RESERVED_SLOTS \u2014 the reserved slot FLOOR for the " + "retain (memory write) lane, carved from that same total; unset means " + "upstream's own 0, i.e. no floor and retain competes for the shared " + "pool; and " + "the pre-0.8.6 name HINDSIGHT_API_WORKER_<TYPE>_MAX_SLOTS is still " + "accepted for every operation type and normalised to " + "..._RESERVED_SLOTS on the way in, because setting both names for " + "one type is a hard boot failure in the engine and switchroom " + "therefore emits only the canonical name; and " + "HINDSIGHT_API_SEMANTIC_MIN_SIMILARITY \u2014 the cosine-similarity floor a " + "candidate must reach to be returned by the semantic retrieval arm at " + "all (0..1); unset means upstream's own 0.3, and where it should sit " + "depends on the bank's embedding model and phrasing diversity, so " + "switchroom ships no opinion; and " + "HINDSIGHT_MCP_RECALL_BUDGET_MODE \u2014 the rollback knob for switchroom's " + "mcp-recall-token-budget image patch; `legacy` restores upstream's " + "exact pre-patch recall returns, anything else or unset is the " + "honest-envelope mode; and " + "HINDSIGHT_API_LLM_TEMPERATURE_REFLECT \u2014 upstream's own per-op reflect " + "temperature knob, made live by switchroom's reflect-temperature image " + "patch; a float, or `none` to omit the kwarg (provider default, " + "upstream's accidental pre-patch behaviour); unset means the image's " + "baked default 0.1; and " + "HINDSIGHT_API_CONSOLIDATION_RECALL_MAX_CONCURRENT \u2014 the background " + "half of switchroom's recall-admission split (#3660): of the " + "RECALL_MAX_CONCURRENT admission slots, at most this many may be held " + "by background consolidation recalls at once, so foreground per-turn " + "recall always keeps the remainder; must be >= 1 and strictly less " + "than RECALL_MAX_CONCURRENT or the engine refuses to boot; unset " + "means the image's derived default min(2, RECALL_MAX_CONCURRENT - 1); " + "1 biases hard toward the interactive lane while a consolidation " + "backlog drains; and " + "HINDSIGHT_MM_REFRESH_MIN_INTERVAL_S \u2014 the rollback/tuning knob for " + "switchroom's MM-refresh-debounce image patch: the minimum seconds " + "between consolidation-triggered refreshes of one mental model; unset " + "means the image's baked 3600, 0 restores upstream's " + "refresh-every-round behaviour; explicit and cron-scheduled refreshes " + "are never debounced; and " + "HINDSIGHT_API_TEMPORAL_LANGUAGES \u2014 the language set dateparser is " + "restricted to during temporal query analysis, made live by switchroom's " + "temporal-language image patch (which ended a 200+-locale auto-detection " + "pass that blocked the shared asyncio loop on every recall); " + "comma-separated, unset means the image's baked `en`, set e.g. `en,es` to " + "restore i18n parsing; and " + "HINDSIGHT_API_TEMPORAL_MAX_QUERY_CHARS \u2014 the max chars of query text " + "dateparser.search_dates() is handed during temporal analysis (0 = " + "unlimited), made live by switchroom's temporal-offload image patch " + "(which moved the synchronous extraction off the shared asyncio loop onto " + "a single-worker thread and bounds its input, ending multi-second loop " + "stalls on multi-KB consolidation queries); unset means the image's baked " + "2000, set 0 to restore an unbounded full scan), plus the " + "embedded-PostgreSQL (pg0) sizing keys switchroom manages in " + "src/setup/hindsight-pg-defaults.ts (`HINDSIGHT_PG_ENV_KEYS`: " + "SWITCHROOM_HINDSIGHT_PG_EFFECTIVE_CACHE_SIZE, " + "SWITCHROOM_HINDSIGHT_PG_SHARED_BUFFERS \u2014 a postgres size string such " + "as `4GB`, or the sentinel `off` to leave pg0's own default for that " + "one knob). A value set here " + "REPLACES switchroom's default and is emitted even when the gating " + "capability is absent, so an operator can always force a knob. Other " + "`HINDSIGHT_API_*` keys are deliberately IGNORED \u2014 a blanket " + "passthrough would collide with the vars startHindsight() derives " + "itself (HINDSIGHT_API_PORT, the retain token/deadline budget).")
|
|
22001
22001
|
});
|
|
22002
22002
|
MicrosoftWorkspaceConfigSchema = exports_external.object({
|
|
22003
22003
|
microsoft_client_id: exports_external.string().min(1).optional().describe("Microsoft OAuth application (client) ID from Entra portal " + "(literal string or vault reference e.g. " + "'vault:microsoft-oauth-client-id'). OPTIONAL \u2014 omit it to use " + "switchroom's shipped default Microsoft app (zero-config). " + "Set it only to bring your own Entra app (BYO)."),
|
|
@@ -82242,6 +82242,9 @@ function closeFlushCompletionWindows(registry, now) {
|
|
|
82242
82242
|
registry?.completeAll?.(now);
|
|
82243
82243
|
return closed;
|
|
82244
82244
|
}
|
|
82245
|
+
function parkedTurnStartCount() {
|
|
82246
|
+
return parkedTurnStarts.length;
|
|
82247
|
+
}
|
|
82245
82248
|
function drainParkedTurnStartsForChat(deps, chatId, threadId) {
|
|
82246
82249
|
const wantThread = envThreadIdNum(threadId);
|
|
82247
82250
|
const drained = [];
|
|
@@ -87376,7 +87379,8 @@ var HINDSIGHT_PERF_OVERRIDE_ONLY_KEYS = new Set([
|
|
|
87376
87379
|
"HINDSIGHT_API_RETAIN_WALL_TIMEOUT",
|
|
87377
87380
|
"HINDSIGHT_API_CONSOLIDATION_RECALL_MAX_CONCURRENT",
|
|
87378
87381
|
"HINDSIGHT_MM_REFRESH_MIN_INTERVAL_S",
|
|
87379
|
-
"HINDSIGHT_API_TEMPORAL_LANGUAGES"
|
|
87382
|
+
"HINDSIGHT_API_TEMPORAL_LANGUAGES",
|
|
87383
|
+
"HINDSIGHT_API_TEMPORAL_MAX_QUERY_CHARS"
|
|
87380
87384
|
]);
|
|
87381
87385
|
var HINDSIGHT_WORKER_SLOT_TYPES = [
|
|
87382
87386
|
"consolidation",
|
|
@@ -92780,7 +92784,24 @@ function redeliverBufferedInbound(buffer, agent, send, spool, onDelivered) {
|
|
|
92780
92784
|
const pending = buffer.drain(agent);
|
|
92781
92785
|
let redelivered = 0;
|
|
92782
92786
|
let rebuffered = 0;
|
|
92787
|
+
let retracted = 0;
|
|
92783
92788
|
for (const { merged, originals } of planBufferedRedelivery(pending)) {
|
|
92789
|
+
let proceed = true;
|
|
92790
|
+
if (buffer.beforeRedeliver != null) {
|
|
92791
|
+
try {
|
|
92792
|
+
proceed = buffer.beforeRedeliver(merged);
|
|
92793
|
+
} catch (e) {
|
|
92794
|
+
proceed = true;
|
|
92795
|
+
process.stderr.write(`redeliver beforeRedeliver threw \u2014 failing open (deliver): ${String(e)}
|
|
92796
|
+
`);
|
|
92797
|
+
}
|
|
92798
|
+
}
|
|
92799
|
+
if (!proceed) {
|
|
92800
|
+
for (const o of originals)
|
|
92801
|
+
spool?.ack(o);
|
|
92802
|
+
retracted += originals.length;
|
|
92803
|
+
continue;
|
|
92804
|
+
}
|
|
92784
92805
|
let delivered = false;
|
|
92785
92806
|
try {
|
|
92786
92807
|
delivered = send(merged);
|
|
@@ -92798,7 +92819,7 @@ function redeliverBufferedInbound(buffer, agent, send, spool, onDelivered) {
|
|
|
92798
92819
|
rebuffered += originals.length;
|
|
92799
92820
|
}
|
|
92800
92821
|
}
|
|
92801
|
-
return { drained: pending.length, redelivered, rebuffered };
|
|
92822
|
+
return { drained: pending.length, redelivered, rebuffered, retracted };
|
|
92802
92823
|
}
|
|
92803
92824
|
function isMergeableUserInbound(msg) {
|
|
92804
92825
|
return msg.type === "inbound" && (msg.meta == null || msg.meta.source == null && msg.meta.button_callback == null);
|
|
@@ -92915,7 +92936,62 @@ function createPendingInboundBuffer(opts = {}) {
|
|
|
92915
92936
|
for (const q of queues.values())
|
|
92916
92937
|
n += q.length;
|
|
92917
92938
|
return n;
|
|
92939
|
+
},
|
|
92940
|
+
beforeRedeliver: opts.beforeRedeliver
|
|
92941
|
+
};
|
|
92942
|
+
}
|
|
92943
|
+
|
|
92944
|
+
// gateway/represent-guard.ts
|
|
92945
|
+
function shouldSuppressRepresent(o, deps) {
|
|
92946
|
+
if (!deps.historyEnabled)
|
|
92947
|
+
return false;
|
|
92948
|
+
if (o.lastRepresentedAt == null) {
|
|
92949
|
+
if (o.openedAt == null)
|
|
92950
|
+
return false;
|
|
92951
|
+
return deps.hasOutboundDeliveredSince(o.chatId, o.openedAt, o.threadId);
|
|
92952
|
+
}
|
|
92953
|
+
return deps.hasOutboundDeliveredSince(o.chatId, o.lastRepresentedAt, o.threadId);
|
|
92954
|
+
}
|
|
92955
|
+
|
|
92956
|
+
// gateway/represent-delivery-guard.ts
|
|
92957
|
+
function makeRepresentRedeliveryGuard(deps) {
|
|
92958
|
+
return (msg) => {
|
|
92959
|
+
if (!deps.enabled)
|
|
92960
|
+
return true;
|
|
92961
|
+
if (msg.meta?.source !== "obligation_represent")
|
|
92962
|
+
return true;
|
|
92963
|
+
const originTurnId = msg.meta?.origin_turn_id;
|
|
92964
|
+
if (originTurnId == null)
|
|
92965
|
+
return true;
|
|
92966
|
+
const o = deps.ledger.get(originTurnId);
|
|
92967
|
+
if (o == null) {
|
|
92968
|
+
deps.log(`telegram gateway: represent retracted at delivery \u2014 obligation already ` + `closed since decision (no re-fire) origin=${originTurnId}
|
|
92969
|
+
`);
|
|
92970
|
+
return false;
|
|
92971
|
+
}
|
|
92972
|
+
const suppress = shouldSuppressRepresent(o, {
|
|
92973
|
+
historyEnabled: deps.historyEnabled,
|
|
92974
|
+
hasOutboundDeliveredSince: (chatId, sinceMs, threadId) => deps.hasOutboundDeliveredSince(chatId, sinceMs, threadId, deps.minReplyChars)
|
|
92975
|
+
});
|
|
92976
|
+
if (suppress) {
|
|
92977
|
+
deps.ledger.close(originTurnId);
|
|
92978
|
+
deps.log(`telegram gateway: represent retracted at delivery \u2014 reply landed since ` + `decision (no re-fire) origin=${originTurnId}
|
|
92979
|
+
`);
|
|
92980
|
+
return false;
|
|
92918
92981
|
}
|
|
92982
|
+
return true;
|
|
92983
|
+
};
|
|
92984
|
+
}
|
|
92985
|
+
function makeSessionBusyDrainDeferral(boundMs) {
|
|
92986
|
+
let deferringSince = null;
|
|
92987
|
+
return (busy, now) => {
|
|
92988
|
+
if (!busy || boundMs <= 0) {
|
|
92989
|
+
deferringSince = null;
|
|
92990
|
+
return false;
|
|
92991
|
+
}
|
|
92992
|
+
if (deferringSince == null)
|
|
92993
|
+
deferringSince = now;
|
|
92994
|
+
return now - deferringSince < boundMs;
|
|
92919
92995
|
};
|
|
92920
92996
|
}
|
|
92921
92997
|
|
|
@@ -92987,6 +93063,9 @@ class ObligationLedger {
|
|
|
92987
93063
|
isOpen(originTurnId) {
|
|
92988
93064
|
return this.open.has(originTurnId);
|
|
92989
93065
|
}
|
|
93066
|
+
get(originTurnId) {
|
|
93067
|
+
return this.open.get(originTurnId);
|
|
93068
|
+
}
|
|
92990
93069
|
hasOpen() {
|
|
92991
93070
|
return this.open.size > 0;
|
|
92992
93071
|
}
|
|
@@ -94512,7 +94591,24 @@ function redeliverBufferedInbound2(buffer, agent, send, spool, onDelivered) {
|
|
|
94512
94591
|
const pending = buffer.drain(agent);
|
|
94513
94592
|
let redelivered = 0;
|
|
94514
94593
|
let rebuffered = 0;
|
|
94594
|
+
let retracted = 0;
|
|
94515
94595
|
for (const { merged, originals } of planBufferedRedelivery2(pending)) {
|
|
94596
|
+
let proceed = true;
|
|
94597
|
+
if (buffer.beforeRedeliver != null) {
|
|
94598
|
+
try {
|
|
94599
|
+
proceed = buffer.beforeRedeliver(merged);
|
|
94600
|
+
} catch (e) {
|
|
94601
|
+
proceed = true;
|
|
94602
|
+
process.stderr.write(`redeliver beforeRedeliver threw \u2014 failing open (deliver): ${String(e)}
|
|
94603
|
+
`);
|
|
94604
|
+
}
|
|
94605
|
+
}
|
|
94606
|
+
if (!proceed) {
|
|
94607
|
+
for (const o of originals)
|
|
94608
|
+
spool?.ack(o);
|
|
94609
|
+
retracted += originals.length;
|
|
94610
|
+
continue;
|
|
94611
|
+
}
|
|
94516
94612
|
let delivered = false;
|
|
94517
94613
|
try {
|
|
94518
94614
|
delivered = send(merged);
|
|
@@ -94530,7 +94626,7 @@ function redeliverBufferedInbound2(buffer, agent, send, spool, onDelivered) {
|
|
|
94530
94626
|
rebuffered += originals.length;
|
|
94531
94627
|
}
|
|
94532
94628
|
}
|
|
94533
|
-
return { drained: pending.length, redelivered, rebuffered };
|
|
94629
|
+
return { drained: pending.length, redelivered, rebuffered, retracted };
|
|
94534
94630
|
}
|
|
94535
94631
|
function isMergeableUserInbound2(msg) {
|
|
94536
94632
|
return msg.type === "inbound" && (msg.meta == null || msg.meta.source == null && msg.meta.button_callback == null);
|
|
@@ -95425,6 +95521,9 @@ class ObligationLedger2 {
|
|
|
95425
95521
|
isOpen(originTurnId) {
|
|
95426
95522
|
return this.open.has(originTurnId);
|
|
95427
95523
|
}
|
|
95524
|
+
get(originTurnId) {
|
|
95525
|
+
return this.open.get(originTurnId);
|
|
95526
|
+
}
|
|
95428
95527
|
hasOpen() {
|
|
95429
95528
|
return this.open.size > 0;
|
|
95430
95529
|
}
|
|
@@ -95587,23 +95686,24 @@ function driveEscalation(args) {
|
|
|
95587
95686
|
});
|
|
95588
95687
|
}
|
|
95589
95688
|
|
|
95590
|
-
// gateway/represent-guard.ts
|
|
95591
|
-
function shouldSuppressRepresent(o, deps) {
|
|
95592
|
-
if (!deps.historyEnabled)
|
|
95593
|
-
return false;
|
|
95594
|
-
if (o.lastRepresentedAt == null) {
|
|
95595
|
-
if (o.openedAt == null)
|
|
95596
|
-
return false;
|
|
95597
|
-
return deps.hasOutboundDeliveredSince(o.chatId, o.openedAt, o.threadId);
|
|
95598
|
-
}
|
|
95599
|
-
return deps.hasOutboundDeliveredSince(o.chatId, o.lastRepresentedAt, o.threadId);
|
|
95600
|
-
}
|
|
95601
|
-
|
|
95602
95689
|
// gateway/escalation-bridge-gate.ts
|
|
95603
95690
|
function shouldDeferEscalationForBridge(deps) {
|
|
95604
95691
|
return !deps.bridgeAlive;
|
|
95605
95692
|
}
|
|
95606
95693
|
|
|
95694
|
+
// gateway/stream-render.ts
|
|
95695
|
+
init_history();
|
|
95696
|
+
init_rich_send();
|
|
95697
|
+
var PARKED_TURN_START_TTL_MS2 = 30 * 60000;
|
|
95698
|
+
var QUEUED_CARD_ENABLED2 = process.env.SWITCHROOM_QUEUED_CARD !== "0";
|
|
95699
|
+
var BUZZ_ENABLED2 = process.env.BUZZ_ENABLED === "1" || process.env.BUZZ_ENABLED === "true";
|
|
95700
|
+
var BUZZ_ORIGIN_STAMP_ACTIVE2 = BUZZ_ENABLED2 && isBuzzTurnRoutingEnabled();
|
|
95701
|
+
var parkedTurnStarts2 = [];
|
|
95702
|
+
var flushCompletionTracker2 = new FlushCompletionTracker;
|
|
95703
|
+
function parkedTurnStartCount2() {
|
|
95704
|
+
return parkedTurnStarts2.length;
|
|
95705
|
+
}
|
|
95706
|
+
|
|
95607
95707
|
// gateway/obligation-wiring.ts
|
|
95608
95708
|
function createObligationWiring(deps) {
|
|
95609
95709
|
const {
|
|
@@ -95682,7 +95782,9 @@ function createObligationWiring(deps) {
|
|
|
95682
95782
|
return;
|
|
95683
95783
|
const agent = process.env.SWITCHROOM_AGENT_NAME ?? "";
|
|
95684
95784
|
const now = Date.now();
|
|
95685
|
-
const
|
|
95785
|
+
const liveTurn = getCurrentTurn();
|
|
95786
|
+
const sessionBusy = liveTurn != null && liveTurn.endedAt == null || parkedTurnStartCount2() > 0;
|
|
95787
|
+
const backgroundWorkActive = OBLIGATION_BACKGROUND_WORK_GRACE_MS > 0 && (agentHasInFlightBackgroundWork(now) || sessionBusy);
|
|
95686
95788
|
const decision = obligationLedger.decideAtIdle(OBLIGATION_ESCALATE_GRACE_MS > 0 || backgroundWorkActive || OBLIGATION_REPRESENT_GRACE_MS > 0 ? {
|
|
95687
95789
|
now,
|
|
95688
95790
|
graceMs: OBLIGATION_ESCALATE_GRACE_MS,
|
|
@@ -95694,7 +95796,7 @@ function createObligationWiring(deps) {
|
|
|
95694
95796
|
if (decision.action === "none" || o == null) {
|
|
95695
95797
|
if (backgroundWorkActive && obligationLedger.hasOpen() && now - lastBgWorkDeferLogMs > 60000) {
|
|
95696
95798
|
lastBgWorkDeferLogMs = now;
|
|
95697
|
-
process.stderr.write(`telegram gateway: obligation sweep deferred \u2014 in-flight autonomous sub-agent work ` + `(${obligationLedger.size()} open, bounded ${Math.round(OBLIGATION_BACKGROUND_WORK_GRACE_MS / 60000)}m from receipt)
|
|
95799
|
+
process.stderr.write(`telegram gateway: obligation sweep deferred \u2014 in-flight autonomous sub-agent work ` + `or busy CLI session behind a poke-cleared turn ` + `(${obligationLedger.size()} open, bounded ${Math.round(OBLIGATION_BACKGROUND_WORK_GRACE_MS / 60000)}m from receipt)
|
|
95698
95800
|
`);
|
|
95699
95801
|
}
|
|
95700
95802
|
return;
|
|
@@ -102479,10 +102581,10 @@ function startOutboxSweep(deps) {
|
|
|
102479
102581
|
}
|
|
102480
102582
|
|
|
102481
102583
|
// ../src/build-info.ts
|
|
102482
|
-
var VERSION2 = "0.20.
|
|
102483
|
-
var COMMIT_SHA = "
|
|
102484
|
-
var COMMIT_DATE = "2026-08-
|
|
102485
|
-
var LATEST_PR =
|
|
102584
|
+
var VERSION2 = "0.20.7";
|
|
102585
|
+
var COMMIT_SHA = "bc107185";
|
|
102586
|
+
var COMMIT_DATE = "2026-08-04T10:20:28Z";
|
|
102587
|
+
var LATEST_PR = 4343;
|
|
102486
102588
|
var COMMITS_AHEAD_OF_TAG = 0;
|
|
102487
102589
|
|
|
102488
102590
|
// gateway/boot-version.ts
|
|
@@ -108501,7 +108603,8 @@ var pendingInboundBuffer = createPendingInboundBuffer({
|
|
|
108501
108603
|
const threadOpts = evThread != null ? { message_thread_id: evThread } : {};
|
|
108502
108604
|
swallowingApiCall(() => bot.api.sendMessage(chat, "\u23F3 Messages are arriving faster than I can process them. Your messages are saved and will be handled once I finish the current turn \u2014 if any can't be picked up, I'll ask you to resend it.", { ...threadOpts }), { chat_id: chat, verb: "inbound-buffer-eviction" });
|
|
108503
108605
|
},
|
|
108504
|
-
onHandbackEnqueue: (chatId, threadId, ts) => subagentHandbackMarker.record(chatId, threadId, ts)
|
|
108606
|
+
onHandbackEnqueue: (chatId, threadId, ts) => subagentHandbackMarker.record(chatId, threadId, ts),
|
|
108607
|
+
beforeRedeliver: makeRepresentRedeliveryGuard({ enabled: OBLIGATION_LEDGER_ENABLED, historyEnabled: HISTORY_ENABLED, ledger: obligationLedger, hasOutboundDeliveredSince, minReplyChars: OBLIGATION_REPRESENT_GUARD_MIN_REPLY_CHARS, log: (l) => process.stderr.write(l) })
|
|
108505
108608
|
});
|
|
108506
108609
|
function agentHasInFlightBackgroundWork(now) {
|
|
108507
108610
|
if (countRunningWorkers() > 0)
|
|
@@ -109532,12 +109635,16 @@ if (isGatewayMain)
|
|
|
109532
109635
|
}
|
|
109533
109636
|
})();
|
|
109534
109637
|
var IDLE_DRAIN_INTERVAL_MS = 5000;
|
|
109638
|
+
var idleDrainBusyDefer = makeSessionBusyDrainDeferral(OBLIGATION_BACKGROUND_WORK_GRACE_MS);
|
|
109535
109639
|
if (isGatewayMain && !STATIC) {
|
|
109536
109640
|
setInterval(() => {
|
|
109537
109641
|
const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? "";
|
|
109538
109642
|
const r = idleDrainTick(pendingInboundBuffer, selfAgent, () => {
|
|
109539
109643
|
if (turnInFlightForGate())
|
|
109540
109644
|
return false;
|
|
109645
|
+
const sessionBusy = currentTurn != null && currentTurn.endedAt == null || parkedTurnStartCount() > 0;
|
|
109646
|
+
if (idleDrainBusyDefer(sessionBusy, Date.now()))
|
|
109647
|
+
return false;
|
|
109541
109648
|
const c = ipcServer.getClient(selfAgent);
|
|
109542
109649
|
return c != null && c.isAlive();
|
|
109543
109650
|
}, (m) => ipcServer.sendToAgent(selfAgent, m), inboundSpool, trackRedeliveredInbound);
|
|
@@ -461,7 +461,7 @@ import {
|
|
|
461
461
|
type SendReplyGatewayDeps,
|
|
462
462
|
type DeliverCapturedProseDeps,
|
|
463
463
|
} from './outbound-send-path.js'
|
|
464
|
-
import { handleSessionEvent as handleSessionEventCore, drainParkedTurnStartsForChat, closeFlushCompletionWindows } from './stream-render.js'
|
|
464
|
+
import { handleSessionEvent as handleSessionEventCore, drainParkedTurnStartsForChat, closeFlushCompletionWindows, parkedTurnStartCount } from './stream-render.js'
|
|
465
465
|
import { createNarrativeLane } from './narrative-lane.js'
|
|
466
466
|
import {
|
|
467
467
|
parseAgentCallback,
|
|
@@ -659,6 +659,7 @@ import { handleRequestDriveApproval } from './drive-write-approval.js'
|
|
|
659
659
|
import { handleRequestMs365Approval } from './ms365-write-approval.js'
|
|
660
660
|
import { buildDiffPreviewCard } from './diff-preview-card.js'
|
|
661
661
|
import { createPendingInboundBuffer, redeliverBufferedInbound, idleDrainTick } from './pending-inbound-buffer.js'
|
|
662
|
+
import { makeRepresentRedeliveryGuard, makeSessionBusyDrainDeferral } from './represent-delivery-guard.js'
|
|
662
663
|
import { isCronIdentity, isCronInjectFire, deliverInjectWithFallback, replyCallerIsForeignSession } from './cron-session.js'
|
|
663
664
|
import {
|
|
664
665
|
ObligationLedger,
|
|
@@ -9788,6 +9789,11 @@ const pendingInboundBuffer = createPendingInboundBuffer({
|
|
|
9788
9789
|
// enqueue chokepoint so a boot-replayed handback (not just the live synthesis
|
|
9789
9790
|
// push) populates it. Every `subagent_handback` push funnels through here.
|
|
9790
9791
|
onHandbackEnqueue: (chatId, threadId, ts) => subagentHandbackMarker.record(chatId, threadId, ts),
|
|
9792
|
+
// F1 (fix/represent-double-send-delivery-recheck) — delivery-time represent
|
|
9793
|
+
// retract. Every drain path hands its buffered inbounds through this gate; a
|
|
9794
|
+
// stale `obligation_represent` (its reply landed since the sweep decided it) is
|
|
9795
|
+
// dropped here rather than duplicated into the CLI queue. See represent-delivery-guard.ts.
|
|
9796
|
+
beforeRedeliver: makeRepresentRedeliveryGuard({ enabled: OBLIGATION_LEDGER_ENABLED, historyEnabled: HISTORY_ENABLED, ledger: obligationLedger, hasOutboundDeliveredSince, minReplyChars: OBLIGATION_REPRESENT_GUARD_MIN_REPLY_CHARS, log: (l) => process.stderr.write(l) }),
|
|
9791
9797
|
})
|
|
9792
9798
|
|
|
9793
9799
|
// PR2 obligation-ledger idle sweep. Re-present an OPEN obligation only at a
|
|
@@ -11742,30 +11748,19 @@ if (isGatewayMain) (() => { // #2996 P0c: gated — starts webhook-ingest UDS s
|
|
|
11742
11748
|
})()
|
|
11743
11749
|
|
|
11744
11750
|
// ─── Opportunistic idle-drain of pendingInboundBuffer ─────────────────────
|
|
11745
|
-
//
|
|
11746
|
-
//
|
|
11747
|
-
//
|
|
11748
|
-
//
|
|
11749
|
-
//
|
|
11750
|
-
//
|
|
11751
|
-
//
|
|
11752
|
-
// while idle; last `bridge registered` predated the buffer push, so
|
|
11753
|
-
// onClientRegistered's drain never ran for it).
|
|
11754
|
-
//
|
|
11755
|
-
// This is the third drain trigger. It's gated to be zero-cost and
|
|
11756
|
-
// zero-churn: skip entirely when nothing is buffered (one Map.get, no
|
|
11757
|
-
// log), when the bridge isn't alive (exactly sendToAgent's own guard —
|
|
11758
|
-
// so we never drain into a dead bridge and re-buffer/log-spin), OR
|
|
11759
|
-
// when a turn is in flight. The turn gate is #1556: a message
|
|
11760
|
-
// delivered while a turn is active is NOT safely queued by the bridge
|
|
11761
|
-
// — claude types it into its TUI composer and the auto-submit races
|
|
11762
|
-
// turn-completion, stranding it (the lawgpt wedge). Draining only at
|
|
11763
|
-
// `activeTurnStartedAt.size === 0` guarantees the channel notification
|
|
11764
|
-
// lands at an idle prompt and submits as a fresh turn. Only when there
|
|
11765
|
-
// IS a buffered message AND a live bridge AND no active turn do we
|
|
11766
|
-
// reuse the #1546 `redeliverBufferedInbound` (lossless: re-buffers any
|
|
11767
|
-
// per-message miss).
|
|
11751
|
+
// The THIRD drain trigger, beside bridge re-register (onClientRegistered) and
|
|
11752
|
+
// the silence-poke framework fallback (#1546): neither fires when a message is
|
|
11753
|
+
// buffered during a bridge-IPC flap that settles with no clean re-register while
|
|
11754
|
+
// claude is idle, so it orphans until a manual restart (finn, 2026-05-19). Gated
|
|
11755
|
+
// zero-cost/zero-churn: skip when nothing is buffered, when the bridge is dead,
|
|
11756
|
+
// or when a turn is in flight (#1556 — a message delivered mid-turn is typed into
|
|
11757
|
+
// claude's TUI composer and the auto-submit races turn-completion, stranding it).
|
|
11768
11758
|
const IDLE_DRAIN_INTERVAL_MS = 5000
|
|
11759
|
+
// F2 (drain half) — bounded busy-defer. A ~5-min poke can clear the machine turn
|
|
11760
|
+
// while the CLI is still producing the answer; draining a represent into that busy
|
|
11761
|
+
// session re-queues it BEHIND the real reply → a duplicate. Defer while busy,
|
|
11762
|
+
// bounded so a wedged session still drains (F1 then retracts it post-reply).
|
|
11763
|
+
const idleDrainBusyDefer = makeSessionBusyDrainDeferral(OBLIGATION_BACKGROUND_WORK_GRACE_MS)
|
|
11769
11764
|
if (isGatewayMain && !STATIC) {
|
|
11770
11765
|
setInterval(() => {
|
|
11771
11766
|
const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? ''
|
|
@@ -11775,8 +11770,11 @@ if (isGatewayMain && !STATIC) {
|
|
|
11775
11770
|
() => {
|
|
11776
11771
|
// #1556: never drain mid-turn — that re-creates the composer
|
|
11777
11772
|
// wedge this buffer exists to prevent. Gate reads the delivery
|
|
11778
|
-
// machine (turnInFlightForGate)
|
|
11773
|
+
// machine (turnInFlightForGate) AND, per F2, stream-render's live-turn
|
|
11774
|
+
// mirror (a poke can clear the former while the latter is still busy).
|
|
11779
11775
|
if (turnInFlightForGate()) return false
|
|
11776
|
+
const sessionBusy = (currentTurn != null && currentTurn.endedAt == null) || parkedTurnStartCount() > 0
|
|
11777
|
+
if (idleDrainBusyDefer(sessionBusy, Date.now())) return false
|
|
11780
11778
|
const c = ipcServer.getClient(selfAgent)
|
|
11781
11779
|
return c != null && c.isAlive()
|
|
11782
11780
|
},
|
|
@@ -178,6 +178,15 @@ export class ObligationLedger {
|
|
|
178
178
|
return this.open.has(originTurnId)
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
+
/** The open obligation for `originTurnId`, or undefined if none is open. Read-
|
|
182
|
+
* only accessor for the delivery-time represent re-check (represent-delivery-
|
|
183
|
+
* guard.ts): a represent buffered while the obligation was open must be re-
|
|
184
|
+
* evaluated against the obligation's CURRENT cutoff at the moment it is handed
|
|
185
|
+
* to the CLI bridge, since a reply may have landed between decision and drain. */
|
|
186
|
+
get(originTurnId: string): Obligation | undefined {
|
|
187
|
+
return this.open.get(originTurnId)
|
|
188
|
+
}
|
|
189
|
+
|
|
181
190
|
hasOpen(): boolean {
|
|
182
191
|
return this.open.size > 0
|
|
183
192
|
}
|
|
@@ -23,6 +23,7 @@ import { buildObligationRepresentInbound, type Obligation } from './obligation-l
|
|
|
23
23
|
import { driveEscalation } from './escalation-drive.js'
|
|
24
24
|
import { shouldSuppressRepresent } from './represent-guard.js'
|
|
25
25
|
import { shouldDeferEscalationForBridge } from './escalation-bridge-gate.js'
|
|
26
|
+
import { parkedTurnStartCount } from './stream-render.js'
|
|
26
27
|
|
|
27
28
|
export function createObligationWiring(deps: ObligationWiringDeps) {
|
|
28
29
|
const {
|
|
@@ -161,13 +162,26 @@ function obligationSweep(): void {
|
|
|
161
162
|
if (turnInFlightForGate()) return // a turn is running — let it finish/answer
|
|
162
163
|
const agent = process.env.SWITCHROOM_AGENT_NAME ?? ''
|
|
163
164
|
const now = Date.now()
|
|
165
|
+
// Session-busy signal (F2, decision half). A ~5-min silence poke can clear the
|
|
166
|
+
// machine turn (`turnInFlightForGate()` above → false) while the CLI session is
|
|
167
|
+
// STILL producing the answer — stream-render's live-turn mirror (`currentTurn`
|
|
168
|
+
// whose endedAt is null, or a parked turn-start) still knows it is busy. Without
|
|
169
|
+
// this, the sweep decides + buffers a represent mid-answer; the real reply lands
|
|
170
|
+
// seconds later and the model consumes the queued represent → a duplicate answer
|
|
171
|
+
// (the two-authorities-disagree defect). We treat a busy mirror the SAME as
|
|
172
|
+
// in-flight background work: defer the represent, BOUNDED by the same grace, so a
|
|
173
|
+
// wedged/hung session cannot silence a genuinely-unanswered turn forever.
|
|
174
|
+
const liveTurn = getCurrentTurn()
|
|
175
|
+
const sessionBusy = (liveTurn != null && liveTurn.endedAt == null) || parkedTurnStartCount() > 0
|
|
164
176
|
// Background-work grace: while genuine autonomous sub-agent work is in flight
|
|
165
177
|
// (a running worker, or an orphaned foreground sub-agent — neither visible to
|
|
166
|
-
// the turn machine),
|
|
167
|
-
//
|
|
168
|
-
//
|
|
178
|
+
// the turn machine), OR the CLI session is still busy behind a poke-cleared
|
|
179
|
+
// machine turn, an obligation younger than the ceiling is NOT re-presented
|
|
180
|
+
// /escalated. Bounded by OBLIGATION_BACKGROUND_WORK_GRACE_MS (measured from
|
|
181
|
+
// openedAt in decideAtIdle) so escalation always eventually fires. =0 disables it.
|
|
169
182
|
const backgroundWorkActive =
|
|
170
|
-
OBLIGATION_BACKGROUND_WORK_GRACE_MS > 0 &&
|
|
183
|
+
OBLIGATION_BACKGROUND_WORK_GRACE_MS > 0 &&
|
|
184
|
+
(agentHasInFlightBackgroundWork(now) || sessionBusy)
|
|
171
185
|
// Grace window: skip an obligation whose handling turn ended < grace ago — its
|
|
172
186
|
// trailing slow/worker answer may still be landing (over-escalation fix).
|
|
173
187
|
// Per-represent grace: skip an obligation re-presented < grace ago — prevents
|
|
@@ -189,6 +203,7 @@ function obligationSweep(): void {
|
|
|
189
203
|
lastBgWorkDeferLogMs = now
|
|
190
204
|
process.stderr.write(
|
|
191
205
|
`telegram gateway: obligation sweep deferred — in-flight autonomous sub-agent work ` +
|
|
206
|
+
`or busy CLI session behind a poke-cleared turn ` +
|
|
192
207
|
`(${obligationLedger.size()} open, bounded ${Math.round(OBLIGATION_BACKGROUND_WORK_GRACE_MS / 60_000)}m from receipt)\n`,
|
|
193
208
|
)
|
|
194
209
|
}
|
|
@@ -49,6 +49,16 @@ export interface PendingInboundBuffer {
|
|
|
49
49
|
depth: (agent: string) => number
|
|
50
50
|
/** Test-only: total depth across all agents. */
|
|
51
51
|
totalDepth: () => number
|
|
52
|
+
/**
|
|
53
|
+
* Delivery-time gate consulted by `redeliverBufferedInbound` for EVERY message
|
|
54
|
+
* it is about to hand to the bridge. Returns TRUE to proceed, FALSE to RETRACT
|
|
55
|
+
* (drop) the message (the spool entry is acked so it is not boot-replayed, and
|
|
56
|
+
* it is NOT re-buffered). The gateway wires this to the represent delivery
|
|
57
|
+
* re-check (represent-delivery-guard.ts): a represent buffered while its
|
|
58
|
+
* obligation was open must be re-evaluated at drain time, since a reply may have
|
|
59
|
+
* landed between the sweep's decision and this drain. Undefined ⇒ never retract.
|
|
60
|
+
*/
|
|
61
|
+
beforeRedeliver?: (msg: InboundMessage) => boolean
|
|
52
62
|
}
|
|
53
63
|
|
|
54
64
|
export interface PendingInboundBufferOptions {
|
|
@@ -94,6 +104,13 @@ export interface PendingInboundBufferOptions {
|
|
|
94
104
|
* breaks the push hot path.
|
|
95
105
|
*/
|
|
96
106
|
onHandbackEnqueue?: (chatId: string, threadId: number | undefined, ts: number) => void
|
|
107
|
+
/**
|
|
108
|
+
* Delivery-time retract gate — see `PendingInboundBuffer.beforeRedeliver`. The
|
|
109
|
+
* gateway supplies the represent delivery re-check here so EVERY drain path
|
|
110
|
+
* (idle-drain, bridge re-register, silence-poke fallback, turn-end) inherits it
|
|
111
|
+
* by construction rather than by per-call-site discipline.
|
|
112
|
+
*/
|
|
113
|
+
beforeRedeliver?: (msg: InboundMessage) => boolean
|
|
97
114
|
}
|
|
98
115
|
|
|
99
116
|
/**
|
|
@@ -123,16 +140,38 @@ export function redeliverBufferedInbound(
|
|
|
123
140
|
// silently dropped (clerk 2026-06-03). `send` returning true only means
|
|
124
141
|
// the bytes reached the bridge, NOT that claude consumed them.
|
|
125
142
|
onDelivered?: (merged: InboundMessage, originals: InboundMessage[]) => void,
|
|
126
|
-
): { drained: number; redelivered: number; rebuffered: number } {
|
|
143
|
+
): { drained: number; redelivered: number; rebuffered: number; retracted: number } {
|
|
127
144
|
const pending = buffer.drain(agent)
|
|
128
145
|
let redelivered = 0
|
|
129
146
|
let rebuffered = 0
|
|
147
|
+
let retracted = 0
|
|
130
148
|
// Collapse consecutive same-sender Telegram user messages into one turn
|
|
131
149
|
// (see planBufferedRedelivery) so a forwarded burst that spanned a turn
|
|
132
150
|
// boundary doesn't fan out into N sequential replies. System inbounds
|
|
133
151
|
// (vault grants, approvals, cron, handbacks — anything with meta.source)
|
|
134
152
|
// are never merged and are delivered individually exactly as before.
|
|
135
153
|
for (const { merged, originals } of planBufferedRedelivery(pending)) {
|
|
154
|
+
// Delivery-time retract (F1): a buffered `obligation_represent` whose reply
|
|
155
|
+
// has landed since the sweep decided it is stale — drop it rather than hand
|
|
156
|
+
// a duplicate to the CLI queue. Ack the spool so it is not boot-replayed and
|
|
157
|
+
// do NOT re-buffer. The gateway closure closes the ledger + logs the retract.
|
|
158
|
+
// FAIL-OPEN: a throwing predicate must never silence or lose a real message,
|
|
159
|
+
// nor abort the drain loop (which would strand every following message and
|
|
160
|
+
// crash the setInterval tick). On throw we treat it as "deliver".
|
|
161
|
+
let proceed = true
|
|
162
|
+
if (buffer.beforeRedeliver != null) {
|
|
163
|
+
try {
|
|
164
|
+
proceed = buffer.beforeRedeliver(merged)
|
|
165
|
+
} catch (e) {
|
|
166
|
+
proceed = true
|
|
167
|
+
process.stderr.write(`redeliver beforeRedeliver threw — failing open (deliver): ${String(e)}\n`)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (!proceed) {
|
|
171
|
+
for (const o of originals) spool?.ack(o)
|
|
172
|
+
retracted += originals.length
|
|
173
|
+
continue
|
|
174
|
+
}
|
|
136
175
|
let delivered = false
|
|
137
176
|
try {
|
|
138
177
|
delivered = send(merged)
|
|
@@ -157,7 +196,7 @@ export function redeliverBufferedInbound(
|
|
|
157
196
|
rebuffered += originals.length
|
|
158
197
|
}
|
|
159
198
|
}
|
|
160
|
-
return { drained: pending.length, redelivered, rebuffered }
|
|
199
|
+
return { drained: pending.length, redelivered, rebuffered, retracted }
|
|
161
200
|
}
|
|
162
201
|
|
|
163
202
|
/** True when `msg` is an ordinary Telegram user message eligible to be
|
|
@@ -312,7 +351,7 @@ export function idleDrainTick(
|
|
|
312
351
|
// enrols redelivered inbounds in the deliver-until-acked queue (parity with
|
|
313
352
|
// the bridgeUp drain — clerk lost-message incident, 2026-06-03).
|
|
314
353
|
onDelivered?: (merged: InboundMessage, originals: InboundMessage[]) => void,
|
|
315
|
-
): { drained: number; redelivered: number; rebuffered: number } | null {
|
|
354
|
+
): { drained: number; redelivered: number; rebuffered: number; retracted: number } | null {
|
|
316
355
|
if (!agent) return null
|
|
317
356
|
if (buffer.depth(agent) === 0) return null
|
|
318
357
|
if (!isBridgeAlive()) return null
|
|
@@ -412,5 +451,6 @@ export function createPendingInboundBuffer(
|
|
|
412
451
|
for (const q of queues.values()) n += q.length
|
|
413
452
|
return n
|
|
414
453
|
},
|
|
454
|
+
beforeRedeliver: opts.beforeRedeliver,
|
|
415
455
|
}
|
|
416
456
|
}
|