switchroom 0.18.9 → 0.18.10
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 -0
- package/dist/auth-broker/index.js +198 -13
- package/dist/cli/notion-write-pretool.mjs +1 -0
- package/dist/cli/switchroom.js +28 -4
- package/dist/host-control/main.js +3 -2
- package/dist/vault/approvals/kernel-server.js +2 -1
- package/dist/vault/broker/server.js +2 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +119 -37
- package/profiles/_shared/dev-protocol.md.hbs +42 -0
- package/skills/dev-protocol/SKILL.md +131 -0
- package/telegram-plugin/README.md +2 -1
- package/telegram-plugin/admin-commands/dispatch.test.ts +40 -2
- package/telegram-plugin/admin-commands/index.ts +6 -1
- package/telegram-plugin/bridge/bridge.ts +23 -1
- package/telegram-plugin/bridge/crash-breadcrumb.ts +42 -0
- package/telegram-plugin/chat-lock.ts +13 -0
- package/telegram-plugin/dist/bridge/bridge.js +24 -1
- package/telegram-plugin/dist/gateway/gateway.js +1831 -263
- package/telegram-plugin/dist/server.js +29 -2
- package/telegram-plugin/fallback-card-collapse.ts +131 -0
- package/telegram-plugin/gateway/bridge-dead-watchdog.ts +546 -0
- package/telegram-plugin/gateway/effort-command.ts +47 -3
- package/telegram-plugin/gateway/gateway.ts +1435 -211
- package/telegram-plugin/gateway/model-command.ts +94 -8
- package/telegram-plugin/gateway/pending-session-command.ts +365 -0
- package/telegram-plugin/gateway/permission-timeout.ts +25 -0
- package/telegram-plugin/gateway/resume-inbound-builder.ts +23 -3
- package/telegram-plugin/gateway/session-model-file.ts +166 -23
- package/telegram-plugin/gateway/stop-command.ts +56 -0
- package/telegram-plugin/photo-precheck.ts +201 -0
- package/telegram-plugin/quota-watch.ts +141 -2
- package/telegram-plugin/registry/subagents-schema.ts +26 -3
- package/telegram-plugin/registry/subagents.test.ts +67 -0
- package/telegram-plugin/retry-api-call.ts +31 -0
- package/telegram-plugin/subagent-watcher.ts +392 -1
- package/telegram-plugin/tests/bridge-dead-watchdog.test.ts +576 -0
- package/telegram-plugin/tests/buffer-gate-broadened.test.ts +11 -5
- package/telegram-plugin/tests/chat-lock-unhandled-rejection.test.ts +101 -0
- package/telegram-plugin/tests/crash-breadcrumb.test.ts +57 -0
- package/telegram-plugin/tests/effort-command.test.ts +59 -2
- package/telegram-plugin/tests/fallback-card-collapse.test.ts +104 -0
- package/telegram-plugin/tests/gateway-pending-command-wiring.test.ts +124 -0
- package/telegram-plugin/tests/gateway-secret-detect.test.ts +7 -1
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +19 -11
- package/telegram-plugin/tests/model-command.test.ts +46 -3
- package/telegram-plugin/tests/pending-session-command.test.ts +322 -0
- package/telegram-plugin/tests/permission-timeout.test.ts +26 -0
- package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +16 -0
- package/telegram-plugin/tests/photo-dimension-fallback.test.ts +129 -0
- package/telegram-plugin/tests/photo-precheck.test.ts +240 -0
- package/telegram-plugin/tests/photo-reroute-wiring.test.ts +85 -0
- package/telegram-plugin/tests/quota-watch.test.ts +225 -0
- package/telegram-plugin/tests/session-model-file.test.ts +101 -2
- package/telegram-plugin/tests/stop-command.test.ts +234 -0
- package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +27 -9
- package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +398 -0
- package/telegram-plugin/tests/subagent-watcher-stall-terminal.test.ts +172 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +37 -0
- package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +18 -4
- package/telegram-plugin/welcome-text.ts +4 -3
- package/telegram-plugin/worker-activity-feed.ts +27 -0
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
resolveInterruptMaxWaitMs,
|
|
42
42
|
resolveSafeBoundaryEnabled,
|
|
43
43
|
} from './interrupt-defer.js'
|
|
44
|
+
import { parseStopKeyword, buildStopReply } from './stop-command.js'
|
|
44
45
|
import { shouldPostBusyAck, formatBusyAckText, BUSY_ACK_STEP_AGE_THRESHOLD_MS } from './busy-ack.js'
|
|
45
46
|
import {
|
|
46
47
|
resolveStickerSendArgs,
|
|
@@ -123,6 +124,7 @@ import {
|
|
|
123
124
|
approvalTtlMs,
|
|
124
125
|
ttlForTool,
|
|
125
126
|
buildTimedOutCardEdits,
|
|
127
|
+
buildCancelledCardEdits,
|
|
126
128
|
STALE_TAP_NOTICE,
|
|
127
129
|
type PermissionCardRef,
|
|
128
130
|
} from './permission-timeout.js'
|
|
@@ -183,7 +185,9 @@ import {
|
|
|
183
185
|
createRetryApiCall,
|
|
184
186
|
createSwallowingRetryApiCall,
|
|
185
187
|
retryWithThreadFallback,
|
|
188
|
+
isPhotoDimensionRejectError,
|
|
186
189
|
} from '../retry-api-call.js'
|
|
190
|
+
import { classifyPhotoFile, rerouteResultSuffix } from '../photo-precheck.js'
|
|
187
191
|
import { installTgPostLogger, withTgPostTags } from '../shared/bot-runtime.js'
|
|
188
192
|
import { floodStatePath, makeFloodWaitRecorder } from '../flood-circuit-breaker.js'
|
|
189
193
|
import { buildAttachmentPath, assertInsideInbox } from '../attachment-path.js'
|
|
@@ -387,11 +391,14 @@ import {
|
|
|
387
391
|
MODEL_CALLBACK_PREFIX,
|
|
388
392
|
MODEL_CALLBACK_HEADER,
|
|
389
393
|
MODEL_CALLBACK_SR,
|
|
394
|
+
MODEL_CALLBACK_ALIAS,
|
|
390
395
|
MODEL_CALLBACK_PAGE_EXTERNAL,
|
|
391
396
|
MODEL_CALLBACK_PAGE_MAIN,
|
|
392
397
|
srFriendlyLabel,
|
|
393
398
|
expandSrAlias,
|
|
394
399
|
isSrModel,
|
|
400
|
+
isBusyRefusalText,
|
|
401
|
+
isOfflineTrustedModelToken,
|
|
395
402
|
type ModelMenuDeps,
|
|
396
403
|
type ModelCommandDeps,
|
|
397
404
|
type ModelMenuReply,
|
|
@@ -405,9 +412,16 @@ import {
|
|
|
405
412
|
writeRelaunchModelIntent,
|
|
406
413
|
clearRelaunchModelIntent,
|
|
407
414
|
intentForRestartReason,
|
|
415
|
+
readSessionModelFile,
|
|
416
|
+
RELAUNCH_MODEL_INTENT_FILE,
|
|
417
|
+
GATEWAY_SHUTDOWN_INTENT_REASON_PREFIX,
|
|
418
|
+
clearStaleGatewayShutdownIntent,
|
|
419
|
+
writeSessionEffortFile,
|
|
420
|
+
clearSessionEffortFile,
|
|
421
|
+
readSessionEffortFile,
|
|
408
422
|
} from './session-model-file.js'
|
|
409
423
|
import { discoverModels, selectModel } from '../../src/agents/model-picker.js'
|
|
410
|
-
import { resolveMainModel } from '../../src/agents/scaffold.js'
|
|
424
|
+
import { resolveMainModel, SWITCHROOM_DEFAULT_THINKING_EFFORT } from '../../src/agents/scaffold.js'
|
|
411
425
|
import {
|
|
412
426
|
parseEffortCommand,
|
|
413
427
|
handleEffortCommand,
|
|
@@ -417,6 +431,18 @@ import {
|
|
|
417
431
|
type EffortCommandDeps,
|
|
418
432
|
type EffortMenuReply,
|
|
419
433
|
} from './effort-command.js'
|
|
434
|
+
import {
|
|
435
|
+
createPendingSessionCommandSlots,
|
|
436
|
+
drainCapDecision as pendingCmdDrainCapDecision,
|
|
437
|
+
shutdownResolutionActions as pendingCmdShutdownResolutionActions,
|
|
438
|
+
resolveForRestart as pendingCmdResolveForRestart,
|
|
439
|
+
drainTakenCommands as pendingCmdDrainTaken,
|
|
440
|
+
type ShutdownResolutionAction,
|
|
441
|
+
ackText as pendingCmdAckText,
|
|
442
|
+
supersededText as pendingCmdSupersededText,
|
|
443
|
+
type PendingSessionCommand,
|
|
444
|
+
} from './pending-session-command.js'
|
|
445
|
+
import type { EffortLevel } from './effort-command.js'
|
|
420
446
|
import { registerSwitchroomBotCommands } from './register-bot-commands.js'
|
|
421
447
|
import { registerOpsInfoCommands } from './bot-commands-ops-info.js'
|
|
422
448
|
import { applyEffort } from '../../src/agents/effort-picker.js'
|
|
@@ -657,7 +683,14 @@ import {
|
|
|
657
683
|
buildQuotaClaimKey,
|
|
658
684
|
QUOTA_WATCH_CLAIM_WINDOW_MS,
|
|
659
685
|
isLiveCorroboration,
|
|
686
|
+
evaluateFleetRollAnnounce,
|
|
687
|
+
FLEET_ROLL_ANNOUNCE_KEY,
|
|
660
688
|
} from '../quota-watch.js'
|
|
689
|
+
import {
|
|
690
|
+
createModelUnavailableCardRegistry,
|
|
691
|
+
decideAnnouncementDelivery,
|
|
692
|
+
foldAnnouncementIntoCard,
|
|
693
|
+
} from '../fallback-card-collapse.js'
|
|
661
694
|
import { buildSnapshotsFromState, buildSnapshotsFromCachedState, zipProbeResults } from '../auth-snapshot-format.js'
|
|
662
695
|
import { maskUsername } from '../demo-mask.js'
|
|
663
696
|
import {
|
|
@@ -711,6 +744,13 @@ import {
|
|
|
711
744
|
buildResumeDeferredReportInbound,
|
|
712
745
|
decideBootResumeKind,
|
|
713
746
|
} from './resume-inbound-builder.js'
|
|
747
|
+
import {
|
|
748
|
+
createBridgeDeadWatchdog,
|
|
749
|
+
consumeBridgeDeadEscalationMarker,
|
|
750
|
+
buildBridgeDeadIdleNoticeInbound,
|
|
751
|
+
DEFAULT_BRIDGE_DEAD_GRACE_MS,
|
|
752
|
+
} from './bridge-dead-watchdog.js'
|
|
753
|
+
import { findAgentProcessInContainer } from './boot-probes.js'
|
|
714
754
|
import { applySubagentsSchema, getSubagentByJsonlId, resolveSubagentOriginTurnKey, listNonTerminalSubagentsForTurn } from '../registry/subagents-schema.js'
|
|
715
755
|
import type { InterruptedSubagent } from './resume-inbound-builder.js'
|
|
716
756
|
import { resolveWorkerFeedDispatch, type WorkerFeedDispatch } from './worker-feed-dispatch.js'
|
|
@@ -966,6 +1006,24 @@ function triggerSelfRestart(
|
|
|
966
1006
|
}
|
|
967
1007
|
}
|
|
968
1008
|
|
|
1009
|
+
// #3018 finding 4: a gateway-only bounce (supervisor relaunch, bare gateway
|
|
1010
|
+
// unit restart) leaves the shutdown handler's deploy-survival keep-intent
|
|
1011
|
+
// stamp on disk UNCONSUMED — start.sh only runs on a container-level boot.
|
|
1012
|
+
// If this gateway boot still sees a gateway-shutdown-stamped intent, the
|
|
1013
|
+
// preceding bounce was gateway-only: clear it so a genuine crash inside the
|
|
1014
|
+
// 10-min freshness window can't be converted into a "keep" (crash-reverts
|
|
1015
|
+
// policy intact). A real container stop/deploy consumes the file in start.sh
|
|
1016
|
+
// before any gateway boots, so a legitimate deploy stamp is never touched;
|
|
1017
|
+
// triggerSelfRestart / user-slash stamps use un-prefixed reasons.
|
|
1018
|
+
{
|
|
1019
|
+
const bootSmDir = resolveAgentDirFromEnv()
|
|
1020
|
+
if (bootSmDir != null && clearStaleGatewayShutdownIntent(bootSmDir)) {
|
|
1021
|
+
process.stderr.write(
|
|
1022
|
+
'telegram gateway: cleared stale gateway-shutdown relaunch-model intent (previous bounce was gateway-only — container never restarted)\n',
|
|
1023
|
+
)
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
|
|
969
1027
|
// Cached lazily — the claude CLI binary doesn't change inside a running
|
|
970
1028
|
// gateway process; on `switchroom update` the gateway restarts, refreshing this.
|
|
971
1029
|
let cachedClaudeCliVersion: string | null | undefined = undefined
|
|
@@ -1485,6 +1543,10 @@ let turnsDb: ReturnType<typeof openTurnsDb> | null = null
|
|
|
1485
1543
|
// Stashed here; pushed to the spool once it's constructed below. The spool's
|
|
1486
1544
|
// turn_key-keyed dedup makes a re-stash across multiple restarts a no-op.
|
|
1487
1545
|
let bootResumeInbound: { agent: string; msg: InboundMessage } | null = null
|
|
1546
|
+
// #3038 cross-boot damper: consecutive bridge-dead escalations by PRIOR
|
|
1547
|
+
// boots (the consumed marker's `count`; 0 when no fresh marker). Set in
|
|
1548
|
+
// the boot block below, consumed by the watchdog constructor further down.
|
|
1549
|
+
let bridgeDeadPriorStreak = 0
|
|
1488
1550
|
try {
|
|
1489
1551
|
// STATE_DIR is `<agentDir>/telegram` in production. openTurnsDb expects
|
|
1490
1552
|
// the parent (agent dir) and joins `telegram/registry.db` itself.
|
|
@@ -1540,6 +1602,32 @@ try {
|
|
|
1540
1602
|
process.stderr.write(`telegram gateway: turn-registry initialized at ${join(agentDir, 'telegram', 'registry.db')}\n`)
|
|
1541
1603
|
}
|
|
1542
1604
|
|
|
1605
|
+
// #3038 — bridge-dead escalation marker. If the PREVIOUS gateway bounced
|
|
1606
|
+
// this container because the MCP bridge died (see bridge-dead-watchdog.ts),
|
|
1607
|
+
// it left a marker so THIS boot's resume inbound can state the real cause
|
|
1608
|
+
// instead of implying an operator restart or a watchdog timeout. Consumed
|
|
1609
|
+
// (always cleared) whether or not a turn was in flight.
|
|
1610
|
+
const bridgeDeadMarker = consumeBridgeDeadEscalationMarker(
|
|
1611
|
+
join(STATE_DIR, 'bridge-dead-escalation.json'),
|
|
1612
|
+
)
|
|
1613
|
+
if (bridgeDeadMarker != null) {
|
|
1614
|
+
bridgeDeadPriorStreak = bridgeDeadMarker.count ?? 1
|
|
1615
|
+
process.stderr.write(
|
|
1616
|
+
`telegram gateway: boot: prior restart was a bridge-dead escalation (reason=${bridgeDeadMarker.reason}` +
|
|
1617
|
+
`, consecutive=${bridgeDeadPriorStreak}` +
|
|
1618
|
+
`${bridgeDeadMarker.crashTail ? `, crashTail=${bridgeDeadMarker.crashTail}` : ''})\n`,
|
|
1619
|
+
)
|
|
1620
|
+
}
|
|
1621
|
+
const bridgeDeadRestartCause = bridgeDeadMarker != null
|
|
1622
|
+
? {
|
|
1623
|
+
reason: bridgeDeadMarker.reason,
|
|
1624
|
+
note:
|
|
1625
|
+
'The framework itself triggered this restart: your Telegram MCP bridge process had died ' +
|
|
1626
|
+
'(chat tools were unavailable — you could not send replies), so the container was bounced ' +
|
|
1627
|
+
'to restore the chat surface. This was NOT an operator-initiated restart and NOT a hang-watchdog kill.',
|
|
1628
|
+
}
|
|
1629
|
+
: undefined
|
|
1630
|
+
|
|
1543
1631
|
// Build the boot resume/report inbound for the LATEST turn if it was
|
|
1544
1632
|
// interrupted. selectResumeBuilder owns the resume-vs-report policy.
|
|
1545
1633
|
const pending = findLatestTurnIfInterrupted(turnsDb)
|
|
@@ -1613,7 +1701,11 @@ try {
|
|
|
1613
1701
|
if (bootResumeKind === 'resume') {
|
|
1614
1702
|
bootResumeInbound = {
|
|
1615
1703
|
agent: selfAgent,
|
|
1616
|
-
msg: buildResumeInterruptedInbound({
|
|
1704
|
+
msg: buildResumeInterruptedInbound({
|
|
1705
|
+
turn: pending,
|
|
1706
|
+
subagents: interruptedSubagents,
|
|
1707
|
+
restartCause: bridgeDeadRestartCause,
|
|
1708
|
+
}),
|
|
1617
1709
|
}
|
|
1618
1710
|
} else if (bootResumeKind === 'report') {
|
|
1619
1711
|
// idleMs: this boot's measured marker age if it just classified this
|
|
@@ -1629,7 +1721,12 @@ try {
|
|
|
1629
1721
|
if (idleMs == null) idleMs = Math.max(0, Date.now() - pending.started_at)
|
|
1630
1722
|
bootResumeInbound = {
|
|
1631
1723
|
agent: selfAgent,
|
|
1632
|
-
msg: buildResumeWatchdogReportInbound({
|
|
1724
|
+
msg: buildResumeWatchdogReportInbound({
|
|
1725
|
+
turn: pending,
|
|
1726
|
+
idleMs,
|
|
1727
|
+
subagents: interruptedSubagents,
|
|
1728
|
+
restartCause: bridgeDeadRestartCause,
|
|
1729
|
+
}),
|
|
1633
1730
|
}
|
|
1634
1731
|
} else if (bootResumeKind === 'defer-loop' || bootResumeKind === 'defer-suppressed') {
|
|
1635
1732
|
// Passive deferred-report: work was in flight but we decline to
|
|
@@ -1641,6 +1738,7 @@ try {
|
|
|
1641
1738
|
turn: pending,
|
|
1642
1739
|
reason: bootResumeKind === 'defer-loop' ? 'loop-guard' : 'clean-restart-suppressed',
|
|
1643
1740
|
subagents: interruptedSubagents,
|
|
1741
|
+
restartCause: bridgeDeadRestartCause,
|
|
1644
1742
|
}),
|
|
1645
1743
|
}
|
|
1646
1744
|
}
|
|
@@ -1665,6 +1763,38 @@ try {
|
|
|
1665
1763
|
}
|
|
1666
1764
|
}
|
|
1667
1765
|
|
|
1766
|
+
// #3038 review finding 2 — idle-case honesty. When the previous boot was
|
|
1767
|
+
// a bridge-dead escalation but NO turn was interrupted (idle agent, dead
|
|
1768
|
+
// bridge), the consumed marker's cause would otherwise reach stderr only:
|
|
1769
|
+
// the agent and the user never learn why the container bounced. Surface
|
|
1770
|
+
// it once, via the same boot-inbound channel the resume path uses, routed
|
|
1771
|
+
// to the agent's default/owner chat.
|
|
1772
|
+
if (bridgeDeadMarker != null && bootResumeInbound == null && selfAgent) {
|
|
1773
|
+
const idleNoticeChat = (() => {
|
|
1774
|
+
try {
|
|
1775
|
+
return loadAccess().allowFrom[0] ?? null
|
|
1776
|
+
} catch {
|
|
1777
|
+
return null
|
|
1778
|
+
}
|
|
1779
|
+
})()
|
|
1780
|
+
if (idleNoticeChat != null) {
|
|
1781
|
+
bootResumeInbound = {
|
|
1782
|
+
agent: selfAgent,
|
|
1783
|
+
msg: buildBridgeDeadIdleNoticeInbound({
|
|
1784
|
+
chatId: String(idleNoticeChat),
|
|
1785
|
+
marker: bridgeDeadMarker,
|
|
1786
|
+
}),
|
|
1787
|
+
}
|
|
1788
|
+
process.stderr.write(
|
|
1789
|
+
`telegram gateway: boot: bridge-dead idle notice queued chat=${idleNoticeChat} (no turn was in flight)\n`,
|
|
1790
|
+
)
|
|
1791
|
+
} else {
|
|
1792
|
+
process.stderr.write(
|
|
1793
|
+
`telegram gateway: boot: bridge-dead idle notice skipped — no allowFrom chat to surface it in\n`,
|
|
1794
|
+
)
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1668
1798
|
// Diagnostic env file (one-shot, sourced by start.sh) — kept for the
|
|
1669
1799
|
// wake-audit context. The injected inbound above is the real wake signal;
|
|
1670
1800
|
// these vars are passive context only.
|
|
@@ -2619,6 +2749,19 @@ async function deliverButtonTapInbound(
|
|
|
2619
2749
|
|
|
2620
2750
|
const pendingRestarts = new Map<string, number>() // agentName -> timestamp when restart was requested
|
|
2621
2751
|
|
|
2752
|
+
// Deterministic ack-queue-apply-confirm for /model + /effort issued mid-turn
|
|
2753
|
+
// (#3017). One slot per kind (model|effort): same-kind last-write-wins,
|
|
2754
|
+
// cross-kind coexist (#3018 finding 2). Enqueued at the busy gates (typed
|
|
2755
|
+
// and menu), drained at the SAME model-idle gate as pendingRestarts (turn
|
|
2756
|
+
// complete + the reaper cap), which then EDITS the ack card into the
|
|
2757
|
+
// confirmation. See pending-session-command.ts for the contract.
|
|
2758
|
+
const pendingSessionCommand = createPendingSessionCommandSlots()
|
|
2759
|
+
// Bounded drain-cap for a queued /model|/effort command whose session never
|
|
2760
|
+
// cleanly idles — mirrors PENDING_RESTART_DRAIN_CAP_MS. After this, force the
|
|
2761
|
+
// apply-or-report so the ack card never dangles unresolved — but ONLY while
|
|
2762
|
+
// no turn is in flight (#3018 finding 1; see drainCapDecision).
|
|
2763
|
+
const PENDING_CMD_DRAIN_CAP_MS = 60_000
|
|
2764
|
+
|
|
2622
2765
|
// ─── Proactive context compaction (session.max_context_tokens) ──────────
|
|
2623
2766
|
//
|
|
2624
2767
|
// Opt-in: when the resolved agent config sets session.max_context_tokens,
|
|
@@ -3766,6 +3909,194 @@ async function fireDeferredInterrupt(reason: 'boundary' | 'timeout'): Promise<vo
|
|
|
3766
3909
|
}
|
|
3767
3910
|
}
|
|
3768
3911
|
|
|
3912
|
+
// #3020 — halt-now (the operator kill switch: empty `!`, `/stop`, bare "stop").
|
|
3913
|
+
//
|
|
3914
|
+
// Unlike a `!` interrupt WITH a body (which stashes a replacement inbound in
|
|
3915
|
+
// `pendingDeferredInterrupt` and re-delivers it), a halt has no replacement:
|
|
3916
|
+
// the operator just wants the in-flight turn dead. Shared sequence:
|
|
3917
|
+
// 1. Honor the safe-boundary deferral (same `decideInterruptTiming` +
|
|
3918
|
+
// max-wait config the `!` path uses) so we don't C-c mid-tool-call.
|
|
3919
|
+
// 2. SIGINT via tmux send-keys (`sendAgentInterrupt`) — the same primitive
|
|
3920
|
+
// the `!` path fires.
|
|
3921
|
+
// 3. Cancel the killed turn's obligation (the user explicitly cancelled it;
|
|
3922
|
+
// re-presenting it later would be wrong).
|
|
3923
|
+
// 4. Deterministically release the turn's busy state. With no replacement
|
|
3924
|
+
// inbound there is no later event guaranteed to end the turn — an
|
|
3925
|
+
// interrupted claude turn may never emit `turn_end`, and leaning on the
|
|
3926
|
+
// busy-key reaper leaves the chat gated for minutes. Same primitives
|
|
3927
|
+
// the reply path uses (`endCurrentTurnAtomic` + `releaseTurnBufferGate`),
|
|
3928
|
+
// both idempotent, reaper stays as backstop. The obligation cancel in
|
|
3929
|
+
// step 3 runs FIRST so `endCurrentTurnAtomic`'s no-reply branch is a
|
|
3930
|
+
// ledger no-op instead of stamping a grace clock on a cancelled turn.
|
|
3931
|
+
//
|
|
3932
|
+
// The boundary wait is event-driven: `waitForSafeBoundary` parks a waiter
|
|
3933
|
+
// that the session-event ingest kicks (`notifyHaltBoundaryWaiters`) whenever
|
|
3934
|
+
// the tracker might have drained, with the configured max-wait as the bound.
|
|
3935
|
+
|
|
3936
|
+
const haltBoundaryWaiters = new Set<() => void>()
|
|
3937
|
+
|
|
3938
|
+
function notifyHaltBoundaryWaiters(): void {
|
|
3939
|
+
if (haltBoundaryWaiters.size === 0) return
|
|
3940
|
+
for (const check of [...haltBoundaryWaiters]) check()
|
|
3941
|
+
}
|
|
3942
|
+
|
|
3943
|
+
function waitForSafeBoundary(maxWaitMs: number): Promise<'boundary' | 'timeout'> {
|
|
3944
|
+
if (!toolFlightTracker.isMidToolCall()) return Promise.resolve('boundary')
|
|
3945
|
+
return new Promise(resolve => {
|
|
3946
|
+
let done = false
|
|
3947
|
+
const finish = (reason: 'boundary' | 'timeout') => {
|
|
3948
|
+
if (done) return
|
|
3949
|
+
done = true
|
|
3950
|
+
clearTimeout(timer)
|
|
3951
|
+
haltBoundaryWaiters.delete(check)
|
|
3952
|
+
resolve(reason)
|
|
3953
|
+
}
|
|
3954
|
+
const timer = setTimeout(() => finish('timeout'), maxWaitMs)
|
|
3955
|
+
const check = () => {
|
|
3956
|
+
if (!toolFlightTracker.isMidToolCall()) finish('boundary')
|
|
3957
|
+
}
|
|
3958
|
+
haltBoundaryWaiters.add(check)
|
|
3959
|
+
})
|
|
3960
|
+
}
|
|
3961
|
+
|
|
3962
|
+
// #3020 item 4 — a halted turn may be suspended INSIDE an MCP permission
|
|
3963
|
+
// call with a live Approve/Deny card in chat. The card's pending entry keeps
|
|
3964
|
+
// the buffer gate closed (`hasPendingApproval`), and a later Approve tap
|
|
3965
|
+
// would dispatch a verdict into an idle session. Deny each pending request
|
|
3966
|
+
// (unblocking the suspended call before/as the C-c lands) and edit its
|
|
3967
|
+
// card(s) to a cancelled state with the keyboard stripped.
|
|
3968
|
+
function cancelPendingPermissionsForHalt(origin: string): void {
|
|
3969
|
+
if (pendingPermissions.size === 0) return
|
|
3970
|
+
for (const [requestId, details] of pendingPermissions) {
|
|
3971
|
+
// halted-turn-verdict: the turn this card parked is being KILLED by the
|
|
3972
|
+
// operator — there is nothing to resume, so no resumeReactionAfterVerdict
|
|
3973
|
+
// / postPermissionResumeMessage pairing. The card edit ("⏹ Cancelled")
|
|
3974
|
+
// is the visible terminal state and the /stop reply is the operator ack.
|
|
3975
|
+
dispatchPermissionVerdict({
|
|
3976
|
+
type: 'permission',
|
|
3977
|
+
requestId,
|
|
3978
|
+
behavior: 'deny',
|
|
3979
|
+
message: 'Cancelled — the operator stopped this turn. Do not retry.',
|
|
3980
|
+
})
|
|
3981
|
+
void stripCancelledPermissionCards(details.card_text, details.cards)
|
|
3982
|
+
pendingPermissions.delete(requestId)
|
|
3983
|
+
permCardStore.remove(requestId)
|
|
3984
|
+
process.stderr.write(
|
|
3985
|
+
`telegram gateway: halt-now cancelled pending permission origin=${origin} ` +
|
|
3986
|
+
`request=${requestId} tool=${details.tool_name}\n`,
|
|
3987
|
+
)
|
|
3988
|
+
}
|
|
3989
|
+
}
|
|
3990
|
+
|
|
3991
|
+
async function stripCancelledPermissionCards(
|
|
3992
|
+
cardText: string,
|
|
3993
|
+
cards: PermissionCardRef[],
|
|
3994
|
+
): Promise<void> {
|
|
3995
|
+
for (const edit of buildCancelledCardEdits(cardText, cards)) {
|
|
3996
|
+
await swallowingApiCall(
|
|
3997
|
+
// allow-raw-bot-api: routed through swallowingApiCall (retry policy); message-id-targeted edit (no thread to lose). Passing {} as opts (no reply_markup) strips the stale Allow/Deny keyboard atomically with the text edit.
|
|
3998
|
+
() => bot.api.editMessageText(edit.chatId, edit.messageId, richMessage(edit.text), {}),
|
|
3999
|
+
{ chat_id: edit.chatId, verb: 'permission_halt.strip' },
|
|
4000
|
+
)
|
|
4001
|
+
}
|
|
4002
|
+
}
|
|
4003
|
+
|
|
4004
|
+
async function executeHaltNow(origin: string): Promise<void> {
|
|
4005
|
+
const agentName = process.env.SWITCHROOM_AGENT_NAME
|
|
4006
|
+
if (!agentName) return
|
|
4007
|
+
// #3020 item 3 — snapshot the halt target at request entry. The boundary
|
|
4008
|
+
// wait below can park for seconds; if the targeted turn ends naturally in
|
|
4009
|
+
// that window and a NEW turn starts, firing the C-c would kill an innocent
|
|
4010
|
+
// turn the requester never saw. Identity is the currentTurn object itself
|
|
4011
|
+
// (registryKey included, for the log line).
|
|
4012
|
+
const haltTarget = currentTurn
|
|
4013
|
+
const haltTargetKey = haltTarget?.registryKey ?? null
|
|
4014
|
+
const access = loadAccess()
|
|
4015
|
+
const timing = decideInterruptTiming({
|
|
4016
|
+
safeBoundaryEnabled: resolveSafeBoundaryEnabled(access.interruptSafeBoundary),
|
|
4017
|
+
midToolCall: toolFlightTracker.isMidToolCall(),
|
|
4018
|
+
})
|
|
4019
|
+
const startedAt = Date.now()
|
|
4020
|
+
if (timing === 'defer') {
|
|
4021
|
+
const maxWaitMs = resolveInterruptMaxWaitMs(access.interruptMaxWaitMs)
|
|
4022
|
+
const reason = await waitForSafeBoundary(maxWaitMs)
|
|
4023
|
+
process.stderr.write(
|
|
4024
|
+
`telegram gateway: halt-now boundary-wait origin=${origin} reason=${reason} ` +
|
|
4025
|
+
`waited_ms=${Date.now() - startedAt} in_flight=${toolFlightTracker.inFlightCount()}\n`,
|
|
4026
|
+
)
|
|
4027
|
+
}
|
|
4028
|
+
// #3020 item 3 — wrong-turn kill guard: if the turn changed while we
|
|
4029
|
+
// waited (the target ended naturally; a new turn may already be running),
|
|
4030
|
+
// no-op the C-c AND the teardown — the requester's turn already ended and
|
|
4031
|
+
// the current busy/permission state belongs to someone else. This also
|
|
4032
|
+
// skips the benign-but-pointless idle-session C-c when target-ended → idle.
|
|
4033
|
+
if (currentTurn !== haltTarget) {
|
|
4034
|
+
process.stderr.write(
|
|
4035
|
+
`telegram gateway: halt-now skipped — turn changed during boundary wait ` +
|
|
4036
|
+
`origin=${origin} target_key=${haltTargetKey ?? 'none'} ` +
|
|
4037
|
+
`current_key=${currentTurn?.registryKey ?? 'none'}\n`,
|
|
4038
|
+
)
|
|
4039
|
+
return
|
|
4040
|
+
}
|
|
4041
|
+
try {
|
|
4042
|
+
// Same tmux-direct rationale as the `!` path: the gateway runs inside the
|
|
4043
|
+
// agent container, so PID-probing interruptAgent can't work — send-keys can.
|
|
4044
|
+
const { sendAgentInterrupt } = await import('../../src/agents/tmux.js')
|
|
4045
|
+
const r = sendAgentInterrupt({ agentName })
|
|
4046
|
+
if ('ok' in r) {
|
|
4047
|
+
process.stderr.write(
|
|
4048
|
+
`telegram gateway: halt-now SIGINT delivered via tmux send-keys origin=${origin} agent=${agentName}\n`,
|
|
4049
|
+
)
|
|
4050
|
+
} else {
|
|
4051
|
+
process.stderr.write(
|
|
4052
|
+
`telegram gateway: halt-now SIGINT via tmux failed origin=${origin} agent=${agentName}: ${r.error}\n`,
|
|
4053
|
+
)
|
|
4054
|
+
}
|
|
4055
|
+
} catch (err) {
|
|
4056
|
+
process.stderr.write(`telegram gateway: halt-now SIGINT failed origin=${origin}: ${(err as Error).message}\n`)
|
|
4057
|
+
}
|
|
4058
|
+
// The SIGINT just killed the in-flight turn — cancel its obligation so the
|
|
4059
|
+
// cancelled question isn't re-presented/escalated later. Must run before the
|
|
4060
|
+
// teardown below (it reads `currentTurn`).
|
|
4061
|
+
cancelInterruptedObligation()
|
|
4062
|
+
// Item 4 — deny + strip any Approve/Deny cards belonging to the halted
|
|
4063
|
+
// turn so the buffer gate (`hasPendingApproval`) opens and a later tap
|
|
4064
|
+
// can't dispatch into an idle session.
|
|
4065
|
+
cancelPendingPermissionsForHalt(origin)
|
|
4066
|
+
// Deterministic busy release (step 4 above).
|
|
4067
|
+
const turn = currentTurn
|
|
4068
|
+
if (turn != null) {
|
|
4069
|
+
// Stamp the registry row as a CLEAN end (`endedVia: 'stop'`). A killed
|
|
4070
|
+
// turn never emits the session `turn_end` event, so without this the row
|
|
4071
|
+
// stays open (`ended_at IS NULL`) and `findLatestTurnIfInterrupted` would
|
|
4072
|
+
// fire a resume_interrupted synthetic for it at the next boot — resuming
|
|
4073
|
+
// work the operator explicitly cancelled. 'stop' is the resume gate's
|
|
4074
|
+
// "ended cleanly" verdict, which is exactly what a deliberate cancel is.
|
|
4075
|
+
if (turnsDb != null && turn.registryKey != null) {
|
|
4076
|
+
const _turnKey = turn.registryKey
|
|
4077
|
+
try {
|
|
4078
|
+
recordTurnEnd(turnsDb, {
|
|
4079
|
+
turnKey: _turnKey,
|
|
4080
|
+
endedVia: 'stop' as const,
|
|
4081
|
+
lastAssistantMsgId: turn.lastAssistantMsgId,
|
|
4082
|
+
lastAssistantDone: turn.lastAssistantDone,
|
|
4083
|
+
toolCallCount: turn.toolCallCount,
|
|
4084
|
+
})
|
|
4085
|
+
} catch (err) {
|
|
4086
|
+
process.stderr.write(`telegram gateway: recordTurnEnd(halt) failed turnKey=${_turnKey}: ${(err as Error).message}\n`)
|
|
4087
|
+
}
|
|
4088
|
+
}
|
|
4089
|
+
const key = statusKey(turn.sessionChatId, turn.sessionThreadId)
|
|
4090
|
+
endCurrentTurnAtomic(turn)
|
|
4091
|
+
releaseTurnBufferGate(key, turn)
|
|
4092
|
+
}
|
|
4093
|
+
// #3020 item 5 — the busy release above may have opened the idle gate, and
|
|
4094
|
+
// no turn_end event follows a halt to trigger the usual idle-drain hook.
|
|
4095
|
+
// Kick the queued session-command drain now so a queued /model or /effort
|
|
4096
|
+
// applies promptly instead of waiting for the 60s reaper.
|
|
4097
|
+
if (!turnInFlightForGate()) void drainPendingSessionCommand()
|
|
4098
|
+
}
|
|
4099
|
+
|
|
3769
4100
|
// #549 fix — preamble suppression for the answer-stream path.
|
|
3770
4101
|
//
|
|
3771
4102
|
// Background: assistant text emitted before a tool_use is "preamble"
|
|
@@ -4103,6 +4434,13 @@ function purgeReactionTracking(key: string, endingTurn?: CurrentTurn): void {
|
|
|
4103
4434
|
// serialize gate): a pending self-restart or proactive compaction must
|
|
4104
4435
|
// fire when claude is idle regardless of whether the last turn replied.
|
|
4105
4436
|
if (!turnInFlightForGate()) {
|
|
4437
|
+
// Apply any /model|/effort command queued mid-turn (#3017) BEFORE the
|
|
4438
|
+
// restart drain reads the pending-restart map — drainPendingSessionCommand
|
|
4439
|
+
// itself checks pendingRestarts and, when a restart is also pending,
|
|
4440
|
+
// reports "restarting" on the ack card instead of a false confirmation.
|
|
4441
|
+
// The sr-*→Claude menu apply may ITSELF enqueue a restart, which the
|
|
4442
|
+
// restart drain below then picks up. Async + best-effort (own try/catch).
|
|
4443
|
+
void drainPendingSessionCommand()
|
|
4106
4444
|
if (pendingRestarts.size > 0) {
|
|
4107
4445
|
for (const [agentName, _timestamp] of pendingRestarts.entries()) {
|
|
4108
4446
|
triggerSelfRestart(agentName, 'turn-complete-pending-restart');
|
|
@@ -6389,6 +6727,34 @@ const pendingStateReaper = setInterval(() => {
|
|
|
6389
6727
|
triggerSelfRestart(agentName, 'restart-drain-cap-forced', 100)
|
|
6390
6728
|
}
|
|
6391
6729
|
}
|
|
6730
|
+
// Drain cap for queued /model|/effort commands (#3017): if the session
|
|
6731
|
+
// never cleanly idled, force the apply-or-report so the ack card never
|
|
6732
|
+
// dangles unresolved. drainPendingSessionCommand no-ops on an idle-gate race
|
|
6733
|
+
// (its own re-entrancy guard) and reports honestly when a restart is pending.
|
|
6734
|
+
// #3018 finding 1: NEVER force while a turn is genuinely in flight — a
|
|
6735
|
+
// forced mid-turn drain hits the typed model handler's busy gate (edits the
|
|
6736
|
+
// ack card into the old "try again" refusal, choice destroyed) and /effort
|
|
6737
|
+
// has no busy gate at all (would type into the live claude input). The cap
|
|
6738
|
+
// only rescues the missed-idle-gate case; mid-turn we keep waiting and the
|
|
6739
|
+
// idle gate drains at turn end.
|
|
6740
|
+
const queuedCmds = pendingSessionCommand.list()
|
|
6741
|
+
const cmdDecision = pendingCmdDrainCapDecision(
|
|
6742
|
+
queuedCmds,
|
|
6743
|
+
now,
|
|
6744
|
+
PENDING_CMD_DRAIN_CAP_MS,
|
|
6745
|
+
turnInFlightForGate(),
|
|
6746
|
+
)
|
|
6747
|
+
if (cmdDecision === 'defer-turn-in-flight') {
|
|
6748
|
+
process.stderr.write(
|
|
6749
|
+
`telegram gateway: [pending-cmd-drain] deferred reason=turn-in-flight queued=${queuedCmds.map(c => c.kind).join(',')}\n`,
|
|
6750
|
+
)
|
|
6751
|
+
} else if (cmdDecision === 'force') {
|
|
6752
|
+
const oldest = Math.min(...queuedCmds.map(c => c.requestedAt))
|
|
6753
|
+
process.stderr.write(
|
|
6754
|
+
`telegram gateway: [pending-cmd-drain] forcing queued=${queuedCmds.map(c => `${c.kind}:${c.targetLabel}`).join(',')} waited=${Math.round((now - oldest) / 1000)}s threshold=${Math.round(PENDING_CMD_DRAIN_CAP_MS / 1000)}s\n`,
|
|
6755
|
+
)
|
|
6756
|
+
void drainPendingSessionCommand()
|
|
6757
|
+
}
|
|
6392
6758
|
}, 60_000)
|
|
6393
6759
|
pendingStateReaper.unref()
|
|
6394
6760
|
|
|
@@ -6560,6 +6926,10 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
|
|
|
6560
6926
|
const modelUnavailable = resolveModelUnavailableFromOperatorEvent(event)
|
|
6561
6927
|
let renderedText: string
|
|
6562
6928
|
let renderedKeyboard: ReturnType<typeof renderOperatorEvent>['keyboard'] | undefined
|
|
6929
|
+
// #3031 PR 3 — when the card promises an in-flight auto-failover, record
|
|
6930
|
+
// each per-chat send in the collapse registry so a SUCCESSFUL swap edits
|
|
6931
|
+
// the card (single evolving message) instead of sending a second one.
|
|
6932
|
+
let cardPromisedFallback = false
|
|
6563
6933
|
if (modelUnavailable) {
|
|
6564
6934
|
// Two questions, asked synchronously to avoid the "card promises
|
|
6565
6935
|
// an announcement that never arrives" trap:
|
|
@@ -6584,6 +6954,7 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
|
|
|
6584
6954
|
autoFallbackInFlight: willActuallyFire,
|
|
6585
6955
|
})
|
|
6586
6956
|
renderedKeyboard = undefined
|
|
6957
|
+
cardPromisedFallback = willActuallyFire
|
|
6587
6958
|
// Trigger fleet-wide auto-fallback. Pre-fix this branch only
|
|
6588
6959
|
// rendered the card; the fallback machinery was unreachable from
|
|
6589
6960
|
// here. We fire-and-forget so card delivery is never blocked on
|
|
@@ -6654,11 +7025,26 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
|
|
|
6654
7025
|
// very next line is what unlocks the raw bot.api call.
|
|
6655
7026
|
// Opts now includes message_thread_id when supergroup mode is on.
|
|
6656
7027
|
// allow-raw-bot-api: operator-event broadcast loop; topic-aware opts
|
|
6657
|
-
void bot.api.sendRichMessage(chat_id, richMessage(renderedText), opts as never)
|
|
6658
|
-
|
|
6659
|
-
|
|
6660
|
-
|
|
6661
|
-
|
|
7028
|
+
void bot.api.sendRichMessage(chat_id, richMessage(renderedText), opts as never)
|
|
7029
|
+
.then((sent: { message_id: number }) => {
|
|
7030
|
+
// #3031 PR 3 — remember the fallback-promising card so the swap
|
|
7031
|
+
// announcement can EDIT it (collapse) instead of sending anew.
|
|
7032
|
+
// Recorded only after the send resolves (we need the message_id);
|
|
7033
|
+
// the collapse path degrades to a plain send when no record exists.
|
|
7034
|
+
if (cardPromisedFallback) {
|
|
7035
|
+
modelUnavailableCardRegistry.record(String(chat_id), {
|
|
7036
|
+
messageId: sent.message_id,
|
|
7037
|
+
text: renderedText,
|
|
7038
|
+
atMs: Date.now(),
|
|
7039
|
+
promisedFallback: true,
|
|
7040
|
+
})
|
|
7041
|
+
}
|
|
7042
|
+
})
|
|
7043
|
+
.catch(e => {
|
|
7044
|
+
process.stderr.write(
|
|
7045
|
+
`telegram gateway: operator-event send to ${chat_id} failed agent=${agent} kind=${kind}: ${e}\n`,
|
|
7046
|
+
)
|
|
7047
|
+
})
|
|
6662
7048
|
}
|
|
6663
7049
|
}
|
|
6664
7050
|
|
|
@@ -8789,6 +9175,51 @@ async function stripStalePermissionCard(card: PersistedPermCard): Promise<void>
|
|
|
8789
9175
|
}
|
|
8790
9176
|
}
|
|
8791
9177
|
|
|
9178
|
+
// ─── #3038 — bridge-dead watchdog ────────────────────────────────────────
|
|
9179
|
+
// When the gateway (re)starts, the MCP bridge inside the running claude
|
|
9180
|
+
// session normally re-registers on the IPC socket within seconds. If it
|
|
9181
|
+
// died with the previous gateway (Claude Code never respawns a dead MCP
|
|
9182
|
+
// server), the session stays alive but toolless/mute forever. This
|
|
9183
|
+
// watchdog escalates: no real bridge registered within the grace window
|
|
9184
|
+
// while claude is alive → bounce the container once (reason
|
|
9185
|
+
// 'bridge-dead-resume') so the MCP server respawns. See
|
|
9186
|
+
// bridge-dead-watchdog.ts for the guard rails.
|
|
9187
|
+
// Config: SWITCHROOM_BRIDGE_DEAD_GRACE_MS (default 90s),
|
|
9188
|
+
// SWITCHROOM_BRIDGE_DEAD_ESCALATION=0 disables.
|
|
9189
|
+
const BRIDGE_DEAD_ESCALATION_ENABLED = process.env.SWITCHROOM_BRIDGE_DEAD_ESCALATION !== '0'
|
|
9190
|
+
const BRIDGE_DEAD_GRACE_MS = (() => {
|
|
9191
|
+
const v = Number(process.env.SWITCHROOM_BRIDGE_DEAD_GRACE_MS)
|
|
9192
|
+
return Number.isFinite(v) && v > 0 ? v : DEFAULT_BRIDGE_DEAD_GRACE_MS
|
|
9193
|
+
})()
|
|
9194
|
+
const bridgeDeadWatchdog = createBridgeDeadWatchdog({
|
|
9195
|
+
graceMs: BRIDGE_DEAD_GRACE_MS,
|
|
9196
|
+
// Require a CONFIDENT claude match (comm === 'claude'), not
|
|
9197
|
+
// findAgentProcessInContainer's heaviest-node fallback: an orphaned or
|
|
9198
|
+
// unrelated node process must never flip a "claude not up" retry into a
|
|
9199
|
+
// container bounce (#3038 review finding 4). False negatives are safe —
|
|
9200
|
+
// the watchdog then retries forever instead of escalating.
|
|
9201
|
+
isSessionAlive: () => findAgentProcessInContainer()?.comm === 'claude',
|
|
9202
|
+
// `shuttingDown` is declared (let, module scope) further down this file;
|
|
9203
|
+
// the closure only runs when the grace timer fires, long after module
|
|
9204
|
+
// init completes, so the TDZ is never hit.
|
|
9205
|
+
isShuttingDown: () => shuttingDown,
|
|
9206
|
+
escalate: (reason) =>
|
|
9207
|
+
triggerSelfRestart(process.env.SWITCHROOM_AGENT_NAME ?? '', reason, 1500),
|
|
9208
|
+
crashLogPath: join(STATE_DIR, 'bridge-crash.log'),
|
|
9209
|
+
markerPath: join(STATE_DIR, 'bridge-dead-escalation.json'),
|
|
9210
|
+
log: (line) => process.stderr.write(`${line}\n`),
|
|
9211
|
+
// Cross-boot damper (#3038 review finding 1): the consumed marker's
|
|
9212
|
+
// consecutive-escalation count. At the cap, arm() stands down loudly
|
|
9213
|
+
// instead of restart-looping a deterministically-failing bridge.
|
|
9214
|
+
priorStreak: bridgeDeadPriorStreak,
|
|
9215
|
+
})
|
|
9216
|
+
if (BRIDGE_DEAD_ESCALATION_ENABLED) {
|
|
9217
|
+
bridgeDeadWatchdog.arm()
|
|
9218
|
+
process.stderr.write(
|
|
9219
|
+
`telegram gateway: [bridge-dead-watchdog] armed (grace=${BRIDGE_DEAD_GRACE_MS}ms)\n`,
|
|
9220
|
+
)
|
|
9221
|
+
}
|
|
9222
|
+
|
|
8792
9223
|
const ipcServer: IpcServer = createIpcServer({
|
|
8793
9224
|
socketPath: SOCKET_PATH,
|
|
8794
9225
|
|
|
@@ -8818,6 +9249,13 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
8818
9249
|
const bridgeUpEffects = client.agentName != null
|
|
8819
9250
|
? shadowEmit({ kind: 'bridgeUp', at: Date.now() })
|
|
8820
9251
|
: []
|
|
9252
|
+
// #3038 — a REAL (named, non-cron) bridge registered: stand the
|
|
9253
|
+
// bridge-dead watchdog down. Anonymous clients (recall.py, mcp
|
|
9254
|
+
// handshakes) and cron-session bridges must NOT satisfy it — the
|
|
9255
|
+
// watchdog gates on the identity INTERNALLY (isRealBridgeIdentity), so
|
|
9256
|
+
// this call is safe wherever it sits relative to the cron early-return
|
|
9257
|
+
// above (#3038 review finding 5).
|
|
9258
|
+
bridgeDeadWatchdog.noteBridgeRegistered(client.agentName)
|
|
8821
9259
|
client.send({ type: 'status', status: 'agent_connected' })
|
|
8822
9260
|
|
|
8823
9261
|
// Phase 2b PR 3a — bridgeUp cutover. The state machine's `bridgeUp`
|
|
@@ -9007,6 +9445,12 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
9007
9445
|
if (client.agentName != null) {
|
|
9008
9446
|
process.stderr.write(`telegram gateway: bridge disconnected — agent=${client.agentName}\n`)
|
|
9009
9447
|
shadowEmit({ kind: 'bridgeDown', at: Date.now() })
|
|
9448
|
+
// #3038 — the real bridge went away mid-life. Re-arm the grace
|
|
9449
|
+
// window: a normal claude restart re-registers within seconds and
|
|
9450
|
+
// stands it down; a bridge that died for good escalates once (the
|
|
9451
|
+
// once-per-boot fuse inside the watchdog caps it). Cron/anonymous
|
|
9452
|
+
// identities are ignored inside the watchdog itself (finding 5).
|
|
9453
|
+
if (BRIDGE_DEAD_ESCALATION_ENABLED) bridgeDeadWatchdog.noteBridgeDisconnected(client.agentName)
|
|
9010
9454
|
}
|
|
9011
9455
|
|
|
9012
9456
|
// Scope the flush to clients that actually registered as an agent.
|
|
@@ -9094,6 +9538,9 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
9094
9538
|
if (pendingDeferredInterrupt != null && !toolFlightTracker.isMidToolCall()) {
|
|
9095
9539
|
void fireDeferredInterrupt('boundary')
|
|
9096
9540
|
}
|
|
9541
|
+
// #3020: a parked halt-now (/stop, bare "stop", empty `!`) waits on the
|
|
9542
|
+
// same boundary — kick its waiters whenever the tracker may have drained.
|
|
9543
|
+
notifyHaltBoundaryWaiters()
|
|
9097
9544
|
// #1122 silence-poke: surface activity signals from the session
|
|
9098
9545
|
// stream so the 300s framework-fallback message wording is honest
|
|
9099
9546
|
// (thinking vs working, plus the longest-running in-flight tool).
|
|
@@ -11711,35 +12158,111 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
11711
12158
|
}
|
|
11712
12159
|
}
|
|
11713
12160
|
|
|
12161
|
+
// #3033 layer-2 (pre-send validation): probe each photo-extension file
|
|
12162
|
+
// BEFORE it hits the wire and pre-route any that Telegram's photo path
|
|
12163
|
+
// would reject (extreme aspect ratio, width+height over cap, >10MB) as
|
|
12164
|
+
// documents. One bad photo fails a WHOLE sendMediaGroup album with an
|
|
12165
|
+
// opaque 400 — catching it here keeps the good files deliverable and
|
|
12166
|
+
// sends the offender as a document on the first attempt. Probe
|
|
12167
|
+
// failures keep the photo route; the reactive #3022 fallback backstops.
|
|
12168
|
+
const photoPrecheck = new Map<string, ReturnType<typeof classifyPhotoFile>>()
|
|
12169
|
+
// #3038 polish: files that ultimately went out as documents despite a
|
|
12170
|
+
// photo extension (precheck reroute or reactive fallback). Feeds two
|
|
12171
|
+
// honesty surfaces: the reply tool result suffix (so the agent doesn't
|
|
12172
|
+
// claim an inline image rendered) and attachment_kinds history (record
|
|
12173
|
+
// what was actually sent).
|
|
12174
|
+
const documentReroutes: Array<{ path: string; reason: string }> = []
|
|
12175
|
+
const sentAsDocument = new Set<string>()
|
|
12176
|
+
for (const f of files) {
|
|
12177
|
+
if (!PHOTO_EXTS.has(extname(f).toLowerCase())) continue
|
|
12178
|
+
const cls = classifyPhotoFile(f)
|
|
12179
|
+
photoPrecheck.set(f, cls)
|
|
12180
|
+
if (cls.route === 'document') {
|
|
12181
|
+
documentReroutes.push({ path: f, reason: cls.reason })
|
|
12182
|
+
sentAsDocument.add(f)
|
|
12183
|
+
process.stderr.write(
|
|
12184
|
+
`telegram gateway: photo-precheck rerouting ${f} as document (${cls.reason})\n`,
|
|
12185
|
+
)
|
|
12186
|
+
}
|
|
12187
|
+
}
|
|
12188
|
+
const sendableAsPhoto = (f: string) =>
|
|
12189
|
+
PHOTO_EXTS.has(extname(f).toLowerCase()) && photoPrecheck.get(f)?.route !== 'document'
|
|
12190
|
+
|
|
11714
12191
|
// #273: when files is 2-10 photos, batch them into a single
|
|
11715
12192
|
// sendMediaGroup album rather than N separate sendPhoto calls. The
|
|
11716
12193
|
// user's device fires one notification for the album instead of N
|
|
11717
12194
|
// (notification-budget protection per the issue's JTBD note). Falls
|
|
11718
12195
|
// back to the per-file path for any non-all-photo set.
|
|
12196
|
+
//
|
|
12197
|
+
// #3038 known tradeoff: ONE precheck-rerouted photo in the set drops
|
|
12198
|
+
// the WHOLE album to per-file sends — the user gets N notifications
|
|
12199
|
+
// instead of 1. Deliberate: sendMediaGroup can't mix photo and
|
|
12200
|
+
// document media, and a partial album plus a stray document is more
|
|
12201
|
+
// confusing than N files. Revisit only if mixed albums become common.
|
|
11719
12202
|
const allPhotos = files.length >= 2 && files.length <= 10
|
|
11720
|
-
&& files.every(
|
|
12203
|
+
&& files.every(sendableAsPhoto)
|
|
11721
12204
|
// #1075: thread-id-bearing file sends. Mirror the chunk-loop's
|
|
11722
12205
|
// THREAD_NOT_FOUND fallback (deleted topic → drop the thread and
|
|
11723
12206
|
// resend on the main chat) so an attachment-bearing reply doesn't
|
|
11724
12207
|
// crash when the user deletes the topic mid-flight.
|
|
11725
12208
|
const replyParams =
|
|
11726
12209
|
reply_to != null && replyMode !== 'off' ? { reply_parameters: { message_id: reply_to } } : {}
|
|
11727
|
-
|
|
11728
|
-
|
|
11729
|
-
|
|
11730
|
-
|
|
11731
|
-
|
|
11732
|
-
|
|
12210
|
+
// Send one file as a document, routed through the same thread-fallback
|
|
12211
|
+
// policy as the photo path. `InputFile` streams are single-use, so a
|
|
12212
|
+
// document retry after a failed sendPhoto must build a FRESH InputFile
|
|
12213
|
+
// from the path. Returns the sent message (with its echoed thread id).
|
|
12214
|
+
const sendAsDocument = (f: string) =>
|
|
12215
|
+
retryWithThreadFallback<{ message_id: number; message_thread_id?: number }>(
|
|
11733
12216
|
robustApiCall,
|
|
11734
12217
|
(tid) => {
|
|
11735
12218
|
const baseOpts = {
|
|
11736
12219
|
...replyParams,
|
|
11737
12220
|
...(tid != null ? { message_thread_id: tid } : {}),
|
|
11738
12221
|
}
|
|
11739
|
-
|
|
12222
|
+
// allow-raw-bot-api: wrapped in retryWithThreadFallback (retry policy); topic-aware document fallback
|
|
12223
|
+
return lockedBot.api.sendDocument(chat_id, new InputFile(f), baseOpts)
|
|
11740
12224
|
},
|
|
11741
|
-
{ threadId, chat_id, verb: '
|
|
12225
|
+
{ threadId, chat_id, verb: 'sendDocument' },
|
|
11742
12226
|
)
|
|
12227
|
+
|
|
12228
|
+
if (allPhotos) {
|
|
12229
|
+
const media = files.map((f) => ({
|
|
12230
|
+
type: 'photo' as const,
|
|
12231
|
+
media: new InputFile(f),
|
|
12232
|
+
}))
|
|
12233
|
+
let sent: Array<{ message_id: number; message_thread_id?: number }>
|
|
12234
|
+
try {
|
|
12235
|
+
sent = await retryWithThreadFallback(
|
|
12236
|
+
robustApiCall,
|
|
12237
|
+
(tid) => {
|
|
12238
|
+
const baseOpts = {
|
|
12239
|
+
...replyParams,
|
|
12240
|
+
...(tid != null ? { message_thread_id: tid } : {}),
|
|
12241
|
+
}
|
|
12242
|
+
return lockedBot.api.sendMediaGroup(chat_id, media, baseOpts)
|
|
12243
|
+
},
|
|
12244
|
+
{ threadId, chat_id, verb: 'sendMediaGroup' },
|
|
12245
|
+
)
|
|
12246
|
+
} catch (err) {
|
|
12247
|
+
// Graceful fallback: one bad photo in the album (e.g. a tall phone
|
|
12248
|
+
// screenshot → PHOTO_INVALID_DIMENSIONS) fails the WHOLE media group.
|
|
12249
|
+
// Rather than surface an error, re-send each file individually as a
|
|
12250
|
+
// document so the user still receives all of them. See #klanker
|
|
12251
|
+
// 2026-07-10 incident + isPhotoDimensionRejectError.
|
|
12252
|
+
if (!isPhotoDimensionRejectError(err)) throw err
|
|
12253
|
+
process.stderr.write(
|
|
12254
|
+
`telegram gateway: sendMediaGroup rejected the photo album ` +
|
|
12255
|
+
`(${err instanceof Error ? err.message : String(err)}); ` +
|
|
12256
|
+
`falling back to per-file sendDocument\n`,
|
|
12257
|
+
)
|
|
12258
|
+
sent = []
|
|
12259
|
+
const albumReason = 'Telegram rejected the photo album; whole album re-sent as documents'
|
|
12260
|
+
for (const f of files) {
|
|
12261
|
+
sent.push(await sendAsDocument(f))
|
|
12262
|
+
sentAsDocument.add(f)
|
|
12263
|
+
documentReroutes.push({ path: f, reason: albumReason })
|
|
12264
|
+
}
|
|
12265
|
+
}
|
|
11743
12266
|
if (threadId != null) {
|
|
11744
12267
|
// If the fallback dropped the thread id, propagate that decision
|
|
11745
12268
|
// to subsequent calls in this reply (no further retries needed).
|
|
@@ -11752,22 +12275,41 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
11752
12275
|
for (const m of sent) sentIds.push(m.message_id)
|
|
11753
12276
|
} else {
|
|
11754
12277
|
for (const f of files) {
|
|
11755
|
-
const ext = extname(f).toLowerCase()
|
|
11756
12278
|
const input = new InputFile(f)
|
|
11757
|
-
|
|
11758
|
-
|
|
11759
|
-
|
|
11760
|
-
|
|
11761
|
-
|
|
11762
|
-
|
|
11763
|
-
|
|
11764
|
-
|
|
11765
|
-
|
|
11766
|
-
|
|
11767
|
-
|
|
11768
|
-
|
|
11769
|
-
|
|
11770
|
-
|
|
12279
|
+
// Photo-ext files that failed the pre-send probe route straight to
|
|
12280
|
+
// sendDocument (see photoPrecheck above) instead of bouncing a 400.
|
|
12281
|
+
const isPhoto = sendableAsPhoto(f)
|
|
12282
|
+
let sent: { message_id: number; message_thread_id?: number }
|
|
12283
|
+
try {
|
|
12284
|
+
sent = await retryWithThreadFallback<{ message_id: number; message_thread_id?: number }>(
|
|
12285
|
+
robustApiCall,
|
|
12286
|
+
(tid) => {
|
|
12287
|
+
const baseOpts = {
|
|
12288
|
+
...replyParams,
|
|
12289
|
+
...(tid != null ? { message_thread_id: tid } : {}),
|
|
12290
|
+
}
|
|
12291
|
+
return isPhoto
|
|
12292
|
+
? lockedBot.api.sendPhoto(chat_id, input, baseOpts)
|
|
12293
|
+
: lockedBot.api.sendDocument(chat_id, input, baseOpts)
|
|
12294
|
+
},
|
|
12295
|
+
{ threadId, chat_id, verb: isPhoto ? 'sendPhoto' : 'sendDocument' },
|
|
12296
|
+
)
|
|
12297
|
+
} catch (err) {
|
|
12298
|
+
// Graceful fallback: an image Telegram won't accept as a photo
|
|
12299
|
+
// (dimensions out of range / too large — a tall phone screenshot
|
|
12300
|
+
// is the canonical trigger, PHOTO_INVALID_DIMENSIONS) is re-sent
|
|
12301
|
+
// as a document so the user still receives the file instead of
|
|
12302
|
+
// getting nothing. Non-photo-dimension errors propagate as before.
|
|
12303
|
+
if (!(isPhoto && isPhotoDimensionRejectError(err))) throw err
|
|
12304
|
+
process.stderr.write(
|
|
12305
|
+
`telegram gateway: sendPhoto rejected ${f} ` +
|
|
12306
|
+
`(${err instanceof Error ? err.message : String(err)}); ` +
|
|
12307
|
+
`falling back to sendDocument\n`,
|
|
12308
|
+
)
|
|
12309
|
+
sent = await sendAsDocument(f)
|
|
12310
|
+
sentAsDocument.add(f)
|
|
12311
|
+
documentReroutes.push({ path: f, reason: 'Telegram rejected it as a photo; re-sent as document' })
|
|
12312
|
+
}
|
|
11771
12313
|
// Mirror the threadId-clear above so the *next* file in the
|
|
11772
12314
|
// loop skips the doomed thread without paying for another
|
|
11773
12315
|
// round trip + retry.
|
|
@@ -11778,9 +12320,13 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
11778
12320
|
}
|
|
11779
12321
|
}
|
|
11780
12322
|
|
|
11781
|
-
|
|
12323
|
+
// #3038: surface photo→document reroutes in the tool result — the
|
|
12324
|
+
// agent otherwise only "sees" success and may tell the user an inline
|
|
12325
|
+
// image rendered when it went out as a file attachment.
|
|
12326
|
+
const result = (sentIds.length === 1
|
|
11782
12327
|
? `sent (id: ${sentIds[0]})`
|
|
11783
|
-
: `sent ${sentIds.length} parts (ids: ${sentIds.join(', ')})`
|
|
12328
|
+
: `sent ${sentIds.length} parts (ids: ${sentIds.join(', ')})`)
|
|
12329
|
+
+ rerouteResultSuffix(documentReroutes)
|
|
11784
12330
|
|
|
11785
12331
|
if (HISTORY_ENABLED && sentIds.length > 0) {
|
|
11786
12332
|
try {
|
|
@@ -11790,9 +12336,14 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
11790
12336
|
const attachKinds: (string | null)[] = []
|
|
11791
12337
|
for (let i = 0; i < textCount; i++) { texts.push(chunks[i] ?? ''); attachKinds.push(null) }
|
|
11792
12338
|
for (let i = 0; i < fileCount; i++) {
|
|
11793
|
-
const
|
|
11794
|
-
|
|
11795
|
-
|
|
12339
|
+
const f = files[i] ?? ''
|
|
12340
|
+
const ext = extname(f).toLowerCase()
|
|
12341
|
+
// #3038: record what was ACTUALLY sent — a photo-extension file
|
|
12342
|
+
// rerouted to sendDocument (precheck or reactive fallback) is a
|
|
12343
|
+
// 'document' in history, not a 'photo' from its raw extension.
|
|
12344
|
+
const kind = PHOTO_EXTS.has(ext) && !sentAsDocument.has(f) ? 'photo' : 'document'
|
|
12345
|
+
texts.push(`(${kind}: ${f})`)
|
|
12346
|
+
attachKinds.push(kind)
|
|
11796
12347
|
}
|
|
11797
12348
|
recordOutbound({ chat_id, thread_id: threadId ?? null, message_ids: sentIds, texts, attachment_kinds: attachKinds })
|
|
11798
12349
|
} catch (err) {
|
|
@@ -16334,6 +16885,16 @@ async function handleInboundCoalesced(
|
|
|
16334
16885
|
return handleInbound(ctx, text, downloadImage, attachment)
|
|
16335
16886
|
}
|
|
16336
16887
|
|
|
16888
|
+
// #3020: bare "stop" is a kill switch, same urgency as `!` — bypass the
|
|
16889
|
+
// coalesce window so it fires mid-turn instead of merging into a normal
|
|
16890
|
+
// turn (and so an earlier buffered message can't prepend itself and defeat
|
|
16891
|
+
// the exact-word parse in handleInbound). Pure text only: a photo/document
|
|
16892
|
+
// captioned "stop" is content for the agent (e.g. a stop sign to look at),
|
|
16893
|
+
// not a halt request — attachments flow through the normal pipeline.
|
|
16894
|
+
if (parseStopKeyword(text) && downloadImage == null && attachment == null) {
|
|
16895
|
+
return handleInbound(ctx, text, downloadImage, attachment)
|
|
16896
|
+
}
|
|
16897
|
+
|
|
16337
16898
|
const hasAttachment = downloadImage != null || attachment != null
|
|
16338
16899
|
const maxAttachments = coalesceMaxAttachments()
|
|
16339
16900
|
|
|
@@ -16641,11 +17202,58 @@ async function handleInbound(
|
|
|
16641
17202
|
// Authorization: same allowFrom gate as any inbound message —
|
|
16642
17203
|
// unauthorized senders never reach this code (gate() above).
|
|
16643
17204
|
// Interrupt requires the same trust as sending a normal message.
|
|
17205
|
+
// #3020: bare operator "stop" — the kill switch without the `!` marker.
|
|
17206
|
+
// Exact-word only (parseStopKeyword): "stop the build" flows to the agent
|
|
17207
|
+
// as a normal turn. Intercepted here (never forwarded) so it fires mid-turn
|
|
17208
|
+
// instead of queueing behind the very turn it's trying to cancel.
|
|
17209
|
+
// Authorization: same allowFrom gate as any inbound (gate() above).
|
|
17210
|
+
// Pure text only (7b): a photo/attachment captioned "stop" is content for
|
|
17211
|
+
// the agent, not a halt request — it must never trip the halt-and-drop
|
|
17212
|
+
// path (the caption + attachment flow through as a normal turn).
|
|
17213
|
+
if (
|
|
17214
|
+
parseStopKeyword(text) &&
|
|
17215
|
+
downloadImage == null &&
|
|
17216
|
+
attachment == null &&
|
|
17217
|
+
(extraAttachments == null || extraAttachments.length === 0)
|
|
17218
|
+
) {
|
|
17219
|
+
const queuedLabels = pendingSessionCommand.list().map(c => `/${c.kind} ${c.targetLabel}`)
|
|
17220
|
+
const inFlight = turnInFlightForGate()
|
|
17221
|
+
process.stderr.write(
|
|
17222
|
+
`telegram gateway: stop-keyword received chat_id=${chat_id} in_flight_turn=${inFlight} ` +
|
|
17223
|
+
`queued_cmds=${queuedLabels.length}\n`,
|
|
17224
|
+
)
|
|
17225
|
+
if (inFlight) {
|
|
17226
|
+
if (msgId != null) {
|
|
17227
|
+
void bot.api.setMessageReaction(chat_id, msgId, [
|
|
17228
|
+
{ type: 'emoji', emoji: '⚡' as ReactionTypeEmoji['emoji'] },
|
|
17229
|
+
]).catch(() => {})
|
|
17230
|
+
}
|
|
17231
|
+
await executeHaltNow('stop-keyword')
|
|
17232
|
+
}
|
|
17233
|
+
const stopReply = buildStopReply(inFlight, queuedLabels)
|
|
17234
|
+
// #1075: thread-id-bearing — swallow so a deleted topic can't crash us.
|
|
17235
|
+
await swallowingApiCall(
|
|
17236
|
+
() =>
|
|
17237
|
+
bot.api.sendMessage(
|
|
17238
|
+
chat_id,
|
|
17239
|
+
stopReply.text,
|
|
17240
|
+
messageThreadId != null ? { message_thread_id: messageThreadId } : {},
|
|
17241
|
+
),
|
|
17242
|
+
{
|
|
17243
|
+
chat_id,
|
|
17244
|
+
verb: 'stop-keyword-reply',
|
|
17245
|
+
...(messageThreadId != null ? { threadId: messageThreadId } : {}),
|
|
17246
|
+
},
|
|
17247
|
+
)
|
|
17248
|
+
return
|
|
17249
|
+
}
|
|
17250
|
+
|
|
16644
17251
|
const interrupt = parseInterruptMarker(text)
|
|
16645
17252
|
// Problem B: defer this `!`'s SIGINT to a safe boundary instead of firing it
|
|
16646
17253
|
// synchronously below. Set only when the `interrupt.safe_boundary` flag is on
|
|
16647
17254
|
// AND a top-level tool call is in flight AND the body is non-empty (an empty
|
|
16648
|
-
// `!` is
|
|
17255
|
+
// `!` is a halt-now — routed through `executeHaltNow`, which honors the same
|
|
17256
|
+
// safe-boundary deferral internally, #3020). When set, we skip the
|
|
16649
17257
|
// synchronous SIGINT here and stash the built inbound at the delivery site.
|
|
16650
17258
|
let deferInterrupt = false
|
|
16651
17259
|
if (interrupt.isInterrupt) {
|
|
@@ -16667,6 +17275,29 @@ async function handleInbound(
|
|
|
16667
17275
|
{ type: 'emoji', emoji: '⚡' as ReactionTypeEmoji['emoji'] },
|
|
16668
17276
|
]).catch(() => {})
|
|
16669
17277
|
}
|
|
17278
|
+
if (interrupt.emptyBody) {
|
|
17279
|
+
// #3020: empty `!` is a pure halt (no replacement body) — same shared
|
|
17280
|
+
// sequence as /stop: safe-boundary deferral, tmux C-c, obligation
|
|
17281
|
+
// cancel, deterministic busy release.
|
|
17282
|
+
await executeHaltNow('bang-empty')
|
|
17283
|
+
// #1075: thread-id-bearing — route through swallowingApiCall so
|
|
17284
|
+
// a deleted topic doesn't crash the gateway; the reaction
|
|
17285
|
+
// already acked the user so a missing follow-up is tolerable.
|
|
17286
|
+
await swallowingApiCall(
|
|
17287
|
+
() =>
|
|
17288
|
+
bot.api.sendMessage(
|
|
17289
|
+
chat_id,
|
|
17290
|
+
'⚡ Interrupted. Send your replacement instruction now.',
|
|
17291
|
+
messageThreadId != null ? { message_thread_id: messageThreadId } : {},
|
|
17292
|
+
),
|
|
17293
|
+
{
|
|
17294
|
+
chat_id,
|
|
17295
|
+
verb: 'interrupt-empty-body',
|
|
17296
|
+
...(messageThreadId != null ? { threadId: messageThreadId } : {}),
|
|
17297
|
+
},
|
|
17298
|
+
)
|
|
17299
|
+
return
|
|
17300
|
+
}
|
|
16670
17301
|
if (agentName && !deferInterrupt) {
|
|
16671
17302
|
try {
|
|
16672
17303
|
// The gateway runs INSIDE the agent container in docker mode,
|
|
@@ -16696,25 +17327,6 @@ async function handleInbound(
|
|
|
16696
17327
|
// interrupted (user-redirected) question isn't re-presented/escalated later.
|
|
16697
17328
|
cancelInterruptedObligation()
|
|
16698
17329
|
}
|
|
16699
|
-
if (interrupt.emptyBody) {
|
|
16700
|
-
// #1075: thread-id-bearing — route through swallowingApiCall so
|
|
16701
|
-
// a deleted topic doesn't crash the gateway; the reaction
|
|
16702
|
-
// already acked the user so a missing follow-up is tolerable.
|
|
16703
|
-
await swallowingApiCall(
|
|
16704
|
-
() =>
|
|
16705
|
-
bot.api.sendMessage(
|
|
16706
|
-
chat_id,
|
|
16707
|
-
'⚡ Interrupted. Send your replacement instruction now.',
|
|
16708
|
-
messageThreadId != null ? { message_thread_id: messageThreadId } : {},
|
|
16709
|
-
),
|
|
16710
|
-
{
|
|
16711
|
-
chat_id,
|
|
16712
|
-
verb: 'interrupt-empty-body',
|
|
16713
|
-
...(messageThreadId != null ? { threadId: messageThreadId } : {}),
|
|
16714
|
-
},
|
|
16715
|
-
)
|
|
16716
|
-
return
|
|
16717
|
-
}
|
|
16718
17330
|
// Replace the inbound text with the body and continue normal
|
|
16719
17331
|
// processing. The agent receives a fresh turn with no `!` prefix.
|
|
16720
17332
|
text = interrupt.body
|
|
@@ -19866,62 +20478,406 @@ function modelMenuReplyMarkup(reply: ModelMenuReply): InlineKeyboard | undefined
|
|
|
19866
20478
|
return kb
|
|
19867
20479
|
}
|
|
19868
20480
|
|
|
19869
|
-
|
|
19870
|
-
|
|
19871
|
-
|
|
19872
|
-
|
|
19873
|
-
|
|
19874
|
-
|
|
19875
|
-
|
|
19876
|
-
|
|
19877
|
-
|
|
19878
|
-
|
|
19879
|
-
|
|
19880
|
-
|
|
19881
|
-
|
|
19882
|
-
|
|
19883
|
-
|
|
19884
|
-
|
|
19885
|
-
|
|
19886
|
-
// override inside scheduleModelRelaunch, and an unverified switch carries no
|
|
19887
|
-
// selectedModel so /status is never lied to.
|
|
19888
|
-
const requested = parsed.kind === 'set' ? expandSrAlias(parsed.model) : null
|
|
19889
|
-
let persistWarning = ''
|
|
20481
|
+
/**
|
|
20482
|
+
* Record a POSITIVELY-CONFIRMED typed `/model` switch: set the in-memory
|
|
20483
|
+
* override so `/status` reflects the live model and persist the sticky
|
|
20484
|
+
* `.session-model` carrier. Shared by the live `bot.command('model')` handler
|
|
20485
|
+
* and the deferred (queued mid-turn) apply so both record identically. Returns
|
|
20486
|
+
* a persist-warning suffix to append to the reply body (empty when clean).
|
|
20487
|
+
*
|
|
20488
|
+
* The `/status` honesty invariant lives here: only `reply.selectedModel`
|
|
20489
|
+
* (present only on a confirmed switch) records; an unverified inject records
|
|
20490
|
+
* nothing. `/model default` clears the carrier idempotently.
|
|
20491
|
+
*/
|
|
20492
|
+
function recordTypedModelSwitch(
|
|
20493
|
+
reply: { text: string; selectedModel?: string },
|
|
20494
|
+
requestedModelArg: string | null,
|
|
20495
|
+
deps: ModelCommandDeps,
|
|
20496
|
+
): string {
|
|
20497
|
+
const requested = requestedModelArg != null ? expandSrAlias(requestedModelArg) : null
|
|
19890
20498
|
if (requested?.toLowerCase() === 'default') {
|
|
19891
|
-
// `/model default` clears the sticky file even WITHOUT a positive
|
|
19892
|
-
// confirmation: claude's arg-form switch can be silent, and a surviving
|
|
19893
|
-
// sticky file would resurrect the old model on the next keep-relaunch.
|
|
19894
|
-
// Clearing is idempotent; the in-memory override change stays gated on a
|
|
19895
|
-
// confirmed switch so an unverified inject never lies to /status.
|
|
19896
20499
|
const smDir = resolveAgentDirFromEnv()
|
|
19897
20500
|
if (smDir) clearSessionModelFile(smDir)
|
|
19898
20501
|
if (reply.selectedModel) sessionModelSource.setOverride(null)
|
|
19899
|
-
|
|
19900
|
-
|
|
19901
|
-
|
|
19902
|
-
|
|
19903
|
-
|
|
19904
|
-
|
|
19905
|
-
|
|
19906
|
-
|
|
19907
|
-
|
|
19908
|
-
|
|
20502
|
+
return ''
|
|
20503
|
+
}
|
|
20504
|
+
if (!reply.selectedModel) return ''
|
|
20505
|
+
sessionModelSource.setOverride(reply.selectedModel)
|
|
20506
|
+
const smDir = resolveAgentDirFromEnv()
|
|
20507
|
+
if (smDir && requested && isValidModelArg(requested) && !isSrModel(requested)) {
|
|
20508
|
+
try {
|
|
20509
|
+
writeSessionModelFile(
|
|
20510
|
+
smDir,
|
|
20511
|
+
requested,
|
|
20512
|
+
readConfiguredDefaultModel(smDir) ??
|
|
20513
|
+
resolveMainModel(deps.getConfiguredModel() ?? undefined),
|
|
20514
|
+
)
|
|
20515
|
+
} catch (err) {
|
|
20516
|
+
process.stderr.write(
|
|
20517
|
+
`telegram gateway: session-model persist failed (typed /model): ${(err as Error)?.message ?? String(err)}\n`,
|
|
20518
|
+
)
|
|
20519
|
+
return '\n⚠️ Couldn’t persist the sticky override — the switch is live now but won’t survive a relaunch.'
|
|
20520
|
+
}
|
|
20521
|
+
}
|
|
20522
|
+
return ''
|
|
20523
|
+
}
|
|
20524
|
+
|
|
20525
|
+
/**
|
|
20526
|
+
* Record a model-MENU callback outcome (persist/clear sticky override) and
|
|
20527
|
+
* drive an sr-*→Claude graceful restart when the tap crosses that boundary.
|
|
20528
|
+
* Extracted from the live `mdl:*` dispatcher so the deferred (queued mid-turn)
|
|
20529
|
+
* apply records + restarts identically. Does NOT edit any Telegram message —
|
|
20530
|
+
* callers own the card edit. Returns a restart notice when a session restart
|
|
20531
|
+
* was scheduled (the card should then drop its keyboard).
|
|
20532
|
+
*/
|
|
20533
|
+
function recordModelMenuSideEffects(
|
|
20534
|
+
outcome: Awaited<ReturnType<typeof handleModelMenuCallback>>,
|
|
20535
|
+
modelDeps: ModelCommandDeps,
|
|
20536
|
+
cbChatId: string,
|
|
20537
|
+
cbThreadId: number | undefined,
|
|
20538
|
+
prevSessionModel: string | null,
|
|
20539
|
+
): { restartNotice?: string } {
|
|
20540
|
+
// Record a successful session switch so /status reflects what's actually
|
|
20541
|
+
// running, and persist the STICKY override
|
|
20542
|
+
// (reference/rfcs/session-model-stickiness.md): the canonical token (never
|
|
20543
|
+
// the display label) goes to the durable `.session-model`; a confirmed
|
|
20544
|
+
// "Default (recommended)" selection clears it instead.
|
|
20545
|
+
if (outcome.selectedModel) {
|
|
20546
|
+
sessionModelSource.setOverride(outcome.selectedModel)
|
|
20547
|
+
const smDir = resolveAgentDirFromEnv()
|
|
20548
|
+
if (smDir && outcome.selectedModelToken) {
|
|
20549
|
+
try {
|
|
20550
|
+
writeSessionModelFile(
|
|
19909
20551
|
smDir,
|
|
19910
|
-
|
|
20552
|
+
outcome.selectedModelToken,
|
|
19911
20553
|
readConfiguredDefaultModel(smDir) ??
|
|
19912
|
-
resolveMainModel(
|
|
20554
|
+
resolveMainModel(modelDeps.getConfiguredModel() ?? undefined),
|
|
19913
20555
|
)
|
|
19914
20556
|
} catch (err) {
|
|
19915
|
-
|
|
19916
|
-
// and the disk disagree silently.
|
|
19917
|
-
persistWarning =
|
|
20557
|
+
outcome.reply.text +=
|
|
19918
20558
|
'\n⚠️ Couldn’t persist the sticky override — the switch is live now but won’t survive a relaunch.'
|
|
19919
20559
|
process.stderr.write(
|
|
19920
|
-
`telegram gateway: session-model persist failed (
|
|
20560
|
+
`telegram gateway: session-model persist failed (menu): ${(err as Error)?.message ?? String(err)}\n`,
|
|
19921
20561
|
)
|
|
19922
20562
|
}
|
|
19923
20563
|
}
|
|
19924
20564
|
}
|
|
20565
|
+
if (outcome.clearedDefault) {
|
|
20566
|
+
const smDir = resolveAgentDirFromEnv()
|
|
20567
|
+
if (smDir) clearSessionModelFile(smDir)
|
|
20568
|
+
}
|
|
20569
|
+
|
|
20570
|
+
// sr-* → Claude transition: the picker-select only changes the session model
|
|
20571
|
+
// label, but the sr-* LiteLLM routing context persists until the session is
|
|
20572
|
+
// torn down — a graceful restart (same mechanism as /restart) is required.
|
|
20573
|
+
if (outcome.selectedModel && isSrToClaudeTransition(prevSessionModel, outcome.selectedModel)) {
|
|
20574
|
+
const agentName = getMyAgentName()
|
|
20575
|
+
// Carry the requested Claude model across the restart via the SAME durable
|
|
20576
|
+
// `.session-model` override a Claude → sr-* switch uses — otherwise boot
|
|
20577
|
+
// launches the CONFIGURED default and the tapped model is silently dropped.
|
|
20578
|
+
const agentDir = resolveAgentDirFromEnv()
|
|
20579
|
+
const token = outcome.selectedModelToken
|
|
20580
|
+
if (agentDir && token) {
|
|
20581
|
+
try {
|
|
20582
|
+
writeSessionModelFile(
|
|
20583
|
+
agentDir,
|
|
20584
|
+
token,
|
|
20585
|
+
readConfiguredDefaultModel(agentDir) ??
|
|
20586
|
+
resolveMainModel(modelDeps.getConfiguredModel() ?? undefined),
|
|
20587
|
+
)
|
|
20588
|
+
sessionModelSource.setOverride(token)
|
|
20589
|
+
} catch (e) {
|
|
20590
|
+
process.stderr.write(`telegram gateway: sr-to-claude session-model write failed: ${(e as Error)?.message ?? String(e)}\n`)
|
|
20591
|
+
}
|
|
20592
|
+
} else if (agentDir) {
|
|
20593
|
+
// Default-row tap while on sr-*: the restart must land on the configured
|
|
20594
|
+
// default — a stale sticky override would resurrect the old model.
|
|
20595
|
+
clearSessionModelFile(agentDir)
|
|
20596
|
+
}
|
|
20597
|
+
// Write the restart marker so the post-restart boot card edits into this chat.
|
|
20598
|
+
writeRestartMarker({ chat_id: cbChatId, thread_id: cbThreadId ?? null, ack_message_id: null, ts: Date.now() })
|
|
20599
|
+
stampUserRestartReason('user: sr-to-claude model switch (menu)')
|
|
20600
|
+
if (turnInFlightForGate()) {
|
|
20601
|
+
// Defer restart until the in-flight turn completes (same gate as /restart).
|
|
20602
|
+
pendingRestarts.set(agentName, Date.now())
|
|
20603
|
+
} else {
|
|
20604
|
+
void sweepBeforeSelfRestart().finally(() =>
|
|
20605
|
+
triggerSelfRestart(agentName, 'sr-to-claude-model-switch', 1500),
|
|
20606
|
+
)
|
|
20607
|
+
}
|
|
20608
|
+
return {
|
|
20609
|
+
restartNotice: `🔄 Switching from **${escapeHtmlForTg(prevSessionModel!)}** back to Claude — restarting session cleanly. Claude will be ready in ~30s.`,
|
|
20610
|
+
}
|
|
20611
|
+
}
|
|
20612
|
+
return {}
|
|
20613
|
+
}
|
|
20614
|
+
|
|
20615
|
+
// ─── Mid-turn ack-queue-apply-confirm for /model + /effort (#3017) ──────────
|
|
20616
|
+
//
|
|
20617
|
+
// See pending-session-command.ts for the contract. The busy gates ENQUEUE
|
|
20618
|
+
// here + ACK; drainPendingSessionCommand() APPLIES the moment the agent idles
|
|
20619
|
+
// (from the same model-idle gate that drains pendingRestarts, plus the reaper
|
|
20620
|
+
// cap) and EDITS the ack card into the confirmation.
|
|
20621
|
+
|
|
20622
|
+
/**
|
|
20623
|
+
* Edit a queued command's ack card (chatId+messageId) into a new body,
|
|
20624
|
+
* dropping any inline keyboard. Best-effort — a deleted card just logs.
|
|
20625
|
+
* Mirrors redeliverBufferedInbound's edit-on-deliver via the shared retry
|
|
20626
|
+
* policy so flood-wait / not-found are handled by the standard wrapper.
|
|
20627
|
+
*/
|
|
20628
|
+
async function editPendingCommandCard(chatId: string, messageId: number, text: string): Promise<void> {
|
|
20629
|
+
try {
|
|
20630
|
+
await robustApiCall(
|
|
20631
|
+
() =>
|
|
20632
|
+
// allow-raw-bot-api: deferred command-card edit routed through robustApiCall (not in the THREAD_NOT_FOUND blast pattern)
|
|
20633
|
+
lockedBot.api.editMessageText(chatId, messageId, richMessage(hardenCardBreaks(text)), {
|
|
20634
|
+
reply_markup: { inline_keyboard: [] },
|
|
20635
|
+
}),
|
|
20636
|
+
{ chat_id: chatId, verb: 'pending-cmd-card' },
|
|
20637
|
+
)
|
|
20638
|
+
} catch (err) {
|
|
20639
|
+
process.stderr.write(
|
|
20640
|
+
`telegram gateway: pending-command card edit failed chat=${chatId} msg=${messageId}: ${(err as Error)?.message ?? String(err)}\n`,
|
|
20641
|
+
)
|
|
20642
|
+
}
|
|
20643
|
+
}
|
|
20644
|
+
|
|
20645
|
+
/**
|
|
20646
|
+
* Enqueue a mid-turn session command (same-kind last-write-wins; a /model and
|
|
20647
|
+
* a /effort coexist). If it displaces an earlier same-kind command, edit that
|
|
20648
|
+
* stale ack card into a "superseded" note so the operator's replaced choice is
|
|
20649
|
+
* never silently lost.
|
|
20650
|
+
*/
|
|
20651
|
+
function enqueueSessionCommand(cmd: PendingSessionCommand): void {
|
|
20652
|
+
const displaced = pendingSessionCommand.set(cmd)
|
|
20653
|
+
if (displaced) {
|
|
20654
|
+
void editPendingCommandCard(
|
|
20655
|
+
displaced.ackChatId,
|
|
20656
|
+
displaced.ackMessageId,
|
|
20657
|
+
pendingCmdSupersededText(displaced, cmd, escapeHtmlForTg),
|
|
20658
|
+
)
|
|
20659
|
+
}
|
|
20660
|
+
// #3018 finding 5 — enqueue/turn-end race: if the idle gate fired between
|
|
20661
|
+
// the busy check and this enqueue, no further turn-end will drain the slot
|
|
20662
|
+
// and the command would sit until the reaper cap. If the session is idle
|
|
20663
|
+
// NOW, kick the drain immediately (re-entrancy-guarded, no-op if a racing
|
|
20664
|
+
// drain already ran).
|
|
20665
|
+
if (!turnInFlightForGate()) void drainPendingSessionCommand()
|
|
20666
|
+
}
|
|
20667
|
+
|
|
20668
|
+
/** Apply a queued typed/menu-alias model command at idle; return the reply body. */
|
|
20669
|
+
async function applyQueuedModelCommand(cmd: PendingSessionCommand): Promise<string> {
|
|
20670
|
+
const deps = buildModelDeps({ chatId: cmd.chatId, threadId: cmd.threadId })
|
|
20671
|
+
if (cmd.origin === 'menu') {
|
|
20672
|
+
// Menu SELECT (mdl:s:<tag>) — replay the callback handler (discovery is
|
|
20673
|
+
// idle-safe now) and record via the shared side-effects helper.
|
|
20674
|
+
const prevSessionModel = sessionModelSource.getOverride()
|
|
20675
|
+
const outcome = await handleModelMenuCallback(cmd.arg, deps)
|
|
20676
|
+
const { restartNotice } = recordModelMenuSideEffects(
|
|
20677
|
+
outcome,
|
|
20678
|
+
deps,
|
|
20679
|
+
cmd.chatId,
|
|
20680
|
+
cmd.threadId,
|
|
20681
|
+
prevSessionModel,
|
|
20682
|
+
)
|
|
20683
|
+
return restartNotice ?? outcome.reply.text
|
|
20684
|
+
}
|
|
20685
|
+
// Typed (and alias/sr menu taps converted to typed at enqueue): run the real
|
|
20686
|
+
// handler + shared recording.
|
|
20687
|
+
const reply = await handleModelCommand({ kind: 'set', model: cmd.arg }, deps)
|
|
20688
|
+
const warning = recordTypedModelSwitch(reply, cmd.arg, deps)
|
|
20689
|
+
return reply.text + warning
|
|
20690
|
+
}
|
|
20691
|
+
|
|
20692
|
+
/** Apply a queued typed/menu effort command at idle; return the reply body. */
|
|
20693
|
+
async function applyQueuedEffortCommand(cmd: PendingSessionCommand): Promise<string> {
|
|
20694
|
+
const deps = buildEffortDeps()
|
|
20695
|
+
if (cmd.origin === 'menu') {
|
|
20696
|
+
const outcome = await handleEffortMenuCallback(cmd.arg, deps)
|
|
20697
|
+
return outcome.reply.text
|
|
20698
|
+
}
|
|
20699
|
+
const parsed =
|
|
20700
|
+
cmd.arg === 'default'
|
|
20701
|
+
? ({ kind: 'default' } as const)
|
|
20702
|
+
: ({ kind: 'set', level: cmd.arg as EffortLevel } as const)
|
|
20703
|
+
const reply = await handleEffortCommand(parsed, deps)
|
|
20704
|
+
return reply.text
|
|
20705
|
+
}
|
|
20706
|
+
|
|
20707
|
+
/**
|
|
20708
|
+
* Re-enqueue a command the drain took but could not safely apply (a turn
|
|
20709
|
+
* raced in). If a NEWER same-kind command was enqueued while the drain was
|
|
20710
|
+
* mid-flight, the newer one wins (last-write-wins invariant) and this older
|
|
20711
|
+
* card is edited to superseded instead.
|
|
20712
|
+
*/
|
|
20713
|
+
function reEnqueueUnlessSuperseded(cmd: PendingSessionCommand): void {
|
|
20714
|
+
const newer = pendingSessionCommand.get(cmd.kind)
|
|
20715
|
+
if (newer != null) {
|
|
20716
|
+
void editPendingCommandCard(
|
|
20717
|
+
cmd.ackChatId,
|
|
20718
|
+
cmd.ackMessageId,
|
|
20719
|
+
pendingCmdSupersededText(cmd, newer, escapeHtmlForTg),
|
|
20720
|
+
)
|
|
20721
|
+
return
|
|
20722
|
+
}
|
|
20723
|
+
pendingSessionCommand.set(cmd)
|
|
20724
|
+
}
|
|
20725
|
+
|
|
20726
|
+
/**
|
|
20727
|
+
* #3039 — execute a shutdown/restart resolution action's durable write:
|
|
20728
|
+
* carry a queued (typed) choice across the bounce via the boot carriers
|
|
20729
|
+
* (`.session-model` / `.session-effort`) instead of asking for a re-issue.
|
|
20730
|
+
* Returns the ack-card text to edit in (persisted vs re-issue fallback).
|
|
20731
|
+
*/
|
|
20732
|
+
function persistQueuedCommandForRestart(action: ShutdownResolutionAction): string {
|
|
20733
|
+
if (action.persist == null) return action.reissueText
|
|
20734
|
+
const agentDir = resolveAgentDirFromEnv()
|
|
20735
|
+
if (agentDir == null) return action.reissueText
|
|
20736
|
+
try {
|
|
20737
|
+
switch (action.persist) {
|
|
20738
|
+
case 'model': {
|
|
20739
|
+
// #3042 blocker 2a: this token was QUEUED, never confirmed by claude.
|
|
20740
|
+
// Under the keep-by-default boot a garbage-but-shape-valid token
|
|
20741
|
+
// persisted here would crashloop `claude --model <garbage>` with the
|
|
20742
|
+
// gateway dead. Only offline-trustable tokens (static Claude aliases,
|
|
20743
|
+
// curated sr-* alias targets) may be persisted unconfirmed; anything
|
|
20744
|
+
// else gets the honest "couldn't verify — re-issue" card instead.
|
|
20745
|
+
if (!isOfflineTrustedModelToken(action.arg)) {
|
|
20746
|
+
return `↩️ Couldn’t verify \`${escapeHtmlForTg(action.cmd.targetLabel || action.arg)}\` as a known model without the live session — it was NOT saved. Re-issue \`/model ${escapeHtmlForTg(action.arg)}\` once the agent is back.`
|
|
20747
|
+
}
|
|
20748
|
+
const configured =
|
|
20749
|
+
readConfiguredDefaultModel(agentDir) ?? resolveMainModel(undefined)
|
|
20750
|
+
writeSessionModelFile(agentDir, expandSrAlias(action.arg), configured)
|
|
20751
|
+
// Boot default is keep (#3039), but stamp explicit keep-intent for
|
|
20752
|
+
// reason-honesty in the boot notice.
|
|
20753
|
+
writeRelaunchModelIntent(agentDir, 'keep', 'queued /model carried across restart')
|
|
20754
|
+
break
|
|
20755
|
+
}
|
|
20756
|
+
case 'clear-model':
|
|
20757
|
+
clearSessionModelFile(agentDir)
|
|
20758
|
+
break
|
|
20759
|
+
case 'effort':
|
|
20760
|
+
writeSessionEffortFile(agentDir, action.arg, getConfiguredEffortForPersist())
|
|
20761
|
+
break
|
|
20762
|
+
case 'clear-effort':
|
|
20763
|
+
clearSessionEffortFile(agentDir)
|
|
20764
|
+
break
|
|
20765
|
+
}
|
|
20766
|
+
return action.persistedText
|
|
20767
|
+
} catch (err) {
|
|
20768
|
+
process.stderr.write(
|
|
20769
|
+
`telegram gateway: queued-command persist-for-restart failed kind=${action.cmd.kind} arg=${action.arg}: ${(err as Error)?.message ?? String(err)}\n`,
|
|
20770
|
+
)
|
|
20771
|
+
return action.reissueText
|
|
20772
|
+
}
|
|
20773
|
+
}
|
|
20774
|
+
|
|
20775
|
+
/**
|
|
20776
|
+
* The cascade-resolved thinking_effort, for `.session-effort`'s
|
|
20777
|
+
* configuredDefaultAtWrite. #3042 item 5: on a transient `agent list`
|
|
20778
|
+
* failure fall back to the scaffold default rather than null — persisting ''
|
|
20779
|
+
* would trip a bogus "configured default effort changed" clear+alert at the
|
|
20780
|
+
* next boot (start.sh bakes the same default when yaml is unset).
|
|
20781
|
+
*/
|
|
20782
|
+
function getConfiguredEffortForPersist(): string {
|
|
20783
|
+
try {
|
|
20784
|
+
type AgentListResp = { agents: Array<{ name: string; thinking_effort?: string | null }> }
|
|
20785
|
+
const data = switchroomExecJson<AgentListResp>(['agent', 'list'])
|
|
20786
|
+
return (
|
|
20787
|
+
data?.agents?.find(a => a.name === getMyAgentName())?.thinking_effort ??
|
|
20788
|
+
SWITCHROOM_DEFAULT_THINKING_EFFORT
|
|
20789
|
+
)
|
|
20790
|
+
} catch {
|
|
20791
|
+
return SWITCHROOM_DEFAULT_THINKING_EFFORT
|
|
20792
|
+
}
|
|
20793
|
+
}
|
|
20794
|
+
|
|
20795
|
+
// Synchronous re-entrancy guard — the idle gate can fire several times per
|
|
20796
|
+
// turn-end; the async apply must not double-dispatch before the first settles
|
|
20797
|
+
// (mirrors compactDispatching).
|
|
20798
|
+
let pendingCmdDraining = false
|
|
20799
|
+
|
|
20800
|
+
/**
|
|
20801
|
+
* Drain the queued session command(s) when the agent goes idle: apply each,
|
|
20802
|
+
* then edit its ack card into the confirmation (or a failure banner). Called
|
|
20803
|
+
* from the model-idle gate (turn complete), the post-enqueue idle kick, and
|
|
20804
|
+
* the reaper drain-cap. No-op when the slots are empty. If a restart is (or
|
|
20805
|
+
* becomes — a model apply can itself enqueue one) pending, the session is
|
|
20806
|
+
* going away — a Claude-model / effort session change would not survive, so
|
|
20807
|
+
* report that honestly rather than falsely confirming.
|
|
20808
|
+
*/
|
|
20809
|
+
async function drainPendingSessionCommand(): Promise<void> {
|
|
20810
|
+
if (pendingCmdDraining) return
|
|
20811
|
+
if (pendingSessionCommand.size === 0) return
|
|
20812
|
+
pendingCmdDraining = true
|
|
20813
|
+
try {
|
|
20814
|
+
// Iteration + loss-safety invariants live in drainTakenCommands
|
|
20815
|
+
// (pending-session-command.ts, unit-tested): a restart-pending command is
|
|
20816
|
+
// persisted to the boot carriers; an early stop (turn raced in / handler
|
|
20817
|
+
// busy-refusal) re-enqueues EVERY not-yet-applied taken command (#3042
|
|
20818
|
+
// blocker 1) instead of dropping the rest of the batch.
|
|
20819
|
+
await pendingCmdDrainTaken(pendingSessionCommand.takeAll(), {
|
|
20820
|
+
restartPending: () => pendingRestarts.size > 0,
|
|
20821
|
+
turnInFlight: () => turnInFlightForGate(),
|
|
20822
|
+
apply: cmd =>
|
|
20823
|
+
cmd.kind === 'model' ? applyQueuedModelCommand(cmd) : applyQueuedEffortCommand(cmd),
|
|
20824
|
+
isBusyRefusal: isBusyRefusalText,
|
|
20825
|
+
resolveForRestartText: cmd =>
|
|
20826
|
+
persistQueuedCommandForRestart(pendingCmdResolveForRestart(cmd, escapeHtmlForTg)),
|
|
20827
|
+
editCard: (cmd, text) => editPendingCommandCard(cmd.ackChatId, cmd.ackMessageId, text),
|
|
20828
|
+
reEnqueue: reEnqueueUnlessSuperseded,
|
|
20829
|
+
failureText: (cmd, err) =>
|
|
20830
|
+
`❌ Couldn’t apply the queued ${cmd.kind} switch to \`${escapeHtmlForTg(cmd.targetLabel)}\`: ${escapeHtmlForTg((err as Error)?.message ?? String(err))}`,
|
|
20831
|
+
})
|
|
20832
|
+
} finally {
|
|
20833
|
+
pendingCmdDraining = false
|
|
20834
|
+
}
|
|
20835
|
+
}
|
|
20836
|
+
|
|
20837
|
+
bot.command('model', async ctx => {
|
|
20838
|
+
if (!isAuthorizedSender(ctx)) return
|
|
20839
|
+
const text = ctx.message?.text ?? ctx.channelPost?.text ?? ''
|
|
20840
|
+
const parsed = parseModelCommand(text) ?? { kind: 'show' as const }
|
|
20841
|
+
const chatId = String(ctx.chat!.id)
|
|
20842
|
+
const threadId = resolveThreadId(chatId, ctx.message?.message_thread_id)
|
|
20843
|
+
const deps = buildModelDeps({ chatId, threadId })
|
|
20844
|
+
if (parsed.kind === 'show' && process.env.SWITCHROOM_MODEL_MENU !== '0') {
|
|
20845
|
+
const menu = await buildModelMenu(deps)
|
|
20846
|
+
await switchroomReply(ctx, menu.text, { html: true, reply_markup: modelMenuReplyMarkup(menu) })
|
|
20847
|
+
return
|
|
20848
|
+
}
|
|
20849
|
+
// Mid-turn: instead of dead-ending ("Try again in a moment"), ACK + QUEUE +
|
|
20850
|
+
// apply-on-idle + confirm (#3017). The typed set path either injects into
|
|
20851
|
+
// claude's input box or triggers a carrier restart — both unsafe mid-turn.
|
|
20852
|
+
if (parsed.kind === 'set' && deps.isBusy()) {
|
|
20853
|
+
const target = expandSrAlias(parsed.model)
|
|
20854
|
+
const sent = await ctx.replyWithRichMessage(
|
|
20855
|
+
richMessage(hardenCardBreaks(pendingCmdAckText('model', target, escapeHtmlForTg))),
|
|
20856
|
+
threadId != null ? { message_thread_id: threadId } : {},
|
|
20857
|
+
)
|
|
20858
|
+
enqueueSessionCommand({
|
|
20859
|
+
kind: 'model',
|
|
20860
|
+
origin: 'typed',
|
|
20861
|
+
arg: target,
|
|
20862
|
+
targetLabel: target,
|
|
20863
|
+
chatId,
|
|
20864
|
+
threadId,
|
|
20865
|
+
ackChatId: chatId,
|
|
20866
|
+
ackMessageId: (sent as { message_id: number }).message_id,
|
|
20867
|
+
requestedAt: Date.now(),
|
|
20868
|
+
})
|
|
20869
|
+
return
|
|
20870
|
+
}
|
|
20871
|
+
const reply = await handleModelCommand(parsed, deps)
|
|
20872
|
+
// Record a POSITIVELY-CONFIRMED typed switch so /status reflects what's
|
|
20873
|
+
// actually running (shared with the deferred/menu paths). The sr-*/relaunch
|
|
20874
|
+
// paths already set the override inside scheduleModelRelaunch; an unverified
|
|
20875
|
+
// switch carries no selectedModel so /status is never lied to.
|
|
20876
|
+
const persistWarning = recordTypedModelSwitch(
|
|
20877
|
+
reply,
|
|
20878
|
+
parsed.kind === 'set' ? parsed.model : null,
|
|
20879
|
+
deps,
|
|
20880
|
+
)
|
|
19925
20881
|
await switchroomReply(ctx, reply.text + persistWarning, { html: reply.html })
|
|
19926
20882
|
})
|
|
19927
20883
|
|
|
@@ -19936,12 +20892,36 @@ bot.command('model', async ctx => {
|
|
|
19936
20892
|
// effort-command.ts so it's unit-testable without booting the bot.
|
|
19937
20893
|
function buildEffortDeps(): EffortCommandDeps {
|
|
19938
20894
|
return {
|
|
19939
|
-
|
|
20895
|
+
// #3039: single persistence choke point — EVERY positively-confirmed
|
|
20896
|
+
// effort apply (typed, menu tap, queued drain) durably records the level
|
|
20897
|
+
// to `.session-effort`, which start.sh resolves into `--effort` on every
|
|
20898
|
+
// boot. `/effort default` clears it via clearSessionEffort (the handler
|
|
20899
|
+
// clears AFTER its restore-apply, so the wrapper's write is undone).
|
|
20900
|
+
applyEffort: async (agent, level) => {
|
|
20901
|
+
const result = await applyEffort(agent, level)
|
|
20902
|
+
if (result.ok) {
|
|
20903
|
+
const agentDir = resolveAgentDirFromEnv()
|
|
20904
|
+
if (agentDir) {
|
|
20905
|
+
try {
|
|
20906
|
+
writeSessionEffortFile(agentDir, level, getConfiguredEffortForPersist())
|
|
20907
|
+
} catch (err) {
|
|
20908
|
+
process.stderr.write(
|
|
20909
|
+
`telegram gateway: session-effort persist failed level=${level}: ${(err as Error)?.message ?? String(err)}\n`,
|
|
20910
|
+
)
|
|
20911
|
+
}
|
|
20912
|
+
}
|
|
20913
|
+
}
|
|
20914
|
+
return result
|
|
20915
|
+
},
|
|
19940
20916
|
getAgentName: getMyAgentName,
|
|
19941
|
-
getConfiguredEffort: () =>
|
|
19942
|
-
|
|
19943
|
-
const
|
|
19944
|
-
|
|
20917
|
+
getConfiguredEffort: () => getConfiguredEffortForPersist(),
|
|
20918
|
+
clearSessionEffort: () => {
|
|
20919
|
+
const agentDir = resolveAgentDirFromEnv()
|
|
20920
|
+
if (agentDir) clearSessionEffortFile(agentDir)
|
|
20921
|
+
},
|
|
20922
|
+
getSessionEffort: () => {
|
|
20923
|
+
const agentDir = resolveAgentDirFromEnv()
|
|
20924
|
+
return agentDir ? (readSessionEffortFile(agentDir)?.level ?? null) : null
|
|
19945
20925
|
},
|
|
19946
20926
|
escapeHtml: escapeHtmlForTg,
|
|
19947
20927
|
}
|
|
@@ -19967,6 +20947,31 @@ bot.command('effort', async ctx => {
|
|
|
19967
20947
|
await switchroomReply(ctx, menu.text, { html: true, reply_markup: effortMenuReplyMarkup(menu) })
|
|
19968
20948
|
return
|
|
19969
20949
|
}
|
|
20950
|
+
// Mid-turn: ACK + QUEUE + apply-on-idle + confirm (#3017) — parity with
|
|
20951
|
+
// /model. `applyEffort` mid-turn silently maybe-failed ("couldn't confirm it
|
|
20952
|
+
// applied") before this gate existed. `currentTurn !== null` is the same
|
|
20953
|
+
// busy signal the model path reads via deps.isBusy().
|
|
20954
|
+
if ((parsed.kind === 'set' || parsed.kind === 'default') && currentTurn !== null) {
|
|
20955
|
+
const requestedLevel = parsed.kind === 'set' ? parsed.level : 'default'
|
|
20956
|
+
const chatId = String(ctx.chat!.id)
|
|
20957
|
+
const threadId = resolveThreadId(chatId, ctx.message?.message_thread_id)
|
|
20958
|
+
const sent = await ctx.replyWithRichMessage(
|
|
20959
|
+
richMessage(hardenCardBreaks(pendingCmdAckText('effort', requestedLevel, escapeHtmlForTg))),
|
|
20960
|
+
threadId != null ? { message_thread_id: threadId } : {},
|
|
20961
|
+
)
|
|
20962
|
+
enqueueSessionCommand({
|
|
20963
|
+
kind: 'effort',
|
|
20964
|
+
origin: 'typed',
|
|
20965
|
+
arg: requestedLevel,
|
|
20966
|
+
targetLabel: requestedLevel,
|
|
20967
|
+
chatId,
|
|
20968
|
+
threadId,
|
|
20969
|
+
ackChatId: chatId,
|
|
20970
|
+
ackMessageId: (sent as { message_id: number }).message_id,
|
|
20971
|
+
requestedAt: Date.now(),
|
|
20972
|
+
})
|
|
20973
|
+
return
|
|
20974
|
+
}
|
|
19970
20975
|
const reply = await handleEffortCommand(parsed, deps)
|
|
19971
20976
|
await switchroomReply(ctx, reply.text, { html: reply.html })
|
|
19972
20977
|
})
|
|
@@ -19983,7 +20988,10 @@ bot.command('agentstart', async ctx => {
|
|
|
19983
20988
|
)
|
|
19984
20989
|
})
|
|
19985
20990
|
|
|
19986
|
-
|
|
20991
|
+
// #3020: container stop lives on /agentstop (pairs with /agentstart above).
|
|
20992
|
+
// It WAS /stop; that word now cancels the in-flight turn instead — the
|
|
20993
|
+
// intuitive meaning for a chat-first operator.
|
|
20994
|
+
bot.command('agentstop', async ctx => {
|
|
19987
20995
|
if (!isAuthorizedSender(ctx)) return
|
|
19988
20996
|
const name = ctx.match?.trim() || getMyAgentName()
|
|
19989
20997
|
try { assertSafeAgentName(name) } catch { await switchroomReply(ctx, 'Invalid agent name.'); return }
|
|
@@ -19995,6 +21003,32 @@ bot.command('stop', async ctx => {
|
|
|
19995
21003
|
)
|
|
19996
21004
|
})
|
|
19997
21005
|
|
|
21006
|
+
// #3020: /stop — cancel the agent's in-flight turn (the typed sibling of the
|
|
21007
|
+
// bare "stop" keyword and the empty-`!` interrupt; shared executeHaltNow).
|
|
21008
|
+
bot.command('stop', async ctx => {
|
|
21009
|
+
if (!isAuthorizedSender(ctx)) return
|
|
21010
|
+
// 7a: /stop takes NO argument. "/stop worker" is almost certainly old
|
|
21011
|
+
// container-stop muscle memory (the verb that moved to /agentstop) — warn
|
|
21012
|
+
// and do NOT halt, so a mis-remembered command can't kill an unrelated turn.
|
|
21013
|
+
const arg = ctx.match?.trim()
|
|
21014
|
+
if (arg) {
|
|
21015
|
+
await switchroomReply(
|
|
21016
|
+
ctx,
|
|
21017
|
+
`/stop takes no argument — it cancels MY in-flight turn. ` +
|
|
21018
|
+
`To stop a container, use /agentstop ${arg}. Nothing was stopped.`,
|
|
21019
|
+
)
|
|
21020
|
+
return
|
|
21021
|
+
}
|
|
21022
|
+
const queuedLabels = pendingSessionCommand.list().map(c => `/${c.kind} ${c.targetLabel}`)
|
|
21023
|
+
const inFlight = turnInFlightForGate()
|
|
21024
|
+
if (!inFlight) {
|
|
21025
|
+
await switchroomReply(ctx, buildStopReply(false, queuedLabels).text)
|
|
21026
|
+
return
|
|
21027
|
+
}
|
|
21028
|
+
await executeHaltNow('stop-command')
|
|
21029
|
+
await switchroomReply(ctx, buildStopReply(true, queuedLabels).text)
|
|
21030
|
+
})
|
|
21031
|
+
|
|
19998
21032
|
bot.command('restart', async ctx => {
|
|
19999
21033
|
if (!isAuthorizedSender(ctx)) return
|
|
20000
21034
|
const name = ctx.match?.trim() || getMyAgentName()
|
|
@@ -20033,12 +21067,13 @@ bot.command('restart', async ctx => {
|
|
|
20033
21067
|
// greeting card shows "Restarted user: /restart from chat" instead
|
|
20034
21068
|
// of whatever reason the downstream CLI would default to.
|
|
20035
21069
|
stampUserRestartReason('user: /restart from chat')
|
|
20036
|
-
// /restart is
|
|
20037
|
-
//
|
|
20038
|
-
// default
|
|
21070
|
+
// #3039: /restart is "bounce the session", NOT "clear my model" — the
|
|
21071
|
+
// durable override survives every restart and is cleared only by
|
|
21072
|
+
// `/model default`. Stamp keep for reason-honesty in the boot notice
|
|
21073
|
+
// (absence of intent keeps anyway under the keep-by-default boot).
|
|
20039
21074
|
{
|
|
20040
21075
|
const smDir = resolveAgentDirFromEnv()
|
|
20041
|
-
if (smDir) writeRelaunchModelIntent(smDir, '
|
|
21076
|
+
if (smDir) writeRelaunchModelIntent(smDir, 'keep', 'user: /restart from chat')
|
|
20042
21077
|
}
|
|
20043
21078
|
await sweepBeforeSelfRestart()
|
|
20044
21079
|
const hostdResp = await tryHostdDispatch(getMyAgentName(), {
|
|
@@ -20852,6 +21887,16 @@ const fleetFallbackGate = createFleetFallbackGate({
|
|
|
20852
21887
|
brokerReachable: isAuthBrokerSocketReachable,
|
|
20853
21888
|
})
|
|
20854
21889
|
|
|
21890
|
+
/**
|
|
21891
|
+
* #3031 PR 3 — per-chat registry of the most recent model-unavailable card
|
|
21892
|
+
* that promised an in-flight auto-failover. On a SUCCESSFUL swap the
|
|
21893
|
+
* announcement is folded into an EDIT of that card (single evolving card);
|
|
21894
|
+
* on every failure / no-op outcome the notice stays a separate message
|
|
21895
|
+
* (promise-honesty: the 2026-06-06→07 incident contract). See
|
|
21896
|
+
* fallback-card-collapse.ts for the decision seam.
|
|
21897
|
+
*/
|
|
21898
|
+
const modelUnavailableCardRegistry = createModelUnavailableCardRegistry()
|
|
21899
|
+
|
|
20855
21900
|
/**
|
|
20856
21901
|
* Resume-after-swap gate (auth-failover-stall fix). Owns the single-flight +
|
|
20857
21902
|
* staleness decision for re-running the turn a mid-turn 429 killed. See
|
|
@@ -21061,6 +22106,34 @@ async function doFireFleetAutoFallback(triggerAgent: string, untilMs?: number):
|
|
|
21061
22106
|
// not the user's answer — silence the open ping.
|
|
21062
22107
|
const opts = { disable_notification: true }
|
|
21063
22108
|
for (const chat_id of access.allowFrom) {
|
|
22109
|
+
// #3031 PR 3 — collapse: on a SUCCESSFUL swap, fold the announcement
|
|
22110
|
+
// into an EDIT of the model-unavailable card this gateway just sent to
|
|
22111
|
+
// this chat (single evolving card). One-shot `take` clears the record
|
|
22112
|
+
// either way. Every non-switched outcome that REACHES this loop — and
|
|
22113
|
+
// any edit failure — falls through to the pre-fix separate send.
|
|
22114
|
+
// Known PRE-EXISTING hole, unchanged by the collapse (#3035 review,
|
|
22115
|
+
// finding 2): the all-blocked cooldown early-return above exits before
|
|
22116
|
+
// this loop, so a cooldown-suppressed all-blocked REPEAT delivers
|
|
22117
|
+
// neither edit nor message even when a promising card exists. The
|
|
22118
|
+
// first all-blocked card of a window does arrive and answers the
|
|
22119
|
+
// promise; only the repeats inside the 30-min window are silent.
|
|
22120
|
+
const card = modelUnavailableCardRegistry.take(String(chat_id), Date.now())
|
|
22121
|
+
if (decideAnnouncementDelivery(outcome.kind, card) === 'edit' && card) {
|
|
22122
|
+
try {
|
|
22123
|
+
// allow-raw-bot-api: guarded edit; failure falls through to the wrapped send below
|
|
22124
|
+
await bot.api.editMessageText(
|
|
22125
|
+
chat_id,
|
|
22126
|
+
card.messageId,
|
|
22127
|
+
richMessage(foldAnnouncementIntoCard(card.text, outcome.announcement)),
|
|
22128
|
+
{},
|
|
22129
|
+
)
|
|
22130
|
+
continue
|
|
22131
|
+
} catch (err) {
|
|
22132
|
+
process.stderr.write(
|
|
22133
|
+
`telegram gateway: [fleet-fallback] card edit failed chat=${chat_id} — sending announcement separately: ${(err as Error)?.message ?? err}\n`,
|
|
22134
|
+
)
|
|
22135
|
+
}
|
|
22136
|
+
}
|
|
21064
22137
|
void swallowingApiCall(
|
|
21065
22138
|
// allow-raw-bot-api: wrapped in swallowingApiCall (retry policy)
|
|
21066
22139
|
() => bot.api.sendRichMessage(chat_id, richMessage(outcome.announcement), opts),
|
|
@@ -21312,6 +22385,61 @@ async function runQuotaWatch(opts: { bootTick?: boolean } = {}): Promise<void> {
|
|
|
21312
22385
|
}
|
|
21313
22386
|
}
|
|
21314
22387
|
|
|
22388
|
+
// #3031 PR 3 — broker-initiated proactive roll announcement. The broker's
|
|
22389
|
+
// fleetQuotaProbeTick roll previously emitted stdout + audit only (zero
|
|
22390
|
+
// operator Telegram visibility); it now records `last_fleet_roll` in
|
|
22391
|
+
// list-state. Edge-triggered on the roll's `at` timestamp; deduped across
|
|
22392
|
+
// the multi-gateway fan-in via the same broker claim-notification verb the
|
|
22393
|
+
// per-account pushes use — one card per roll per chat, fleet-wide.
|
|
22394
|
+
{
|
|
22395
|
+
const rollPrev = watchState[FLEET_ROLL_ANNOUNCE_KEY] ?? emptyAccountState()
|
|
22396
|
+
const rollDecision = evaluateFleetRollAnnounce({
|
|
22397
|
+
roll: listStateData.last_fleet_roll ?? null,
|
|
22398
|
+
prev: rollPrev,
|
|
22399
|
+
now,
|
|
22400
|
+
})
|
|
22401
|
+
if (rollDecision.kind === 'notify') {
|
|
22402
|
+
for (const chat_id of access.allowFrom) {
|
|
22403
|
+
if (tuning.fleetDedup) {
|
|
22404
|
+
const granted = await claimQuotaNotification(
|
|
22405
|
+
brokerClient,
|
|
22406
|
+
buildQuotaClaimKey(
|
|
22407
|
+
FLEET_ROLL_ANNOUNCE_KEY,
|
|
22408
|
+
String(listStateData.last_fleet_roll!.at),
|
|
22409
|
+
chat_id,
|
|
22410
|
+
),
|
|
22411
|
+
)
|
|
22412
|
+
if (!granted) {
|
|
22413
|
+
process.stderr.write(
|
|
22414
|
+
`telegram gateway: quota-watch: fleet-roll claim denied chat=${chat_id} — another agent notified\n`,
|
|
22415
|
+
)
|
|
22416
|
+
continue
|
|
22417
|
+
}
|
|
22418
|
+
}
|
|
22419
|
+
await swallowingApiCall(
|
|
22420
|
+
() =>
|
|
22421
|
+
bot.api.sendRichMessage(chat_id, richMessage(rollDecision.message), {
|
|
22422
|
+
// Reassuring status notice, not the user's answer — silent.
|
|
22423
|
+
disable_notification: true,
|
|
22424
|
+
}),
|
|
22425
|
+
{ chat_id, verb: 'quota-watch.fleet-roll' },
|
|
22426
|
+
)
|
|
22427
|
+
}
|
|
22428
|
+
// Persist immediately — mirrors the all-exhausted block's rationale:
|
|
22429
|
+
// a per-account early return below must not drop the latch advance.
|
|
22430
|
+
watchState = patchQuotaWatchState(watchState, FLEET_ROLL_ANNOUNCE_KEY, rollDecision.newState)
|
|
22431
|
+
try {
|
|
22432
|
+
saveQuotaWatchState(stateDir, watchState)
|
|
22433
|
+
} catch (err) {
|
|
22434
|
+
process.stderr.write(`telegram gateway: quota-watch: fleet-roll state save failed: ${err}\n`)
|
|
22435
|
+
}
|
|
22436
|
+
process.stderr.write(
|
|
22437
|
+
`telegram gateway: quota-watch: announced broker fleet roll ` +
|
|
22438
|
+
`${listStateData.last_fleet_roll!.from} → ${listStateData.last_fleet_roll!.to}\n`,
|
|
22439
|
+
)
|
|
22440
|
+
}
|
|
22441
|
+
}
|
|
22442
|
+
|
|
21315
22443
|
// First pass: evaluate all accounts against cached state. Collect
|
|
21316
22444
|
// labels that need a live probe (i.e. accounts with a detected transition
|
|
21317
22445
|
// that we're about to notify about). We probe those to get fresh
|
|
@@ -21331,9 +22459,13 @@ async function runQuotaWatch(opts: { bootTick?: boolean } = {}): Promise<void> {
|
|
|
21331
22459
|
let reconciledCount = 0
|
|
21332
22460
|
let mutatedState = watchState
|
|
21333
22461
|
|
|
22462
|
+
// #3031 PR 3 — thread the last broker roll so a 🟡 throttling push for the
|
|
22463
|
+
// just-rolled-off account is latched silently (the roll card covered it).
|
|
22464
|
+
const lastRoll = listStateData.last_fleet_roll ?? null
|
|
22465
|
+
|
|
21334
22466
|
for (const snap of snapshots) {
|
|
21335
22467
|
const prev = watchState[snap.label] ?? emptyAccountState()
|
|
21336
|
-
const decision = evaluateQuotaWatchAccount({ agentName, snap, prev, now, bootTick, tuning })
|
|
22468
|
+
const decision = evaluateQuotaWatchAccount({ agentName, snap, prev, now, bootTick, tuning, lastRoll })
|
|
21337
22469
|
if (decision.kind === 'reconcile') {
|
|
21338
22470
|
mutatedState = patchQuotaWatchState(mutatedState, decision.accountLabel, decision.newAccountState)
|
|
21339
22471
|
reconciledCount++
|
|
@@ -21428,7 +22560,7 @@ async function runQuotaWatch(opts: { bootTick?: boolean } = {}): Promise<void> {
|
|
|
21428
22560
|
// staleness gate never misfires on data we JUST probed.
|
|
21429
22561
|
const enrichedSnap = { ...snapshots[snapIndex]!, quota: freshEntry!.result.data, capturedAtMs: undefined }
|
|
21430
22562
|
const prev = watchState[accountLabel] ?? emptyAccountState()
|
|
21431
|
-
const re = evaluateQuotaWatchAccount({ agentName, snap: enrichedSnap, prev, now, bootTick, tuning })
|
|
22563
|
+
const re = evaluateQuotaWatchAccount({ agentName, snap: enrichedSnap, prev, now, bootTick, tuning, lastRoll })
|
|
21432
22564
|
// If the fresh probe still shows the same transition, use the
|
|
21433
22565
|
// enriched message. If it no longer shows a transition (e.g. the
|
|
21434
22566
|
// account recovered in the 100ms between listState and probe),
|
|
@@ -22704,9 +23836,37 @@ bot.on('callback_query:data', async ctx => {
|
|
|
22704
23836
|
await ctx.answerCallbackQuery({ text: 'Not authorized.' })
|
|
22705
23837
|
return
|
|
22706
23838
|
}
|
|
22707
|
-
//
|
|
22708
|
-
//
|
|
22709
|
-
//
|
|
23839
|
+
// Mid-turn: ACK + QUEUE + apply-on-idle + confirm (#3017). `applyEffort`
|
|
23840
|
+
// types `/effort <level>` into claude's input box + drives the confirm
|
|
23841
|
+
// modal — mid-turn that queues the text as user input instead of switching
|
|
23842
|
+
// (the silent maybe-fail this fix closes). Parity with the model-menu gate.
|
|
23843
|
+
const effLevel = data.startsWith('eff:s:') ? data.slice('eff:s:'.length) : null
|
|
23844
|
+
const effCardMsgId = ctx.callbackQuery?.message?.message_id
|
|
23845
|
+
if (effLevel != null && currentTurn !== null && effCardMsgId != null) {
|
|
23846
|
+
await ctx
|
|
23847
|
+
.answerCallbackQuery({ text: '📥 Queued — applies when the turn ends' })
|
|
23848
|
+
.catch(() => {})
|
|
23849
|
+
const effChatId = String(ctx.chat?.id ?? '')
|
|
23850
|
+
const effThreadId = resolveThreadId(effChatId, ctx.callbackQuery?.message?.message_thread_id)
|
|
23851
|
+
await ctx
|
|
23852
|
+
.editMessageText(richMessage(hardenCardBreaks(pendingCmdAckText('effort', effLevel, escapeHtmlForTg))), {
|
|
23853
|
+
reply_markup: { inline_keyboard: [] },
|
|
23854
|
+
})
|
|
23855
|
+
.catch(() => {})
|
|
23856
|
+
enqueueSessionCommand({
|
|
23857
|
+
kind: 'effort',
|
|
23858
|
+
origin: 'menu',
|
|
23859
|
+
arg: data,
|
|
23860
|
+
targetLabel: effLevel,
|
|
23861
|
+
chatId: effChatId,
|
|
23862
|
+
threadId: effThreadId,
|
|
23863
|
+
ackChatId: effChatId,
|
|
23864
|
+
ackMessageId: effCardMsgId,
|
|
23865
|
+
requestedAt: Date.now(),
|
|
23866
|
+
})
|
|
23867
|
+
return
|
|
23868
|
+
}
|
|
23869
|
+
// Idle: apply directly. No picker-driving concern — just ack and apply.
|
|
22710
23870
|
await ctx.answerCallbackQuery({ text: 'Setting effort…' }).catch(() => {})
|
|
22711
23871
|
try {
|
|
22712
23872
|
const outcome = await handleEffortMenuCallback(data, buildEffortDeps())
|
|
@@ -22745,12 +23905,51 @@ bot.on('callback_query:data', async ctx => {
|
|
|
22745
23905
|
const cbChatId = String(ctx.chat?.id ?? '')
|
|
22746
23906
|
const cbThreadId = resolveThreadId(cbChatId, ctx.callbackQuery?.message?.message_thread_id)
|
|
22747
23907
|
const modelDeps = buildModelDeps({ chatId: cbChatId, threadId: cbThreadId })
|
|
22748
|
-
// Mid-turn
|
|
22749
|
-
//
|
|
22750
|
-
//
|
|
22751
|
-
//
|
|
22752
|
-
//
|
|
23908
|
+
// Mid-turn (#3017): a model-SWITCH tap no longer dead-ends. ACK + QUEUE +
|
|
23909
|
+
// apply-on-idle + confirm — the tap edits the menu card into the ack, which
|
|
23910
|
+
// the idle drain then edits into the confirmation. Non-switch taps (Refresh
|
|
23911
|
+
// / page-nav / header) still just toast — they drive the picker (discovery)
|
|
23912
|
+
// which is unsafe mid-turn and carry no operator choice to preserve.
|
|
22753
23913
|
if (modelDeps.isBusy()) {
|
|
23914
|
+
// Classify: sr-*/alias taps carry a real model token → queue as a typed
|
|
23915
|
+
// apply (reuses the typed drain + recording). A select tag (mdl:s:<tag>)
|
|
23916
|
+
// needs idle discovery to resolve → queue as a menu apply (replayed).
|
|
23917
|
+
const cardMsgId = ctx.callbackQuery?.message?.message_id
|
|
23918
|
+
let queueArg: string | null = null
|
|
23919
|
+
let queueOrigin: 'typed' | 'menu' = 'menu'
|
|
23920
|
+
let queueLabel = 'the selected model'
|
|
23921
|
+
if (data.startsWith(MODEL_CALLBACK_SR)) {
|
|
23922
|
+
const srName = data.slice(MODEL_CALLBACK_SR.length)
|
|
23923
|
+
if (isValidModelArg(srName)) { queueArg = srName; queueOrigin = 'typed'; queueLabel = srFriendlyLabel(srName) }
|
|
23924
|
+
} else if (data.startsWith(MODEL_CALLBACK_ALIAS)) {
|
|
23925
|
+
const alias = data.slice(MODEL_CALLBACK_ALIAS.length)
|
|
23926
|
+
if (isValidModelArg(alias)) { queueArg = alias; queueOrigin = 'typed'; queueLabel = alias }
|
|
23927
|
+
} else if (data.startsWith('mdl:s:')) {
|
|
23928
|
+
queueArg = data; queueOrigin = 'menu'
|
|
23929
|
+
}
|
|
23930
|
+
if (queueArg != null && cardMsgId != null) {
|
|
23931
|
+
await ctx
|
|
23932
|
+
.answerCallbackQuery({ text: '📥 Queued — applies when the turn ends' })
|
|
23933
|
+
.catch(() => {})
|
|
23934
|
+
await ctx
|
|
23935
|
+
.editMessageText(richMessage(hardenCardBreaks(pendingCmdAckText('model', queueLabel, escapeHtmlForTg))), {
|
|
23936
|
+
reply_markup: { inline_keyboard: [] },
|
|
23937
|
+
})
|
|
23938
|
+
.catch(() => {})
|
|
23939
|
+
enqueueSessionCommand({
|
|
23940
|
+
kind: 'model',
|
|
23941
|
+
origin: queueOrigin,
|
|
23942
|
+
arg: queueArg,
|
|
23943
|
+
targetLabel: queueLabel,
|
|
23944
|
+
chatId: cbChatId,
|
|
23945
|
+
threadId: cbThreadId,
|
|
23946
|
+
ackChatId: cbChatId,
|
|
23947
|
+
ackMessageId: cardMsgId,
|
|
23948
|
+
requestedAt: Date.now(),
|
|
23949
|
+
})
|
|
23950
|
+
return
|
|
23951
|
+
}
|
|
23952
|
+
// Non-switch tap (or unresolvable) — keep the menu intact, just toast.
|
|
22754
23953
|
await ctx
|
|
22755
23954
|
.answerCallbackQuery({ text: '⏳ Agent is mid-turn — tap again when it’s idle', show_alert: false })
|
|
22756
23955
|
.catch(() => {})
|
|
@@ -22814,105 +24013,32 @@ bot.on('callback_query:data', async ctx => {
|
|
|
22814
24013
|
}
|
|
22815
24014
|
return
|
|
22816
24015
|
}
|
|
22817
|
-
const didInterimSrEdit = false
|
|
22818
24016
|
try {
|
|
22819
24017
|
const prevSessionModel = sessionModelSource.getOverride()
|
|
22820
24018
|
const outcome = await handleModelMenuCallback(data, modelDeps)
|
|
22821
|
-
//
|
|
22822
|
-
//
|
|
22823
|
-
//
|
|
22824
|
-
//
|
|
22825
|
-
//
|
|
22826
|
-
if (outcome.
|
|
22827
|
-
|
|
22828
|
-
|
|
22829
|
-
|
|
22830
|
-
|
|
22831
|
-
|
|
22832
|
-
|
|
22833
|
-
|
|
22834
|
-
|
|
22835
|
-
|
|
22836
|
-
|
|
22837
|
-
|
|
22838
|
-
|
|
22839
|
-
// the same card instead of only stderr.
|
|
22840
|
-
outcome.reply.text +=
|
|
22841
|
-
'\n⚠️ Couldn’t persist the sticky override — the switch is live now but won’t survive a relaunch.'
|
|
22842
|
-
process.stderr.write(
|
|
22843
|
-
`telegram gateway: session-model persist failed (menu): ${(err as Error)?.message ?? String(err)}\n`,
|
|
22844
|
-
)
|
|
22845
|
-
}
|
|
22846
|
-
}
|
|
22847
|
-
}
|
|
22848
|
-
if (outcome.clearedDefault) {
|
|
22849
|
-
const smDir = resolveAgentDirFromEnv()
|
|
22850
|
-
if (smDir) clearSessionModelFile(smDir)
|
|
22851
|
-
}
|
|
22852
|
-
// toastOnly: leave the menu untouched — but only if we haven't already
|
|
22853
|
-
// cleared its buttons with the interim sr-* edit. If we have, fall
|
|
22854
|
-
// through to the final edit so the message is recovered (busyReply or
|
|
22855
|
-
// the full menu) rather than left permanently button-less.
|
|
22856
|
-
if (outcome.toastOnly && !didInterimSrEdit) return
|
|
22857
|
-
|
|
22858
|
-
// sr-* → Claude transition via the model menu: trigger a graceful restart.
|
|
22859
|
-
// Switching FROM an sr-* (LiteLLM/OpenRouter) model BACK to a Claude model
|
|
22860
|
-
// via the picker requires a session restart — the picker-select only changes
|
|
22861
|
-
// the session model label, but the sr-* LiteLLM routing context persists
|
|
22862
|
-
// until the session is torn down. Same mechanism as the /restart command.
|
|
22863
|
-
if (outcome.selectedModel && isSrToClaudeTransition(prevSessionModel, outcome.selectedModel)) {
|
|
22864
|
-
const agentName = getMyAgentName()
|
|
22865
|
-
// Replace the menu with a restart notice (no buttons — session is ending).
|
|
24019
|
+
// toastOnly: leave the menu untouched — a mid-turn refusal keeps its
|
|
24020
|
+
// buttons so the operator can tap again. (In the enqueue world this
|
|
24021
|
+
// branch is unreachable — the dispatcher enqueues switches mid-turn
|
|
24022
|
+
// before ever calling the handler — but retained for callers that skip
|
|
24023
|
+
// the dispatcher gate.)
|
|
24024
|
+
if (outcome.toastOnly) return
|
|
24025
|
+
// Record the switch (persist/clear sticky override) + drive an sr-*→Claude
|
|
24026
|
+
// graceful restart when needed. Shared with the deferred (queued) apply so
|
|
24027
|
+
// both surfaces record identically. Returns a restart notice when a
|
|
24028
|
+
// session restart was scheduled.
|
|
24029
|
+
const { restartNotice } = recordModelMenuSideEffects(
|
|
24030
|
+
outcome,
|
|
24031
|
+
modelDeps,
|
|
24032
|
+
cbChatId,
|
|
24033
|
+
cbThreadId,
|
|
24034
|
+
prevSessionModel,
|
|
24035
|
+
)
|
|
24036
|
+
if (restartNotice) {
|
|
22866
24037
|
await ctx
|
|
22867
|
-
.editMessageText(
|
|
22868
|
-
richMessage(`🔄 Switching from **${escapeHtmlForTg(prevSessionModel!)}** back to Claude — restarting session cleanly. Claude will be ready in ~30s.`),
|
|
22869
|
-
{ reply_markup: { inline_keyboard: [] } },
|
|
22870
|
-
)
|
|
24038
|
+
.editMessageText(richMessage(restartNotice), { reply_markup: { inline_keyboard: [] } })
|
|
22871
24039
|
.catch(() => {})
|
|
22872
|
-
// Carry the requested Claude model across the restart via the SAME
|
|
22873
|
-
// durable `.session-model` override a Claude → sr-* switch uses —
|
|
22874
|
-
// otherwise boot launches the CONFIGURED default and the tapped model
|
|
22875
|
-
// is silently dropped. `selectedModelToken` is a real `claude --model`
|
|
22876
|
-
// token (alias or full claude-* id); a "Default"-row tap yields no
|
|
22877
|
-
// token → clear the override and boot the configured default
|
|
22878
|
-
// (correct). start.sh's LiteLLM-down guard only skips sr-* overrides,
|
|
22879
|
-
// so a Claude token is never dropped.
|
|
22880
|
-
{
|
|
22881
|
-
const agentDir = resolveAgentDirFromEnv()
|
|
22882
|
-
const token = outcome.selectedModelToken
|
|
22883
|
-
if (agentDir && token) {
|
|
22884
|
-
try {
|
|
22885
|
-
writeSessionModelFile(
|
|
22886
|
-
agentDir,
|
|
22887
|
-
token,
|
|
22888
|
-
readConfiguredDefaultModel(agentDir) ??
|
|
22889
|
-
resolveMainModel(modelDeps.getConfiguredModel() ?? undefined),
|
|
22890
|
-
)
|
|
22891
|
-
sessionModelSource.setOverride(token)
|
|
22892
|
-
} catch (e) {
|
|
22893
|
-
process.stderr.write(`telegram gateway: sr-to-claude session-model write failed: ${(e as Error)?.message ?? String(e)}\n`)
|
|
22894
|
-
}
|
|
22895
|
-
} else if (agentDir) {
|
|
22896
|
-
// Default-row tap while on sr-*: the restart must land on the
|
|
22897
|
-
// configured default — a stale sticky override would resurrect
|
|
22898
|
-
// the old model on the next keep-relaunch.
|
|
22899
|
-
clearSessionModelFile(agentDir)
|
|
22900
|
-
}
|
|
22901
|
-
}
|
|
22902
|
-
// Write the restart marker so the post-restart boot card edits into this chat.
|
|
22903
|
-
writeRestartMarker({ chat_id: cbChatId, thread_id: cbThreadId ?? null, ack_message_id: null, ts: Date.now() })
|
|
22904
|
-
stampUserRestartReason('user: sr-to-claude model switch (menu)')
|
|
22905
|
-
if (turnInFlightForGate()) {
|
|
22906
|
-
// Defer restart until the in-flight turn completes (same gate as /restart).
|
|
22907
|
-
pendingRestarts.set(agentName, Date.now())
|
|
22908
|
-
} else {
|
|
22909
|
-
void sweepBeforeSelfRestart().finally(() =>
|
|
22910
|
-
triggerSelfRestart(agentName, 'sr-to-claude-model-switch', 1500),
|
|
22911
|
-
)
|
|
22912
|
-
}
|
|
22913
24040
|
return
|
|
22914
24041
|
}
|
|
22915
|
-
|
|
22916
24042
|
await ctx
|
|
22917
24043
|
.editMessageText(richMessage(outcome.reply.text), {
|
|
22918
24044
|
reply_markup: modelMenuReplyMarkup(outcome.reply) ?? { inline_keyboard: [] },
|
|
@@ -25157,6 +26283,10 @@ async function shutdown(signal: string): Promise<void> {
|
|
|
25157
26283
|
shuttingDown = true
|
|
25158
26284
|
const agentName = process.env.SWITCHROOM_AGENT_NAME ?? '-'
|
|
25159
26285
|
process.stderr.write('telegram gateway: shutting down\n')
|
|
26286
|
+
// #3038 — never let a pending bridge-dead grace timer race a shutdown
|
|
26287
|
+
// already in progress (its check() also skips on shuttingDown; this is
|
|
26288
|
+
// the belt to that brace).
|
|
26289
|
+
bridgeDeadWatchdog.stop()
|
|
25160
26290
|
|
|
25161
26291
|
// Write the clean-shutdown sentinel BEFORE any drain work begins so
|
|
25162
26292
|
// even if the drain hangs and the +5s force-exit kills us, the marker
|
|
@@ -25191,10 +26321,66 @@ async function shutdown(signal: string): Promise<void> {
|
|
|
25191
26321
|
} catch (err) {
|
|
25192
26322
|
process.stderr.write(`telegram gateway: shutdown.clean_marker_write_failed err=${(err as Error).message}\n`)
|
|
25193
26323
|
}
|
|
26324
|
+
// #3017 — persist a Telegram-set model across a GRACEFUL deploy/restart.
|
|
26325
|
+
// Boot default is REVERT, and an EXTERNAL deploy (SIGTERM to PID 1 from
|
|
26326
|
+
// `switchroom apply` / `docker compose up`) never routes through
|
|
26327
|
+
// triggerSelfRestart, so it stamps no `.relaunch-model-intent` and start.sh
|
|
26328
|
+
// drops the user's `/model` choice (the overlord `fable`→`opus` revert on
|
|
26329
|
+
// the v0.18.9 roll). A graceful OS-signal shutdown IS a clean, planned
|
|
26330
|
+
// bounce — stamp keep-intent for an active `.session-model` override so the
|
|
26331
|
+
// chosen model survives and start.sh re-confirms it via `.session-model-alert`
|
|
26332
|
+
// on boot. Respect an intent an initiator already stamped (a /restart stamps
|
|
26333
|
+
// 'revert' before SIGTERM): only stamp when none exists. A crash routes
|
|
26334
|
+
// through the non-OS-signal branch and still reverts (safe side preserved).
|
|
26335
|
+
try {
|
|
26336
|
+
const smDir = resolveAgentDirFromEnv()
|
|
26337
|
+
if (smDir != null) {
|
|
26338
|
+
const hasOverride = readSessionModelFile(smDir) != null
|
|
26339
|
+
const intentAlreadyStamped = existsSync(join(smDir, RELAUNCH_MODEL_INTENT_FILE))
|
|
26340
|
+
if (hasOverride && !intentAlreadyStamped) {
|
|
26341
|
+
// The GATEWAY_SHUTDOWN_INTENT_REASON_PREFIX makes this stamp
|
|
26342
|
+
// recognisable at the next GATEWAY boot: a gateway-only bounce never
|
|
26343
|
+
// runs start.sh, so a leftover stamp with this prefix is cleared at
|
|
26344
|
+
// boot (clearStaleGatewayShutdownIntent) instead of lingering to
|
|
26345
|
+
// convert a later genuine crash into a "keep" (#3018 finding 4).
|
|
26346
|
+
writeRelaunchModelIntent(smDir, 'keep', `${GATEWAY_SHUTDOWN_INTENT_REASON_PREFIX} graceful ${signal} shutdown (deploy/rolling restart) — preserving user-chosen session model`)
|
|
26347
|
+
process.stderr.write(`telegram gateway: shutdown.session_model_keep_stamped signal=${signal}\n`)
|
|
26348
|
+
}
|
|
26349
|
+
}
|
|
26350
|
+
} catch (err) {
|
|
26351
|
+
process.stderr.write(`telegram gateway: shutdown.session_model_keep_stamp_failed err=${(err as Error).message}\n`)
|
|
26352
|
+
}
|
|
25194
26353
|
} else {
|
|
25195
26354
|
process.stderr.write(`telegram gateway: shutdown.clean_marker_skipped signal=${signal} (crash path — banner will fire on next boot)\n`)
|
|
25196
26355
|
}
|
|
25197
26356
|
|
|
26357
|
+
// #3018 finding 3 + #3039: resolve any queued /model|/effort ack cards. The
|
|
26358
|
+
// gateway (and with it the in-memory queue) is going away — persist each
|
|
26359
|
+
// typed choice to the durable boot carriers (`.session-model` /
|
|
26360
|
+
// `.session-effort`) so it still deterministically applies as the agent
|
|
26361
|
+
// boots, and edit the ack card to say so. Only an unresolvable menu-tag
|
|
26362
|
+
// selection falls back to a re-issue note. Best-effort and time-bounded so
|
|
26363
|
+
// a wedged Telegram API can't block shutdown.
|
|
26364
|
+
const orphanedCmdActions = pendingCmdShutdownResolutionActions(pendingSessionCommand, escapeHtmlForTg)
|
|
26365
|
+
const orphanedCmdEdits = orphanedCmdActions.map(a => ({
|
|
26366
|
+
chatId: a.cmd.ackChatId,
|
|
26367
|
+
messageId: a.cmd.ackMessageId,
|
|
26368
|
+
text: persistQueuedCommandForRestart(a),
|
|
26369
|
+
}))
|
|
26370
|
+
if (orphanedCmdEdits.length > 0) {
|
|
26371
|
+
process.stderr.write(
|
|
26372
|
+
`telegram gateway: shutdown.pending_cmd_resolved count=${orphanedCmdEdits.length}\n`,
|
|
26373
|
+
)
|
|
26374
|
+
await Promise.race([
|
|
26375
|
+
Promise.allSettled(
|
|
26376
|
+
orphanedCmdEdits.map(e => editPendingCommandCard(e.chatId, e.messageId, e.text)),
|
|
26377
|
+
),
|
|
26378
|
+
new Promise<void>(resolve => {
|
|
26379
|
+
setTimeout(resolve, SHUTDOWN_PROGRESS_FLUSH_BUDGET_MS).unref()
|
|
26380
|
+
}),
|
|
26381
|
+
])
|
|
26382
|
+
}
|
|
26383
|
+
|
|
25198
26384
|
// Stage 3c: stamp any in-flight turn as endedVia='sigterm' (or 'restart'
|
|
25199
26385
|
// for the schedule_restart-initiated case where pendingRestarts is set).
|
|
25200
26386
|
// Best-effort — SIGKILL / OOM skip this path entirely and the next-boot
|
|
@@ -26309,6 +27495,44 @@ void (async () => {
|
|
|
26309
27495
|
// Gated to background completions: foreground sub-agents
|
|
26310
27496
|
// need nothing here, and 'orphan' is a stale historical-at-
|
|
26311
27497
|
// boot row, not a fresh completion the user is waiting on.
|
|
27498
|
+
// Issue #3023 (card resurrection). A worker whose card was
|
|
27499
|
+
// FALSELY finalised (silent-stall synthesis fired, then the
|
|
27500
|
+
// JSONL resumed growing) is being revived by the watcher. This
|
|
27501
|
+
// callback ONLY clears the worker feed's durable `finalized` gate
|
|
27502
|
+
// (workerActivityFeed.resurrect) — it does NOT itself paint or pin
|
|
27503
|
+
// anything. The actual repaint + re-pin happen DOWNSTREAM on the
|
|
27504
|
+
// next replayed `running` cue: the watcher's re-registration
|
|
27505
|
+
// replays `onProgress`, which calls `workerActivityFeed.update()`
|
|
27506
|
+
// (now un-gated) to first-paint a FRESH `🛠 Worker` message, and
|
|
27507
|
+
// its `.then(reconcileWorkerPin(agentId, wkChat, true))` pins that
|
|
27508
|
+
// new message via the `wk:<agentId>` status-pin. Clearing the gate
|
|
27509
|
+
// here FIRST is the ordering requirement — without it those first
|
|
27510
|
+
// replayed ticks would be swallowed by the finalized gate and no
|
|
27511
|
+
// new card would ever paint. Net effect restores the operator
|
|
27512
|
+
// invariant "active work must always be visible" for the case
|
|
27513
|
+
// PR #3019's in-flight gate can't fully prevent.
|
|
27514
|
+
onResurrect: (agentId, _description) => {
|
|
27515
|
+
try {
|
|
27516
|
+
workerActivityFeed?.resurrect(agentId)
|
|
27517
|
+
} catch (err) {
|
|
27518
|
+
process.stderr.write(
|
|
27519
|
+
`telegram gateway: worker resurrect error agent=${agentId}: ${(err as Error).message}\n`,
|
|
27520
|
+
)
|
|
27521
|
+
}
|
|
27522
|
+
process.stderr.write(
|
|
27523
|
+
`telegram gateway: worker ${agentId} card RESURRECTED — false terminal finish reversed, worker resumed (issue #3023)\n`,
|
|
27524
|
+
)
|
|
27525
|
+
},
|
|
27526
|
+
// Issue #3023 (bounded chain). A worker resurrected once and then
|
|
27527
|
+
// falsely finalised again is NOT resurrected forever — the
|
|
27528
|
+
// watcher names it lost. Surface the fact in the log; the
|
|
27529
|
+
// handback (onFinish) still delivered the synthesised result, so
|
|
27530
|
+
// there is nothing further to paint.
|
|
27531
|
+
onWorkerLost: (agentId, _description) => {
|
|
27532
|
+
process.stderr.write(
|
|
27533
|
+
`telegram gateway: worker ${agentId} NAMED AS LOST — falsely finalised twice, resurrection chain bound reached (issue #3023)\n`,
|
|
27534
|
+
)
|
|
27535
|
+
},
|
|
26312
27536
|
onFinish: ({ agentId, outcome, description, resultText, toolCount, durationMs, background: entryBackground }) => {
|
|
26313
27537
|
// Reaction promotion: if the parent turn already ended
|
|
26314
27538
|
// with this (or another) worker still running, its 👍 was
|