switchroom 0.20.5 → 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 +188 -31
- package/telegram-plugin/gateway/boot-sweep-gate.ts +24 -0
- package/telegram-plugin/gateway/gateway.ts +31 -33
- 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/stale-pin-sweep-store.ts +39 -2
- package/telegram-plugin/gateway/stale-pin-sweep.test.ts +162 -0
- package/telegram-plugin/gateway/stream-render.ts +13 -0
- package/telegram-plugin/tests/boot-sweep-gate.test.ts +15 -2
- 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",
|
|
@@ -89491,6 +89495,7 @@ async function runBootPinSweepSteps(deps) {
|
|
|
89491
89495
|
await step("status-pin-cleanup", deps.statusPinCleanup, log);
|
|
89492
89496
|
await step("activity-card-reaper", deps.activityCardReaper, log);
|
|
89493
89497
|
await step("queued-card-reaper", deps.queuedCardReaper, log);
|
|
89498
|
+
await step("seed-prune-sweep-cursors", async () => deps.seedPruneSweepCursors(), log);
|
|
89494
89499
|
await step("enable-sweep", async () => deps.enableSweep(), log);
|
|
89495
89500
|
for (const t of targets) {
|
|
89496
89501
|
await step(`stale-pin-sweep:${sweepTargetKey(t.chatId, t.threadId)}`, () => deps.sweepTarget(t), log);
|
|
@@ -90303,6 +90308,56 @@ function createGatewayStalePinSweeper(w) {
|
|
|
90303
90308
|
});
|
|
90304
90309
|
}
|
|
90305
90310
|
|
|
90311
|
+
// gateway/stale-pin-sweep-store.ts
|
|
90312
|
+
var SWEEP_ENVELOPE_VERSION2 = 1;
|
|
90313
|
+
function isCursorRow2(x) {
|
|
90314
|
+
if (x == null || typeof x !== "object")
|
|
90315
|
+
return false;
|
|
90316
|
+
const o = x;
|
|
90317
|
+
return typeof o.chatId === "string" && o.chatId.length > 0 && (o.threadId === undefined || typeof o.threadId === "number") && (o.kind === "dm" || o.kind === "forum-topic" || o.kind === "supergroup") && typeof o.popped === "number" && typeof o.done === "boolean" && typeof o.attempts === "number" && (o.doneIds === undefined || Array.isArray(o.doneIds) && o.doneIds.every((n) => typeof n === "number")) && (o.lastStatus === undefined || typeof o.lastStatus === "string") && typeof o.updatedAt === "number";
|
|
90318
|
+
}
|
|
90319
|
+
function loadSweepCursors2(path2, fs2) {
|
|
90320
|
+
if (!fs2.existsSync(path2))
|
|
90321
|
+
return [];
|
|
90322
|
+
let raw = "";
|
|
90323
|
+
try {
|
|
90324
|
+
raw = fs2.readFileSync(path2);
|
|
90325
|
+
} catch {
|
|
90326
|
+
return [];
|
|
90327
|
+
}
|
|
90328
|
+
let parsed;
|
|
90329
|
+
try {
|
|
90330
|
+
parsed = JSON.parse(raw);
|
|
90331
|
+
} catch {
|
|
90332
|
+
return [];
|
|
90333
|
+
}
|
|
90334
|
+
if (parsed == null || typeof parsed !== "object")
|
|
90335
|
+
return [];
|
|
90336
|
+
const env = parsed;
|
|
90337
|
+
if (!Number.isInteger(env.v) || env.v < 1 || !Array.isArray(env.cursors)) {
|
|
90338
|
+
return [];
|
|
90339
|
+
}
|
|
90340
|
+
return env.cursors.filter(isCursorRow2);
|
|
90341
|
+
}
|
|
90342
|
+
function persistSweepCursors2(path2, fs2, cursors, log = (l) => process.stderr.write(l)) {
|
|
90343
|
+
const env = { v: SWEEP_ENVELOPE_VERSION2, cursors: [...cursors] };
|
|
90344
|
+
try {
|
|
90345
|
+
fs2.writeFileSync(path2, JSON.stringify(env));
|
|
90346
|
+
} catch (err) {
|
|
90347
|
+
log(`stale-pin-sweep-store: persist FAILED path=${path2}: ${err.message} \u2014 ` + `durability degraded; a restart mid-sweep will re-seed instead of resume
|
|
90348
|
+
`);
|
|
90349
|
+
}
|
|
90350
|
+
}
|
|
90351
|
+
function pruneSweepCursors(cursors) {
|
|
90352
|
+
return cursors.filter((c) => !c.done);
|
|
90353
|
+
}
|
|
90354
|
+
function reseedSweepLedger(path2, fs2, log = (l) => process.stderr.write(l)) {
|
|
90355
|
+
const rows = loadSweepCursors2(path2, fs2);
|
|
90356
|
+
const pruned = pruneSweepCursors(rows);
|
|
90357
|
+
if (pruned.length !== rows.length)
|
|
90358
|
+
persistSweepCursors2(path2, fs2, pruned, log);
|
|
90359
|
+
}
|
|
90360
|
+
|
|
90306
90361
|
// gateway/webhook-ingest-server.ts
|
|
90307
90362
|
import net4 from "node:net";
|
|
90308
90363
|
import { chmodSync as chmodSync10, existsSync as existsSync39, unlinkSync as unlinkSync18 } from "node:fs";
|
|
@@ -92729,7 +92784,24 @@ function redeliverBufferedInbound(buffer, agent, send, spool, onDelivered) {
|
|
|
92729
92784
|
const pending = buffer.drain(agent);
|
|
92730
92785
|
let redelivered = 0;
|
|
92731
92786
|
let rebuffered = 0;
|
|
92787
|
+
let retracted = 0;
|
|
92732
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
|
+
}
|
|
92733
92805
|
let delivered = false;
|
|
92734
92806
|
try {
|
|
92735
92807
|
delivered = send(merged);
|
|
@@ -92747,7 +92819,7 @@ function redeliverBufferedInbound(buffer, agent, send, spool, onDelivered) {
|
|
|
92747
92819
|
rebuffered += originals.length;
|
|
92748
92820
|
}
|
|
92749
92821
|
}
|
|
92750
|
-
return { drained: pending.length, redelivered, rebuffered };
|
|
92822
|
+
return { drained: pending.length, redelivered, rebuffered, retracted };
|
|
92751
92823
|
}
|
|
92752
92824
|
function isMergeableUserInbound(msg) {
|
|
92753
92825
|
return msg.type === "inbound" && (msg.meta == null || msg.meta.source == null && msg.meta.button_callback == null);
|
|
@@ -92864,7 +92936,62 @@ function createPendingInboundBuffer(opts = {}) {
|
|
|
92864
92936
|
for (const q of queues.values())
|
|
92865
92937
|
n += q.length;
|
|
92866
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;
|
|
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;
|
|
92867
92991
|
}
|
|
92992
|
+
if (deferringSince == null)
|
|
92993
|
+
deferringSince = now;
|
|
92994
|
+
return now - deferringSince < boundMs;
|
|
92868
92995
|
};
|
|
92869
92996
|
}
|
|
92870
92997
|
|
|
@@ -92936,6 +93063,9 @@ class ObligationLedger {
|
|
|
92936
93063
|
isOpen(originTurnId) {
|
|
92937
93064
|
return this.open.has(originTurnId);
|
|
92938
93065
|
}
|
|
93066
|
+
get(originTurnId) {
|
|
93067
|
+
return this.open.get(originTurnId);
|
|
93068
|
+
}
|
|
92939
93069
|
hasOpen() {
|
|
92940
93070
|
return this.open.size > 0;
|
|
92941
93071
|
}
|
|
@@ -94461,7 +94591,24 @@ function redeliverBufferedInbound2(buffer, agent, send, spool, onDelivered) {
|
|
|
94461
94591
|
const pending = buffer.drain(agent);
|
|
94462
94592
|
let redelivered = 0;
|
|
94463
94593
|
let rebuffered = 0;
|
|
94594
|
+
let retracted = 0;
|
|
94464
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
|
+
}
|
|
94465
94612
|
let delivered = false;
|
|
94466
94613
|
try {
|
|
94467
94614
|
delivered = send(merged);
|
|
@@ -94479,7 +94626,7 @@ function redeliverBufferedInbound2(buffer, agent, send, spool, onDelivered) {
|
|
|
94479
94626
|
rebuffered += originals.length;
|
|
94480
94627
|
}
|
|
94481
94628
|
}
|
|
94482
|
-
return { drained: pending.length, redelivered, rebuffered };
|
|
94629
|
+
return { drained: pending.length, redelivered, rebuffered, retracted };
|
|
94483
94630
|
}
|
|
94484
94631
|
function isMergeableUserInbound2(msg) {
|
|
94485
94632
|
return msg.type === "inbound" && (msg.meta == null || msg.meta.source == null && msg.meta.button_callback == null);
|
|
@@ -95374,6 +95521,9 @@ class ObligationLedger2 {
|
|
|
95374
95521
|
isOpen(originTurnId) {
|
|
95375
95522
|
return this.open.has(originTurnId);
|
|
95376
95523
|
}
|
|
95524
|
+
get(originTurnId) {
|
|
95525
|
+
return this.open.get(originTurnId);
|
|
95526
|
+
}
|
|
95377
95527
|
hasOpen() {
|
|
95378
95528
|
return this.open.size > 0;
|
|
95379
95529
|
}
|
|
@@ -95536,23 +95686,24 @@ function driveEscalation(args) {
|
|
|
95536
95686
|
});
|
|
95537
95687
|
}
|
|
95538
95688
|
|
|
95539
|
-
// gateway/represent-guard.ts
|
|
95540
|
-
function shouldSuppressRepresent(o, deps) {
|
|
95541
|
-
if (!deps.historyEnabled)
|
|
95542
|
-
return false;
|
|
95543
|
-
if (o.lastRepresentedAt == null) {
|
|
95544
|
-
if (o.openedAt == null)
|
|
95545
|
-
return false;
|
|
95546
|
-
return deps.hasOutboundDeliveredSince(o.chatId, o.openedAt, o.threadId);
|
|
95547
|
-
}
|
|
95548
|
-
return deps.hasOutboundDeliveredSince(o.chatId, o.lastRepresentedAt, o.threadId);
|
|
95549
|
-
}
|
|
95550
|
-
|
|
95551
95689
|
// gateway/escalation-bridge-gate.ts
|
|
95552
95690
|
function shouldDeferEscalationForBridge(deps) {
|
|
95553
95691
|
return !deps.bridgeAlive;
|
|
95554
95692
|
}
|
|
95555
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
|
+
|
|
95556
95707
|
// gateway/obligation-wiring.ts
|
|
95557
95708
|
function createObligationWiring(deps) {
|
|
95558
95709
|
const {
|
|
@@ -95631,7 +95782,9 @@ function createObligationWiring(deps) {
|
|
|
95631
95782
|
return;
|
|
95632
95783
|
const agent = process.env.SWITCHROOM_AGENT_NAME ?? "";
|
|
95633
95784
|
const now = Date.now();
|
|
95634
|
-
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);
|
|
95635
95788
|
const decision = obligationLedger.decideAtIdle(OBLIGATION_ESCALATE_GRACE_MS > 0 || backgroundWorkActive || OBLIGATION_REPRESENT_GRACE_MS > 0 ? {
|
|
95636
95789
|
now,
|
|
95637
95790
|
graceMs: OBLIGATION_ESCALATE_GRACE_MS,
|
|
@@ -95643,7 +95796,7 @@ function createObligationWiring(deps) {
|
|
|
95643
95796
|
if (decision.action === "none" || o == null) {
|
|
95644
95797
|
if (backgroundWorkActive && obligationLedger.hasOpen() && now - lastBgWorkDeferLogMs > 60000) {
|
|
95645
95798
|
lastBgWorkDeferLogMs = now;
|
|
95646
|
-
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)
|
|
95647
95800
|
`);
|
|
95648
95801
|
}
|
|
95649
95802
|
return;
|
|
@@ -102428,10 +102581,10 @@ function startOutboxSweep(deps) {
|
|
|
102428
102581
|
}
|
|
102429
102582
|
|
|
102430
102583
|
// ../src/build-info.ts
|
|
102431
|
-
var VERSION2 = "0.20.
|
|
102432
|
-
var COMMIT_SHA = "
|
|
102433
|
-
var COMMIT_DATE = "2026-08-
|
|
102434
|
-
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;
|
|
102435
102588
|
var COMMITS_AHEAD_OF_TAG = 0;
|
|
102436
102589
|
|
|
102437
102590
|
// gateway/boot-version.ts
|
|
@@ -108140,19 +108293,17 @@ async function unpinAllStatusPins() {
|
|
|
108140
108293
|
}
|
|
108141
108294
|
var stalePinSweepEligible = false;
|
|
108142
108295
|
var STALE_PIN_SWEEP_STORE_PATH = join71(STATE_DIR, "stale-pin-sweep.json");
|
|
108296
|
+
var sweepStoreFs = {
|
|
108297
|
+
readFileSync: (p) => readFileSync64(p, "utf-8"),
|
|
108298
|
+
writeFileSync: (p, data) => atomicWriteFileSync(p, data, 384),
|
|
108299
|
+
existsSync: (p) => existsSync61(p)
|
|
108300
|
+
};
|
|
108143
108301
|
var stalePinSweeper = createGatewayStalePinSweeper({
|
|
108144
108302
|
telegram: { handle: () => lockedBot, call: robustApiCall },
|
|
108145
108303
|
claims: () => statusPinClaims.values(),
|
|
108146
108304
|
loadPinRows: () => statusPinPersistEnabled || bannerPinPersistEnabled || toolPinPersistEnabled ? loadStatusPins2(STATUS_PIN_STORE_PATH, statusPinStoreFs) : [],
|
|
108147
108305
|
eligible: () => stalePinSweepEligible,
|
|
108148
|
-
store: {
|
|
108149
|
-
path: STALE_PIN_SWEEP_STORE_PATH,
|
|
108150
|
-
fs: {
|
|
108151
|
-
readFileSync: (p) => readFileSync64(p, "utf-8"),
|
|
108152
|
-
writeFileSync: (p, data) => atomicWriteFileSync(p, data, 384),
|
|
108153
|
-
existsSync: (p) => existsSync61(p)
|
|
108154
|
-
}
|
|
108155
|
-
},
|
|
108306
|
+
store: { path: STALE_PIN_SWEEP_STORE_PATH, fs: sweepStoreFs },
|
|
108156
108307
|
allowUnpinAllForumTopic: process.env.SWITCHROOM_PIN_SWEEP_UNPIN_ALL_TOPIC == null ? undefined : process.env.SWITCHROOM_PIN_SWEEP_UNPIN_ALL_TOPIC === "1"
|
|
108157
108308
|
});
|
|
108158
108309
|
function runBootPinCleanupAndStalePinSweep() {
|
|
@@ -108168,6 +108319,7 @@ function runBootPinCleanupAndStalePinSweep() {
|
|
|
108168
108319
|
enableSweep: () => {
|
|
108169
108320
|
stalePinSweepEligible = true;
|
|
108170
108321
|
},
|
|
108322
|
+
seedPruneSweepCursors: () => reseedSweepLedger(STALE_PIN_SWEEP_STORE_PATH, sweepStoreFs),
|
|
108171
108323
|
sweepTarget: (t) => stalePinSweeper.sweepTarget(t),
|
|
108172
108324
|
log: (line) => process.stderr.write(line)
|
|
108173
108325
|
});
|
|
@@ -108451,7 +108603,8 @@ var pendingInboundBuffer = createPendingInboundBuffer({
|
|
|
108451
108603
|
const threadOpts = evThread != null ? { message_thread_id: evThread } : {};
|
|
108452
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" });
|
|
108453
108605
|
},
|
|
108454
|
-
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) })
|
|
108455
108608
|
});
|
|
108456
108609
|
function agentHasInFlightBackgroundWork(now) {
|
|
108457
108610
|
if (countRunningWorkers() > 0)
|
|
@@ -109482,12 +109635,16 @@ if (isGatewayMain)
|
|
|
109482
109635
|
}
|
|
109483
109636
|
})();
|
|
109484
109637
|
var IDLE_DRAIN_INTERVAL_MS = 5000;
|
|
109638
|
+
var idleDrainBusyDefer = makeSessionBusyDrainDeferral(OBLIGATION_BACKGROUND_WORK_GRACE_MS);
|
|
109485
109639
|
if (isGatewayMain && !STATIC) {
|
|
109486
109640
|
setInterval(() => {
|
|
109487
109641
|
const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? "";
|
|
109488
109642
|
const r = idleDrainTick(pendingInboundBuffer, selfAgent, () => {
|
|
109489
109643
|
if (turnInFlightForGate())
|
|
109490
109644
|
return false;
|
|
109645
|
+
const sessionBusy = currentTurn != null && currentTurn.endedAt == null || parkedTurnStartCount() > 0;
|
|
109646
|
+
if (idleDrainBusyDefer(sessionBusy, Date.now()))
|
|
109647
|
+
return false;
|
|
109491
109648
|
const c = ipcServer.getClient(selfAgent);
|
|
109492
109649
|
return c != null && c.isAlive();
|
|
109493
109650
|
}, (m) => ipcServer.sendToAgent(selfAgent, m), inboundSpool, trackRedeliveredInbound);
|
|
@@ -103,6 +103,21 @@ export interface BootPinSweepSteps {
|
|
|
103
103
|
/** Flips the flag authorising the stale-pin drain — for this sweep AND for
|
|
104
104
|
* later lazy first-inbound sweeps. */
|
|
105
105
|
enableSweep: () => void
|
|
106
|
+
/**
|
|
107
|
+
* BOOT-SEED PRUNE (#3953 regression fix). Drops discharged (`done: true`)
|
|
108
|
+
* cursor rows from the sweep ledger so a `(chat, thread)` that drained in a
|
|
109
|
+
* PRIOR session is re-evaluated live this boot, instead of short-circuiting
|
|
110
|
+
* forever on a stale `done`. Kind-agnostic — clears DM, forum-topic and
|
|
111
|
+
* supergroup rows alike. Forfeited rows (`attempts >= SWEEP_MAX_ATTEMPTS`)
|
|
112
|
+
* are RETAINED by the underlying `pruneSweepCursors`, so a chat the bot
|
|
113
|
+
* cannot pin does not re-burn its attempt budget every boot.
|
|
114
|
+
*
|
|
115
|
+
* MUST stay BOOT-scoped. The lazy first-inbound sweep fires on EVERY message;
|
|
116
|
+
* pruning there would re-arm a full re-drain per message = a Telegram flood.
|
|
117
|
+
* Runs once here, before the `sweepTarget` loop, so the loop re-evaluates the
|
|
118
|
+
* freshly-reseeded ledger.
|
|
119
|
+
*/
|
|
120
|
+
seedPruneSweepCursors: () => void
|
|
106
121
|
sweepTarget: (target: SweepTarget) => Promise<unknown>
|
|
107
122
|
log?: (line: string) => void
|
|
108
123
|
}
|
|
@@ -161,6 +176,15 @@ export async function runBootPinSweepSteps(deps: BootPinSweepSteps): Promise<voi
|
|
|
161
176
|
await step('activity-card-reaper', deps.activityCardReaper, log)
|
|
162
177
|
await step('queued-card-reaper', deps.queuedCardReaper, log)
|
|
163
178
|
|
|
179
|
+
// Boot-seed prune (#3953): reseed the cursor ledger so a chat that drained in
|
|
180
|
+
// a prior session is re-evaluated live below, rather than short-circuiting on
|
|
181
|
+
// a stale `done`. Boot-scoped ONLY — never on the per-inbound path. Runs
|
|
182
|
+
// BEFORE enableSweep so eligibility (which also gates the lazy first-inbound
|
|
183
|
+
// sweep) is granted only once the ledger is reseeded — no window where an
|
|
184
|
+
// inbound observes a stale `done`. Isolated: a throw here must not strand the
|
|
185
|
+
// drain, and enableSweep below still runs regardless.
|
|
186
|
+
await step('seed-prune-sweep-cursors', async () => deps.seedPruneSweepCursors(), log)
|
|
187
|
+
|
|
164
188
|
// Unconditional: reached even when every step above threw. See the docblock.
|
|
165
189
|
await step('enable-sweep', async () => deps.enableSweep(), log)
|
|
166
190
|
for (const t of targets) {
|
|
@@ -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,
|
|
@@ -648,6 +648,7 @@ import { withDeadline } from './with-deadline.js'
|
|
|
648
648
|
import { createStatusPinApi, type PinCapableBot, type RobustApiSeam } from './status-pin-api.js'
|
|
649
649
|
import { collectSweepTargets, type StalePinSweeper, type SweepTarget } from './stale-pin-sweep.js'
|
|
650
650
|
import { createGatewayStalePinSweeper } from './stale-pin-sweep-wiring.js'
|
|
651
|
+
import { reseedSweepLedger } from './stale-pin-sweep-store.js'
|
|
651
652
|
import { atomicWriteFileSync } from '../../src/util/atomic.js'
|
|
652
653
|
import { startWebhookIngestServer } from './webhook-ingest-server.js'
|
|
653
654
|
import { recordWebhookEvent } from '../../src/web/webhook-gateway-record.js'
|
|
@@ -658,6 +659,7 @@ import { handleRequestDriveApproval } from './drive-write-approval.js'
|
|
|
658
659
|
import { handleRequestMs365Approval } from './ms365-write-approval.js'
|
|
659
660
|
import { buildDiffPreviewCard } from './diff-preview-card.js'
|
|
660
661
|
import { createPendingInboundBuffer, redeliverBufferedInbound, idleDrainTick } from './pending-inbound-buffer.js'
|
|
662
|
+
import { makeRepresentRedeliveryGuard, makeSessionBusyDrainDeferral } from './represent-delivery-guard.js'
|
|
661
663
|
import { isCronIdentity, isCronInjectFire, deliverInjectWithFallback, replyCallerIsForeignSession } from './cron-session.js'
|
|
662
664
|
import {
|
|
663
665
|
ObligationLedger,
|
|
@@ -9041,6 +9043,11 @@ let stalePinSweepEligible = false
|
|
|
9041
9043
|
// the previous complete ledger or the new one — never a truncated file that
|
|
9042
9044
|
// forgets an in-flight drain.
|
|
9043
9045
|
const STALE_PIN_SWEEP_STORE_PATH = join(STATE_DIR, 'stale-pin-sweep.json')
|
|
9046
|
+
const sweepStoreFs = {
|
|
9047
|
+
readFileSync: (p: string) => readFileSync(p, 'utf-8'),
|
|
9048
|
+
writeFileSync: (p: string, data: string) => atomicWriteFileSync(p, data, 0o600),
|
|
9049
|
+
existsSync: (p: string) => existsSync(p),
|
|
9050
|
+
}
|
|
9044
9051
|
const stalePinSweeper: StalePinSweeper = createGatewayStalePinSweeper({
|
|
9045
9052
|
telegram: { handle: () => lockedBot, call: robustApiCall },
|
|
9046
9053
|
claims: () => statusPinClaims.values(),
|
|
@@ -9049,14 +9056,7 @@ const stalePinSweeper: StalePinSweeper = createGatewayStalePinSweeper({
|
|
|
9049
9056
|
? loadStatusPins(STATUS_PIN_STORE_PATH, statusPinStoreFs)
|
|
9050
9057
|
: [],
|
|
9051
9058
|
eligible: () => stalePinSweepEligible,
|
|
9052
|
-
store: {
|
|
9053
|
-
path: STALE_PIN_SWEEP_STORE_PATH,
|
|
9054
|
-
fs: {
|
|
9055
|
-
readFileSync: (p) => readFileSync(p, 'utf-8'),
|
|
9056
|
-
writeFileSync: (p, data) => atomicWriteFileSync(p, data, 0o600),
|
|
9057
|
-
existsSync: (p) => existsSync(p),
|
|
9058
|
-
},
|
|
9059
|
-
},
|
|
9059
|
+
store: { path: STALE_PIN_SWEEP_STORE_PATH, fs: sweepStoreFs },
|
|
9060
9060
|
// Per-deployment override only. UNSET (the normal case) means "take the
|
|
9061
9061
|
// standing policy" — UNPIN_ALL_FORUM_TOPIC_ENABLED in stale-pin-sweep.ts,
|
|
9062
9062
|
// i.e. the WHOLESALE topic drain stays off because it also removes pins this
|
|
@@ -9099,6 +9099,7 @@ function runBootPinCleanupAndStalePinSweep(): Promise<void> {
|
|
|
9099
9099
|
enableSweep: () => {
|
|
9100
9100
|
stalePinSweepEligible = true
|
|
9101
9101
|
},
|
|
9102
|
+
seedPruneSweepCursors: () => reseedSweepLedger(STALE_PIN_SWEEP_STORE_PATH, sweepStoreFs),
|
|
9102
9103
|
sweepTarget: (t: SweepTarget) => stalePinSweeper.sweepTarget(t),
|
|
9103
9104
|
log: (line) => process.stderr.write(line),
|
|
9104
9105
|
})
|
|
@@ -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
|
}
|