switchroom 0.13.10 → 0.13.12
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/bridge/bridge.js +23 -4
- package/telegram-plugin/dist/gateway/gateway.js +51 -74
- package/telegram-plugin/dist/server.js +23 -4
- package/telegram-plugin/gateway/gateway.ts +44 -78
- package/telegram-plugin/gateway/subagent-handback-inbound-builder.ts +82 -0
- package/telegram-plugin/model-unavailable.ts +11 -1
- package/telegram-plugin/operator-events.fixtures.json +14 -24
- package/telegram-plugin/operator-events.ts +11 -2
- package/telegram-plugin/session-tail.ts +71 -4
- package/telegram-plugin/subagent-watcher.ts +13 -20
- package/telegram-plugin/tests/fleet-state-watcher.test.ts +0 -1
- package/telegram-plugin/tests/model-unavailable.test.ts +15 -2
- package/telegram-plugin/tests/operator-events-session-tail.test.ts +53 -2
- package/telegram-plugin/tests/operator-events.test.ts +14 -7
- package/telegram-plugin/tests/subagent-handback-decision.test.ts +112 -0
- package/telegram-plugin/tests/subagent-registry-bugs.test.ts +1 -3
- package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +0 -1
- package/telegram-plugin/tests/subagent-watcher-parent-marker.test.ts +0 -1
- package/telegram-plugin/tests/subagent-watcher-stall-notification.test.ts +1 -4
- package/telegram-plugin/tests/subagent-watcher-stall-terminal.test.ts +0 -1
- package/telegram-plugin/tests/subagent-watcher.test.ts +15 -5
- package/telegram-plugin/tests/turn-flush-safety.test.ts +29 -81
- package/telegram-plugin/turn-flush-safety.ts +23 -53
package/dist/cli/switchroom.js
CHANGED
|
@@ -47314,8 +47314,8 @@ var {
|
|
|
47314
47314
|
} = import__.default;
|
|
47315
47315
|
|
|
47316
47316
|
// src/build-info.ts
|
|
47317
|
-
var VERSION = "0.13.
|
|
47318
|
-
var COMMIT_SHA = "
|
|
47317
|
+
var VERSION = "0.13.12";
|
|
47318
|
+
var COMMIT_SHA = "18363dfb";
|
|
47319
47319
|
|
|
47320
47320
|
// src/cli/agent.ts
|
|
47321
47321
|
init_source();
|
package/package.json
CHANGED
|
@@ -23004,7 +23004,7 @@ function classifyInner(raw) {
|
|
|
23004
23004
|
return "rate-limited";
|
|
23005
23005
|
}
|
|
23006
23006
|
if (errorType === "overloaded_error" || errorCode === "overloaded_error" || sdkCode === "overloaded_error" || message.toLowerCase().includes("overloaded_error") || message.toLowerCase().includes("overloaded")) {
|
|
23007
|
-
return "
|
|
23007
|
+
return "rate-limited";
|
|
23008
23008
|
}
|
|
23009
23009
|
if (errorType === "agent-crashed" || errorCode === "agent-crashed") {
|
|
23010
23010
|
return "agent-crashed";
|
|
@@ -23349,6 +23349,12 @@ function projectSubagentLine(line, agentId, state) {
|
|
|
23349
23349
|
}
|
|
23350
23350
|
return [];
|
|
23351
23351
|
}
|
|
23352
|
+
function extractRetryState(obj) {
|
|
23353
|
+
return {
|
|
23354
|
+
retryAttempt: typeof obj.retryAttempt === "number" ? obj.retryAttempt : null,
|
|
23355
|
+
maxRetries: typeof obj.maxRetries === "number" ? obj.maxRetries : null
|
|
23356
|
+
};
|
|
23357
|
+
}
|
|
23352
23358
|
function detectErrorInTranscriptLine(line) {
|
|
23353
23359
|
if (!line || line.length > 2 * 1024 * 1024)
|
|
23354
23360
|
return null;
|
|
@@ -23366,7 +23372,13 @@ function detectErrorInTranscriptLine(line) {
|
|
|
23366
23372
|
const errStr = typeof obj.error === "string" ? obj.error : "";
|
|
23367
23373
|
const text = extractAssistantText(obj);
|
|
23368
23374
|
const kind2 = status === 429 ? "quota-exhausted" : classifyClaudeError({ type: errStr, status, message: text });
|
|
23369
|
-
return {
|
|
23375
|
+
return {
|
|
23376
|
+
kind: kind2,
|
|
23377
|
+
raw: obj,
|
|
23378
|
+
detail: text || errStr || "api error",
|
|
23379
|
+
transient: kind2 === "rate-limited",
|
|
23380
|
+
terminal: true
|
|
23381
|
+
};
|
|
23370
23382
|
}
|
|
23371
23383
|
const isErrorLine = type === "api_error" || type === "error";
|
|
23372
23384
|
const embeddedError = typeof obj.error === "object" && obj.error != null ? obj.error : null;
|
|
@@ -23375,7 +23387,10 @@ function detectErrorInTranscriptLine(line) {
|
|
|
23375
23387
|
const raw = embeddedError ?? obj;
|
|
23376
23388
|
const kind = classifyClaudeError(embeddedError ?? obj);
|
|
23377
23389
|
const detail = extractDetailMessage(embeddedError) ?? extractDetailMessage(obj) ?? String(type ?? "");
|
|
23378
|
-
|
|
23390
|
+
const transient = kind === "rate-limited";
|
|
23391
|
+
const retry = extractRetryState(obj);
|
|
23392
|
+
const terminal = !transient ? true : retry.retryAttempt != null && retry.maxRetries != null ? retry.retryAttempt >= retry.maxRetries : isErrorLine;
|
|
23393
|
+
return { kind, raw, detail, transient, terminal };
|
|
23379
23394
|
}
|
|
23380
23395
|
function extractDetailMessage(obj) {
|
|
23381
23396
|
if (!obj)
|
|
@@ -23497,7 +23512,11 @@ function startSessionTail(config2) {
|
|
|
23497
23512
|
try {
|
|
23498
23513
|
const errEvent = detectErrorInTranscriptLine(line);
|
|
23499
23514
|
if (errEvent) {
|
|
23500
|
-
|
|
23515
|
+
if (errEvent.terminal || !errEvent.transient) {
|
|
23516
|
+
onOperatorEvent(errEvent);
|
|
23517
|
+
} else {
|
|
23518
|
+
log?.(`session-tail: transient overload suppressed (in-flight retry) kind=${errEvent.kind}`);
|
|
23519
|
+
}
|
|
23501
23520
|
}
|
|
23502
23521
|
} catch (err) {
|
|
23503
23522
|
log?.(`session-tail: onOperatorEvent threw: ${err.message}`);
|
|
@@ -39632,7 +39632,8 @@ function resolveModelUnavailableFromOperatorEvent(ev) {
|
|
|
39632
39632
|
return detectModelUnavailable(detail) ?? { kind: "quota_exhausted", raw: detail };
|
|
39633
39633
|
}
|
|
39634
39634
|
if (ev.kind === "rate-limited") {
|
|
39635
|
-
|
|
39635
|
+
const detected = detectModelUnavailable(detail);
|
|
39636
|
+
return detected?.kind === "quota_exhausted" ? detected : null;
|
|
39636
39637
|
}
|
|
39637
39638
|
if (ev.kind === "unknown-5xx") {
|
|
39638
39639
|
return detectModelUnavailable(detail) ?? { kind: "overload", raw: detail };
|
|
@@ -40592,28 +40593,12 @@ function isSilentFlushMarker2(text) {
|
|
|
40592
40593
|
}
|
|
40593
40594
|
return SILENT_MARKERS2.has(trimmed.toUpperCase());
|
|
40594
40595
|
}
|
|
40595
|
-
var REPLY_CALLED_TAIL_MIN_CHARS = 40;
|
|
40596
40596
|
function decideTurnFlush(input) {
|
|
40597
40597
|
const flushEnabled = input.flushEnabled !== false;
|
|
40598
40598
|
if (!flushEnabled)
|
|
40599
40599
|
return { kind: "skip", reason: "flag-disabled" };
|
|
40600
|
-
if (input.replyCalled)
|
|
40601
|
-
|
|
40602
|
-
const tail = input.capturedText.slice(tailIdx).join(`
|
|
40603
|
-
`).trim();
|
|
40604
|
-
const minChars = input.replyCalledTailMinChars ?? REPLY_CALLED_TAIL_MIN_CHARS;
|
|
40605
|
-
if (tail.length === 0) {
|
|
40606
|
-
return { kind: "skip", reason: "reply-called" };
|
|
40607
|
-
}
|
|
40608
|
-
if (tail.length < minChars) {
|
|
40609
|
-
return { kind: "skip", reason: "reply-called-no-new-text" };
|
|
40610
|
-
}
|
|
40611
|
-
if (input.chatId == null)
|
|
40612
|
-
return { kind: "skip", reason: "no-inbound-chat" };
|
|
40613
|
-
if (isSilentFlushMarker2(tail))
|
|
40614
|
-
return { kind: "skip", reason: "silent-marker" };
|
|
40615
|
-
return { kind: "flush", text: tail };
|
|
40616
|
-
}
|
|
40600
|
+
if (input.replyCalled)
|
|
40601
|
+
return { kind: "skip", reason: "reply-called" };
|
|
40617
40602
|
if (input.chatId == null)
|
|
40618
40603
|
return { kind: "skip", reason: "no-inbound-chat" };
|
|
40619
40604
|
const joined = input.capturedText.join(`
|
|
@@ -44782,6 +44767,31 @@ ${result}
|
|
|
44782
44767
|
}
|
|
44783
44768
|
};
|
|
44784
44769
|
}
|
|
44770
|
+
function decideSubagentHandback(input) {
|
|
44771
|
+
if (input.handbackEnvValue === "0") {
|
|
44772
|
+
return { deliver: false, reason: "env-disabled" };
|
|
44773
|
+
}
|
|
44774
|
+
if (input.outcome !== "completed" && input.outcome !== "failed") {
|
|
44775
|
+
return { deliver: false, reason: "outcome-not-terminal" };
|
|
44776
|
+
}
|
|
44777
|
+
if (!input.isBackground) {
|
|
44778
|
+
return { deliver: false, reason: "foreground" };
|
|
44779
|
+
}
|
|
44780
|
+
const chatId = input.fleetChatId || input.ownerChatId;
|
|
44781
|
+
if (!chatId) {
|
|
44782
|
+
return { deliver: false, reason: "no-chat" };
|
|
44783
|
+
}
|
|
44784
|
+
const inbound = buildSubagentHandbackInbound({
|
|
44785
|
+
ctx: {
|
|
44786
|
+
chatId,
|
|
44787
|
+
taskDescription: input.taskDescription,
|
|
44788
|
+
resultText: input.resultText,
|
|
44789
|
+
outcome: input.outcome
|
|
44790
|
+
},
|
|
44791
|
+
...input.nowMs !== undefined ? { nowMs: input.nowMs } : {}
|
|
44792
|
+
});
|
|
44793
|
+
return { deliver: true, chatId, inbound };
|
|
44794
|
+
}
|
|
44785
44795
|
|
|
44786
44796
|
// gateway/poll-health.ts
|
|
44787
44797
|
var DEFAULT_LOG = (msg) => {
|
|
@@ -46628,14 +46638,6 @@ function startSubagentWatcher(config) {
|
|
|
46628
46638
|
return;
|
|
46629
46639
|
if (entry.state === "done" && !entry.completionNotified) {
|
|
46630
46640
|
entry.completionNotified = true;
|
|
46631
|
-
const desc = escapeHtml8(truncate3(entry.description, 80));
|
|
46632
|
-
const summary = entry.lastSummaryLine ? ` \u2014 ${escapeHtml8(truncate3(entry.lastSummaryLine, 120))}` : "";
|
|
46633
|
-
const tools = entry.toolCount > 0 ? ` (${entry.toolCount} tools)` : "";
|
|
46634
|
-
try {
|
|
46635
|
-
config.sendNotification(`\u2713 Worker done: ${desc}${tools}${summary}`);
|
|
46636
|
-
} catch (err) {
|
|
46637
|
-
log?.(`subagent-watcher: completion notification error: ${err.message}`);
|
|
46638
|
-
}
|
|
46639
46641
|
if (config.onFinish) {
|
|
46640
46642
|
try {
|
|
46641
46643
|
config.onFinish({
|
|
@@ -48001,11 +48003,11 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
|
|
|
48001
48003
|
}
|
|
48002
48004
|
|
|
48003
48005
|
// ../src/build-info.ts
|
|
48004
|
-
var VERSION = "0.13.
|
|
48005
|
-
var COMMIT_SHA = "
|
|
48006
|
-
var COMMIT_DATE = "2026-05-
|
|
48006
|
+
var VERSION = "0.13.12";
|
|
48007
|
+
var COMMIT_SHA = "18363dfb";
|
|
48008
|
+
var COMMIT_DATE = "2026-05-22T19:32:19+10:00";
|
|
48007
48009
|
var LATEST_PR = null;
|
|
48008
|
-
var COMMITS_AHEAD_OF_TAG =
|
|
48010
|
+
var COMMITS_AHEAD_OF_TAG = 5;
|
|
48009
48011
|
|
|
48010
48012
|
// gateway/boot-version.ts
|
|
48011
48013
|
function formatRelativeAgo(iso) {
|
|
@@ -49628,7 +49630,7 @@ function emitGatewayOperatorEvent(event) {
|
|
|
49628
49630
|
let renderedText;
|
|
49629
49631
|
let renderedKeyboard;
|
|
49630
49632
|
if (modelUnavailable) {
|
|
49631
|
-
const isAutoKind = modelUnavailable.kind === "quota_exhausted"
|
|
49633
|
+
const isAutoKind = modelUnavailable.kind === "quota_exhausted";
|
|
49632
49634
|
const willActuallyFire = isAutoKind && wouldFireFleetAutoFallback();
|
|
49633
49635
|
process.stderr.write(`telegram gateway: operator-event suppressing-raw-stderr-for-model-unavailable agent=${agent} kind=${kind} detected=${modelUnavailable.kind} autoKind=${isAutoKind} willFire=${willActuallyFire}
|
|
49634
49636
|
`);
|
|
@@ -51568,7 +51570,6 @@ function handleSessionEvent(ev) {
|
|
|
51568
51570
|
gatewayReceiveAt: startedAt,
|
|
51569
51571
|
replyCalled: false,
|
|
51570
51572
|
capturedText: [],
|
|
51571
|
-
capturedTextLenAtLastReply: 0,
|
|
51572
51573
|
orphanedReplyTimeoutId: null,
|
|
51573
51574
|
registryKey: null,
|
|
51574
51575
|
lastAssistantMsgId: null,
|
|
@@ -51633,7 +51634,6 @@ function handleSessionEvent(ev) {
|
|
|
51633
51634
|
const name = ev.toolName;
|
|
51634
51635
|
if (isTelegramReplyTool(name)) {
|
|
51635
51636
|
turn.replyCalled = true;
|
|
51636
|
-
turn.capturedTextLenAtLastReply = turn.capturedText.length;
|
|
51637
51637
|
if (turn.orphanedReplyTimeoutId != null) {
|
|
51638
51638
|
clearTimeout(turn.orphanedReplyTimeoutId);
|
|
51639
51639
|
turn.orphanedReplyTimeoutId = null;
|
|
@@ -51796,13 +51796,8 @@ function handleSessionEvent(ev) {
|
|
|
51796
51796
|
chatId: turn.sessionChatId,
|
|
51797
51797
|
replyCalled: turn.replyCalled,
|
|
51798
51798
|
capturedText: turn.capturedText,
|
|
51799
|
-
capturedTextLenAtLastReply: turn.capturedTextLenAtLastReply,
|
|
51800
51799
|
flushEnabled: TURN_FLUSH_SAFETY_ENABLED
|
|
51801
51800
|
});
|
|
51802
|
-
if (flushDecision.kind === "flush" && turn.replyCalled) {
|
|
51803
|
-
process.stderr.write(`telegram gateway: WARN post-reply-tail flush (#1291) \u2014 model emitted ${flushDecision.text.length} chars after a prior reply call without a follow-up reply tool chat=${chatId} turnStartedAt=${turn.startedAt}
|
|
51804
|
-
`);
|
|
51805
|
-
}
|
|
51806
51801
|
if (flushDecision.kind === "skip" && flushDecision.reason !== "reply-called") {
|
|
51807
51802
|
process.stderr.write(`telegram gateway: turn-flush skipped \u2014 reason=${flushDecision.reason}
|
|
51808
51803
|
`);
|
|
@@ -57282,20 +57277,6 @@ var didOneTimeSetup = false;
|
|
|
57282
57277
|
agentCwd: watcherAgentDir,
|
|
57283
57278
|
db: turnsDb,
|
|
57284
57279
|
parentStateDir: STATE_DIR,
|
|
57285
|
-
sendNotification: (text) => {
|
|
57286
|
-
const ownerChatId = loadAccess().allowFrom[0];
|
|
57287
|
-
if (!ownerChatId)
|
|
57288
|
-
return;
|
|
57289
|
-
swallowingApiCall(() => lockedBot.api.sendMessage(ownerChatId, text, {
|
|
57290
|
-
parse_mode: "HTML",
|
|
57291
|
-
link_preview_options: { is_disabled: true },
|
|
57292
|
-
...TOPIC_ID != null ? { message_thread_id: TOPIC_ID } : {}
|
|
57293
|
-
}), {
|
|
57294
|
-
chat_id: ownerChatId,
|
|
57295
|
-
verb: "subagent-watcher-notification",
|
|
57296
|
-
...TOPIC_ID != null ? { threadId: TOPIC_ID } : {}
|
|
57297
|
-
});
|
|
57298
|
-
},
|
|
57299
57280
|
log: (msg) => process.stderr.write(`telegram gateway: ${msg}
|
|
57300
57281
|
`),
|
|
57301
57282
|
onStall: (agentId, idleMs, description) => {
|
|
@@ -57321,17 +57302,13 @@ var didOneTimeSetup = false;
|
|
|
57321
57302
|
}
|
|
57322
57303
|
},
|
|
57323
57304
|
onFinish: ({ agentId, outcome, description, resultText }) => {
|
|
57324
|
-
|
|
57325
|
-
return;
|
|
57326
|
-
if (outcome !== "completed" && outcome !== "failed")
|
|
57327
|
-
return;
|
|
57328
|
-
let chatId = "";
|
|
57305
|
+
let fleetChatId = "";
|
|
57329
57306
|
let isBackground = false;
|
|
57330
57307
|
try {
|
|
57331
57308
|
const fleets = progressDriver?.peekAllFleets() ?? [];
|
|
57332
57309
|
for (const f of fleets) {
|
|
57333
57310
|
if (f.fleet.has(agentId)) {
|
|
57334
|
-
|
|
57311
|
+
fleetChatId = f.chatId ?? "";
|
|
57335
57312
|
break;
|
|
57336
57313
|
}
|
|
57337
57314
|
}
|
|
@@ -57343,24 +57320,24 @@ var didOneTimeSetup = false;
|
|
|
57343
57320
|
isBackground = row.background === 1;
|
|
57344
57321
|
} catch {}
|
|
57345
57322
|
}
|
|
57346
|
-
|
|
57347
|
-
|
|
57348
|
-
|
|
57349
|
-
|
|
57350
|
-
|
|
57323
|
+
const decision = decideSubagentHandback({
|
|
57324
|
+
handbackEnvValue: process.env.SWITCHROOM_SUBAGENT_HANDBACK,
|
|
57325
|
+
outcome,
|
|
57326
|
+
isBackground,
|
|
57327
|
+
fleetChatId,
|
|
57328
|
+
ownerChatId: loadAccess().allowFrom[0] ?? "",
|
|
57329
|
+
taskDescription: description,
|
|
57330
|
+
resultText
|
|
57331
|
+
});
|
|
57332
|
+
if (!decision.deliver) {
|
|
57333
|
+
if (decision.reason === "no-chat") {
|
|
57334
|
+
process.stderr.write(`telegram gateway: subagent-handback ${agentId} \u2014 no chat to deliver to; skipped
|
|
57351
57335
|
`);
|
|
57336
|
+
}
|
|
57352
57337
|
return;
|
|
57353
57338
|
}
|
|
57354
|
-
|
|
57355
|
-
|
|
57356
|
-
chatId: String(handbackChatId),
|
|
57357
|
-
taskDescription: description,
|
|
57358
|
-
resultText,
|
|
57359
|
-
outcome
|
|
57360
|
-
}
|
|
57361
|
-
});
|
|
57362
|
-
pendingInboundBuffer.push(process.env.SWITCHROOM_AGENT_NAME ?? "", inbound);
|
|
57363
|
-
process.stderr.write(`telegram gateway: subagent-handback queued agent=${agentId} outcome=${outcome} chat=${handbackChatId} resultChars=${resultText.length}
|
|
57339
|
+
pendingInboundBuffer.push(process.env.SWITCHROOM_AGENT_NAME ?? "", decision.inbound);
|
|
57340
|
+
process.stderr.write(`telegram gateway: subagent-handback queued agent=${agentId} outcome=${outcome} chat=${decision.chatId} resultChars=${resultText.length}
|
|
57364
57341
|
`);
|
|
57365
57342
|
}
|
|
57366
57343
|
});
|
|
@@ -17029,7 +17029,7 @@ function classifyInner(raw) {
|
|
|
17029
17029
|
return "rate-limited";
|
|
17030
17030
|
}
|
|
17031
17031
|
if (errorType === "overloaded_error" || errorCode === "overloaded_error" || sdkCode === "overloaded_error" || message.toLowerCase().includes("overloaded_error") || message.toLowerCase().includes("overloaded")) {
|
|
17032
|
-
return "
|
|
17032
|
+
return "rate-limited";
|
|
17033
17033
|
}
|
|
17034
17034
|
if (errorType === "agent-crashed" || errorCode === "agent-crashed") {
|
|
17035
17035
|
return "agent-crashed";
|
|
@@ -17387,6 +17387,12 @@ function projectSubagentLine(line, agentId, state) {
|
|
|
17387
17387
|
}
|
|
17388
17388
|
return [];
|
|
17389
17389
|
}
|
|
17390
|
+
function extractRetryState(obj) {
|
|
17391
|
+
return {
|
|
17392
|
+
retryAttempt: typeof obj.retryAttempt === "number" ? obj.retryAttempt : null,
|
|
17393
|
+
maxRetries: typeof obj.maxRetries === "number" ? obj.maxRetries : null
|
|
17394
|
+
};
|
|
17395
|
+
}
|
|
17390
17396
|
function detectErrorInTranscriptLine(line) {
|
|
17391
17397
|
if (!line || line.length > 2 * 1024 * 1024)
|
|
17392
17398
|
return null;
|
|
@@ -17404,7 +17410,13 @@ function detectErrorInTranscriptLine(line) {
|
|
|
17404
17410
|
const errStr = typeof obj.error === "string" ? obj.error : "";
|
|
17405
17411
|
const text = extractAssistantText(obj);
|
|
17406
17412
|
const kind2 = status === 429 ? "quota-exhausted" : classifyClaudeError({ type: errStr, status, message: text });
|
|
17407
|
-
return {
|
|
17413
|
+
return {
|
|
17414
|
+
kind: kind2,
|
|
17415
|
+
raw: obj,
|
|
17416
|
+
detail: text || errStr || "api error",
|
|
17417
|
+
transient: kind2 === "rate-limited",
|
|
17418
|
+
terminal: true
|
|
17419
|
+
};
|
|
17408
17420
|
}
|
|
17409
17421
|
const isErrorLine = type === "api_error" || type === "error";
|
|
17410
17422
|
const embeddedError = typeof obj.error === "object" && obj.error != null ? obj.error : null;
|
|
@@ -17413,7 +17425,10 @@ function detectErrorInTranscriptLine(line) {
|
|
|
17413
17425
|
const raw = embeddedError ?? obj;
|
|
17414
17426
|
const kind = classifyClaudeError(embeddedError ?? obj);
|
|
17415
17427
|
const detail = extractDetailMessage(embeddedError) ?? extractDetailMessage(obj) ?? String(type ?? "");
|
|
17416
|
-
|
|
17428
|
+
const transient = kind === "rate-limited";
|
|
17429
|
+
const retry = extractRetryState(obj);
|
|
17430
|
+
const terminal = !transient ? true : retry.retryAttempt != null && retry.maxRetries != null ? retry.retryAttempt >= retry.maxRetries : isErrorLine;
|
|
17431
|
+
return { kind, raw, detail, transient, terminal };
|
|
17417
17432
|
}
|
|
17418
17433
|
function extractDetailMessage(obj) {
|
|
17419
17434
|
if (!obj)
|
|
@@ -17535,7 +17550,11 @@ function startSessionTail(config2) {
|
|
|
17535
17550
|
try {
|
|
17536
17551
|
const errEvent = detectErrorInTranscriptLine(line);
|
|
17537
17552
|
if (errEvent) {
|
|
17538
|
-
|
|
17553
|
+
if (errEvent.terminal || !errEvent.transient) {
|
|
17554
|
+
onOperatorEvent(errEvent);
|
|
17555
|
+
} else {
|
|
17556
|
+
log?.(`session-tail: transient overload suppressed (in-flight retry) kind=${errEvent.kind}`);
|
|
17557
|
+
}
|
|
17539
17558
|
}
|
|
17540
17559
|
} catch (err) {
|
|
17541
17560
|
log?.(`session-tail: onOperatorEvent threw: ${err.message}`);
|
|
@@ -281,7 +281,7 @@ import {
|
|
|
281
281
|
buildVaultSaveFailedInbound,
|
|
282
282
|
buildVaultSaveDiscardedInbound,
|
|
283
283
|
} from './vault-grant-inbound-builders.js'
|
|
284
|
-
import {
|
|
284
|
+
import { decideSubagentHandback } from './subagent-handback-inbound-builder.js'
|
|
285
285
|
import { createPollHealthCheck, type PollHealthCheckHandle } from './poll-health.js'
|
|
286
286
|
import type {
|
|
287
287
|
ToolCallMessage,
|
|
@@ -1192,14 +1192,6 @@ type CurrentTurn = {
|
|
|
1192
1192
|
gatewayReceiveAt: number
|
|
1193
1193
|
replyCalled: boolean
|
|
1194
1194
|
capturedText: string[]
|
|
1195
|
-
// #1291: snapshot of capturedText.length at the moment of the most
|
|
1196
|
-
// recent reply / stream_reply tool call. Used by decideTurnFlush to
|
|
1197
|
-
// isolate the post-reply tail (e.g. a soft-commit reply followed by
|
|
1198
|
-
// the real substantive answer in terminal text only) and flush it as
|
|
1199
|
-
// a follow-up message. Pre-#1291 the existence of ANY reply call
|
|
1200
|
-
// suppressed flush entirely — that lost long terminal-only answers
|
|
1201
|
-
// after a "let me check" interim reply.
|
|
1202
|
-
capturedTextLenAtLastReply: number
|
|
1203
1195
|
orphanedReplyTimeoutId: ReturnType<typeof setTimeout> | null
|
|
1204
1196
|
registryKey: string | null
|
|
1205
1197
|
// Last assistant outbound message id for the current turn — populated
|
|
@@ -2712,8 +2704,14 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
|
|
|
2712
2704
|
// Card text branches on the AND. wouldFireFleetAutoFallback is a
|
|
2713
2705
|
// pure read of the dedup state; calling fireFleetAutoFallback only
|
|
2714
2706
|
// when both are true keeps the card honest.
|
|
2715
|
-
|
|
2716
|
-
|
|
2707
|
+
// Only a genuine quota / usage-limit hit is addressable by fleet
|
|
2708
|
+
// auto-fallback (swap to an account that still has runway). An
|
|
2709
|
+
// `overload` is transient Anthropic SERVER-side capacity pressure —
|
|
2710
|
+
// every account is equally affected, so failing over does nothing;
|
|
2711
|
+
// it just produces a self-cancelling "probed healthy / Stale event?"
|
|
2712
|
+
// loop on every 529. Overload is handled by Claude Code's own
|
|
2713
|
+
// internal retry, not by switching accounts.
|
|
2714
|
+
const isAutoKind = modelUnavailable.kind === 'quota_exhausted'
|
|
2717
2715
|
const willActuallyFire = isAutoKind && wouldFireFleetAutoFallback()
|
|
2718
2716
|
process.stderr.write(
|
|
2719
2717
|
`telegram gateway: operator-event suppressing-raw-stderr-for-model-unavailable agent=${agent} kind=${kind} detected=${modelUnavailable.kind} autoKind=${isAutoKind} willFire=${willActuallyFire}\n`,
|
|
@@ -5700,7 +5698,6 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
5700
5698
|
gatewayReceiveAt: startedAt,
|
|
5701
5699
|
replyCalled: false,
|
|
5702
5700
|
capturedText: [],
|
|
5703
|
-
capturedTextLenAtLastReply: 0,
|
|
5704
5701
|
orphanedReplyTimeoutId: null,
|
|
5705
5702
|
registryKey: null,
|
|
5706
5703
|
lastAssistantMsgId: null,
|
|
@@ -5801,12 +5798,6 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
5801
5798
|
// placeholder-heartbeat label, which has been retired.
|
|
5802
5799
|
if (isTelegramReplyTool(name)) {
|
|
5803
5800
|
turn.replyCalled = true
|
|
5804
|
-
// #1291: pin the captured-text index at the moment of this reply
|
|
5805
|
-
// tool call. Anything pushed into capturedText after this point
|
|
5806
|
-
// is the post-reply tail (e.g. the substantive answer composed
|
|
5807
|
-
// in terminal text after a soft-commit "on it, back in a few").
|
|
5808
|
-
// decideTurnFlush slices from this index to flush the tail.
|
|
5809
|
-
turn.capturedTextLenAtLastReply = turn.capturedText.length
|
|
5810
5801
|
if (turn.orphanedReplyTimeoutId != null) {
|
|
5811
5802
|
clearTimeout(turn.orphanedReplyTimeoutId)
|
|
5812
5803
|
turn.orphanedReplyTimeoutId = null
|
|
@@ -6066,20 +6057,8 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
6066
6057
|
chatId: turn.sessionChatId,
|
|
6067
6058
|
replyCalled: turn.replyCalled,
|
|
6068
6059
|
capturedText: turn.capturedText,
|
|
6069
|
-
capturedTextLenAtLastReply: turn.capturedTextLenAtLastReply,
|
|
6070
6060
|
flushEnabled: TURN_FLUSH_SAFETY_ENABLED,
|
|
6071
6061
|
})
|
|
6072
|
-
// #1291: when the model emitted a soft-commit reply followed by a
|
|
6073
|
-
// substantive terminal-only answer, decideTurnFlush returns
|
|
6074
|
-
// kind:'flush' with the post-reply tail. Log WARN so this case is
|
|
6075
|
-
// auditable — the model SHOULD have called reply for the tail, but
|
|
6076
|
-
// didn't, and the framework is covering for it.
|
|
6077
|
-
if (flushDecision.kind === 'flush' && turn.replyCalled) {
|
|
6078
|
-
process.stderr.write(
|
|
6079
|
-
`telegram gateway: WARN post-reply-tail flush (#1291) — model emitted ${flushDecision.text.length} chars after a prior reply call without a follow-up reply tool` +
|
|
6080
|
-
` chat=${chatId} turnStartedAt=${turn.startedAt}\n`,
|
|
6081
|
-
)
|
|
6082
|
-
}
|
|
6083
6062
|
if (flushDecision.kind === 'skip' && flushDecision.reason !== 'reply-called') {
|
|
6084
6063
|
process.stderr.write(
|
|
6085
6064
|
`telegram gateway: turn-flush skipped — reason=${flushDecision.reason}\n`,
|
|
@@ -14977,26 +14956,11 @@ void (async () => {
|
|
|
14977
14956
|
// inside the sub-agent. Belt-and-braces with PR #557's
|
|
14978
14957
|
// multi-signal progress gate.
|
|
14979
14958
|
parentStateDir: STATE_DIR,
|
|
14980
|
-
|
|
14981
|
-
|
|
14982
|
-
|
|
14983
|
-
|
|
14984
|
-
|
|
14985
|
-
// gateway. Notifications are best-effort.
|
|
14986
|
-
void swallowingApiCall(
|
|
14987
|
-
() =>
|
|
14988
|
-
lockedBot.api.sendMessage(ownerChatId, text, {
|
|
14989
|
-
parse_mode: 'HTML',
|
|
14990
|
-
link_preview_options: { is_disabled: true },
|
|
14991
|
-
...(TOPIC_ID != null ? { message_thread_id: TOPIC_ID } : {}),
|
|
14992
|
-
}),
|
|
14993
|
-
{
|
|
14994
|
-
chat_id: ownerChatId,
|
|
14995
|
-
verb: 'subagent-watcher-notification',
|
|
14996
|
-
...(TOPIC_ID != null ? { threadId: TOPIC_ID } : {}),
|
|
14997
|
-
},
|
|
14998
|
-
)
|
|
14999
|
-
},
|
|
14959
|
+
// No user-facing notification callback: the card-era
|
|
14960
|
+
// "✓ Worker done" message was retired with the progress
|
|
14961
|
+
// card (#1122). Sub-agent completion reaches the user as
|
|
14962
|
+
// the model's own beat-4 handback reply; the watcher's
|
|
14963
|
+
// role here is registry liveness + the `onFinish` cue.
|
|
15000
14964
|
log: (msg) => process.stderr.write(`telegram gateway: ${msg}\n`),
|
|
15001
14965
|
// Option C (#393): route stall detections into the progress-card
|
|
15002
14966
|
// driver so the pinned card re-renders with a ⚠️ indicator even
|
|
@@ -15063,22 +15027,24 @@ void (async () => {
|
|
|
15063
15027
|
// need nothing here, and 'orphan' is a stale historical-at-
|
|
15064
15028
|
// boot row, not a fresh completion the user is waiting on.
|
|
15065
15029
|
onFinish: ({ agentId, outcome, description, resultText }) => {
|
|
15066
|
-
|
|
15067
|
-
|
|
15068
|
-
|
|
15069
|
-
|
|
15030
|
+
// IO: resolve the fleet chat id and the background flag.
|
|
15031
|
+
// The DECISION (gating + inbound build) is delegated to
|
|
15032
|
+
// the pure `decideSubagentHandback` so it is unit-tested
|
|
15033
|
+
// independent of the gateway — see
|
|
15034
|
+
// `subagent-handback-decision.test.ts`.
|
|
15035
|
+
let fleetChatId = ''
|
|
15070
15036
|
let isBackground = false
|
|
15071
15037
|
try {
|
|
15072
15038
|
const fleets = progressDriver?.peekAllFleets() ?? []
|
|
15073
15039
|
for (const f of fleets) {
|
|
15074
15040
|
if (f.fleet.has(agentId)) {
|
|
15075
|
-
|
|
15041
|
+
fleetChatId = f.chatId ?? ''
|
|
15076
15042
|
break
|
|
15077
15043
|
}
|
|
15078
15044
|
}
|
|
15079
15045
|
} catch {
|
|
15080
15046
|
// peek failures are non-fatal — fall through to the
|
|
15081
|
-
// owner-chat fallback
|
|
15047
|
+
// owner-chat fallback inside decideSubagentHandback.
|
|
15082
15048
|
}
|
|
15083
15049
|
if (turnsDb != null) {
|
|
15084
15050
|
try {
|
|
@@ -15088,36 +15054,36 @@ void (async () => {
|
|
|
15088
15054
|
if (row != null) isBackground = row.background === 1
|
|
15089
15055
|
} catch { /* best-effort */ }
|
|
15090
15056
|
}
|
|
15091
|
-
|
|
15092
|
-
|
|
15093
|
-
|
|
15094
|
-
|
|
15095
|
-
|
|
15096
|
-
|
|
15097
|
-
|
|
15098
|
-
|
|
15099
|
-
|
|
15100
|
-
|
|
15101
|
-
|
|
15102
|
-
|
|
15057
|
+
|
|
15058
|
+
const decision = decideSubagentHandback({
|
|
15059
|
+
handbackEnvValue: process.env.SWITCHROOM_SUBAGENT_HANDBACK,
|
|
15060
|
+
outcome,
|
|
15061
|
+
isBackground,
|
|
15062
|
+
fleetChatId,
|
|
15063
|
+
// Owner-chat fallback: if the progress-driver fleet
|
|
15064
|
+
// entry was already cleaned up, route to the owner
|
|
15065
|
+
// chat. Every switchroom fleet agent is DM-shaped, so
|
|
15066
|
+
// allowFrom[0] is the conversation that dispatched.
|
|
15067
|
+
ownerChatId: loadAccess().allowFrom[0] ?? '',
|
|
15068
|
+
taskDescription: description,
|
|
15069
|
+
resultText,
|
|
15070
|
+
})
|
|
15071
|
+
if (!decision.deliver) {
|
|
15072
|
+
if (decision.reason === 'no-chat') {
|
|
15073
|
+
process.stderr.write(
|
|
15074
|
+
`telegram gateway: subagent-handback ${agentId} — no chat to deliver to; skipped\n`,
|
|
15075
|
+
)
|
|
15076
|
+
}
|
|
15103
15077
|
return
|
|
15104
15078
|
}
|
|
15105
15079
|
|
|
15106
|
-
const inbound = buildSubagentHandbackInbound({
|
|
15107
|
-
ctx: {
|
|
15108
|
-
chatId: String(handbackChatId),
|
|
15109
|
-
taskDescription: description,
|
|
15110
|
-
resultText,
|
|
15111
|
-
outcome,
|
|
15112
|
-
},
|
|
15113
|
-
})
|
|
15114
15080
|
// Deliver via pendingInboundBuffer + the idle-drain tick.
|
|
15115
15081
|
// The drain only releases at an idle prompt (no active
|
|
15116
15082
|
// turn), so the handback always lands as a clean fresh
|
|
15117
15083
|
// turn and never races a turn-in-flight composer (#1556).
|
|
15118
|
-
pendingInboundBuffer.push(process.env.SWITCHROOM_AGENT_NAME ?? '', inbound)
|
|
15084
|
+
pendingInboundBuffer.push(process.env.SWITCHROOM_AGENT_NAME ?? '', decision.inbound)
|
|
15119
15085
|
process.stderr.write(
|
|
15120
|
-
`telegram gateway: subagent-handback queued agent=${agentId} outcome=${outcome} chat=${
|
|
15086
|
+
`telegram gateway: subagent-handback queued agent=${agentId} outcome=${outcome} chat=${decision.chatId} resultChars=${resultText.length}\n`,
|
|
15121
15087
|
)
|
|
15122
15088
|
},
|
|
15123
15089
|
})
|
|
@@ -101,3 +101,85 @@ export function buildSubagentHandbackInbound(opts: {
|
|
|
101
101
|
},
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
|
+
|
|
105
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
106
|
+
// Handback decision (pure — unit-testable gate for the gateway onFinish path)
|
|
107
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Inputs to the handback decision. The gateway's `subagent-watcher`
|
|
111
|
+
* `onFinish` callback does the IO — resolves `isBackground` from the
|
|
112
|
+
* registry DB, `fleetChatId` from the progress-driver fleet, and
|
|
113
|
+
* `ownerChatId` from access.json — then hands the resolved values here.
|
|
114
|
+
* Keeping the *decision* pure makes the gate (which injects turns)
|
|
115
|
+
* testable without standing up a gateway.
|
|
116
|
+
*/
|
|
117
|
+
export interface SubagentHandbackDecisionInput {
|
|
118
|
+
/** `SWITCHROOM_SUBAGENT_HANDBACK` env var value (any non-'0' = enabled). */
|
|
119
|
+
handbackEnvValue: string | undefined
|
|
120
|
+
/** Terminal outcome the watcher reported. */
|
|
121
|
+
outcome: 'completed' | 'failed' | 'orphan'
|
|
122
|
+
/** Whether the sub-agent was a background dispatch (registry DB flag).
|
|
123
|
+
* Foreground sub-agents hand back natively in the parent's turn. */
|
|
124
|
+
isBackground: boolean
|
|
125
|
+
/** Chat id from the progress-driver fleet entry; '' if not found. */
|
|
126
|
+
fleetChatId: string
|
|
127
|
+
/** Owner chat fallback (access.json allowFrom[0]); '' if none. */
|
|
128
|
+
ownerChatId: string
|
|
129
|
+
taskDescription: string
|
|
130
|
+
resultText: string
|
|
131
|
+
/** Deterministic clock for tests. */
|
|
132
|
+
nowMs?: number
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Why a handback was NOT delivered — one of these, or `delivered`. */
|
|
136
|
+
export type SubagentHandbackSkipReason =
|
|
137
|
+
| 'env-disabled'
|
|
138
|
+
| 'outcome-not-terminal'
|
|
139
|
+
| 'foreground'
|
|
140
|
+
| 'no-chat'
|
|
141
|
+
|
|
142
|
+
export type SubagentHandbackDecision =
|
|
143
|
+
| { deliver: false; reason: SubagentHandbackSkipReason }
|
|
144
|
+
| { deliver: true; chatId: string; inbound: InboundMessage }
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Decide whether a finished sub-agent warrants a handback turn, and if
|
|
148
|
+
* so build the inbound. Pure: all IO is the caller's job.
|
|
149
|
+
*
|
|
150
|
+
* Gates, in order:
|
|
151
|
+
* 1. kill-switch — `SWITCHROOM_SUBAGENT_HANDBACK=0` disables entirely.
|
|
152
|
+
* 2. outcome — only `completed`/`failed` hand back; `orphan` is a
|
|
153
|
+
* stale historical-at-boot row, not a fresh completion.
|
|
154
|
+
* 3. foreground — a foreground sub-agent already handed its result
|
|
155
|
+
* back as the Task tool result in the parent's own turn.
|
|
156
|
+
* 4. no-chat — neither the fleet entry nor the owner chat resolved,
|
|
157
|
+
* so there is nowhere to deliver.
|
|
158
|
+
*/
|
|
159
|
+
export function decideSubagentHandback(
|
|
160
|
+
input: SubagentHandbackDecisionInput,
|
|
161
|
+
): SubagentHandbackDecision {
|
|
162
|
+
if (input.handbackEnvValue === '0') {
|
|
163
|
+
return { deliver: false, reason: 'env-disabled' }
|
|
164
|
+
}
|
|
165
|
+
if (input.outcome !== 'completed' && input.outcome !== 'failed') {
|
|
166
|
+
return { deliver: false, reason: 'outcome-not-terminal' }
|
|
167
|
+
}
|
|
168
|
+
if (!input.isBackground) {
|
|
169
|
+
return { deliver: false, reason: 'foreground' }
|
|
170
|
+
}
|
|
171
|
+
const chatId = input.fleetChatId || input.ownerChatId
|
|
172
|
+
if (!chatId) {
|
|
173
|
+
return { deliver: false, reason: 'no-chat' }
|
|
174
|
+
}
|
|
175
|
+
const inbound = buildSubagentHandbackInbound({
|
|
176
|
+
ctx: {
|
|
177
|
+
chatId,
|
|
178
|
+
taskDescription: input.taskDescription,
|
|
179
|
+
resultText: input.resultText,
|
|
180
|
+
outcome: input.outcome,
|
|
181
|
+
},
|
|
182
|
+
...(input.nowMs !== undefined ? { nowMs: input.nowMs } : {}),
|
|
183
|
+
})
|
|
184
|
+
return { deliver: true, chatId, inbound }
|
|
185
|
+
}
|