switchroom 0.17.10 → 0.18.6
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/bin/workspace-dynamic-hook.sh +12 -13
- package/dist/agent-scheduler/index.js +29 -2
- package/dist/auth-broker/index.js +6163 -152
- package/dist/cli/notion-write-pretool.mjs +31 -3
- package/dist/cli/switchroom.js +695 -526
- package/dist/host-control/main.js +6184 -173
- package/dist/vault/approvals/kernel-server.js +5893 -165
- package/dist/vault/broker/server.js +6666 -921
- package/package.json +1 -1
- package/profiles/_base/settings.json.hbs +2 -2
- package/profiles/_base/start.sh.hbs +170 -21
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md.hbs +2 -2
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/skills/switchroom-release/SKILL.md +78 -0
- package/telegram-plugin/auth-snapshot-format.ts +37 -25
- package/telegram-plugin/context-exhaustion.ts +124 -0
- package/telegram-plugin/dist/gateway/gateway.js +25025 -9203
- package/telegram-plugin/gateway/activity-card-store.ts +76 -0
- package/telegram-plugin/gateway/gateway.ts +740 -106
- package/telegram-plugin/gateway/inbound-delivery-gate.ts +26 -0
- package/telegram-plugin/gateway/model-command.ts +70 -10
- package/telegram-plugin/gateway/resolve-person.ts +304 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +21 -1
- package/telegram-plugin/hooks/silent-end-scan.mjs +164 -40
- package/telegram-plugin/operator-events.ts +21 -0
- package/telegram-plugin/package.json +6 -0
- package/telegram-plugin/pending-work-progress.ts +42 -7
- package/telegram-plugin/quota-bar-format.ts +360 -0
- package/telegram-plugin/quota-watch.ts +4 -6
- package/telegram-plugin/registry/turns-schema.test.ts +97 -0
- package/telegram-plugin/registry/turns-schema.ts +78 -0
- package/telegram-plugin/render/ir.ts +209 -0
- package/telegram-plugin/render/parse.ts +363 -0
- package/telegram-plugin/render/render.ts +440 -0
- package/telegram-plugin/render/rich-render.ts +72 -0
- package/telegram-plugin/stream-controller.ts +14 -3
- package/telegram-plugin/subagent-watcher.ts +27 -9
- package/telegram-plugin/tests/activity-card-store.test.ts +94 -0
- package/telegram-plugin/tests/auth-command-format2.test.ts +1 -1
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +51 -16
- package/telegram-plugin/tests/claude-code-event-contract.test.ts +48 -0
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +11 -0
- package/telegram-plugin/tests/feed-survival.test.ts +39 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +81 -0
- package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +82 -0
- package/telegram-plugin/tests/liveness-tracker.test.ts +228 -0
- package/telegram-plugin/tests/model-command.test.ts +193 -16
- package/telegram-plugin/tests/narrative-render.test.ts +125 -0
- package/telegram-plugin/tests/operator-events.test.ts +16 -0
- package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +123 -163
- package/telegram-plugin/tests/pending-work-progress.test.ts +116 -3
- package/telegram-plugin/tests/quota-bar-format.test.ts +444 -0
- package/telegram-plugin/tests/quota-watch.test.ts +1 -4
- package/telegram-plugin/tests/rapid-fire-delivery-ordering.test.ts +149 -0
- package/telegram-plugin/tests/render/parse-torture.test.ts +136 -0
- package/telegram-plugin/tests/render/parse.test.ts +393 -0
- package/telegram-plugin/tests/render/render.test.ts +436 -0
- package/telegram-plugin/tests/render/rich-render.test.ts +85 -0
- package/telegram-plugin/tests/resolve-person.test.ts +290 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +53 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +138 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +61 -0
- package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +155 -1
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +19 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +97 -0
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +98 -3
- package/telegram-plugin/turn-liveness-floor.ts +35 -1
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +99 -7
- package/telegram-plugin/worker-activity-feed.ts +220 -15
- package/telegram-plugin/worktree-watch-cwds.ts +92 -17
- package/vendor/hindsight-memory/scripts/lib/client.py +11 -1
- package/vendor/hindsight-memory/scripts/lib/config.py +9 -2
- package/vendor/hindsight-memory/scripts/recall.py +64 -6
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +1 -0
- package/vendor/hindsight-memory/tests/test_client.py +43 -0
- package/vendor/hindsight-memory/tests/test_recall_precision.py +114 -0
- package/profiles/default/CLAUDE.md +0 -116
- package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
- package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
|
@@ -185,6 +185,12 @@ import {
|
|
|
185
185
|
import { clearStaleTelegramPollingState } from '../startup-reset.js'
|
|
186
186
|
import { gatewayStartupRetry } from './startup-network-retry.js'
|
|
187
187
|
import { writeQuarantineMarker } from './quarantine.js'
|
|
188
|
+
import {
|
|
189
|
+
runPersonDirectoryBootCheck,
|
|
190
|
+
safeResolvePersonName,
|
|
191
|
+
type PersonDirectory,
|
|
192
|
+
type RawPersonEntry,
|
|
193
|
+
} from './resolve-person.js'
|
|
188
194
|
// RFC H §7.3: auth-dashboard + auth-slot-parser deleted. Three chat
|
|
189
195
|
// verbs (/auth show | use | rotate) talk to switchroom-auth-broker
|
|
190
196
|
// via the thin client in src/auth/broker/client.ts.
|
|
@@ -294,6 +300,7 @@ import {
|
|
|
294
300
|
shouldArmOrphanedReplyTimeout,
|
|
295
301
|
ORPHANED_REPLY_TIMEOUT_MS,
|
|
296
302
|
ORPHANED_REPLY_MAX_REARMS,
|
|
303
|
+
LivenessTracker,
|
|
297
304
|
} from '../context-exhaustion.js'
|
|
298
305
|
import {
|
|
299
306
|
decideTurnFlush,
|
|
@@ -346,6 +353,7 @@ import {
|
|
|
346
353
|
buildModelMenu,
|
|
347
354
|
handleModelMenuCallback,
|
|
348
355
|
isSrToClaudeTransition,
|
|
356
|
+
isValidModelArg,
|
|
349
357
|
MODEL_CALLBACK_PREFIX,
|
|
350
358
|
MODEL_CALLBACK_HEADER,
|
|
351
359
|
MODEL_CALLBACK_SR,
|
|
@@ -357,6 +365,7 @@ import {
|
|
|
357
365
|
type ModelMenuReply,
|
|
358
366
|
} from './model-command.js'
|
|
359
367
|
import { discoverModels, selectModel } from '../../src/agents/model-picker.js'
|
|
368
|
+
import { resolveMainModel } from '../../src/agents/scaffold.js'
|
|
360
369
|
import {
|
|
361
370
|
parseEffortCommand,
|
|
362
371
|
handleEffortCommand,
|
|
@@ -419,6 +428,7 @@ import {
|
|
|
419
428
|
writeActivityCardRecord,
|
|
420
429
|
clearActivityCardRecord,
|
|
421
430
|
runActivityCardBootReaper,
|
|
431
|
+
runActivityCardMidSessionReaper,
|
|
422
432
|
restartOrphanCardFinalizeText,
|
|
423
433
|
type ActivityCardStoreFsSeam,
|
|
424
434
|
} from './activity-card-store.js'
|
|
@@ -427,7 +437,7 @@ import { shouldSuppressRepresent } from './represent-guard.js'
|
|
|
427
437
|
import { shouldDeferEscalationForBridge } from './escalation-bridge-gate.js'
|
|
428
438
|
import { createInboundSpool } from './inbound-spool.js'
|
|
429
439
|
import { purgeStaleTurnsForChat } from './turn-state-purge.js'
|
|
430
|
-
import { decideInboundDelivery } from './inbound-delivery-gate.js'
|
|
440
|
+
import { decideInboundDelivery, reserveInboundDelivery } from './inbound-delivery-gate.js'
|
|
431
441
|
import { mayDrainBufferedInbound, shouldArmNoReplyDrain } from './serialize-drain-gate.js'
|
|
432
442
|
import { decideFeedReopen } from './feed-reopen-gate.js'
|
|
433
443
|
import {
|
|
@@ -662,6 +672,7 @@ import {
|
|
|
662
672
|
findRecentTurnsForChat,
|
|
663
673
|
getTurnByKey,
|
|
664
674
|
markTurnResumed,
|
|
675
|
+
reapStaleOpenTurns,
|
|
665
676
|
} from '../registry/turns-schema.js'
|
|
666
677
|
import {
|
|
667
678
|
buildResumeInterruptedInbound,
|
|
@@ -716,6 +727,12 @@ const ACCESS_FILE = join(STATE_DIR, 'access.json')
|
|
|
716
727
|
const APPROVED_DIR = join(STATE_DIR, 'approved')
|
|
717
728
|
const ENV_FILE = join(STATE_DIR, '.env')
|
|
718
729
|
const INBOX_DIR = join(STATE_DIR, 'inbox')
|
|
730
|
+
// person_id name resolution (docs/configuration.md). Sibling to
|
|
731
|
+
// access.json but a COMPLETELY SEPARATE, purely config-derived file —
|
|
732
|
+
// scaffold regenerates it on every reconcile via writeFileSyncIfChanged,
|
|
733
|
+
// the gateway never mutates it, and (unlike access.json) it is read
|
|
734
|
+
// exactly ONCE at boot into PERSON_DIRECTORY below — no hot-reload.
|
|
735
|
+
const PEOPLE_FILE = join(STATE_DIR, 'people.json')
|
|
719
736
|
|
|
720
737
|
/**
|
|
721
738
|
* Trigger a restart of the agent + gateway pair.
|
|
@@ -1219,6 +1236,33 @@ function loadAccess(): Access {
|
|
|
1219
1236
|
return BOOT_ACCESS ?? readAccessFile()
|
|
1220
1237
|
}
|
|
1221
1238
|
|
|
1239
|
+
/**
|
|
1240
|
+
* Read `people.json` (the scaffold's plain projection of `users:` entries
|
|
1241
|
+
* that carry a `person_id`). Fail-open: ENOENT or corrupt/malformed JSON
|
|
1242
|
+
* returns an empty array rather than throwing — this feature must never
|
|
1243
|
+
* block startup. Unlike `access.json` this file is never gateway-mutated,
|
|
1244
|
+
* so there's no "move corrupt file aside" concern; the scaffold owns and
|
|
1245
|
+
* regenerates it on every reconcile.
|
|
1246
|
+
*/
|
|
1247
|
+
function readPeopleFile(): RawPersonEntry[] {
|
|
1248
|
+
try {
|
|
1249
|
+
const raw = readFileSync(PEOPLE_FILE, 'utf8')
|
|
1250
|
+
const parsed = JSON.parse(raw) as { entries?: unknown }
|
|
1251
|
+
if (!Array.isArray(parsed.entries)) return []
|
|
1252
|
+
return parsed.entries as RawPersonEntry[]
|
|
1253
|
+
} catch {
|
|
1254
|
+
return []
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
/**
|
|
1259
|
+
* Boot-time-only person-name directory (see resolve-person.ts module doc).
|
|
1260
|
+
* Populated exactly once by the boot-validation block in the startup IIFE
|
|
1261
|
+
* below, then never reassigned or re-read from disk again for the life of
|
|
1262
|
+
* this process — a config change requires an agent restart.
|
|
1263
|
+
*/
|
|
1264
|
+
let PERSON_DIRECTORY: PersonDirectory = { byTelegramKey: {} }
|
|
1265
|
+
|
|
1222
1266
|
function assertAllowedChat(chat_id: string | number): void {
|
|
1223
1267
|
const id = String(chat_id)
|
|
1224
1268
|
const access = loadAccess()
|
|
@@ -1483,6 +1527,64 @@ function resolveSubagentOriginChat(
|
|
|
1483
1527
|
}
|
|
1484
1528
|
}
|
|
1485
1529
|
|
|
1530
|
+
/**
|
|
1531
|
+
* Tracks worker-feed agents whose owner-DM fallback has already been
|
|
1532
|
+
* logged, so `resolveWorkerFeedChat` emits the routing-decision line once
|
|
1533
|
+
* per agent instead of every watcher tick (~1/s). Capped FIFO at 256 — a
|
|
1534
|
+
* late duplicate log is harmless (one extra stderr line), and the cap keeps
|
|
1535
|
+
* the set bounded across a long gateway lifetime even if origin resolution
|
|
1536
|
+
* is persistently broken fleet-wide (history disabled → every nested worker
|
|
1537
|
+
* hits the fallback). A gateway restart clears it.
|
|
1538
|
+
*/
|
|
1539
|
+
const WORKER_FEED_FALLBACK_LOG_CAP = 256
|
|
1540
|
+
const workerFeedOwnerDmFallbackLogged = new Set<string>()
|
|
1541
|
+
|
|
1542
|
+
/**
|
|
1543
|
+
* Resolve a worker-feed destination chat with a guaranteed last resort.
|
|
1544
|
+
*
|
|
1545
|
+
* The universal-liveness contract is "any active work has a card, at any
|
|
1546
|
+
* nesting depth, with or without a parent turn open." Origin resolution
|
|
1547
|
+
* (`resolveSubagentOriginChat`) only succeeds when the ancestor turn row is
|
|
1548
|
+
* in `turnsDb` and history is enabled; a depth-2+ worker whose chain can't
|
|
1549
|
+
* be walked (history disabled, row reaped, ancestor never stamped) would
|
|
1550
|
+
* otherwise fall through `fleetChatId || allowFrom[0]` and, if those are
|
|
1551
|
+
* empty too, hit the `chatId.length === 0` skip in `workerActivityFeed.update`
|
|
1552
|
+
* — painting NOTHING, silently. That is the one path most likely to violate
|
|
1553
|
+
* "any and all active work has a card."
|
|
1554
|
+
*
|
|
1555
|
+
* This never returns `''`: the precedence is origin chat → fleet chat →
|
|
1556
|
+
* first allowed chat (the owner DM). The owner DM is the durable floor —
|
|
1557
|
+
* "wrong chat" beats "no card." A one-line routing-decision log flags the
|
|
1558
|
+
* fallback so an operator can see the misroute without it reading as an
|
|
1559
|
+
* error (it isn't one — the card surfaced).
|
|
1560
|
+
*/
|
|
1561
|
+
function resolveWorkerFeedChat(
|
|
1562
|
+
agentId: string,
|
|
1563
|
+
fleetChatId: string,
|
|
1564
|
+
): { chatId: string; threadId?: number } {
|
|
1565
|
+
const origin = resolveSubagentOriginChat(agentId)
|
|
1566
|
+
if (origin != null && origin.chatId.length > 0) return origin
|
|
1567
|
+
if (fleetChatId.length > 0) return { chatId: fleetChatId }
|
|
1568
|
+
const ownerDm = loadAccess().allowFrom[0] ?? ''
|
|
1569
|
+
if (origin == null && fleetChatId.length === 0 && ownerDm.length > 0) {
|
|
1570
|
+
// Routing decision, not a warning: origin resolution failed and no
|
|
1571
|
+
// fleet chat is configured, so the nested worker's card lands in the
|
|
1572
|
+
// owner DM. Logged ONCE per agent so the misroute is auditable
|
|
1573
|
+
// without spamming every tick (the watcher drives onProgress ~1/s).
|
|
1574
|
+
if (!workerFeedOwnerDmFallbackLogged.has(agentId)) {
|
|
1575
|
+
workerFeedOwnerDmFallbackLogged.add(agentId)
|
|
1576
|
+
if (workerFeedOwnerDmFallbackLogged.size > WORKER_FEED_FALLBACK_LOG_CAP) {
|
|
1577
|
+
const oldest = workerFeedOwnerDmFallbackLogged.values().next().value
|
|
1578
|
+
if (oldest != null) workerFeedOwnerDmFallbackLogged.delete(oldest)
|
|
1579
|
+
}
|
|
1580
|
+
process.stderr.write(
|
|
1581
|
+
`telegram gateway: worker-feed origin unresolved agent=${agentId} — routing card to owner DM\n`,
|
|
1582
|
+
)
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
return { chatId: ownerDm, threadId: origin?.threadId }
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1486
1588
|
// ─── Periodic history reaper (#1073) ──────────────────────────────────────
|
|
1487
1589
|
// The init-time prune in history.ts only touched the `messages` table.
|
|
1488
1590
|
// `subagents` and `turns` in registry.db grew unbounded — every Agent()
|
|
@@ -1949,6 +2051,25 @@ const obligationEscalateInFlight = new Set<string>()
|
|
|
1949
2051
|
// drain on the bare turn-end signal.
|
|
1950
2052
|
const SERIALIZE_UNTIL_REPLIED_ENABLED =
|
|
1951
2053
|
process.env.SWITCHROOM_SERIALIZE_UNTIL_REPLIED !== '0'
|
|
2054
|
+
// #2917 — rapid-fire per-chat FIFO. The #1556 delivery gate documents its
|
|
2055
|
+
// `turnInFlight` input as a LIVE read ("evaluated at delivery time — not a
|
|
2056
|
+
// receipt-time snapshot"), but handleInbound passes a receipt-time snapshot
|
|
2057
|
+
// (`turnInFlightAtReceipt`) taken at function entry. Under rapid-fire two
|
|
2058
|
+
// same-chat inbounds each snapshot "idle" during the other's async lead-in
|
|
2059
|
+
// (attachment download, composer-clear), both pass the gate, and both are
|
|
2060
|
+
// delivered — so replies come back reordered (observed 1,2,4,5,3,7,8,6). The
|
|
2061
|
+
// fix reads the gate LIVE off `claudeBusyKeys` (which does NOT yet contain
|
|
2062
|
+
// THIS inbound's own key — that is only marked at delivery — so it can't
|
|
2063
|
+
// self-block) AND reserves the key synchronously BEFORE the composer-clear
|
|
2064
|
+
// await, so a concurrent same-chat inbound observes the reservation and
|
|
2065
|
+
// buffers behind it, preserving FIFO. Nothing is dropped: a buffered inbound
|
|
2066
|
+
// drains on turn-complete / idle exactly as before. Kill switch (=0) restores
|
|
2067
|
+
// the receipt-snapshot behaviour. Only the non-cutover path is affected — the
|
|
2068
|
+
// delivery-machine cutover keeps its own at-receipt machine snapshot (reading
|
|
2069
|
+
// the machine live WOULD self-block, since its inbound event already advanced
|
|
2070
|
+
// it for this key).
|
|
2071
|
+
const SERIALIZE_INBOUND_DELIVERY_ENABLED =
|
|
2072
|
+
process.env.SWITCHROOM_SERIALIZE_INBOUND_DELIVERY !== '0'
|
|
1952
2073
|
// Component 2 (bounded no-reply escape hatch). A turn that legitimately
|
|
1953
2074
|
// ends with NO reply (handback ack, NO_REPLY marker, silent-end) sets
|
|
1954
2075
|
// finalAnswerDelivered=false and would block the serialize gate forever.
|
|
@@ -2348,13 +2469,15 @@ type CurrentTurn = {
|
|
|
2348
2469
|
silentAnchorText: string
|
|
2349
2470
|
capturedText: string[]
|
|
2350
2471
|
orphanedReplyTimeoutId: ReturnType<typeof setTimeout> | null
|
|
2351
|
-
//
|
|
2352
|
-
//
|
|
2353
|
-
//
|
|
2354
|
-
//
|
|
2355
|
-
//
|
|
2356
|
-
//
|
|
2357
|
-
|
|
2472
|
+
// Per-turn liveness tracker for the orphaned-reply backstop. Owns
|
|
2473
|
+
// `lastStreamEventAt` (stamped on ANY genuine stream event so a model
|
|
2474
|
+
// reasoning pause keeps the turn "recently streaming" and re-arms the fuse
|
|
2475
|
+
// instead of firing) and the rearm counter (bounded by
|
|
2476
|
+
// ORPHANED_REPLY_MAX_REARMS so a genuinely wedged single tool that never
|
|
2477
|
+
// streams still surfaces after the cap). The counter is zeroed on every
|
|
2478
|
+
// genuine stream event, so the cap only bites CONSECUTIVE silent expiries.
|
|
2479
|
+
// Fresh instance per enqueue (below). See context-exhaustion.ts.
|
|
2480
|
+
liveness: LivenessTracker
|
|
2358
2481
|
// Component 3 (turn-origin reply routing). A stable per-turn identity,
|
|
2359
2482
|
// `${registryKey-or-chatKey}#${startedAt}`, assigned when the turn
|
|
2360
2483
|
// starts and stamped into the inbound meta (`origin_turn_id`) so a reply
|
|
@@ -4044,9 +4167,26 @@ async function resolveCompactCard(
|
|
|
4044
4167
|
{ chat_id: card.chatId, verb: `proactiveCompact.${kind}` },
|
|
4045
4168
|
);
|
|
4046
4169
|
} catch (err) {
|
|
4170
|
+
// Best-effort status-card edit — a transport hiccup (429 / "not
|
|
4171
|
+
// modified" / message gone) is not a liveness-logic error and must
|
|
4172
|
+
// not log a "card edit failed" warning (the warning-as-excuse-for-a-
|
|
4173
|
+
// stale-card anti-pattern). Only a genuine unexpected error warrants
|
|
4174
|
+
// a line; transport classes are silent.
|
|
4175
|
+
const desc = err instanceof Error ? err.message : String(err);
|
|
4176
|
+
const low = desc.toLowerCase();
|
|
4177
|
+
if (
|
|
4178
|
+
low.includes('not modified') ||
|
|
4179
|
+
low.includes('not found') ||
|
|
4180
|
+
low.includes("can't be edited") ||
|
|
4181
|
+
low.includes('cannot be edited') ||
|
|
4182
|
+
low.includes('not enough rights') ||
|
|
4183
|
+
low.includes('429') ||
|
|
4184
|
+
low.includes('retry after')
|
|
4185
|
+
) {
|
|
4186
|
+
return;
|
|
4187
|
+
}
|
|
4047
4188
|
process.stderr.write(
|
|
4048
|
-
`telegram gateway: proactive-compact ${kind} card edit failed:
|
|
4049
|
-
`${err instanceof Error ? err.message : String(err)}\n`,
|
|
4189
|
+
`telegram gateway: proactive-compact ${kind} card edit failed: ${desc}\n`,
|
|
4050
4190
|
);
|
|
4051
4191
|
}
|
|
4052
4192
|
}
|
|
@@ -6230,6 +6370,127 @@ async function activityCardBootReaper(): Promise<void> {
|
|
|
6230
6370
|
)
|
|
6231
6371
|
}
|
|
6232
6372
|
}
|
|
6373
|
+
// ─── Mid-session stale-card reaper (#2918) ──────────────────────────────────
|
|
6374
|
+
// The boot reapers (markOrphanedWithTimeoutClassification + activityCardBoot-
|
|
6375
|
+
// Reaper) run ONCE at startup. A turn whose owning SDK subprocess dies
|
|
6376
|
+
// mid-session (SIGKILL / OOM / crash) without a clean end leaves its turns-DB
|
|
6377
|
+
// row `ended_at IS NULL` and its activity card frozen on "working…" until the
|
|
6378
|
+
// NEXT gateway boot — often many hours. This periodic sweep, running inside the
|
|
6379
|
+
// live gateway, finalizes those orphans without waiting for a restart.
|
|
6380
|
+
//
|
|
6381
|
+
// LIVENESS (the critical correctness guard): a turn/card is reaped ONLY when no
|
|
6382
|
+
// live in-flight turn owns it. The live set is derived from `currentTurnMap`
|
|
6383
|
+
// (per-topic byKey) PLUS the singleton `currentTurn` mirror, so it is correct
|
|
6384
|
+
// whether or not the per-topic-isolation flag is on. A healthy long-running
|
|
6385
|
+
// turn is always in that set and is never swept. A TTL gate is a secondary
|
|
6386
|
+
// guard against the just-started-not-yet-tracked race.
|
|
6387
|
+
//
|
|
6388
|
+
// Kill switch: set SWITCHROOM_MID_SESSION_CARD_REAPER=0 to disable (clean
|
|
6389
|
+
// revert, mirrors #2919). TTL/interval overridable via env for tuning.
|
|
6390
|
+
const MID_SESSION_CARD_REAPER_ENABLED =
|
|
6391
|
+
process.env.SWITCHROOM_MID_SESSION_CARD_REAPER !== '0'
|
|
6392
|
+
const MID_SESSION_CARD_REAPER_TTL_MS = (() => {
|
|
6393
|
+
const v = Number(process.env.SWITCHROOM_MID_SESSION_CARD_REAPER_TTL_MS)
|
|
6394
|
+
return Number.isFinite(v) && v > 0 ? v : 15 * 60_000 // 15 min
|
|
6395
|
+
})()
|
|
6396
|
+
const MID_SESSION_CARD_REAPER_INTERVAL_MS = (() => {
|
|
6397
|
+
const v = Number(process.env.SWITCHROOM_MID_SESSION_CARD_REAPER_INTERVAL_MS)
|
|
6398
|
+
return Number.isFinite(v) && v > 0 ? v : 5 * 60_000 // 5 min
|
|
6399
|
+
})()
|
|
6400
|
+
|
|
6401
|
+
// Snapshot the turn_keys / topic keys owned by a live in-flight turn right now.
|
|
6402
|
+
function liveTurnKeySets(): { registryKeys: Set<string>; topicKeys: Set<string> } {
|
|
6403
|
+
const registryKeys = new Set<string>()
|
|
6404
|
+
const topicKeys = new Set<string>()
|
|
6405
|
+
const add = (t: CurrentTurn | null | undefined): void => {
|
|
6406
|
+
if (t == null) return
|
|
6407
|
+
if (t.registryKey != null && t.registryKey.length > 0) registryKeys.add(t.registryKey)
|
|
6408
|
+
topicKeys.add(statusKey(t.sessionChatId, t.sessionThreadId))
|
|
6409
|
+
}
|
|
6410
|
+
for (const t of currentTurnMap.byKey.values()) add(t)
|
|
6411
|
+
add(currentTurn) // flag-OFF store + most-recent mirror
|
|
6412
|
+
return { registryKeys, topicKeys }
|
|
6413
|
+
}
|
|
6414
|
+
|
|
6415
|
+
async function runMidSessionCardReaper(): Promise<void> {
|
|
6416
|
+
if (!MID_SESSION_CARD_REAPER_ENABLED) return
|
|
6417
|
+
const now = Date.now()
|
|
6418
|
+
const { registryKeys, topicKeys } = liveTurnKeySets()
|
|
6419
|
+
|
|
6420
|
+
// 1) Stamp ownerless open turns-DB rows (the durable spinner signal).
|
|
6421
|
+
if (turnsDb != null) {
|
|
6422
|
+
try {
|
|
6423
|
+
const { reaped, reapedTurnKeys } = reapStaleOpenTurns(turnsDb, {
|
|
6424
|
+
activeTurnKeys: registryKeys,
|
|
6425
|
+
ttlMs: MID_SESSION_CARD_REAPER_TTL_MS,
|
|
6426
|
+
now,
|
|
6427
|
+
})
|
|
6428
|
+
if (reaped > 0) {
|
|
6429
|
+
process.stderr.write(
|
|
6430
|
+
`telegram gateway: mid-session reaper stamped ${reaped} orphaned turn(s) ` +
|
|
6431
|
+
`as 'restart' (${reapedTurnKeys.join(',')})\n`,
|
|
6432
|
+
)
|
|
6433
|
+
}
|
|
6434
|
+
} catch (err) {
|
|
6435
|
+
process.stderr.write(
|
|
6436
|
+
`telegram gateway: mid-session turn reaper error: ${(err as Error).message}\n`,
|
|
6437
|
+
)
|
|
6438
|
+
}
|
|
6439
|
+
}
|
|
6440
|
+
|
|
6441
|
+
// 2) Finalize the leftover visible activity card(s) for those dead turns.
|
|
6442
|
+
if (activityCardPersistEnabled) {
|
|
6443
|
+
try {
|
|
6444
|
+
const { finalized, vanished, total } = await runActivityCardMidSessionReaper({
|
|
6445
|
+
path: ACTIVITY_CARD_STORE_PATH,
|
|
6446
|
+
fs: activityCardStoreFs,
|
|
6447
|
+
isLive: (record) => topicKeys.has(record.turnKey),
|
|
6448
|
+
ttlMs: MID_SESSION_CARD_REAPER_TTL_MS,
|
|
6449
|
+
now,
|
|
6450
|
+
finalizeCard: (record) =>
|
|
6451
|
+
robustApiCall(
|
|
6452
|
+
() =>
|
|
6453
|
+
lockedBot.api.editMessageText(
|
|
6454
|
+
record.chatId,
|
|
6455
|
+
record.activityMessageId,
|
|
6456
|
+
richMessage(restartOrphanCardFinalizeText(record.startedAt)),
|
|
6457
|
+
{},
|
|
6458
|
+
),
|
|
6459
|
+
{
|
|
6460
|
+
chat_id: record.chatId,
|
|
6461
|
+
...(record.threadId != null ? { threadId: record.threadId } : {}),
|
|
6462
|
+
verb: 'activity-card.mid-session-reap-finalize',
|
|
6463
|
+
},
|
|
6464
|
+
),
|
|
6465
|
+
unpinCard: (record) =>
|
|
6466
|
+
robustApiCall(
|
|
6467
|
+
() => lockedBot.api.unpinChatMessage(record.chatId, record.activityMessageId),
|
|
6468
|
+
{
|
|
6469
|
+
chat_id: record.chatId,
|
|
6470
|
+
...(record.threadId != null ? { threadId: record.threadId } : {}),
|
|
6471
|
+
verb: 'activity-card.mid-session-reap-unpin',
|
|
6472
|
+
},
|
|
6473
|
+
),
|
|
6474
|
+
})
|
|
6475
|
+
if (total > 0) {
|
|
6476
|
+
process.stderr.write(
|
|
6477
|
+
`telegram gateway: activity-card: mid-session finalized ${finalized}/${total} ` +
|
|
6478
|
+
`(vanished ${vanished}/${total}) orphaned card(s) (at-most-once)\n`,
|
|
6479
|
+
)
|
|
6480
|
+
}
|
|
6481
|
+
} catch (err) {
|
|
6482
|
+
process.stderr.write(
|
|
6483
|
+
`telegram gateway: mid-session card reaper error: ${(err as Error).message}\n`,
|
|
6484
|
+
)
|
|
6485
|
+
}
|
|
6486
|
+
}
|
|
6487
|
+
}
|
|
6488
|
+
|
|
6489
|
+
const midSessionCardReaper = setInterval(() => {
|
|
6490
|
+
void runMidSessionCardReaper()
|
|
6491
|
+
}, MID_SESSION_CARD_REAPER_INTERVAL_MS)
|
|
6492
|
+
midSessionCardReaper.unref()
|
|
6493
|
+
|
|
6233
6494
|
// NOTE: statusPinBootCleanup() is deliberately NOT invoked here at import time.
|
|
6234
6495
|
// The status-pin store is a SHARED per-agent file, and cleanup issues real
|
|
6235
6496
|
// unpinChatMessage calls. On a double-boot the losing gateway must NOT touch
|
|
@@ -6596,6 +6857,11 @@ function parsePositiveMsEnv(name: string, fallbackMs: number): number {
|
|
|
6596
6857
|
const n = Number(raw)
|
|
6597
6858
|
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallbackMs
|
|
6598
6859
|
}
|
|
6860
|
+
// Orphaned-reply "recently streaming" window (thinking-pause fix). If a genuine
|
|
6861
|
+
// stream event landed within this window, the fuse re-arms instead of firing —
|
|
6862
|
+
// so a long model reasoning pause (which emits no text/tool events) is
|
|
6863
|
+
// survivable while a genuine multi-minute hang still surfaces. Default 120 s.
|
|
6864
|
+
const ORPHANED_REPLY_STREAM_WINDOW_MS = parsePositiveMsEnv('SWITCHROOM_ORPHANED_REPLY_STREAM_WINDOW_MS', 120_000)
|
|
6599
6865
|
const SILENCE_FALLBACK_MS = parsePositiveMsEnv('SWITCHROOM_SILENCE_FALLBACK_MS', 300_000)
|
|
6600
6866
|
const SILENCE_FALLBACK_HARD_MS = parsePositiveMsEnv('SWITCHROOM_SILENCE_FALLBACK_HARD_MS', 900_000)
|
|
6601
6867
|
// #2527 — mid-turn liveness floor threshold (default 45s). Still gates the
|
|
@@ -12411,20 +12677,32 @@ function resetOrphanedReplyTimeout(): void {
|
|
|
12411
12677
|
}
|
|
12412
12678
|
return false
|
|
12413
12679
|
})()
|
|
12414
|
-
|
|
12415
|
-
|
|
12416
|
-
|
|
12417
|
-
|
|
12418
|
-
|
|
12419
|
-
|
|
12420
|
-
|
|
12421
|
-
|
|
12422
|
-
|
|
12423
|
-
|
|
12424
|
-
|
|
12425
|
-
|
|
12426
|
-
|
|
12427
|
-
|
|
12680
|
+
// Route the rearm decision through the per-turn LivenessTracker. It
|
|
12681
|
+
// rearms when working OR recently-streaming (thinking-pause survival)
|
|
12682
|
+
// OR human-waiting; working/recently-streaming rearms count against
|
|
12683
|
+
// ORPHANED_REPLY_MAX_REARMS while human-wait rearms stay uncapped.
|
|
12684
|
+
const now = Date.now()
|
|
12685
|
+
const recentlyStreaming = t.liveness.recentlyStreaming(now, ORPHANED_REPLY_STREAM_WINDOW_MS)
|
|
12686
|
+
const decision = t.liveness.decideOnExpiry({
|
|
12687
|
+
working,
|
|
12688
|
+
humanWaiting,
|
|
12689
|
+
now,
|
|
12690
|
+
windowMs: ORPHANED_REPLY_STREAM_WINDOW_MS,
|
|
12691
|
+
maxRearms: ORPHANED_REPLY_MAX_REARMS,
|
|
12692
|
+
})
|
|
12693
|
+
if (decision.rearm) {
|
|
12694
|
+
process.stderr.write(
|
|
12695
|
+
`telegram gateway: orphaned-reply fuse expired — re-arming` +
|
|
12696
|
+
` (rearm ${t.liveness.orphanedReplyRearmCount}/${ORPHANED_REPLY_MAX_REARMS},` +
|
|
12697
|
+
` in_flight=${toolFlightTracker.inFlightCount()},` +
|
|
12698
|
+
` human_wait=${humanWaiting},` +
|
|
12699
|
+
` recently_streaming=${recentlyStreaming},` +
|
|
12700
|
+
` bg_work=${pendingProgress.hasPendingAsyncDispatch(turnKey)})\n`,
|
|
12701
|
+
)
|
|
12702
|
+
resetOrphanedReplyTimeout()
|
|
12703
|
+
return
|
|
12704
|
+
}
|
|
12705
|
+
if (decision.countsAgainstCap) {
|
|
12428
12706
|
process.stderr.write(
|
|
12429
12707
|
`telegram gateway: orphaned-reply rearm cap reached (${ORPHANED_REPLY_MAX_REARMS}) — forcing backstop despite working state\n`,
|
|
12430
12708
|
)
|
|
@@ -12732,7 +13010,22 @@ async function drainActivitySummary(
|
|
|
12732
13010
|
turn.activityLastSentRender = target
|
|
12733
13011
|
} catch (err) {
|
|
12734
13012
|
const msg = err instanceof Error ? err.message : String(err)
|
|
12735
|
-
|
|
13013
|
+
const low = msg.toLowerCase()
|
|
13014
|
+
// Transport-class failures (429, message gone, "not modified") must
|
|
13015
|
+
// NOT inflate `activityDrainFailures` — that counter flags a turn as
|
|
13016
|
+
// DEGRADED on turn-end, and a transient rate-limit or an
|
|
13017
|
+
// already-deleted message is not a logic defect. Counting them would
|
|
13018
|
+
// false-flag a healthy turn. "not modified" is success; gone/429 are
|
|
13019
|
+
// retried or harmless. Only a genuine send/open failure counts.
|
|
13020
|
+
const isTransport =
|
|
13021
|
+
low.includes('not modified') ||
|
|
13022
|
+
low.includes('not found') ||
|
|
13023
|
+
low.includes("can't be edited") ||
|
|
13024
|
+
low.includes('cannot be edited') ||
|
|
13025
|
+
low.includes('not enough rights') ||
|
|
13026
|
+
low.includes('429') ||
|
|
13027
|
+
low.includes('retry after')
|
|
13028
|
+
if (!isTransport) {
|
|
12736
13029
|
turn.activityDrainFailures += 1
|
|
12737
13030
|
// Surface the failing anchor + topic: the resume-400 bug fed a
|
|
12738
13031
|
// fabricated 13-digit message_id as the reply anchor here, so every
|
|
@@ -12900,11 +13193,19 @@ function feedHeartbeatTick(): void {
|
|
|
12900
13193
|
// durable record once it completes.
|
|
12901
13194
|
// - 'emit' → genuine in-flight post-answer activity; render the card below.
|
|
12902
13195
|
const subagentAt = turn.subagentActivityAt
|
|
13196
|
+
// Fix 3 (sub-agent-delegation freeze): a foreground `Task`/`Agent` still
|
|
13197
|
+
// tracked in `turn.foregroundSubAgents` is POSITIVE evidence the worker
|
|
13198
|
+
// has not reported finished — see the doc comment on
|
|
13199
|
+
// `PostAnswerLivenessInput.stillDispatched` in turn-liveness-floor.ts for
|
|
13200
|
+
// why this must bypass the staleness cap rather than let a single long
|
|
13201
|
+
// silent step freeze the card mid-delegation.
|
|
13202
|
+
const stillDispatched = turn.foregroundSubAgents.size > 0
|
|
12903
13203
|
const livenessVerdict = evaluatePostAnswerLiveness({
|
|
12904
13204
|
subagentActivityAt: subagentAt,
|
|
12905
13205
|
finalAnswerDeliveredAt: turn.finalAnswerDeliveredAt,
|
|
12906
13206
|
now: Date.now(),
|
|
12907
13207
|
staleCapMs: POST_ANSWER_LIVENESS_STALE_MS,
|
|
13208
|
+
stillDispatched,
|
|
12908
13209
|
})
|
|
12909
13210
|
if (livenessVerdict !== 'emit' || subagentAt == null) return // idle gap or stale worker → stay silent (the `== null` also narrows subagentAt for the elapsed below)
|
|
12910
13211
|
// A background worker is genuinely active after the answer. Open or maintain
|
|
@@ -13074,7 +13375,23 @@ function clearActivitySummary(turn: CurrentTurn, finalHtmlOverride?: string | nu
|
|
|
13074
13375
|
{ chat_id: chat, ...(thread != null ? { threadId: thread } : {}), verb: 'activity-summary.delete' },
|
|
13075
13376
|
)
|
|
13076
13377
|
} catch (err) {
|
|
13077
|
-
|
|
13378
|
+
// Best-effort teardown of a status card. A transport-class failure
|
|
13379
|
+
// (message already deleted, chat gone, 429) is not a liveness-logic
|
|
13380
|
+
// error — "message to delete not found" is the desired end state
|
|
13381
|
+
// here, and a 429 on a teardown delete is retried by robustApiCall.
|
|
13382
|
+
// Stay silent on those; warn only on a genuinely unexpected error.
|
|
13383
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
13384
|
+
const low = msg.toLowerCase()
|
|
13385
|
+
if (
|
|
13386
|
+
low.includes('not modified') ||
|
|
13387
|
+
low.includes('not found') ||
|
|
13388
|
+
low.includes('not enough rights') ||
|
|
13389
|
+
low.includes('429') ||
|
|
13390
|
+
low.includes('retry after')
|
|
13391
|
+
) {
|
|
13392
|
+
return
|
|
13393
|
+
}
|
|
13394
|
+
process.stderr.write(`telegram gateway: activity-summary delete failed: ${msg}\n`)
|
|
13078
13395
|
}
|
|
13079
13396
|
return
|
|
13080
13397
|
}
|
|
@@ -13100,10 +13417,26 @@ function clearActivitySummary(turn: CurrentTurn, finalHtmlOverride?: string | nu
|
|
|
13100
13417
|
{ chat_id: chat, ...(thread != null ? { threadId: thread } : {}), verb: 'activity-summary.finalize' },
|
|
13101
13418
|
)
|
|
13102
13419
|
} catch (err) {
|
|
13420
|
+
// Same transport-class discipline as the delete path: the card
|
|
13421
|
+
// finalize is a best-effort liveness edit. "not modified" = the card
|
|
13422
|
+
// already shows the finalized body (success); "not found" / "not
|
|
13423
|
+
// enough rights" = the message is gone (nothing to finalize); 429 is
|
|
13424
|
+
// retried by robustApiCall. None of those are liveness-logic errors
|
|
13425
|
+
// and none warrant a stderr warning. Warn only on the unexpected.
|
|
13103
13426
|
const msg = err instanceof Error ? err.message : String(err)
|
|
13104
|
-
|
|
13105
|
-
|
|
13427
|
+
const low = msg.toLowerCase()
|
|
13428
|
+
if (
|
|
13429
|
+
low.includes('not modified') ||
|
|
13430
|
+
low.includes('not found') ||
|
|
13431
|
+
low.includes("can't be edited") ||
|
|
13432
|
+
low.includes('cannot be edited') ||
|
|
13433
|
+
low.includes('not enough rights') ||
|
|
13434
|
+
low.includes('429') ||
|
|
13435
|
+
low.includes('retry after')
|
|
13436
|
+
) {
|
|
13437
|
+
return
|
|
13106
13438
|
}
|
|
13439
|
+
process.stderr.write(`telegram gateway: activity-summary finalize failed: ${msg}\n`)
|
|
13107
13440
|
}
|
|
13108
13441
|
})
|
|
13109
13442
|
}
|
|
@@ -13278,6 +13611,22 @@ function surfaceConsolidationLegibility(
|
|
|
13278
13611
|
}
|
|
13279
13612
|
|
|
13280
13613
|
function handleSessionEvent(ev: SessionEvent): void {
|
|
13614
|
+
// Per-turn liveness stamp (orphaned-reply thinking-pause fix). Stamp
|
|
13615
|
+
// lastStreamEventAt AND reset the rearm counter on ANY genuine stream event,
|
|
13616
|
+
// under ONE shared predicate: a live turn is present and this is not the
|
|
13617
|
+
// synthetic durationMs===-1 turn_end (the fire callback's own re-dispatch).
|
|
13618
|
+
// The counter reset MUST live here at the dispatcher — NOT inside
|
|
13619
|
+
// resetOrphanedReplyTimeout() (which is called from the fire callback one
|
|
13620
|
+
// line after the counter increments) and NOT tied to a single case (e.g.
|
|
13621
|
+
// tool_result does not call resetOrphanedReplyTimeout). onStreamEvent applies
|
|
13622
|
+
// the `!(turn_end && -1)` half of the predicate internally.
|
|
13623
|
+
{
|
|
13624
|
+
const liveTurn = currentTurn
|
|
13625
|
+
if (liveTurn != null) {
|
|
13626
|
+
const durationMs = ev.kind === 'turn_end' ? ev.durationMs : undefined
|
|
13627
|
+
liveTurn.liveness.onStreamEvent(ev.kind, durationMs, Date.now())
|
|
13628
|
+
}
|
|
13629
|
+
}
|
|
13281
13630
|
switch (ev.kind) {
|
|
13282
13631
|
case 'enqueue': {
|
|
13283
13632
|
// Drain any orphaned typing-wrap entries left over from a crashed
|
|
@@ -13314,6 +13663,14 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
13314
13663
|
prior.answerStream.stop()
|
|
13315
13664
|
prior.answerStream = null
|
|
13316
13665
|
}
|
|
13666
|
+
// Bounded-leak hardening (A5): clear the prior turn's orphaned-reply
|
|
13667
|
+
// fuse before it is superseded. The fire callback re-reads currentTurn
|
|
13668
|
+
// and no-ops on a stale turn, but proactively clearing the timer avoids
|
|
13669
|
+
// a bounded pile-up of dangling timers across rapid steer/queue turns.
|
|
13670
|
+
if (prior?.orphanedReplyTimeoutId != null) {
|
|
13671
|
+
clearTimeout(prior.orphanedReplyTimeoutId)
|
|
13672
|
+
prior.orphanedReplyTimeoutId = null
|
|
13673
|
+
}
|
|
13317
13674
|
// #1067: swap the entire turn atom in one assignment. Every
|
|
13318
13675
|
// handler captures `const turn = currentTurn` at entry, so a
|
|
13319
13676
|
// captured-then-awaited read can't reattribute to the new turn.
|
|
@@ -13373,7 +13730,9 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
13373
13730
|
silentAnchorText: '',
|
|
13374
13731
|
capturedText: [],
|
|
13375
13732
|
orphanedReplyTimeoutId: null,
|
|
13376
|
-
|
|
13733
|
+
// Fresh liveness tracker: lastStreamEventAt seeded to the turn start
|
|
13734
|
+
// so a turn that never streams still trips the fuse after windowMs.
|
|
13735
|
+
liveness: new LivenessTracker(startedAt),
|
|
13377
13736
|
turnId,
|
|
13378
13737
|
registryKey: null,
|
|
13379
13738
|
noReplyDrainTimer: null,
|
|
@@ -14043,10 +14402,25 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
14043
14402
|
if (ev.durationMs === -1) {
|
|
14044
14403
|
const turn = currentTurn
|
|
14045
14404
|
const key = turn != null ? statusKey(turn.sessionChatId, turn.sessionThreadId) : ''
|
|
14046
|
-
|
|
14405
|
+
// Widened to also suppress while the turn is RECENTLY STREAMING — a
|
|
14406
|
+
// model reasoning pause emits no tool/text events (so
|
|
14407
|
+
// isLegitimatelyWorking is false) but a genuine stream landed within
|
|
14408
|
+
// the window, so the turn is alive and must not be torn down.
|
|
14409
|
+
// ACCEPTED TRADE-OFF (F3): the context-exhaustion recovery latency via
|
|
14410
|
+
// THIS backstop path grows from ~30 s to ~120-150 s, because the
|
|
14411
|
+
// "Prompt is too long" marker is itself a genuine `text` event that
|
|
14412
|
+
// stamps recentlyStreaming. This is acceptable — the primary
|
|
14413
|
+
// context-exhaustion teardown is the immediate `endCurrentTurnAtomic`
|
|
14414
|
+
// in `case 'text'` (isContextExhaustionText) above; this backstop only
|
|
14415
|
+
// matters if that path is missed, and it still COMPLETES once the
|
|
14416
|
+
// window lapses. Recovery is delayed, never suppressed forever.
|
|
14417
|
+
const recentlyStreaming =
|
|
14418
|
+
turn != null && turn.liveness.recentlyStreaming(Date.now(), ORPHANED_REPLY_STREAM_WINDOW_MS)
|
|
14419
|
+
if (isLegitimatelyWorking(key) || recentlyStreaming) {
|
|
14047
14420
|
process.stderr.write(
|
|
14048
14421
|
`telegram gateway: synthetic turn_end suppressed — legitimately working` +
|
|
14049
14422
|
` (in_flight=${toolFlightTracker.inFlightCount()},` +
|
|
14423
|
+
` recently_streaming=${recentlyStreaming},` +
|
|
14050
14424
|
` bg_work=${turn != null ? pendingProgress.hasPendingAsyncDispatch(key) : false})\n`,
|
|
14051
14425
|
)
|
|
14052
14426
|
return
|
|
@@ -15340,30 +15714,32 @@ async function handleInbound(
|
|
|
15340
15714
|
// network RTT) but not a user-perceived end-to-end measurement.
|
|
15341
15715
|
const inboundReceivedAt = Date.now()
|
|
15342
15716
|
|
|
15343
|
-
//
|
|
15344
|
-
//
|
|
15345
|
-
//
|
|
15346
|
-
//
|
|
15347
|
-
//
|
|
15348
|
-
|
|
15349
|
-
//
|
|
15350
|
-
// inbound event advances it. A fresh-turn inbound transitions the
|
|
15351
|
-
// machine idle→in_turn; reading after the emit would see THIS
|
|
15352
|
-
// message's own just-started turn and self-block it (the same
|
|
15353
|
-
// self-block hazard the claudeBusyKeys snapshot below guards). When
|
|
15354
|
-
// the kill-switch is off this is null and the gate uses the legacy
|
|
15717
|
+
// PR3b-cutover: snapshot the machine's in-turn state AT RECEIPT, before
|
|
15718
|
+
// this handler emits the `inbound` event (that emit is DEFERRED below to
|
|
15719
|
+
// the delivery-commit point — search DEFERRED_INBOUND_EMIT). A fresh-turn
|
|
15720
|
+
// inbound transitions the machine idle→in_turn; reading after the emit
|
|
15721
|
+
// would see THIS message's own just-started turn and self-block it (the
|
|
15722
|
+
// same self-block hazard the claudeBusyKeys snapshot below guards). Null
|
|
15723
|
+
// when the kill-switch is off, in which case the gate uses the legacy
|
|
15355
15724
|
// claudeBusyKeys read.
|
|
15725
|
+
//
|
|
15726
|
+
// WHY THE INBOUND EMIT IS DEFERRED (overlord /usage wedge, 2026-07-08):
|
|
15727
|
+
// the `inbound` event drives the now-AUTHORITATIVE turn-in-flight gate
|
|
15728
|
+
// (turnInFlightForGate → isMachineInTurn). Emitting it HERE — at handler
|
|
15729
|
+
// entry, before the intercept gauntlet below (permission-reply, /auth
|
|
15730
|
+
// paste-back, interrupt-empty, secret-detect drop, …) — drove the machine
|
|
15731
|
+
// into `bridge_alive_in_turn` for messages that then EARLY-RETURN as an
|
|
15732
|
+
// intercept and never become a turn. No delivery, no claudeBusyKeys mark,
|
|
15733
|
+
// and critically no `turnEnd` ever fires, so the machine held the gate
|
|
15734
|
+
// closed until the 5-min TTL tick force-cleared it — buffering every
|
|
15735
|
+
// subsequent inbound (including /usage) the whole time. That is exactly
|
|
15736
|
+
// the dangerous `machine_over_holds` divergence gate-parity-probe.ts
|
|
15737
|
+
// flags. The imperative claudeBusyKeys tracker got it right (never marked
|
|
15738
|
+
// busy for the intercepted message); the machine was mis-fed. Fix: emit
|
|
15739
|
+
// `inbound` only once the message clears every intercept and reaches the
|
|
15740
|
+
// deliver-or-buffer decision, keeping the machine in lockstep with the
|
|
15741
|
+
// imperative delivery lifecycle it models.
|
|
15356
15742
|
const machineInTurnAtReceipt = isDeliveryCutoverEnabled() ? isMachineInTurn() : null
|
|
15357
|
-
shadowEmit({
|
|
15358
|
-
kind: 'inbound',
|
|
15359
|
-
key: _shadowKey,
|
|
15360
|
-
msg: {
|
|
15361
|
-
msgId: ctx.message?.message_id ?? 0,
|
|
15362
|
-
isSteering: false, // refined in PR 3 — for now shadow conservatively classifies as non-steering
|
|
15363
|
-
payload: null,
|
|
15364
|
-
},
|
|
15365
|
-
at: Date.now(),
|
|
15366
|
-
})
|
|
15367
15743
|
|
|
15368
15744
|
// #1556 self-blocking fix (v0.12.22): snapshot the live turn-state
|
|
15369
15745
|
// BEFORE the fresh-turn branch (line ~7357) sets activeTurnStartedAt
|
|
@@ -16435,12 +16811,28 @@ async function handleInbound(
|
|
|
16435
16811
|
TOPIC_FRAMING_ENABLED && messageThreadId != null
|
|
16436
16812
|
? 'This message belongs to the current topic only — answer ONLY this question, in this topic. Do not also answer a pending message from another topic.'
|
|
16437
16813
|
: undefined
|
|
16814
|
+
// person_id name resolution (docs/configuration.md, resolve-person.ts):
|
|
16815
|
+
// chat-scoped, boot-time-static lookup — falls back to today's raw
|
|
16816
|
+
// id/username behavior (fail-open) whenever unresolved or when group
|
|
16817
|
+
// membership can't be positively confirmed from access.json's allowFrom.
|
|
16818
|
+
// Never touches access.json itself and never blocks/denies anything.
|
|
16819
|
+
const rawUser = from.username ?? String(from.id)
|
|
16820
|
+
const displayUser = safeResolvePersonName(
|
|
16821
|
+
PERSON_DIRECTORY,
|
|
16822
|
+
{
|
|
16823
|
+
telegramId: String(from.id),
|
|
16824
|
+
username: from.username,
|
|
16825
|
+
isDm: isDmChatId(chat_id),
|
|
16826
|
+
groupAllowFrom: access.groups[chat_id]?.allowFrom,
|
|
16827
|
+
},
|
|
16828
|
+
rawUser,
|
|
16829
|
+
)
|
|
16438
16830
|
const inboundMsg: InboundMessage = {
|
|
16439
16831
|
type: 'inbound',
|
|
16440
16832
|
chatId: chat_id,
|
|
16441
16833
|
...(messageThreadId != null ? { threadId: messageThreadId } : {}),
|
|
16442
16834
|
messageId: msgId ?? 0,
|
|
16443
|
-
user:
|
|
16835
|
+
user: displayUser,
|
|
16444
16836
|
userId: from.id,
|
|
16445
16837
|
ts: ctx.message?.date ?? Math.floor(Date.now() / 1000),
|
|
16446
16838
|
text: effectiveText,
|
|
@@ -16455,7 +16847,7 @@ async function handleInbound(
|
|
|
16455
16847
|
meta: {
|
|
16456
16848
|
chat_id,
|
|
16457
16849
|
...(msgId != null ? { message_id: String(msgId) } : {}),
|
|
16458
|
-
user:
|
|
16850
|
+
user: displayUser,
|
|
16459
16851
|
user_id: String(from.id),
|
|
16460
16852
|
ts: new Date((ctx.message?.date ?? 0) * 1000).toISOString(),
|
|
16461
16853
|
...(messageThreadId != null ? { message_thread_id: String(messageThreadId) } : {}),
|
|
@@ -16560,6 +16952,29 @@ async function handleInbound(
|
|
|
16560
16952
|
return
|
|
16561
16953
|
}
|
|
16562
16954
|
|
|
16955
|
+
// DEFERRED_INBOUND_EMIT — drive the delivery state machine's `inbound`
|
|
16956
|
+
// event HERE, not at handler entry. Every intercept/early-return above
|
|
16957
|
+
// (permission-reply, /auth paste-back, interrupt-empty, secret-detect
|
|
16958
|
+
// drop, drop/pair) has been passed, so any message reaching this point is
|
|
16959
|
+
// a genuine turn the imperative code is about to deliver or buffer. That
|
|
16960
|
+
// keeps the machine's authoritative turn-in-flight state in lockstep with
|
|
16961
|
+
// the imperative delivery it models and can never be advanced into
|
|
16962
|
+
// `bridge_alive_in_turn` by a message that never becomes a turn — the
|
|
16963
|
+
// overlord /usage wedge of 2026-07-08 (see machineInTurnAtReceipt above).
|
|
16964
|
+
// isSteering is now the real classification (computed at ~line 16289), so
|
|
16965
|
+
// the machine correctly distinguishes a mid-turn steer (delivered, no new
|
|
16966
|
+
// turn) from a fresh turn.
|
|
16967
|
+
shadowEmit({
|
|
16968
|
+
kind: 'inbound',
|
|
16969
|
+
key: statusKey(chat_id, messageThreadId) as _ChatKey,
|
|
16970
|
+
msg: {
|
|
16971
|
+
msgId: msgId ?? 0,
|
|
16972
|
+
isSteering,
|
|
16973
|
+
payload: null,
|
|
16974
|
+
},
|
|
16975
|
+
at: Date.now(),
|
|
16976
|
+
})
|
|
16977
|
+
|
|
16563
16978
|
// PR2 obligation-ledger OPEN — BEFORE the buffer-until-idle / deliver split so
|
|
16564
16979
|
// a mid-turn cross-topic inbound (the 715 case) is tracked whether it is
|
|
16565
16980
|
// buffered or delivered now. Idempotent + gated; no-op when the flag is off.
|
|
@@ -16569,21 +16984,32 @@ async function handleInbound(
|
|
|
16569
16984
|
effectiveText,
|
|
16570
16985
|
})
|
|
16571
16986
|
|
|
16572
|
-
|
|
16573
|
-
|
|
16574
|
-
|
|
16575
|
-
|
|
16576
|
-
|
|
16577
|
-
|
|
16578
|
-
|
|
16579
|
-
|
|
16580
|
-
|
|
16581
|
-
|
|
16582
|
-
|
|
16583
|
-
|
|
16584
|
-
|
|
16585
|
-
|
|
16586
|
-
|
|
16987
|
+
// #2917: read the gate LIVE (not the receipt snapshot) on the default path
|
|
16988
|
+
// so a sibling same-chat inbound delivered during THIS handler's async
|
|
16989
|
+
// lead-in is observed here. Reading `claudeBusyKeys` live is self-block-safe:
|
|
16990
|
+
// THIS inbound's own key is only added at delivery (below), never by the
|
|
16991
|
+
// fresh-turn init bundle — so the live read never sees its own key. The
|
|
16992
|
+
// delivery-machine cutover keeps its at-receipt machine snapshot (a live
|
|
16993
|
+
// machine read WOULD self-block, since the inbound event already advanced
|
|
16994
|
+
// the machine for this key). Kill switch (=0) restores the pure snapshot.
|
|
16995
|
+
const gateTurnInFlight =
|
|
16996
|
+
SERIALIZE_INBOUND_DELIVERY_ENABLED && machineInTurnAtReceipt == null
|
|
16997
|
+
? claudeBusyKeys.size > 0
|
|
16998
|
+
: turnInFlightAtReceipt
|
|
16999
|
+
const deliveryGate = reserveInboundDelivery({
|
|
17000
|
+
turnInFlight: gateTurnInFlight,
|
|
17001
|
+
isSteering,
|
|
17002
|
+
// Interrupt-marker carve-out (2026-05-24): the `!`-prefixed body
|
|
17003
|
+
// must bypass the "buffer-until-turn-complete" gate because the
|
|
17004
|
+
// SIGINT'd turn often doesn't emit turn_complete, leaving the
|
|
17005
|
+
// body stranded in pendingInboundBuffer indefinitely. The
|
|
17006
|
+
// `interrupt` const is computed at the start of handleInbound
|
|
17007
|
+
// (line ~7606) and remains in scope here. When the user fires
|
|
17008
|
+
// `!`-with-body, this delivers the body as a fresh inbound to
|
|
17009
|
+
// the freshly-killed bridge.
|
|
17010
|
+
isInterrupt: interrupt.isInterrupt,
|
|
17011
|
+
})
|
|
17012
|
+
if (deliveryGate.decision === 'buffer-until-idle') {
|
|
16587
17013
|
pendingInboundBuffer.push(selfAgent, inboundMsg)
|
|
16588
17014
|
process.stderr.write(
|
|
16589
17015
|
`telegram gateway: inbound held mid-turn agent=${selfAgent} ` +
|
|
@@ -16607,6 +17033,18 @@ async function handleInbound(
|
|
|
16607
17033
|
return
|
|
16608
17034
|
}
|
|
16609
17035
|
|
|
17036
|
+
// #2917: reserve the chat's busy key SYNCHRONOUSLY here — before the
|
|
17037
|
+
// composer-clear await below — so a concurrent same-chat inbound reaching
|
|
17038
|
+
// the LIVE gate above observes this in-flight delivery and buffers behind it
|
|
17039
|
+
// (per-chat FIFO). Without this, both handlers pass the gate during each
|
|
17040
|
+
// other's async lead-in and race to the bridge, reordering the replies.
|
|
17041
|
+
// Only fresh-turn deliveries reserve (steering/interrupt amend a running
|
|
17042
|
+
// turn and must not). Released below if the send misses (bridge offline).
|
|
17043
|
+
let reservedBusyKey: string | null = null
|
|
17044
|
+
if (deliveryGate.reserve && SERIALIZE_INBOUND_DELIVERY_ENABLED && machineInTurnAtReceipt == null) {
|
|
17045
|
+
reservedBusyKey = markClaudeBusyForInbound(inboundMsg)
|
|
17046
|
+
}
|
|
17047
|
+
|
|
16610
17048
|
// Pre-send composer clear (the marko wedge). The inbound is about to be
|
|
16611
17049
|
// delivered as an MCP `notifications/claude/channel` notification, which
|
|
16612
17050
|
// the unmodified CLI appends into its composer and auto-submits ONLY when
|
|
@@ -16636,7 +17074,10 @@ async function handleInbound(
|
|
|
16636
17074
|
|
|
16637
17075
|
const delivered = ipcServer.sendToAgent(selfAgent, inboundMsg)
|
|
16638
17076
|
if (delivered) {
|
|
16639
|
-
|
|
17077
|
+
// Reuse the key reserved synchronously above (#2917) when present, else
|
|
17078
|
+
// mark now — markClaudeBusyForInbound is idempotent (lockstep re-stamp),
|
|
17079
|
+
// so a re-mark is safe and returns the same chat key.
|
|
17080
|
+
const busyKey = reservedBusyKey ?? markClaudeBusyForInbound(inboundMsg)
|
|
16640
17081
|
// Track until claude acks via `enqueue` (the marko drop-wedge): if no ack
|
|
16641
17082
|
// lands, the message stranded in the composer and the sweep re-delivers
|
|
16642
17083
|
// it. Track ONLY messages that produce an `enqueue` to ack against —
|
|
@@ -16658,6 +17099,15 @@ async function handleInbound(
|
|
|
16658
17099
|
}
|
|
16659
17100
|
}
|
|
16660
17101
|
if (!delivered) {
|
|
17102
|
+
// #2917: the synchronous reservation assumed delivery; the send missed
|
|
17103
|
+
// (bridge offline), so release it in lockstep — otherwise the orphaned
|
|
17104
|
+
// busy key would gate every subsequent inbound into the buffer until the
|
|
17105
|
+
// orphan reaper clears it. The message itself is buffered below, so FIFO
|
|
17106
|
+
// is still preserved (it drains in order on the next bridge register).
|
|
17107
|
+
if (reservedBusyKey != null) {
|
|
17108
|
+
claudeBusyKeys.delete(reservedBusyKey)
|
|
17109
|
+
claudeBusyKeySince.delete(reservedBusyKey)
|
|
17110
|
+
}
|
|
16661
17111
|
// Only persist fresh user turns to the durable spool. Steering / `!`
|
|
16662
17112
|
// interrupt / empty bodies are mid-turn amendments or no-ops that would
|
|
16663
17113
|
// arrive orphaned if replayed as a fresh turn after a restart — drop them
|
|
@@ -18223,7 +18673,7 @@ interface ModelDepsRestartContext {
|
|
|
18223
18673
|
}
|
|
18224
18674
|
|
|
18225
18675
|
function buildModelDeps(restartCtx?: ModelDepsRestartContext): ModelMenuDeps & ModelCommandDeps {
|
|
18226
|
-
|
|
18676
|
+
const deps: ModelMenuDeps & ModelCommandDeps = {
|
|
18227
18677
|
discover: (a) => discoverModels(a),
|
|
18228
18678
|
discoverSrModels: async () => {
|
|
18229
18679
|
// /model/info lives on the ROOT proxy (model-mapped surface), NOT the
|
|
@@ -18329,7 +18779,25 @@ function buildModelDeps(restartCtx?: ModelDepsRestartContext): ModelMenuDeps & M
|
|
|
18329
18779
|
)
|
|
18330
18780
|
}
|
|
18331
18781
|
},
|
|
18782
|
+
/**
|
|
18783
|
+
* Session-only switch TO an sr-* (LiteLLM/OpenRouter) model. claude's
|
|
18784
|
+
* native `/model` picker rejects unknown sr-* ids, so we can't inject.
|
|
18785
|
+
* Write the token to the `.session-model-override` carrier file (start.sh
|
|
18786
|
+
* consumes it on the next boot and launches `claude --model <token>`), set
|
|
18787
|
+
* the in-memory session-model so /status stays honest across the restart
|
|
18788
|
+
* window, then run the SAME restart dispatch as scheduleRestart above.
|
|
18789
|
+
*/
|
|
18790
|
+
scheduleModelRelaunch: async (model: string, reason: string) => {
|
|
18791
|
+
const agentDir = resolveAgentDirFromEnv()
|
|
18792
|
+
if (!agentDir) throw new Error('agent dir unresolvable — cannot write session-model carrier')
|
|
18793
|
+
// Carrier: single line, token + newline, no quoting (start.sh strips
|
|
18794
|
+
// whitespace and shape-gates). One-shot — consumed on the next boot.
|
|
18795
|
+
writeFileSync(join(agentDir, '.session-model-override'), `${model}\n`, 'utf8')
|
|
18796
|
+
activeSessionModelOverride = model
|
|
18797
|
+
await deps.scheduleRestart(reason)
|
|
18798
|
+
},
|
|
18332
18799
|
}
|
|
18800
|
+
return deps
|
|
18333
18801
|
}
|
|
18334
18802
|
|
|
18335
18803
|
function modelMenuReplyMarkup(reply: ModelMenuReply): InlineKeyboard | undefined {
|
|
@@ -19804,25 +20272,21 @@ async function runQuotaWatch(opts: { bootTick?: boolean } = {}): Promise<void> {
|
|
|
19804
20272
|
}
|
|
19805
20273
|
} catch (err) {
|
|
19806
20274
|
process.stderr.write(`telegram gateway: quota-watch: probe for crossing accounts failed: ${err}\n`)
|
|
19807
|
-
|
|
19808
|
-
|
|
19809
|
-
|
|
19810
|
-
|
|
19811
|
-
|
|
19812
|
-
|
|
19813
|
-
|
|
19814
|
-
|
|
19815
|
-
|
|
19816
|
-
process.stderr.write(`telegram gateway: quota-watch state persist failed: ${saveErr}\n`)
|
|
19817
|
-
}
|
|
20275
|
+
// A quota notification must never carry numbers we could not verify
|
|
20276
|
+
// live. Leave the crossing accounts' state untouched — the
|
|
20277
|
+
// transition re-evaluates (and re-probes) on the next 15-min tick.
|
|
20278
|
+
// Persist any reconciles already applied, then bail.
|
|
20279
|
+
if (reconciledCount > 0) {
|
|
20280
|
+
try {
|
|
20281
|
+
saveQuotaWatchState(stateDir, mutatedState)
|
|
20282
|
+
} catch (saveErr) {
|
|
20283
|
+
process.stderr.write(`telegram gateway: quota-watch state persist failed: ${saveErr}\n`)
|
|
19818
20284
|
}
|
|
19819
|
-
process.stderr.write(
|
|
19820
|
-
`telegram gateway: quota-watch: deferring ${pendingTransitions.length} notification(s) until probe succeeds\n`,
|
|
19821
|
-
)
|
|
19822
|
-
return
|
|
19823
20285
|
}
|
|
19824
|
-
|
|
19825
|
-
|
|
20286
|
+
process.stderr.write(
|
|
20287
|
+
`telegram gateway: quota-watch: deferring ${pendingTransitions.length} notification(s) until probe succeeds\n`,
|
|
20288
|
+
)
|
|
20289
|
+
return
|
|
19826
20290
|
}
|
|
19827
20291
|
|
|
19828
20292
|
// Build final notifications, enriching the snapshot with fresh probe
|
|
@@ -19872,7 +20336,7 @@ async function runQuotaWatch(opts: { bootTick?: boolean } = {}): Promise<void> {
|
|
|
19872
20336
|
// State normalised by the time of the probe — don't notify.
|
|
19873
20337
|
continue
|
|
19874
20338
|
}
|
|
19875
|
-
} else
|
|
20339
|
+
} else {
|
|
19876
20340
|
// No verified fresh data for this account (per-account probe failure
|
|
19877
20341
|
// or label missing from the batch result). Same rule as the batch
|
|
19878
20342
|
// throw above: never send unverified numbers. State untouched —
|
|
@@ -22554,7 +23018,15 @@ async function handleAuthDashboardCallback(ctx: Context): Promise<void> {
|
|
|
22554
23018
|
tz,
|
|
22555
23019
|
now: renderNow,
|
|
22556
23020
|
demo: refreshDemo,
|
|
22557
|
-
|
|
23021
|
+
// Honesty backstop (same as /usage): a TOTAL probe failure (zero
|
|
23022
|
+
// result rows, nothing served from cache) renders an explicit
|
|
23023
|
+
// "probe failed" marker instead of a false "Live" footer next to
|
|
23024
|
+
// no-data rows.
|
|
23025
|
+
...(staleCachedAtMs != null
|
|
23026
|
+
? { staleCachedAtMs }
|
|
23027
|
+
: probeResp.results.length > 0
|
|
23028
|
+
? { liveProbedAtMs: renderNow.getTime() }
|
|
23029
|
+
: { probeFailed: true }),
|
|
22558
23030
|
})
|
|
22559
23031
|
const kbRows = buildSnapshotKeyboard(snapshots, { now: renderNow, demo: refreshDemo })
|
|
22560
23032
|
const inline_keyboard = kbRows.map((row) =>
|
|
@@ -23020,21 +23492,49 @@ bot.command('usage', async ctx => {
|
|
|
23020
23492
|
// flight; a TTL-hit or failed-probe fallback is tagged served:"cache",
|
|
23021
23493
|
// which we surface as a "⚠ cached Nm ago" footer instead of a false
|
|
23022
23494
|
// live stamp.
|
|
23495
|
+
const renderNow = new Date()
|
|
23023
23496
|
const probeResp = await client.probeQuota(state.accounts.map((a) => a.label)).catch(() => ({ results: [] }))
|
|
23024
23497
|
const { quotas, staleCachedAtMs } = zipProbeResults(
|
|
23025
23498
|
state.accounts.map((a) => a.label),
|
|
23026
23499
|
probeResp.results,
|
|
23027
23500
|
)
|
|
23028
|
-
const {
|
|
23501
|
+
const { buildSnapshotsFromState, buildSnapshotKeyboard } = await import(
|
|
23029
23502
|
'../auth-snapshot-format.js'
|
|
23030
23503
|
)
|
|
23031
|
-
const
|
|
23504
|
+
const { renderUsageCard } = await import('../quota-bar-format.js')
|
|
23032
23505
|
const snapshots = buildSnapshotsFromState(state, quotas)
|
|
23033
|
-
|
|
23034
|
-
|
|
23035
|
-
|
|
23506
|
+
// /usage renders the compact quota-bar block (at-a-glance headroom +
|
|
23507
|
+
// pace tick per window) plus a two-line footer — the actionable
|
|
23508
|
+
// cross-account recommendation verdict and the freshness marker (`⚠
|
|
23509
|
+
// cached Nm ago` when the probe was served stale from cache, else
|
|
23510
|
+
// `Live · refreshed Nm ago`). The old Format 2 table
|
|
23511
|
+
// (renderAuthSnapshotFormat2) is no longer appended here (operator
|
|
23512
|
+
// call, 2026-07-10): the two views were redundant and the table
|
|
23513
|
+
// doubled the message length. Every per-window reset the table
|
|
23514
|
+
// carried has an equivalent in the bar rows' `pct% / <time-left>`
|
|
23515
|
+
// segment, just relative instead of absolute — no info lost; the
|
|
23516
|
+
// recommendation + cached/live footer the table used to carry are
|
|
23517
|
+
// preserved by renderUsageCard.
|
|
23518
|
+
const exhaustedByLabel = new Map<string, boolean>(
|
|
23519
|
+
state.accounts.map((a) => [a.label, a.exhausted]),
|
|
23520
|
+
)
|
|
23521
|
+
const text = renderUsageCard(snapshots, exhaustedByLabel, {
|
|
23522
|
+
now: renderNow,
|
|
23036
23523
|
demo,
|
|
23037
|
-
|
|
23524
|
+
// #2495 Change 2 — a TTL-hit / failed-probe fallback is tagged
|
|
23525
|
+
// served:"cache"; surface it as `⚠ cached Nm ago` instead of a
|
|
23526
|
+
// false live stamp. Otherwise stamp the live refresh time.
|
|
23527
|
+
// Honesty backstop: a TOTAL probe failure (the .catch above
|
|
23528
|
+
// returned `{results: []}` and nothing was served from cache)
|
|
23529
|
+
// must render an explicit "probe failed" marker, NOT a false
|
|
23530
|
+
// "Live" stamp next to "⚠️ no data" rows. Without this the
|
|
23531
|
+
// footer claimed "Live · refreshed 0s ago" while every account
|
|
23532
|
+
// row said "no data — probe failed" (#2959 review finding).
|
|
23533
|
+
...(staleCachedAtMs != null
|
|
23534
|
+
? { staleCachedAtMs }
|
|
23535
|
+
: probeResp.results.length > 0
|
|
23536
|
+
? { liveProbedAtMs: renderNow.getTime() }
|
|
23537
|
+
: { probeFailed: true }),
|
|
23038
23538
|
})
|
|
23039
23539
|
// Preserve the Switch/Refresh/usage/Add inline keyboard on the
|
|
23040
23540
|
// rich-message render — the table card carries the same actions the
|
|
@@ -23385,14 +23885,41 @@ bot.on('callback_query:data', async ctx => {
|
|
|
23385
23885
|
// We track whether we applied the interim edit so we can skip the
|
|
23386
23886
|
// toastOnly short-circuit if we did — a toastOnly return after the interim
|
|
23387
23887
|
// edit would leave the menu stuck button-less.
|
|
23388
|
-
|
|
23888
|
+
// sr-* TARGET tap: switch TO a non-Claude (LiteLLM/OpenRouter) model.
|
|
23889
|
+
// Parity with the text `/model sr-*` path — claude's native picker rejects
|
|
23890
|
+
// unknown sr-* ids, so an in-place inject can't set them. Carry the token
|
|
23891
|
+
// across a graceful restart (the `.session-model-override` carrier) and
|
|
23892
|
+
// relaunch `claude --model sr-*`. Session-only; reverts to the configured
|
|
23893
|
+
// default on the next restart. The sr-* → Claude direction is handled below
|
|
23894
|
+
// via the SELECT/alias outcome + isSrToClaudeTransition.
|
|
23389
23895
|
if (data.startsWith(MODEL_CALLBACK_SR)) {
|
|
23390
|
-
const
|
|
23896
|
+
const srName = data.slice(MODEL_CALLBACK_SR.length)
|
|
23897
|
+
const srLabel = escapeHtmlForTg(srFriendlyLabel(srName))
|
|
23898
|
+
if (!isValidModelArg(srName)) {
|
|
23899
|
+
await ctx
|
|
23900
|
+
.editMessageText(richMessage('❌ Invalid model name'), { reply_markup: { inline_keyboard: [] } })
|
|
23901
|
+
.catch(() => {})
|
|
23902
|
+
return
|
|
23903
|
+
}
|
|
23391
23904
|
await ctx
|
|
23392
|
-
.editMessageText(
|
|
23905
|
+
.editMessageText(
|
|
23906
|
+
richMessage(`🔄 Switching session to **${srLabel}** — restarting (~30s). _Session-only; reverts to the configured default on the next restart._`),
|
|
23907
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
23908
|
+
)
|
|
23393
23909
|
.catch(() => {})
|
|
23394
|
-
|
|
23910
|
+
try {
|
|
23911
|
+
await modelDeps.scheduleModelRelaunch(srName, `user: /model ${srName} (session-only relaunch, menu)`)
|
|
23912
|
+
} catch (err) {
|
|
23913
|
+
await ctx
|
|
23914
|
+
.editMessageText(
|
|
23915
|
+
richMessage(`❌ Could not switch to **${srLabel}**: ${escapeHtmlForTg((err as Error)?.message ?? String(err))}`),
|
|
23916
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
23917
|
+
)
|
|
23918
|
+
.catch(() => {})
|
|
23919
|
+
}
|
|
23920
|
+
return
|
|
23395
23921
|
}
|
|
23922
|
+
const didInterimSrEdit = false
|
|
23396
23923
|
try {
|
|
23397
23924
|
const prevSessionModel = activeSessionModelOverride
|
|
23398
23925
|
const outcome = await handleModelMenuCallback(data, modelDeps)
|
|
@@ -26066,6 +26593,32 @@ void (async () => {
|
|
|
26066
26593
|
|
|
26067
26594
|
if (!didOneTimeSetup) {
|
|
26068
26595
|
didOneTimeSetup = true
|
|
26596
|
+
|
|
26597
|
+
// person_id name resolution (docs/configuration.md, resolve-person.ts):
|
|
26598
|
+
// ONE-TIME boot validation, NOT periodic. `PERSON_DIRECTORY` is
|
|
26599
|
+
// built exactly once, here, from the static in-memory `people.json`
|
|
26600
|
+
// the scaffold projected from `switchroom.yaml`'s `users:` block —
|
|
26601
|
+
// a config change requires an agent restart, this never re-reads.
|
|
26602
|
+
// The orchestration (per-entry validation + dead-man's-switch) is
|
|
26603
|
+
// in the pure, unit-tested `runPersonDirectoryBootCheck` — this
|
|
26604
|
+
// block only performs the resulting I/O (stderr log + the EXISTING
|
|
26605
|
+
// fleet-alert path at low/config severity, kind: 'config-warning',
|
|
26606
|
+
// which must never page like a real outage).
|
|
26607
|
+
{
|
|
26608
|
+
const bootCheck = runPersonDirectoryBootCheck(readPeopleFile)
|
|
26609
|
+
PERSON_DIRECTORY = bootCheck.directory
|
|
26610
|
+
process.stderr.write(bootCheck.logLine + '\n')
|
|
26611
|
+
if (bootCheck.alertDetail != null) {
|
|
26612
|
+
emitGatewayOperatorEvent({
|
|
26613
|
+
kind: 'config-warning',
|
|
26614
|
+
agent: process.env.SWITCHROOM_AGENT_NAME ?? '-',
|
|
26615
|
+
detail: bootCheck.alertDetail,
|
|
26616
|
+
suggestedActions: [],
|
|
26617
|
+
firstSeenAt: new Date(),
|
|
26618
|
+
})
|
|
26619
|
+
}
|
|
26620
|
+
}
|
|
26621
|
+
|
|
26069
26622
|
void registerSwitchroomBotCommands().catch(() => {})
|
|
26070
26623
|
|
|
26071
26624
|
// #613 fix: pre-warm the chatAvailableReactions cache for every
|
|
@@ -26376,6 +26929,73 @@ void (async () => {
|
|
|
26376
26929
|
}
|
|
26377
26930
|
} catch {}
|
|
26378
26931
|
|
|
26932
|
+
// ─── Session-model re-hydration + LiteLLM-down alert (session relaunch) ───
|
|
26933
|
+
//
|
|
26934
|
+
// start.sh writes the EFFECTIVE launched model to `.active-session-model`
|
|
26935
|
+
// on every boot (the model actually passed to `claude --model`). Re-hydrate
|
|
26936
|
+
// the in-memory session-model override from it so `/status` and the welcome
|
|
26937
|
+
// card stay honest after a session-relaunch restart. Only treat it as an
|
|
26938
|
+
// override when it differs from the configured/default model — a plain boot
|
|
26939
|
+
// on the configured model leaves the override null.
|
|
26940
|
+
//
|
|
26941
|
+
// Also consume the `.session-model-alert` sentinel: start.sh drops it when
|
|
26942
|
+
// it had to DROP an sr-* override because LiteLLM was unreachable at boot
|
|
26943
|
+
// (booting on the configured default instead of 4xx-ing against Anthropic).
|
|
26944
|
+
// We turn it into a loud Telegram message to the operator, then delete it.
|
|
26945
|
+
try {
|
|
26946
|
+
const smAgentDir = resolveAgentDirFromEnv()
|
|
26947
|
+
if (smAgentDir) {
|
|
26948
|
+
const activePath = join(smAgentDir, '.active-session-model')
|
|
26949
|
+
if (existsSync(activePath)) {
|
|
26950
|
+
try {
|
|
26951
|
+
const launched = readFileSync(activePath, 'utf8').trim()
|
|
26952
|
+
const configured = (() => {
|
|
26953
|
+
type AgentListResp = { agents: Array<{ name: string; model?: string | null }> }
|
|
26954
|
+
const d = switchroomExecJson<AgentListResp>(['agent', 'list'])
|
|
26955
|
+
const raw = d?.agents?.find(a => a.name === getMyAgentName())?.model ?? null
|
|
26956
|
+
// Resolve through the SAME resolver start.sh's scaffold uses, so an
|
|
26957
|
+
// unset (`null`) or `model: "default"` config value maps to the
|
|
26958
|
+
// switchroom default model id — matching the EFFECTIVE model start.sh
|
|
26959
|
+
// wrote to `.active-session-model`. Comparing the raw (unresolved)
|
|
26960
|
+
// value would flag every ordinary restart of a default-model agent as
|
|
26961
|
+
// a phantom session override.
|
|
26962
|
+
return resolveMainModel(raw ?? undefined)
|
|
26963
|
+
})()
|
|
26964
|
+
activeSessionModelOverride =
|
|
26965
|
+
launched.length > 0 && launched !== configured ? launched : null
|
|
26966
|
+
} catch { /* leave override as-is on a bad read */ }
|
|
26967
|
+
}
|
|
26968
|
+
|
|
26969
|
+
const alertPath = join(smAgentDir, '.session-model-alert')
|
|
26970
|
+
if (existsSync(alertPath)) {
|
|
26971
|
+
let alertText: string | null = null
|
|
26972
|
+
try {
|
|
26973
|
+
alertText = readFileSync(alertPath, 'utf8').trim()
|
|
26974
|
+
} catch { alertText = null }
|
|
26975
|
+
try { unlinkSync(alertPath) } catch { /* best-effort */ }
|
|
26976
|
+
if (alertText && alertText.length > 0) {
|
|
26977
|
+
// Notify EVERY operator, not just allowFrom[0]. Each send is wrapped
|
|
26978
|
+
// in its own catch so one operator's failure (blocked bot, bad chat
|
|
26979
|
+
// id) never stops the rest, and the outer boot flow never crashes.
|
|
26980
|
+
const operators = loadAccess().allowFrom
|
|
26981
|
+
for (const operator of operators) {
|
|
26982
|
+
if (!operator) continue
|
|
26983
|
+
void lockedBot.api
|
|
26984
|
+
.sendMessage(operator, `⚠️ ${alertText}`)
|
|
26985
|
+
.catch((err: unknown) =>
|
|
26986
|
+
process.stderr.write(
|
|
26987
|
+
`telegram gateway: session-model alert send failed for ${operator}: ${(err as Error)?.message ?? String(err)}\n`,
|
|
26988
|
+
),
|
|
26989
|
+
)
|
|
26990
|
+
}
|
|
26991
|
+
process.stderr.write(`telegram gateway: session-model: LiteLLM-down override drop — ${alertText}\n`)
|
|
26992
|
+
}
|
|
26993
|
+
}
|
|
26994
|
+
}
|
|
26995
|
+
} catch (err) {
|
|
26996
|
+
process.stderr.write(`telegram gateway: session-model re-hydration failed: ${(err as Error)?.message ?? String(err)}\n`)
|
|
26997
|
+
}
|
|
26998
|
+
|
|
26379
26999
|
// Credit-exhaustion watcher (#348). Reads `<agentDir>/.claude/.claude.json`
|
|
26380
27000
|
// for `cachedExtraUsageDisabledReason`. Fires a Telegram notification
|
|
26381
27001
|
// on transition into / out of fatal billing states (out_of_credits,
|
|
@@ -26593,6 +27213,20 @@ void (async () => {
|
|
|
26593
27213
|
ownedWorktreeCwds({
|
|
26594
27214
|
self: process.env.SWITCHROOM_AGENT_NAME,
|
|
26595
27215
|
listRecords: listWorktreeRecords,
|
|
27216
|
+
// Durable, non-env identity fallback (#1116 / #2893): when
|
|
27217
|
+
// SWITCHROOM_AGENT_NAME is somehow unset, derive this
|
|
27218
|
+
// agent's own identity from its own directory so worktree
|
|
27219
|
+
// ownership still resolves (env is only the fast path).
|
|
27220
|
+
// `watcherAgentDir` is guaranteed non-null in this branch
|
|
27221
|
+
// (the whole watcher is gated on it above). Kill-switch
|
|
27222
|
+
// SWITCHROOM_WORKTREE_IDENTITY_FALLBACK=0 restores the
|
|
27223
|
+
// pre-fix env-only behaviour.
|
|
27224
|
+
agentDir:
|
|
27225
|
+
process.env.SWITCHROOM_WORKTREE_IDENTITY_FALLBACK === '0'
|
|
27226
|
+
? undefined
|
|
27227
|
+
: watcherAgentDir,
|
|
27228
|
+
log: (msg) =>
|
|
27229
|
+
process.stderr.write(`telegram gateway: ${msg}\n`),
|
|
26596
27230
|
}),
|
|
26597
27231
|
// Bug 0 fix: previously omitted, leaving the watcher unable to
|
|
26598
27232
|
// write liveness/stall/turn_end updates to the registry DB.
|
|
@@ -26939,8 +27573,8 @@ void (async () => {
|
|
|
26939
27573
|
orphanStatusEnabled,
|
|
26940
27574
|
})
|
|
26941
27575
|
if (surface === 'worker-feed') {
|
|
26942
|
-
const
|
|
26943
|
-
const wkChat =
|
|
27576
|
+
const wk = resolveWorkerFeedChat(agentId, fleetChatId)
|
|
27577
|
+
const wkChat = wk.chatId
|
|
26944
27578
|
void workerActivityFeed?.update(
|
|
26945
27579
|
agentId,
|
|
26946
27580
|
wkChat,
|
|
@@ -26952,7 +27586,7 @@ void (async () => {
|
|
|
26952
27586
|
elapsedMs,
|
|
26953
27587
|
state: 'running',
|
|
26954
27588
|
},
|
|
26955
|
-
|
|
27589
|
+
wk.threadId,
|
|
26956
27590
|
)?.then(() => reconcileWorkerPin(agentId, wkChat, true))
|
|
26957
27591
|
return
|
|
26958
27592
|
}
|
|
@@ -27077,8 +27711,8 @@ void (async () => {
|
|
|
27077
27711
|
// resolved (the pinned-card fleet that used to carry the chat
|
|
27078
27712
|
// is gone — see resolveSubagentOriginChat).
|
|
27079
27713
|
if (workerFeedEnabled) {
|
|
27080
|
-
const
|
|
27081
|
-
const wkChat =
|
|
27714
|
+
const wk = resolveWorkerFeedChat(agentId, fleetChatId)
|
|
27715
|
+
const wkChat = wk.chatId
|
|
27082
27716
|
void workerActivityFeed?.update(
|
|
27083
27717
|
agentId,
|
|
27084
27718
|
wkChat,
|
|
@@ -27093,7 +27727,7 @@ void (async () => {
|
|
|
27093
27727
|
elapsedMs,
|
|
27094
27728
|
state: 'running',
|
|
27095
27729
|
},
|
|
27096
|
-
|
|
27730
|
+
wk.threadId,
|
|
27097
27731
|
)?.then(() => reconcileWorkerPin(agentId, wkChat, true))
|
|
27098
27732
|
return
|
|
27099
27733
|
}
|