switchroom 0.14.62 → 0.14.63
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/cli/switchroom.js +2 -2
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +148 -35
- package/telegram-plugin/gateway/auto-classify-mid-turn.ts +119 -0
- package/telegram-plugin/gateway/escalation-drive.ts +79 -0
- package/telegram-plugin/gateway/gateway.ts +146 -52
- package/telegram-plugin/gateway/obligation-ledger.ts +45 -3
- package/telegram-plugin/hooks/tool-label-pretool.mjs +32 -12
- package/telegram-plugin/tests/auto-classify-mid-turn.test.ts +87 -0
- package/telegram-plugin/tests/escalation-drive.test.ts +123 -0
- package/telegram-plugin/tests/obligation-determinism.test.ts +63 -3
- package/telegram-plugin/tests/obligation-ledger.test.ts +92 -0
package/dist/cli/switchroom.js
CHANGED
|
@@ -49452,8 +49452,8 @@ var {
|
|
|
49452
49452
|
} = import__.default;
|
|
49453
49453
|
|
|
49454
49454
|
// src/build-info.ts
|
|
49455
|
-
var VERSION = "0.14.
|
|
49456
|
-
var COMMIT_SHA = "
|
|
49455
|
+
var VERSION = "0.14.63";
|
|
49456
|
+
var COMMIT_SHA = "21a12c16";
|
|
49457
49457
|
|
|
49458
49458
|
// src/cli/agent.ts
|
|
49459
49459
|
init_source();
|
package/package.json
CHANGED
|
@@ -41593,6 +41593,33 @@ function formatReplyToText(text, maxChars) {
|
|
|
41593
41593
|
return escapeXmlAttribute(truncated);
|
|
41594
41594
|
}
|
|
41595
41595
|
|
|
41596
|
+
// gateway/auto-classify-mid-turn.ts
|
|
41597
|
+
function sameThread(a, b) {
|
|
41598
|
+
const norm = (t) => t == null || t === 0 ? null : t;
|
|
41599
|
+
return norm(a) === norm(b);
|
|
41600
|
+
}
|
|
41601
|
+
function autoClassifyMidTurnInbound(i) {
|
|
41602
|
+
if (i.isSteerPrefix)
|
|
41603
|
+
return { decision: "steer", reason: "steer_prefix" };
|
|
41604
|
+
if (i.isQueuePrefix)
|
|
41605
|
+
return { decision: "queue", reason: "queue_prefix" };
|
|
41606
|
+
if (!i.priorTurnInFlight)
|
|
41607
|
+
return { decision: "queue", reason: "not_mid_turn" };
|
|
41608
|
+
const recent = i.msSinceLastAgentOutput != null && i.msSinceLastAgentOutput >= 0;
|
|
41609
|
+
if (i.isDm) {
|
|
41610
|
+
if (i.dmSteerWindowMs <= 0)
|
|
41611
|
+
return { decision: "queue", reason: "dm_disabled" };
|
|
41612
|
+
return recent && i.msSinceLastAgentOutput <= i.dmSteerWindowMs ? { decision: "steer", reason: "dm_recent" } : { decision: "queue", reason: "dm_disabled" };
|
|
41613
|
+
}
|
|
41614
|
+
const topicMatch = sameThread(i.incomingThreadId, i.activeTurnThreadId);
|
|
41615
|
+
if (i.topicSteerWindowMs <= 0) {
|
|
41616
|
+
return { decision: "queue", reason: "topic_disabled", sameTopic: topicMatch };
|
|
41617
|
+
}
|
|
41618
|
+
if (!topicMatch)
|
|
41619
|
+
return { decision: "queue", reason: "cross_topic", sameTopic: false };
|
|
41620
|
+
return recent && i.msSinceLastAgentOutput <= i.topicSteerWindowMs ? { decision: "steer", reason: "same_topic_recent", sameTopic: true } : { decision: "queue", reason: "same_topic_stale", sameTopic: true };
|
|
41621
|
+
}
|
|
41622
|
+
|
|
41596
41623
|
// operator-events.ts
|
|
41597
41624
|
function renderOperatorEvent(ev) {
|
|
41598
41625
|
const agent = escHtml(ev.agent);
|
|
@@ -47443,14 +47470,31 @@ class ObligationLedger {
|
|
|
47443
47470
|
}
|
|
47444
47471
|
return best;
|
|
47445
47472
|
}
|
|
47446
|
-
decideAtIdle() {
|
|
47447
|
-
const o = this.oldest();
|
|
47473
|
+
decideAtIdle(opts) {
|
|
47474
|
+
const o = opts != null && opts.graceMs > 0 ? this.oldestEligible(opts.now, opts.graceMs) : this.oldest();
|
|
47448
47475
|
if (o === undefined)
|
|
47449
47476
|
return { action: "none" };
|
|
47450
47477
|
if (o.representCount >= this.maxRepresents)
|
|
47451
47478
|
return { action: "escalate", obligation: o };
|
|
47452
47479
|
return { action: "represent", obligation: o };
|
|
47453
47480
|
}
|
|
47481
|
+
oldestEligible(now, graceMs) {
|
|
47482
|
+
let best;
|
|
47483
|
+
for (const o of this.open.values()) {
|
|
47484
|
+
if (o.lastTurnEndedAt != null && now - o.lastTurnEndedAt < graceMs)
|
|
47485
|
+
continue;
|
|
47486
|
+
if (best === undefined || o.openedAt < best.openedAt)
|
|
47487
|
+
best = o;
|
|
47488
|
+
}
|
|
47489
|
+
return best;
|
|
47490
|
+
}
|
|
47491
|
+
noteTurnEnded(originTurnId, ts) {
|
|
47492
|
+
const o = this.open.get(originTurnId);
|
|
47493
|
+
if (o === undefined)
|
|
47494
|
+
return;
|
|
47495
|
+
o.lastTurnEndedAt = ts;
|
|
47496
|
+
this.persist();
|
|
47497
|
+
}
|
|
47454
47498
|
resolveCloseTarget(echoedTurnId, liveTurnId) {
|
|
47455
47499
|
if (echoedTurnId != null)
|
|
47456
47500
|
return echoedTurnId;
|
|
@@ -47555,6 +47599,35 @@ function withDeadline(p, ms, timeoutMessage) {
|
|
|
47555
47599
|
});
|
|
47556
47600
|
}
|
|
47557
47601
|
|
|
47602
|
+
// gateway/escalation-drive.ts
|
|
47603
|
+
function driveEscalation(args) {
|
|
47604
|
+
const { escId, inFlight, ledger, send, maxAttempts, deadlineMs } = args;
|
|
47605
|
+
const log = args.log ?? ((l) => process.stderr.write(l));
|
|
47606
|
+
const wd = args.withDeadlineFn ?? withDeadline;
|
|
47607
|
+
if (inFlight.has(escId))
|
|
47608
|
+
return;
|
|
47609
|
+
const attempt = ledger.markEscalateAttempt(escId);
|
|
47610
|
+
inFlight.add(escId);
|
|
47611
|
+
log(`telegram gateway: obligation escalating origin=${escId} attempt=${attempt}/${maxAttempts}
|
|
47612
|
+
`);
|
|
47613
|
+
return wd(send(), deadlineMs, "obligation escalation send timed out").then(() => {
|
|
47614
|
+
ledger.close(escId);
|
|
47615
|
+
log(`telegram gateway: obligation escalation delivered + closed origin=${escId}
|
|
47616
|
+
`);
|
|
47617
|
+
}).catch((err) => {
|
|
47618
|
+
if (attempt >= maxAttempts) {
|
|
47619
|
+
ledger.close(escId);
|
|
47620
|
+
log(`telegram gateway: obligation escalation PERMANENTLY undeliverable after ${attempt} attempts \u2014 closing best-effort origin=${escId}: ${err}
|
|
47621
|
+
`);
|
|
47622
|
+
} else {
|
|
47623
|
+
log(`telegram gateway: obligation escalation send failed (attempt ${attempt}/${maxAttempts}), retrying next sweep origin=${escId}: ${err}
|
|
47624
|
+
`);
|
|
47625
|
+
}
|
|
47626
|
+
}).finally(() => {
|
|
47627
|
+
inFlight.delete(escId);
|
|
47628
|
+
});
|
|
47629
|
+
}
|
|
47630
|
+
|
|
47558
47631
|
// gateway/inbound-spool.ts
|
|
47559
47632
|
function spoolId(msg) {
|
|
47560
47633
|
if (msg.meta?.source === "subagent_handback" && typeof msg.meta?.subagent_jsonl_id === "string" && msg.meta.subagent_jsonl_id.length > 0) {
|
|
@@ -52647,10 +52720,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
|
|
|
52647
52720
|
}
|
|
52648
52721
|
|
|
52649
52722
|
// ../src/build-info.ts
|
|
52650
|
-
var VERSION = "0.14.
|
|
52651
|
-
var COMMIT_SHA = "
|
|
52652
|
-
var COMMIT_DATE = "2026-06-
|
|
52653
|
-
var LATEST_PR =
|
|
52723
|
+
var VERSION = "0.14.63";
|
|
52724
|
+
var COMMIT_SHA = "21a12c16";
|
|
52725
|
+
var COMMIT_DATE = "2026-06-04T22:43:17Z";
|
|
52726
|
+
var LATEST_PR = 2159;
|
|
52654
52727
|
var COMMITS_AHEAD_OF_TAG = 0;
|
|
52655
52728
|
|
|
52656
52729
|
// gateway/boot-version.ts
|
|
@@ -53853,6 +53926,25 @@ var OBLIGATION_REPRESENT_MAX = 2;
|
|
|
53853
53926
|
var OBLIGATION_SWEEP_MS = 5000;
|
|
53854
53927
|
var OBLIGATION_ESCALATE_MAX = 3;
|
|
53855
53928
|
var OBLIGATION_ESCALATE_SEND_DEADLINE_MS = 45000;
|
|
53929
|
+
var OBLIGATION_ESCALATE_GRACE_MS = (() => {
|
|
53930
|
+
const raw = process.env.SWITCHROOM_OBLIGATION_ESCALATE_GRACE_MS;
|
|
53931
|
+
if (raw == null || raw === "")
|
|
53932
|
+
return 45000;
|
|
53933
|
+
const n = Number(raw);
|
|
53934
|
+
return Number.isFinite(n) && n >= 0 ? n : 45000;
|
|
53935
|
+
})();
|
|
53936
|
+
var AUTOCLASSIFY_MIDTURN_SHADOW = process.env.SWITCHROOM_AUTOCLASSIFY_MIDTURN_SHADOW === "1";
|
|
53937
|
+
var lastAgentOutputAt = new Map;
|
|
53938
|
+
var LAST_OUTPUT_MAX_KEYS = 512;
|
|
53939
|
+
function noteAgentOutputAt(key, ts) {
|
|
53940
|
+
lastAgentOutputAt.delete(key);
|
|
53941
|
+
lastAgentOutputAt.set(key, ts);
|
|
53942
|
+
if (lastAgentOutputAt.size > LAST_OUTPUT_MAX_KEYS) {
|
|
53943
|
+
const oldest = lastAgentOutputAt.keys().next().value;
|
|
53944
|
+
if (oldest !== undefined)
|
|
53945
|
+
lastAgentOutputAt.delete(oldest);
|
|
53946
|
+
}
|
|
53947
|
+
}
|
|
53856
53948
|
var OBLIGATION_STORE_PATH = join35(STATE_DIR, "obligations.json");
|
|
53857
53949
|
var obligationStoreFs = {
|
|
53858
53950
|
readFileSync: (p) => readFileSync36(p, "utf8"),
|
|
@@ -54002,6 +54094,17 @@ function reapQueuedStatus(chatId, thread) {
|
|
|
54002
54094
|
}
|
|
54003
54095
|
var toolFlightTracker = new ToolFlightTracker;
|
|
54004
54096
|
var pendingDeferredInterrupt = null;
|
|
54097
|
+
function cancelInterruptedObligation() {
|
|
54098
|
+
if (!OBLIGATION_LEDGER_ENABLED)
|
|
54099
|
+
return;
|
|
54100
|
+
const turn = currentTurn;
|
|
54101
|
+
if (turn == null)
|
|
54102
|
+
return;
|
|
54103
|
+
if (obligationLedger.close(turn.turnId)) {
|
|
54104
|
+
process.stderr.write(`telegram gateway: obligation cancelled by interrupt origin=${turn.turnId}
|
|
54105
|
+
`);
|
|
54106
|
+
}
|
|
54107
|
+
}
|
|
54005
54108
|
async function fireDeferredInterrupt(reason) {
|
|
54006
54109
|
const pending2 = pendingDeferredInterrupt;
|
|
54007
54110
|
if (pending2 == null)
|
|
@@ -54025,6 +54128,7 @@ async function fireDeferredInterrupt(reason) {
|
|
|
54025
54128
|
process.stderr.write(`telegram gateway: deferred-interrupt SIGINT failed: ${err.message}
|
|
54026
54129
|
`);
|
|
54027
54130
|
}
|
|
54131
|
+
cancelInterruptedObligation();
|
|
54028
54132
|
const delivered = ipcServer.sendToAgent(pending2.agentName, pending2.inboundMsg);
|
|
54029
54133
|
if (delivered) {
|
|
54030
54134
|
markClaudeBusyForInbound(pending2.inboundMsg);
|
|
@@ -54163,8 +54267,12 @@ function endCurrentTurnAtomic(turn) {
|
|
|
54163
54267
|
if (currentTurn !== turn)
|
|
54164
54268
|
return;
|
|
54165
54269
|
currentTurn = null;
|
|
54166
|
-
if (OBLIGATION_LEDGER_ENABLED
|
|
54167
|
-
|
|
54270
|
+
if (OBLIGATION_LEDGER_ENABLED) {
|
|
54271
|
+
if (turn.finalAnswerDelivered) {
|
|
54272
|
+
obligationLedger.close(turn.turnId);
|
|
54273
|
+
} else {
|
|
54274
|
+
obligationLedger.noteTurnEnded(turn.turnId, Date.now());
|
|
54275
|
+
}
|
|
54168
54276
|
}
|
|
54169
54277
|
if (turn.noReplyDrainTimer != null) {
|
|
54170
54278
|
clearTimeout(turn.noReplyDrainTimer);
|
|
@@ -55253,7 +55361,7 @@ function obligationSweep() {
|
|
|
55253
55361
|
if (turnInFlightForGate())
|
|
55254
55362
|
return;
|
|
55255
55363
|
const agent = process.env.SWITCHROOM_AGENT_NAME ?? "";
|
|
55256
|
-
const decision = obligationLedger.decideAtIdle();
|
|
55364
|
+
const decision = obligationLedger.decideAtIdle(OBLIGATION_ESCALATE_GRACE_MS > 0 ? { now: Date.now(), graceMs: OBLIGATION_ESCALATE_GRACE_MS } : undefined);
|
|
55257
55365
|
const o = decision.obligation;
|
|
55258
55366
|
if (decision.action === "none" || o == null)
|
|
55259
55367
|
return;
|
|
@@ -55261,35 +55369,20 @@ function obligationSweep() {
|
|
|
55261
55369
|
if (pendingInboundBuffer.depth(agent) > 0)
|
|
55262
55370
|
return;
|
|
55263
55371
|
pendingInboundBuffer.push(agent, buildObligationRepresentInbound(o, Date.now()));
|
|
55264
|
-
const
|
|
55265
|
-
process.stderr.write(`telegram gateway: obligation re-presented origin=${o.originTurnId} attempt=${
|
|
55372
|
+
const attempt = obligationLedger.markRepresented(o.originTurnId);
|
|
55373
|
+
process.stderr.write(`telegram gateway: obligation re-presented origin=${o.originTurnId} attempt=${attempt}/${OBLIGATION_REPRESENT_MAX}
|
|
55266
55374
|
`);
|
|
55267
55375
|
return;
|
|
55268
55376
|
}
|
|
55269
|
-
|
|
55270
|
-
|
|
55271
|
-
|
|
55272
|
-
|
|
55273
|
-
|
|
55274
|
-
|
|
55275
|
-
|
|
55276
|
-
|
|
55277
|
-
|
|
55278
|
-
}), { threadId: o.threadId, chat_id: o.chatId, verb: "obligation.escalate" }), OBLIGATION_ESCALATE_SEND_DEADLINE_MS, "obligation escalation send timed out").then(() => {
|
|
55279
|
-
obligationLedger.close(escId);
|
|
55280
|
-
process.stderr.write(`telegram gateway: obligation escalation delivered + closed origin=${escId}
|
|
55281
|
-
`);
|
|
55282
|
-
}).catch((err) => {
|
|
55283
|
-
if (attempt >= OBLIGATION_ESCALATE_MAX) {
|
|
55284
|
-
obligationLedger.close(escId);
|
|
55285
|
-
process.stderr.write(`telegram gateway: obligation escalation PERMANENTLY undeliverable after ${attempt} attempts \u2014 closing best-effort origin=${escId}: ${err}
|
|
55286
|
-
`);
|
|
55287
|
-
} else {
|
|
55288
|
-
process.stderr.write(`telegram gateway: obligation escalation send failed (attempt ${attempt}/${OBLIGATION_ESCALATE_MAX}), retrying next sweep origin=${escId}: ${err}
|
|
55289
|
-
`);
|
|
55290
|
-
}
|
|
55291
|
-
}).finally(() => {
|
|
55292
|
-
obligationEscalateInFlight.delete(escId);
|
|
55377
|
+
driveEscalation({
|
|
55378
|
+
escId: o.originTurnId,
|
|
55379
|
+
inFlight: obligationEscalateInFlight,
|
|
55380
|
+
ledger: obligationLedger,
|
|
55381
|
+
send: () => retryWithThreadFallback(robustApiCall, (tid) => bot.api.sendMessage(o.chatId, obligationEscalationText(o), {
|
|
55382
|
+
...tid != null ? { message_thread_id: tid } : {}
|
|
55383
|
+
}), { threadId: o.threadId, chat_id: o.chatId, verb: "obligation.escalate" }),
|
|
55384
|
+
maxAttempts: OBLIGATION_ESCALATE_MAX,
|
|
55385
|
+
deadlineMs: OBLIGATION_ESCALATE_SEND_DEADLINE_MS
|
|
55293
55386
|
});
|
|
55294
55387
|
}
|
|
55295
55388
|
if (!STATIC && OBLIGATION_LEDGER_ENABLED) {
|
|
@@ -56201,6 +56294,8 @@ ${url}`;
|
|
|
56201
56294
|
});
|
|
56202
56295
|
noteOutbound(statusKey(chat_id, threadId), Date.now());
|
|
56203
56296
|
noteOutbound2(statusKey(chat_id, threadId), Date.now());
|
|
56297
|
+
if (AUTOCLASSIFY_MIDTURN_SHADOW)
|
|
56298
|
+
noteAgentOutputAt(statusKey(chat_id, threadId), Date.now());
|
|
56204
56299
|
shadowEmit({ kind: "modelOutbound", key: statusKey(chat_id, threadId), at: Date.now() });
|
|
56205
56300
|
if (isFinalAnswerReply({ text: rawText, disableNotification })) {
|
|
56206
56301
|
clearSilentEndState(statusKey(chat_id, threadId));
|
|
@@ -58654,6 +58749,7 @@ async function handleInbound(ctx, text, downloadImage, attachment, extraAttachme
|
|
|
58654
58749
|
process.stderr.write(`telegram gateway: interrupt-marker SIGINT failed: ${err.message}
|
|
58655
58750
|
`);
|
|
58656
58751
|
}
|
|
58752
|
+
cancelInterruptedObligation();
|
|
58657
58753
|
}
|
|
58658
58754
|
if (interrupt.emptyBody) {
|
|
58659
58755
|
await swallowingApiCall(() => bot.api.sendMessage(chat_id, "\u26A1 Interrupted. Send your replacement instruction now.", messageThreadId != null ? { message_thread_id: messageThreadId } : {}), {
|
|
@@ -59020,6 +59116,23 @@ ${preBlock(write.output)}`;
|
|
|
59020
59116
|
isSteering = priorTurnInFlight && isSteerPrefix;
|
|
59021
59117
|
if (priorTurnInFlight)
|
|
59022
59118
|
priorTurnStartedAt = activeTurnStartedAt.get(key);
|
|
59119
|
+
if (AUTOCLASSIFY_MIDTURN_SHADOW && priorTurnInFlight) {
|
|
59120
|
+
const lastOut = lastAgentOutputAt.get(key);
|
|
59121
|
+
const msSinceOut = lastOut != null ? Date.now() - lastOut : null;
|
|
59122
|
+
const shadow = autoClassifyMidTurnInbound({
|
|
59123
|
+
isSteerPrefix,
|
|
59124
|
+
isQueuePrefix: isQueuedPrefix,
|
|
59125
|
+
priorTurnInFlight,
|
|
59126
|
+
isDm: isDmChatId(chat_id),
|
|
59127
|
+
incomingThreadId: messageThreadId ?? null,
|
|
59128
|
+
activeTurnThreadId: currentTurn?.sessionThreadId ?? null,
|
|
59129
|
+
msSinceLastAgentOutput: msSinceOut,
|
|
59130
|
+
dmSteerWindowMs: 0,
|
|
59131
|
+
topicSteerWindowMs: 8000
|
|
59132
|
+
});
|
|
59133
|
+
process.stderr.write(`telegram gateway: autoclassify-shadow chat_id=${chat_id} would=${shadow.decision} reason=${shadow.reason} same_topic=${shadow.sameTopic ?? "-"} ms_since_out=${msSinceOut ?? "-"} actual=${isSteering ? "steer" : "queue"}
|
|
59134
|
+
`);
|
|
59135
|
+
}
|
|
59023
59136
|
if (access.statusReactions !== false) {
|
|
59024
59137
|
if (isSteering) {
|
|
59025
59138
|
bot.api.setMessageReaction(chat_id, msgId, [{ type: "emoji", emoji: "\uD83E\uDD1D" }]).catch(() => {});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* auto-classify-mid-turn.ts — deterministic, model-free classification of a
|
|
3
|
+
* mid-turn inbound into STEER (amend the in-flight turn) vs QUEUE (new task),
|
|
4
|
+
* using TOPIC-vs-active-turn + reply-RECENCY as proxies for intent. No model
|
|
5
|
+
* inference: the gateway must decide at inbound time while the single CLI is
|
|
6
|
+
* busy.
|
|
7
|
+
*
|
|
8
|
+
* Today a no-prefix mid-turn message always QUEUES (the default flipped
|
|
9
|
+
* 2026-04-17 away from the blunt "everything steers" — see
|
|
10
|
+
* reference/steer-or-queue-mid-flight.md). This module is the basis for a
|
|
11
|
+
* smarter default. It ships first in SHADOW mode (the gateway logs what it WOULD
|
|
12
|
+
* decide but still queues), to gather real-world data — how often mid-turn
|
|
13
|
+
* messages are same-topic continuations vs cross-topic new tasks, and the
|
|
14
|
+
* recency distribution — before any behaviour flips on.
|
|
15
|
+
*
|
|
16
|
+
* Signal strength (be honest):
|
|
17
|
+
* - TOPIC (supergroup): STRONG + structural. A message in a DIFFERENT forum
|
|
18
|
+
* topic than the active turn is, by the supergroup-mode invariant, a separate
|
|
19
|
+
* conversation → queue. This needs no timing guess.
|
|
20
|
+
* - RECENCY: weaker. Within the same topic it cannot tell "also do X" (steer)
|
|
21
|
+
* from "new question, same topic" (queue) — only a tight window + the
|
|
22
|
+
* visible/correctable UX (the JTBD doc) makes auto-steer acceptable, and
|
|
23
|
+
* that is gated separately. The recency clock is the agent's LAST OUTPUT
|
|
24
|
+
* (msSinceLastAgentOutput), NOT turn age: a long actively-narrating worker
|
|
25
|
+
* turn must not read "stale".
|
|
26
|
+
* - DM: no topic at all → timing-only (the pre-2026-04-17 regime that
|
|
27
|
+
* over-steered). DM auto-steer is OFF by default (window 0).
|
|
28
|
+
*
|
|
29
|
+
* Pure (no gateway imports) ⇒ unit-testable.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
export type MidTurnClass = 'steer' | 'queue'
|
|
33
|
+
|
|
34
|
+
export type MidTurnReason =
|
|
35
|
+
| 'steer_prefix'
|
|
36
|
+
| 'queue_prefix'
|
|
37
|
+
| 'not_mid_turn'
|
|
38
|
+
| 'cross_topic'
|
|
39
|
+
| 'same_topic_recent'
|
|
40
|
+
| 'same_topic_stale'
|
|
41
|
+
| 'dm_recent'
|
|
42
|
+
| 'dm_disabled'
|
|
43
|
+
| 'topic_disabled'
|
|
44
|
+
|
|
45
|
+
export interface AutoClassifyInput {
|
|
46
|
+
/** Explicit `/steer`|`/s` prefix present — the user's stated intent, authoritative. */
|
|
47
|
+
isSteerPrefix: boolean
|
|
48
|
+
/** Explicit `/queue`|`/q` prefix present. */
|
|
49
|
+
isQueuePrefix: boolean
|
|
50
|
+
/** Is a turn in flight for this chat/thread? (no → not our decision). */
|
|
51
|
+
priorTurnInFlight: boolean
|
|
52
|
+
/** DM (no forum topics) → timing-only. */
|
|
53
|
+
isDm: boolean
|
|
54
|
+
/** Incoming message's thread id (undefined/null in a DM). */
|
|
55
|
+
incomingThreadId: number | null | undefined
|
|
56
|
+
/** The in-flight turn's thread id (currentTurn.sessionThreadId). */
|
|
57
|
+
activeTurnThreadId: number | null | undefined
|
|
58
|
+
/** ms since the agent's LAST visible output in this chat/thread; null when no
|
|
59
|
+
* output has been recorded (cold topic) → treated as not-recent. */
|
|
60
|
+
msSinceLastAgentOutput: number | null
|
|
61
|
+
/** Auto-steer recency window in a DM. 0 (default) = DM auto-steer OFF. */
|
|
62
|
+
dmSteerWindowMs: number
|
|
63
|
+
/** Auto-steer recency window in a supergroup topic. 0 = topic auto-steer OFF. */
|
|
64
|
+
topicSteerWindowMs: number
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface AutoClassifyResult {
|
|
68
|
+
decision: MidTurnClass
|
|
69
|
+
reason: MidTurnReason
|
|
70
|
+
/** Whether the incoming message is in the SAME thread as the active turn
|
|
71
|
+
* (canonicalized). Undefined when not applicable (no prefix-free mid-turn). */
|
|
72
|
+
sameTopic?: boolean
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Canonical thread compare, matching chatKey's collapse (null/undefined/0 → same
|
|
76
|
+
* "no-thread" bucket) — never raw === on raw ids (the silence-poke key-mismatch
|
|
77
|
+
* bug class). */
|
|
78
|
+
function sameThread(a: number | null | undefined, b: number | null | undefined): boolean {
|
|
79
|
+
const norm = (t: number | null | undefined): number | null => (t == null || t === 0 ? null : t)
|
|
80
|
+
return norm(a) === norm(b)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Classify a mid-turn inbound. Precedence: explicit prefix → not-mid-turn →
|
|
85
|
+
* DM(timing) / supergroup(topic then timing). Defaults bias to QUEUE (the safe,
|
|
86
|
+
* reversible, current behaviour); STEER only on a strong/recent signal AND its
|
|
87
|
+
* window enabled.
|
|
88
|
+
*/
|
|
89
|
+
export function autoClassifyMidTurnInbound(i: AutoClassifyInput): AutoClassifyResult {
|
|
90
|
+
// Explicit prefixes always win — the user's stated intent is authoritative.
|
|
91
|
+
if (i.isSteerPrefix) return { decision: 'steer', reason: 'steer_prefix' }
|
|
92
|
+
if (i.isQueuePrefix) return { decision: 'queue', reason: 'queue_prefix' }
|
|
93
|
+
// No turn in flight → caller starts a fresh turn (not a steer/queue decision).
|
|
94
|
+
if (!i.priorTurnInFlight) return { decision: 'queue', reason: 'not_mid_turn' }
|
|
95
|
+
|
|
96
|
+
const recent =
|
|
97
|
+
i.msSinceLastAgentOutput != null && i.msSinceLastAgentOutput >= 0
|
|
98
|
+
|
|
99
|
+
if (i.isDm) {
|
|
100
|
+
// DM: no topic → timing-only. Default OFF (dmSteerWindowMs 0).
|
|
101
|
+
if (i.dmSteerWindowMs <= 0) return { decision: 'queue', reason: 'dm_disabled' }
|
|
102
|
+
return recent && i.msSinceLastAgentOutput! <= i.dmSteerWindowMs
|
|
103
|
+
? { decision: 'steer', reason: 'dm_recent' }
|
|
104
|
+
: { decision: 'queue', reason: 'dm_disabled' }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Supergroup: topic identity is the PRIMARY signal.
|
|
108
|
+
const topicMatch = sameThread(i.incomingThreadId, i.activeTurnThreadId)
|
|
109
|
+
if (i.topicSteerWindowMs <= 0) {
|
|
110
|
+
return { decision: 'queue', reason: 'topic_disabled', sameTopic: topicMatch }
|
|
111
|
+
}
|
|
112
|
+
// Different topic than the in-flight turn → ALWAYS queue (a separate
|
|
113
|
+
// conversation; never steer it into the wrong topic's turn).
|
|
114
|
+
if (!topicMatch) return { decision: 'queue', reason: 'cross_topic', sameTopic: false }
|
|
115
|
+
// Same topic: steer only if recent enough; else a new question → queue.
|
|
116
|
+
return recent && i.msSinceLastAgentOutput! <= i.topicSteerWindowMs
|
|
117
|
+
? { decision: 'steer', reason: 'same_topic_recent', sameTopic: true }
|
|
118
|
+
: { decision: 'queue', reason: 'same_topic_stale', sameTopic: true }
|
|
119
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* escalation-drive.ts — the obligation-ledger escalation step, extracted from
|
|
3
|
+
* obligationSweep so the hang → bounded → terminal behaviour is EXECUTABLE in a
|
|
4
|
+
* test with a fake hanging send. That path is unreachable by the two harnesses
|
|
5
|
+
* we have: mtcute can't make Telegram's API hang, and the synchronous property
|
|
6
|
+
* test can't model a promise that never settles — yet it is exactly the path the
|
|
7
|
+
* total proof flagged as the determinism hole. This file makes the real drive
|
|
8
|
+
* logic the sweep uses testable in isolation (escalation-drive.test.ts), so the
|
|
9
|
+
* fix is verified by execution, not only by reasoning + review.
|
|
10
|
+
*
|
|
11
|
+
* Invariant it upholds: a single escalation attempt
|
|
12
|
+
* - is guarded so a sweep tick cannot fire a second concurrent send for the
|
|
13
|
+
* same obligation while one is awaiting;
|
|
14
|
+
* - bounds the send with `withDeadline`, so the chain ALWAYS settles within
|
|
15
|
+
* `deadlineMs` ⇒ the in-flight flag ALWAYS clears (a hung send becomes a
|
|
16
|
+
* bounded reject, handled like any other failed attempt);
|
|
17
|
+
* - closes the obligation ONLY after a successful send; a transient failure
|
|
18
|
+
* leaves it OPEN (retried next sweep); a permanent failure
|
|
19
|
+
* (attempt ≥ maxAttempts) closes best-effort — so repeated hung/failed sends
|
|
20
|
+
* reach a terminal in a bounded number of sweeps, never an infinite loop.
|
|
21
|
+
*/
|
|
22
|
+
import { withDeadline } from './with-deadline.js'
|
|
23
|
+
|
|
24
|
+
/** The slice of the ledger the escalation step needs. */
|
|
25
|
+
export interface EscalationLedger {
|
|
26
|
+
markEscalateAttempt(originTurnId: string): number
|
|
27
|
+
close(originTurnId: string | null | undefined): boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface DriveEscalationArgs {
|
|
31
|
+
escId: string
|
|
32
|
+
/** Set of origin ids with an escalation send in flight (concurrency guard). */
|
|
33
|
+
inFlight: Set<string>
|
|
34
|
+
ledger: EscalationLedger
|
|
35
|
+
/** Perform the operator-nudge send (already thread-fallback-wrapped). May hang. */
|
|
36
|
+
send: () => Promise<unknown>
|
|
37
|
+
/** Attempts before a permanent failure closes best-effort. */
|
|
38
|
+
maxAttempts: number
|
|
39
|
+
/** Bound on a single send so a hang can't leak the in-flight flag. */
|
|
40
|
+
deadlineMs: number
|
|
41
|
+
log?: (line: string) => void
|
|
42
|
+
/** Injectable for tests; defaults to the real withDeadline. */
|
|
43
|
+
withDeadlineFn?: typeof withDeadline
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Drive one escalation attempt. Returns the settling chain promise (so tests can
|
|
48
|
+
* await it) or `undefined` if the call was a no-op because a send is already in
|
|
49
|
+
* flight for `escId`. obligationSweep calls this as `void driveEscalation(...)`.
|
|
50
|
+
*/
|
|
51
|
+
export function driveEscalation(args: DriveEscalationArgs): Promise<void> | undefined {
|
|
52
|
+
const { escId, inFlight, ledger, send, maxAttempts, deadlineMs } = args
|
|
53
|
+
const log = args.log ?? ((l: string) => process.stderr.write(l))
|
|
54
|
+
const wd = args.withDeadlineFn ?? withDeadline
|
|
55
|
+
if (inFlight.has(escId)) return undefined // a send is already awaiting
|
|
56
|
+
const attempt = ledger.markEscalateAttempt(escId)
|
|
57
|
+
inFlight.add(escId)
|
|
58
|
+
log(`telegram gateway: obligation escalating origin=${escId} attempt=${attempt}/${maxAttempts}\n`)
|
|
59
|
+
return wd(send(), deadlineMs, 'obligation escalation send timed out')
|
|
60
|
+
.then(() => {
|
|
61
|
+
ledger.close(escId)
|
|
62
|
+
log(`telegram gateway: obligation escalation delivered + closed origin=${escId}\n`)
|
|
63
|
+
})
|
|
64
|
+
.catch((err: unknown) => {
|
|
65
|
+
if (attempt >= maxAttempts) {
|
|
66
|
+
ledger.close(escId)
|
|
67
|
+
log(
|
|
68
|
+
`telegram gateway: obligation escalation PERMANENTLY undeliverable after ${attempt} attempts — closing best-effort origin=${escId}: ${err}\n`,
|
|
69
|
+
)
|
|
70
|
+
} else {
|
|
71
|
+
log(
|
|
72
|
+
`telegram gateway: obligation escalation send failed (attempt ${attempt}/${maxAttempts}), retrying next sweep origin=${escId}: ${err}\n`,
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
.finally(() => {
|
|
77
|
+
inFlight.delete(escId)
|
|
78
|
+
})
|
|
79
|
+
}
|