switchroom 0.20.10 → 0.20.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-scheduler/index.js +68 -3
- package/dist/auth-broker/index.js +211 -46
- package/dist/cli/notion-write-pretool.mjs +68 -3
- package/dist/cli/self-improve-apply-guard-pretool.mjs +357 -92
- package/dist/cli/self-improve-stop.mjs +889 -7
- package/dist/cli/skill-validate-pretool.mjs +82 -3
- package/dist/cli/switchroom.js +5063 -2961
- package/dist/host-control/main.js +93 -27
- package/dist/vault/approvals/kernel-server.js +92 -26
- package/dist/vault/broker/server.js +92 -26
- package/examples/personal-google-workspace-mcp/compose.yaml +1 -1
- package/package.json +1 -1
- package/profiles/_shared/agent-self-service.md.hbs +15 -22
- package/profiles/_shared/delegation-golden-rule.md.hbs +1 -1
- package/profiles/_shared/dev-protocol.md.hbs +1 -1
- package/profiles/_shared/execution-discipline.md.hbs +4 -4
- package/profiles/_shared/vault-protocol.md.hbs +2 -18
- package/profiles/default/CLAUDE.md.hbs +3 -5
- package/skills/switchroom-architecture/telegram.md +0 -1
- package/skills/switchroom-cli/SKILL.md +0 -1
- package/telegram-plugin/README.md +2 -11
- package/telegram-plugin/auto-fallback-fleet.ts +37 -2
- package/telegram-plugin/bridge/bridge.ts +0 -12
- package/telegram-plugin/chat-lock.ts +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +0 -12
- package/telegram-plugin/dist/gateway/gateway.js +1454 -976
- package/telegram-plugin/dist/server.js +0 -12
- package/telegram-plugin/fallback-card-collapse.ts +1 -0
- package/telegram-plugin/gateway/auth-command.ts +11 -1
- package/telegram-plugin/gateway/callback-query-handlers.ts +100 -0
- package/telegram-plugin/gateway/eval-case-proposal-card.ts +86 -0
- package/telegram-plugin/gateway/fleet-fallback-notice-cooldown.test.ts +74 -0
- package/telegram-plugin/gateway/fleet-fallback-notice-cooldown.ts +71 -0
- package/telegram-plugin/gateway/gateway.ts +109 -149
- package/telegram-plugin/gateway/ipc-protocol.ts +43 -0
- package/telegram-plugin/gateway/ipc-server.ts +28 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +6 -1
- package/telegram-plugin/gateway/narrative-lane.ts +33 -2
- package/telegram-plugin/gateway/privacy-reset.test.ts +216 -0
- package/telegram-plugin/gateway/privacy-reset.ts +87 -0
- package/telegram-plugin/gateway/privacy-state.test.ts +165 -0
- package/telegram-plugin/gateway/privacy-state.ts +206 -0
- package/telegram-plugin/gateway/self-improve-proposal-wiring.ts +176 -0
- package/telegram-plugin/gateway/stale-pin-sweep-wiring.ts +24 -14
- package/telegram-plugin/gateway/stale-pin-sweep.test.ts +123 -26
- package/telegram-plugin/gateway/stale-pin-sweep.ts +52 -35
- package/telegram-plugin/gateway/status-pin-store.ts +10 -9
- package/telegram-plugin/gateway/stream-render.ts +4 -4
- package/telegram-plugin/gateway/throttle-tier-wiring.ts +15 -4
- package/telegram-plugin/gateway/turn-record-status.ts +32 -1
- package/telegram-plugin/hooks/hooks.json +13 -12
- package/telegram-plugin/hooks/narration-classify.mjs +1 -2
- package/telegram-plugin/hooks/silent-end-scan.mjs +1 -1
- package/telegram-plugin/slot-banner-driver.ts +42 -5
- package/telegram-plugin/status-pin.ts +2 -5
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +24 -0
- package/telegram-plugin/tests/backstop-exactly-once.test.ts +8 -2
- package/telegram-plugin/tests/framework-fallback-duration-guard.test.ts +125 -0
- package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +2 -0
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +97 -0
- package/telegram-plugin/tests/pin-message-tool-retired.test.ts +64 -0
- package/telegram-plugin/tests/privacy-reset-call-sites.test.ts +120 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +38 -0
- package/telegram-plugin/tests/status-pin-store.test.ts +25 -0
- package/telegram-plugin/tests/throttle-tier.test.ts +16 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +67 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +40 -1
- package/telegram-plugin/throttle-tier.ts +12 -3
- package/telegram-plugin/turn-flush-safety.ts +97 -0
- package/telegram-plugin/worker-activity-feed.ts +1 -1
- package/vendor/hindsight-memory/scripts/recall.py +140 -0
- package/vendor/hindsight-memory/scripts/retain.py +306 -0
- package/vendor/hindsight-memory/scripts/subagent_retain.py +29 -1
- package/vendor/hindsight-memory/scripts/tests/test_private_mode.py +415 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_latency_instrumentation.py +277 -0
- package/vendor/hindsight-memory/scripts/tests/test_self_improve_correction_tag.py +167 -0
|
@@ -90,7 +90,7 @@ import { formatTurnLifecycle } from './status-surface-log.js'
|
|
|
90
90
|
import { removeTurnActiveMarker, touchTurnActiveMarker, writeTurnActiveMarker } from './turn-active-marker.js'
|
|
91
91
|
import { withTurnEndGateBackstop } from './turn-end-gate-backstop.js'
|
|
92
92
|
import { decideTurnEndGate } from './turn-end-gate.js'
|
|
93
|
-
import { finalizeBackstopSendGated } from './turn-record-status.js'
|
|
93
|
+
import { finalizeBackstopSendGated, computeTurnDurationMs } from './turn-record-status.js'
|
|
94
94
|
import type { SilentEndDeps } from '../silent-end.js'
|
|
95
95
|
import type { ChatKey as _ChatKey } from './inbound-delivery-machine.js'
|
|
96
96
|
import type { SessionEvent } from '../session-tail.js'
|
|
@@ -2174,7 +2174,7 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
|
|
|
2174
2174
|
chatId,
|
|
2175
2175
|
threadId,
|
|
2176
2176
|
turnId: turn.turnId,
|
|
2177
|
-
turnDurationMs: turn.startedAt
|
|
2177
|
+
turnDurationMs: computeTurnDurationMs(turn.startedAt, Date.now()),
|
|
2178
2178
|
reactionCount: reactionTransitionCounts.get(tKey) ?? 0,
|
|
2179
2179
|
})
|
|
2180
2180
|
}
|
|
@@ -2214,7 +2214,7 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
|
|
|
2214
2214
|
// still appear in turn-duration graphs.
|
|
2215
2215
|
{
|
|
2216
2216
|
const sKey = streamKey(chatId, threadId)
|
|
2217
|
-
const turnDurationMs = turn.startedAt
|
|
2217
|
+
const turnDurationMs = computeTurnDurationMs(turn.startedAt, Date.now())
|
|
2218
2218
|
logStreamingEvent({
|
|
2219
2219
|
kind: 'turn_end',
|
|
2220
2220
|
chatId,
|
|
@@ -2799,7 +2799,7 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
|
|
|
2799
2799
|
finalizeStatusReaction(chatId, threadId, terminalReason)
|
|
2800
2800
|
{
|
|
2801
2801
|
const sKey = streamKey(chatId, threadId)
|
|
2802
|
-
const turnDurationMs = turn.startedAt
|
|
2802
|
+
const turnDurationMs = computeTurnDurationMs(turn.startedAt, Date.now())
|
|
2803
2803
|
logStreamingEvent({
|
|
2804
2804
|
kind: 'turn_end',
|
|
2805
2805
|
chatId,
|
|
@@ -73,6 +73,10 @@ export interface ThrottleBrokerClient {
|
|
|
73
73
|
throttled_until: number
|
|
74
74
|
escalated: boolean
|
|
75
75
|
rolledTo?: string | null
|
|
76
|
+
/** True when the caller has a strict pin (`auth.strict`) — its null
|
|
77
|
+
* `rolledTo` means "riding out the wall", not fleet all-blocked.
|
|
78
|
+
* Absent on pre-flag brokers. */
|
|
79
|
+
caller_pinned_strict?: boolean
|
|
76
80
|
}>
|
|
77
81
|
claimNotification(key: string, windowMs: number): Promise<{ granted: boolean }>
|
|
78
82
|
}
|
|
@@ -214,19 +218,22 @@ export function createThrottleTierRunner(deps: ThrottleTierRunnerDeps): Throttle
|
|
|
214
218
|
client: ThrottleBrokerClient | null,
|
|
215
219
|
account: string | null,
|
|
216
220
|
rolledTo: string | null,
|
|
221
|
+
callerPinnedStrict: boolean,
|
|
217
222
|
triggerAgent: string,
|
|
218
223
|
armedAtMs: number,
|
|
219
224
|
): Promise<void> {
|
|
220
225
|
deps.log(
|
|
221
226
|
`[throttle-tier] escalated to wall account=${account ?? '?'} ` +
|
|
222
|
-
`rolledTo=${rolledTo ?? 'none (all blocked)'}`,
|
|
227
|
+
`rolledTo=${rolledTo ?? (callerPinnedStrict ? 'none (strict pin, riding it out)' : 'none (all blocked)')}`,
|
|
223
228
|
)
|
|
224
229
|
await broadcastDeduped(
|
|
225
230
|
client,
|
|
226
231
|
'throttle-escalation',
|
|
227
232
|
account,
|
|
228
|
-
renderThrottleEscalationNotice({ account, agent: triggerAgent, rolledTo }),
|
|
233
|
+
renderThrottleEscalationNotice({ account, agent: triggerAgent, rolledTo, callerPinnedStrict }),
|
|
229
234
|
)
|
|
235
|
+
// No nudge on a null rolledTo — for a strict pin a resume would replay
|
|
236
|
+
// the turn straight back into the wall it just hit.
|
|
230
237
|
if (rolledTo) nudgeResume('throttle-escalation-resume', armedAtMs)
|
|
231
238
|
}
|
|
232
239
|
|
|
@@ -240,6 +247,7 @@ export function createThrottleTierRunner(deps: ThrottleTierRunnerDeps): Throttle
|
|
|
240
247
|
let account: string | null = null
|
|
241
248
|
let escalated = false
|
|
242
249
|
let rolledTo: string | null = null
|
|
250
|
+
let callerPinnedStrict = false
|
|
243
251
|
try {
|
|
244
252
|
client = await deps.getBrokerClient()
|
|
245
253
|
if (client) {
|
|
@@ -247,6 +255,7 @@ export function createThrottleTierRunner(deps: ThrottleTierRunnerDeps): Throttle
|
|
|
247
255
|
account = r.account
|
|
248
256
|
escalated = r.escalated
|
|
249
257
|
rolledTo = r.rolledTo ?? null
|
|
258
|
+
callerPinnedStrict = r.caller_pinned_strict ?? false
|
|
250
259
|
} else {
|
|
251
260
|
deps.log(
|
|
252
261
|
`[throttle-tier] broker unreachable — notice only, no ledger record agent=${triggerAgent}`,
|
|
@@ -259,7 +268,7 @@ export function createThrottleTierRunner(deps: ThrottleTierRunnerDeps): Throttle
|
|
|
259
268
|
}
|
|
260
269
|
|
|
261
270
|
if (escalated) {
|
|
262
|
-
await announceEscalation(client, account, rolledTo, triggerAgent, armedAtMs)
|
|
271
|
+
await announceEscalation(client, account, rolledTo, callerPinnedStrict, triggerAgent, armedAtMs)
|
|
263
272
|
return
|
|
264
273
|
}
|
|
265
274
|
|
|
@@ -303,6 +312,7 @@ export function createThrottleTierRunner(deps: ThrottleTierRunnerDeps): Throttle
|
|
|
303
312
|
let account: string | null = null
|
|
304
313
|
let escalated = false
|
|
305
314
|
let rolledTo: string | null = null
|
|
315
|
+
let callerPinnedStrict = false
|
|
306
316
|
try {
|
|
307
317
|
client = await deps.getBrokerClient()
|
|
308
318
|
if (client) {
|
|
@@ -312,6 +322,7 @@ export function createThrottleTierRunner(deps: ThrottleTierRunnerDeps): Throttle
|
|
|
312
322
|
account = r.account
|
|
313
323
|
escalated = r.escalated
|
|
314
324
|
rolledTo = r.rolledTo ?? null
|
|
325
|
+
callerPinnedStrict = r.caller_pinned_strict ?? false
|
|
315
326
|
} else {
|
|
316
327
|
deps.log(
|
|
317
328
|
`[throttle-tier] broker unreachable — probe-only skipped agent=${triggerAgent}`,
|
|
@@ -324,7 +335,7 @@ export function createThrottleTierRunner(deps: ThrottleTierRunnerDeps): Throttle
|
|
|
324
335
|
}
|
|
325
336
|
|
|
326
337
|
if (escalated) {
|
|
327
|
-
await announceEscalation(client, account, rolledTo, triggerAgent, armedAtMs)
|
|
338
|
+
await announceEscalation(client, account, rolledTo, callerPinnedStrict, triggerAgent, armedAtMs)
|
|
328
339
|
return
|
|
329
340
|
}
|
|
330
341
|
|
|
@@ -24,6 +24,37 @@
|
|
|
24
24
|
*/
|
|
25
25
|
export type DeliveryOutcome = 'delivered' | 'failed' | 'suppressed'
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* The ONE way a turn's `duration_ms` is computed, shared by every emitter
|
|
29
|
+
* (`buildTurnRecord` → turns.jsonl, and all three `turn_ended` runtime-metric
|
|
30
|
+
* sites: the two stream-render turn-end paths + the liveness framework_fallback).
|
|
31
|
+
*
|
|
32
|
+
* Why a single helper: the three `turn_ended` emitters each hand-rolled the
|
|
33
|
+
* subtraction, and one drifted. `stream-render.ts` guarded with
|
|
34
|
+
* `startedAt > 0 ? now - startedAt : 0`; the framework_fallback path in
|
|
35
|
+
* `liveness-wiring.ts` did a bare `Date.now() - turnStartedAt` with only a
|
|
36
|
+
* `!= null` presence check. When `activeTurnStartedAt` held `0` (or any
|
|
37
|
+
* non-positive / non-finite value), that path emitted `duration_ms = now - 0`,
|
|
38
|
+
* i.e. the absolute Unix-epoch-ms — a ~56,000-year "duration". This poisoned the
|
|
39
|
+
* analysed turn_ended dataset (observed: 110 rows where `duration_ms === ts`),
|
|
40
|
+
* making every latency aggregate unusable.
|
|
41
|
+
*
|
|
42
|
+
* The invariant this helper enforces: a `duration_ms` is ALWAYS a non-negative
|
|
43
|
+
* elapsed-milliseconds value derived from a positive, finite start stamp — never
|
|
44
|
+
* an absolute epoch value, never negative (clock step back), never NaN. A start
|
|
45
|
+
* that is missing / zero / bogus yields `0` (a bounded, filterable sentinel)
|
|
46
|
+
* instead of a value that destroys aggregates.
|
|
47
|
+
*/
|
|
48
|
+
export function computeTurnDurationMs(
|
|
49
|
+
startedAt: number | null | undefined,
|
|
50
|
+
now: number,
|
|
51
|
+
): number {
|
|
52
|
+
if (typeof startedAt !== 'number' || !Number.isFinite(startedAt) || startedAt <= 0) {
|
|
53
|
+
return 0
|
|
54
|
+
}
|
|
55
|
+
return Math.max(0, now - startedAt)
|
|
56
|
+
}
|
|
57
|
+
|
|
27
58
|
/** The status strings written to turns.jsonl. `send_failed` is new in PR B. */
|
|
28
59
|
export type TurnStatus = 'complete' | 'no_reply' | 'send_failed'
|
|
29
60
|
|
|
@@ -261,7 +292,7 @@ export function buildTurnRecord(
|
|
|
261
292
|
return {
|
|
262
293
|
ts: Math.floor(endedAt / 1000),
|
|
263
294
|
agent: turn.agent,
|
|
264
|
-
duration_ms: turn.startedAt
|
|
295
|
+
duration_ms: computeTurnDurationMs(turn.startedAt, endedAt),
|
|
265
296
|
tools: turn.toolCallCount ?? 0,
|
|
266
297
|
status: computeTurnStatus(turn),
|
|
267
298
|
turn_id: turn.turnId,
|
|
@@ -5,16 +5,17 @@
|
|
|
5
5
|
"hooks": [
|
|
6
6
|
{
|
|
7
7
|
"type": "command",
|
|
8
|
-
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\"
|
|
8
|
+
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" bun \"${CLAUDE_PLUGIN_ROOT}/hooks/secret-guard-pretool.mjs\"",
|
|
9
9
|
"timeout": 10
|
|
10
10
|
}
|
|
11
11
|
]
|
|
12
12
|
},
|
|
13
13
|
{
|
|
14
|
+
"matcher": "^mcp__switchroom-telegram__(reply|stream_reply)$",
|
|
14
15
|
"hooks": [
|
|
15
16
|
{
|
|
16
17
|
"type": "command",
|
|
17
|
-
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\"
|
|
18
|
+
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" bun \"${CLAUDE_PLUGIN_ROOT}/hooks/sentinel-reply-guard-pretool.mjs\"",
|
|
18
19
|
"timeout": 5
|
|
19
20
|
}
|
|
20
21
|
]
|
|
@@ -24,7 +25,7 @@
|
|
|
24
25
|
"hooks": [
|
|
25
26
|
{
|
|
26
27
|
"type": "command",
|
|
27
|
-
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\"
|
|
28
|
+
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" bun \"${CLAUDE_PLUGIN_ROOT}/hooks/subagent-tracker-pretool.mjs\"",
|
|
28
29
|
"timeout": 10
|
|
29
30
|
}
|
|
30
31
|
]
|
|
@@ -33,7 +34,7 @@
|
|
|
33
34
|
"hooks": [
|
|
34
35
|
{
|
|
35
36
|
"type": "command",
|
|
36
|
-
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\"
|
|
37
|
+
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" bun \"${CLAUDE_PLUGIN_ROOT}/hooks/tool-label-pretool.mjs\"",
|
|
37
38
|
"timeout": 5
|
|
38
39
|
}
|
|
39
40
|
]
|
|
@@ -43,7 +44,7 @@
|
|
|
43
44
|
"hooks": [
|
|
44
45
|
{
|
|
45
46
|
"type": "command",
|
|
46
|
-
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\"
|
|
47
|
+
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" bun \"${CLAUDE_PLUGIN_ROOT}/hooks/repo-context-pretool.mjs\"",
|
|
47
48
|
"timeout": 5
|
|
48
49
|
}
|
|
49
50
|
]
|
|
@@ -55,7 +56,7 @@
|
|
|
55
56
|
"hooks": [
|
|
56
57
|
{
|
|
57
58
|
"type": "command",
|
|
58
|
-
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\"
|
|
59
|
+
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" bun \"${CLAUDE_PLUGIN_ROOT}/hooks/subagent-tracker-posttool.mjs\"",
|
|
59
60
|
"timeout": 10
|
|
60
61
|
}
|
|
61
62
|
]
|
|
@@ -65,7 +66,7 @@
|
|
|
65
66
|
"hooks": [
|
|
66
67
|
{
|
|
67
68
|
"type": "command",
|
|
68
|
-
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\"
|
|
69
|
+
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" bun \"${CLAUDE_PLUGIN_ROOT}/hooks/sandbox-hint-posttool.mjs\"",
|
|
69
70
|
"timeout": 3
|
|
70
71
|
}
|
|
71
72
|
]
|
|
@@ -76,7 +77,7 @@
|
|
|
76
77
|
"hooks": [
|
|
77
78
|
{
|
|
78
79
|
"type": "command",
|
|
79
|
-
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\"
|
|
80
|
+
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" bun \"${CLAUDE_PLUGIN_ROOT}/hooks/compaction-marker-precompact.mjs\"",
|
|
80
81
|
"timeout": 5
|
|
81
82
|
}
|
|
82
83
|
]
|
|
@@ -87,7 +88,7 @@
|
|
|
87
88
|
"hooks": [
|
|
88
89
|
{
|
|
89
90
|
"type": "command",
|
|
90
|
-
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\"
|
|
91
|
+
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" bun \"${CLAUDE_PLUGIN_ROOT}/hooks/secret-scrub-stop.mjs\"",
|
|
91
92
|
"timeout": 15,
|
|
92
93
|
"async": true
|
|
93
94
|
}
|
|
@@ -97,7 +98,7 @@
|
|
|
97
98
|
"hooks": [
|
|
98
99
|
{
|
|
99
100
|
"type": "command",
|
|
100
|
-
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\"
|
|
101
|
+
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" bun \"${CLAUDE_PLUGIN_ROOT}/hooks/silent-end-interrupt-stop.mjs\"",
|
|
101
102
|
"timeout": 5
|
|
102
103
|
}
|
|
103
104
|
]
|
|
@@ -106,7 +107,7 @@
|
|
|
106
107
|
"hooks": [
|
|
107
108
|
{
|
|
108
109
|
"type": "command",
|
|
109
|
-
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\"
|
|
110
|
+
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" bun \"${CLAUDE_PLUGIN_ROOT}/hooks/dispatch-claim-stop.mjs\"",
|
|
110
111
|
"timeout": 5
|
|
111
112
|
}
|
|
112
113
|
]
|
|
@@ -115,7 +116,7 @@
|
|
|
115
116
|
"hooks": [
|
|
116
117
|
{
|
|
117
118
|
"type": "command",
|
|
118
|
-
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\"
|
|
119
|
+
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" bun \"${CLAUDE_PLUGIN_ROOT}/hooks/tool-label-stop.mjs\"",
|
|
119
120
|
"timeout": 5,
|
|
120
121
|
"async": true
|
|
121
122
|
}
|
|
@@ -108,7 +108,7 @@ export function isStructuralNarration(text, followedByToolUse) {
|
|
|
108
108
|
* follow-up, MF4). A text block followed ONLY by tools in this set is still the
|
|
109
109
|
* TERMINAL answer for backstop-coalescing purposes — these tools carry no
|
|
110
110
|
* model-authored answer text and are the only ones plausibly fired AFTER a
|
|
111
|
-
* terminal answer (answer, then react /
|
|
111
|
+
* terminal answer (answer, then react / typing / edit). Everything NOT in
|
|
112
112
|
* this set — work / deliverable tools (download_attachment, get_recent_messages,
|
|
113
113
|
* send_checklist, ask_user, …) and every non-telegram tool (retain / read /
|
|
114
114
|
* bash / web / …) — is turn-CONTINUING: a text block a real tool followed is
|
|
@@ -123,7 +123,6 @@ export function isStructuralNarration(text, followedByToolUse) {
|
|
|
123
123
|
export const EPHEMERAL_TOOLS = new Set([
|
|
124
124
|
'react',
|
|
125
125
|
'send_typing',
|
|
126
|
-
'pin_message',
|
|
127
126
|
'delete_message',
|
|
128
127
|
'edit_message',
|
|
129
128
|
])
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
// Cross-checked the full tool surface in `telegram-plugin/bridge/bridge.ts`
|
|
42
42
|
// (`TOOL_SCHEMAS`, kept in sync with `gateway/gateway.ts`): `edit_message`
|
|
43
43
|
// explicitly does NOT ping/deliver a fresh answer (its own description says
|
|
44
|
-
// "send a new reply when a long task completes"); `react`,
|
|
44
|
+
// "send a new reply when a long task completes"); `react`,
|
|
45
45
|
// `delete_message`, `forward_message`, `send_typing`, `download_attachment`,
|
|
46
46
|
// `get_recent_messages` carry no model-authored answer text at all;
|
|
47
47
|
// `send_checklist` / `send_sticker` / `send_gif` / `ask_user` /
|
|
@@ -22,6 +22,11 @@
|
|
|
22
22
|
|
|
23
23
|
import type { BannerState } from './slot-banner.js';
|
|
24
24
|
import { decideBannerAction } from './slot-banner.js';
|
|
25
|
+
// Shared rights model (D5): the banner adopts the status-pin path's rights
|
|
26
|
+
// detector, terminal-unpin classifier and per-process negative cache, so the two
|
|
27
|
+
// pin owners agree on which chats are rights-less instead of the banner's old
|
|
28
|
+
// drop-on-ANY-unpin. status-pin.ts is dependency-free.
|
|
29
|
+
import { isPinRightsError, isUnpinTerminalError, type PinRightsCache } from './status-pin.js';
|
|
25
30
|
|
|
26
31
|
/** Minimal subset of grammy's `bot.api` we depend on. Letting tests
|
|
27
32
|
* swap in `fake-bot-api.ts` without dragging in the full Bot type. */
|
|
@@ -72,6 +77,15 @@ export interface RefreshBannerArgs {
|
|
|
72
77
|
/** Optional API-failure observer. Phase identifies which Bot API
|
|
73
78
|
* call failed so the caller can log meaningfully. Default: silent. */
|
|
74
79
|
onError?: (phase: 'pin' | 'edit' | 'unpin', err: unknown) => void;
|
|
80
|
+
/** Shared per-process pin-rights negative cache (status-pin.ts
|
|
81
|
+
* `PinRightsCache`), unifying the banner with the status-pin path on
|
|
82
|
+
* rights-less chats (D5). When the chat is already known rights-less the pin
|
|
83
|
+
* action is skipped so we never emit an un-pinnable notice; a real pin-rights
|
|
84
|
+
* 400 from the pin/unpin verb records the block and a confirmed pin clears
|
|
85
|
+
* it. Fed ONLY from the pin/unpin verbs — a `sendMessage` failure is never
|
|
86
|
+
* cached, because a send failure is not a pin-rights signal. Optional;
|
|
87
|
+
* omitted in unit tests. */
|
|
88
|
+
rightsCache?: Pick<PinRightsCache, 'isBlocked' | 'block' | 'clear'>;
|
|
75
89
|
/** Optional durable-persistence hooks so an orphaned banner pin is
|
|
76
90
|
* recoverable across a gateway crash. The gateway wires these to the
|
|
77
91
|
* shared status-pin store (a distinct `banner:` pinKey), mirroring the
|
|
@@ -127,20 +141,34 @@ export async function refreshBanner(
|
|
|
127
141
|
|
|
128
142
|
if (action.kind === 'unpin') {
|
|
129
143
|
try {
|
|
144
|
+
// allow-raw-pin: the slot banner is a sanctioned separate pin owner (see
|
|
145
|
+
// scripts/check-status-pin-single-path-allowlist.txt); its unpin is not
|
|
146
|
+
// routed through reconcileStatusPin.
|
|
130
147
|
await args.bot.api.unpinChatMessage(args.ownerChatId, action.messageId);
|
|
131
148
|
} catch (err) {
|
|
132
149
|
args.onError?.('unpin', err);
|
|
150
|
+
// Feed the shared negative cache ONLY from a real pin-rights 400 on the
|
|
151
|
+
// unpin verb — never from any other failure class (D5).
|
|
152
|
+
if (isPinRightsError(err)) args.rightsCache?.block(String(args.ownerChatId));
|
|
153
|
+
// Adopt the status-pin terminal-vs-never-confirmed contract (#3664 Defect
|
|
154
|
+
// B) instead of drop-on-ANY-unpin: retain the claim when the unpin did
|
|
155
|
+
// NOT terminally land (transient flood/5xx/network), so the next refresh
|
|
156
|
+
// retries rather than orphaning a still-pinned banner with no record. A
|
|
157
|
+
// terminal 4xx (rights, message gone, bot kicked) — and a success — fall
|
|
158
|
+
// through to the drop below, because re-issuing cannot help.
|
|
159
|
+
if (!isUnpinTerminalError(err)) return args.prevState;
|
|
133
160
|
}
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
// would be more confusing than surfacing it again later. Drop the
|
|
137
|
-
// persisted record too (unpin-then-clear mirrors the status-pin store:
|
|
138
|
-
// a crash between unpin and clear just re-unpins next boot, idempotent).
|
|
161
|
+
// Terminal outcome: drop our claim and the persisted record. A crash
|
|
162
|
+
// between unpin and clear just re-unpins next boot (idempotent).
|
|
139
163
|
safePersist(args.persist?.clear);
|
|
140
164
|
return null;
|
|
141
165
|
}
|
|
142
166
|
|
|
143
167
|
if (action.kind === 'pin') {
|
|
168
|
+
// The banner is a PINNED notice: if this chat is already known rights-less
|
|
169
|
+
// (proved by the status-pin path or an earlier banner pin), skip the whole
|
|
170
|
+
// send+pin — an un-pinnable send is pure noise (D5).
|
|
171
|
+
if (args.rightsCache?.isBlocked(String(args.ownerChatId))) return args.prevState;
|
|
144
172
|
let sent: { message_id: number };
|
|
145
173
|
try {
|
|
146
174
|
// sendRichMessage doesn't accept link_preview_options — omit it.
|
|
@@ -150,6 +178,8 @@ export async function refreshBanner(
|
|
|
150
178
|
disable_notification: true,
|
|
151
179
|
});
|
|
152
180
|
} catch (err) {
|
|
181
|
+
// SEND failure — deliberately NOT a pin-rights signal, so the cache is
|
|
182
|
+
// NEVER written here even though the phase tag below is 'pin' (D5).
|
|
153
183
|
args.onError?.('pin', err);
|
|
154
184
|
return args.prevState;
|
|
155
185
|
}
|
|
@@ -158,17 +188,24 @@ export async function refreshBanner(
|
|
|
158
188
|
// unpins next boot — closing the persist-after-pin leak.
|
|
159
189
|
safePersist(() => args.persist?.pending?.(String(args.ownerChatId), sent.message_id));
|
|
160
190
|
try {
|
|
191
|
+
// allow-raw-pin: sanctioned banner pin owner (see the allowlist).
|
|
161
192
|
await args.bot.api.pinChatMessage(args.ownerChatId, sent.message_id, {
|
|
162
193
|
disable_notification: true,
|
|
163
194
|
});
|
|
164
195
|
} catch (err) {
|
|
165
196
|
args.onError?.('pin', err);
|
|
197
|
+
// The PIN verb failing with a real pin-rights 400 IS the rights signal —
|
|
198
|
+
// record it so later refreshes skip the doomed send+pin (D5).
|
|
199
|
+
if (isPinRightsError(err)) args.rightsCache?.block(String(args.ownerChatId));
|
|
166
200
|
// sendMessage succeeded but pin failed — don't claim the message, and
|
|
167
201
|
// drop the pending record so we don't leave a phantom claim for a pin
|
|
168
202
|
// that never landed.
|
|
169
203
|
safePersist(args.persist?.clear);
|
|
170
204
|
return args.prevState;
|
|
171
205
|
}
|
|
206
|
+
// Pin confirmed — rights are present, so clear any stale block for this chat
|
|
207
|
+
// (mirrors the status-pin path clearing on a successful pin).
|
|
208
|
+
args.rightsCache?.clear(String(args.ownerChatId));
|
|
172
209
|
// Pin confirmed — rewrite the record without the pending flag.
|
|
173
210
|
safePersist(() => args.persist?.confirm?.(String(args.ownerChatId), sent.message_id));
|
|
174
211
|
return { messageId: sent.message_id, slot: action.slot };
|
|
@@ -210,12 +210,9 @@ export function isUnpinTerminalError(err: unknown): boolean {
|
|
|
210
210
|
* Deliberately IN-MEMORY / per-boot only: pin rights may be granted later, and
|
|
211
211
|
* Telegram surfaces the new permission to the bot only on a fresh chat-member
|
|
212
212
|
* fetch — a gateway restart. Clearing the cache on restart is therefore the
|
|
213
|
-
* correct re-enable trigger.
|
|
214
|
-
* also clears its entry (rights were granted mid-session).
|
|
213
|
+
* correct re-enable trigger.
|
|
215
214
|
*
|
|
216
|
-
* Scope: the AUTO status-pin path only
|
|
217
|
-
* never consults this cache — it always attempts and surfaces the error to the
|
|
218
|
-
* agent as a normal tool error.
|
|
215
|
+
* Scope: the AUTO status-pin path only (`pin_status_while_working`).
|
|
219
216
|
*/
|
|
220
217
|
export class PinRightsCache {
|
|
221
218
|
private readonly blocked = new Set<string>()
|
|
@@ -190,6 +190,30 @@ describe('runFleetAutoFallback', () => {
|
|
|
190
190
|
expect(failover).toHaveBeenCalledTimes(1);
|
|
191
191
|
});
|
|
192
192
|
|
|
193
|
+
it('strict-pinned caller: null rolledTo yields the strict-pinned outcome, NOT all-blocked', async () => {
|
|
194
|
+
// agents.<name>.auth.strict — the broker marked the account but
|
|
195
|
+
// deliberately did not roll the caller. The all-blocked card here would
|
|
196
|
+
// claim fleet-wide exhaustion while its own snapshots show healthy
|
|
197
|
+
// accounts.
|
|
198
|
+
const failover = vi.fn(async () => ({
|
|
199
|
+
rolledTo: null, rolled: [], callerPinnedStrict: true,
|
|
200
|
+
}));
|
|
201
|
+
const out = await runFleetAutoFallback({
|
|
202
|
+
state: state('work@x', ['work@x']),
|
|
203
|
+
quotas: [qOk({ fiveHourUtilizationPct: 12, sevenDayUtilizationPct: 30 })],
|
|
204
|
+
failover,
|
|
205
|
+
triggerAgent: 'workbot',
|
|
206
|
+
now: NOW,
|
|
207
|
+
tz: 'UTC',
|
|
208
|
+
rateLimitTrigger: true,
|
|
209
|
+
});
|
|
210
|
+
expect(out.kind).toBe('strict-pinned');
|
|
211
|
+
expect(failover).toHaveBeenCalledTimes(1);
|
|
212
|
+
expect(out.announcement).toContain('strictly pinned');
|
|
213
|
+
expect(out.announcement).toContain('fleet is unaffected');
|
|
214
|
+
expect(out.announcement).not.toContain('blocked');
|
|
215
|
+
});
|
|
216
|
+
|
|
193
217
|
it('idempotency: skips the swap WITHOUT calling failover when active probes healthy', async () => {
|
|
194
218
|
const failover = vi.fn();
|
|
195
219
|
const out = await runFleetAutoFallback({
|
|
@@ -121,9 +121,9 @@ describe('selectBackstopDelivery — deterministic backstop coalescer (#3513 fol
|
|
|
121
121
|
// ── isEphemeralTool — MF4 exact set ─────────────────────────────────────────
|
|
122
122
|
|
|
123
123
|
describe('isEphemeralTool — the exact ephemeral surface set (MF4)', () => {
|
|
124
|
-
it('recognises exactly {react, send_typing,
|
|
124
|
+
it('recognises exactly {react, send_typing, delete_message, edit_message}, MCP-prefixed or bare', () => {
|
|
125
125
|
expect([...EPHEMERAL_TOOLS].sort()).toEqual(
|
|
126
|
-
['delete_message', 'edit_message', '
|
|
126
|
+
['delete_message', 'edit_message', 'react', 'send_typing'].sort(),
|
|
127
127
|
)
|
|
128
128
|
for (const t of EPHEMERAL_TOOLS) {
|
|
129
129
|
expect(isEphemeralTool(t)).toBe(true)
|
|
@@ -132,6 +132,12 @@ describe('isEphemeralTool — the exact ephemeral surface set (MF4)', () => {
|
|
|
132
132
|
}
|
|
133
133
|
})
|
|
134
134
|
|
|
135
|
+
it('pin_message is NOT ephemeral — the pin_message MCP tool was retired (#4452)', () => {
|
|
136
|
+
expect(EPHEMERAL_TOOLS.has('pin_message')).toBe(false)
|
|
137
|
+
expect(isEphemeralTool('pin_message')).toBe(false)
|
|
138
|
+
expect(isEphemeralTool('mcp__switchroom-telegram__pin_message')).toBe(false)
|
|
139
|
+
})
|
|
140
|
+
|
|
135
141
|
it('progress_update is NOT ephemeral (MF4 — dropped from the set)', () => {
|
|
136
142
|
expect(isEphemeralTool('progress_update')).toBe(false)
|
|
137
143
|
expect(isEphemeralTool('mcp__switchroom-telegram__progress_update')).toBe(false)
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* M0 metric-corruption regression — the framework_fallback `turn_ended` emitter
|
|
3
|
+
* must never write an absolute Unix-epoch value as `duration_ms`.
|
|
4
|
+
*
|
|
5
|
+
* ROOT CAUSE (fixed by this change): the 300 s framework-fallback unwedge in
|
|
6
|
+
* `liveness-wiring.ts` read the turn's start from `activeTurnStartedAt.get(key)`
|
|
7
|
+
* and, guarded only by a `!= null` presence check, computed
|
|
8
|
+
* `Date.now() - turnStartedAt` inline. When the map held `0` (a bogus / not-yet
|
|
9
|
+
* stamped start — reproduced here exactly as the parked-turn fixture does with
|
|
10
|
+
* `activeTurnStartedAt.set(KEY, 0)`), it emitted `duration_ms = Date.now() - 0`,
|
|
11
|
+
* i.e. the current epoch-ms — a ~56,000-year "duration". The analysed dataset
|
|
12
|
+
* carried 110 such rows (`duration_ms === ts`, `ended_via: "framework_fallback"`),
|
|
13
|
+
* making every latency aggregate unusable.
|
|
14
|
+
*
|
|
15
|
+
* The two `stream-render.ts` turn_ended paths already guarded with
|
|
16
|
+
* `startedAt > 0 ? … : 0`; this path had drifted. The fix routes all emitters
|
|
17
|
+
* through the shared `computeTurnDurationMs`, which returns 0 for a non-positive
|
|
18
|
+
* start.
|
|
19
|
+
*
|
|
20
|
+
* This test drives the REAL `onFrameworkFallback` (via `buildSilencePokeOptions`,
|
|
21
|
+
* same standard as the other liveness-wiring tests) with a 0 start and asserts on
|
|
22
|
+
* the ACTUAL emitted `turn_ended` row (captured through the real runtime-metrics
|
|
23
|
+
* JSONL sink, pinned to a temp file). It asserts the OUTCOME — the emitted
|
|
24
|
+
* `duration_ms` — not a code path.
|
|
25
|
+
*
|
|
26
|
+
* Pre-fix: `duration_ms` ≈ `Date.now()` (~1.78e12). Post-fix: `duration_ms === 0`.
|
|
27
|
+
*/
|
|
28
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
29
|
+
import { mkdtempSync, readFileSync, existsSync } from 'node:fs'
|
|
30
|
+
import { tmpdir } from 'node:os'
|
|
31
|
+
import { join } from 'node:path'
|
|
32
|
+
import { buildSilencePokeOptions } from '../gateway/liveness-wiring.js'
|
|
33
|
+
import {
|
|
34
|
+
__setRuntimeMetricsPathForTests,
|
|
35
|
+
} from '../runtime-metrics.js'
|
|
36
|
+
import { makeLivenessFixture, makeTurn, statusKeyForTests } from './helpers/liveness-wiring-fixture.js'
|
|
37
|
+
|
|
38
|
+
const CHAT = '-100999'
|
|
39
|
+
|
|
40
|
+
function readTurnEndedRows(path: string): Array<Record<string, unknown>> {
|
|
41
|
+
if (!existsSync(path)) return []
|
|
42
|
+
return readFileSync(path, 'utf-8')
|
|
43
|
+
.split('\n')
|
|
44
|
+
.filter((l) => l.trim() !== '')
|
|
45
|
+
.map((l) => JSON.parse(l) as Record<string, unknown>)
|
|
46
|
+
.filter((r) => r.kind === 'turn_ended')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('framework_fallback turn_ended never emits an epoch value as duration_ms', () => {
|
|
50
|
+
let metricsPath: string
|
|
51
|
+
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
const dir = mkdtempSync(join(tmpdir(), 'fallback-duration-'))
|
|
54
|
+
metricsPath = join(dir, 'runtime-metrics.jsonl')
|
|
55
|
+
__setRuntimeMetricsPathForTests(metricsPath)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
__setRuntimeMetricsPathForTests(null)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('emits duration_ms === 0 for a zero/bogus start instead of Date.now()', async () => {
|
|
63
|
+
const KEY = statusKeyForTests(CHAT, null)
|
|
64
|
+
const fx = makeLivenessFixture()
|
|
65
|
+
|
|
66
|
+
// The wedged turn's start is 0 — the exact corruption trigger. Before the
|
|
67
|
+
// fix this made the fallback emit `Date.now() - 0`.
|
|
68
|
+
fx.activeTurnStartedAt.set(KEY, 0)
|
|
69
|
+
fx.setCurrentTurn(
|
|
70
|
+
makeTurn({ sessionChatId: CHAT, sessionThreadId: undefined, turnId: `${KEY}#42` }),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
const opts = buildSilencePokeOptions(fx.deps)
|
|
74
|
+
await opts.onFrameworkFallback({
|
|
75
|
+
key: KEY,
|
|
76
|
+
chatId: CHAT,
|
|
77
|
+
threadId: null,
|
|
78
|
+
fallbackKind: 'working',
|
|
79
|
+
silenceMs: 302_000,
|
|
80
|
+
inFlightTools: [],
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
const rows = readTurnEndedRows(metricsPath)
|
|
84
|
+
const fallbackRow = rows.find((r) => r.ended_via === 'framework_fallback')
|
|
85
|
+
expect(fallbackRow, 'framework_fallback turn_ended row was emitted').toBeDefined()
|
|
86
|
+
|
|
87
|
+
const duration = fallbackRow!.duration_ms as number
|
|
88
|
+
// The falsifying assertion: pre-fix this is ~Date.now() (an epoch-ms value,
|
|
89
|
+
// ~1.78e12), and in particular equals the row's own `ts`. Post-fix it is 0.
|
|
90
|
+
expect(duration).toBe(0)
|
|
91
|
+
// Belt-and-braces: a duration can never be an absolute epoch stamp, and can
|
|
92
|
+
// never equal the emission timestamp.
|
|
93
|
+
expect(duration).not.toBe(fallbackRow!.ts as number)
|
|
94
|
+
expect(duration).toBeLessThan(365 * 24 * 60 * 60 * 1000) // < 1 year, sane bound
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('emits a real elapsed duration for a valid positive start', async () => {
|
|
98
|
+
const KEY = statusKeyForTests(CHAT, null)
|
|
99
|
+
const fx = makeLivenessFixture()
|
|
100
|
+
|
|
101
|
+
const startedAt = Date.now() - 302_000 // ~5 min ago
|
|
102
|
+
fx.activeTurnStartedAt.set(KEY, startedAt)
|
|
103
|
+
fx.setCurrentTurn(
|
|
104
|
+
makeTurn({ sessionChatId: CHAT, sessionThreadId: undefined, turnId: `${KEY}#43` }),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
const opts = buildSilencePokeOptions(fx.deps)
|
|
108
|
+
await opts.onFrameworkFallback({
|
|
109
|
+
key: KEY,
|
|
110
|
+
chatId: CHAT,
|
|
111
|
+
threadId: null,
|
|
112
|
+
fallbackKind: 'working',
|
|
113
|
+
silenceMs: 302_000,
|
|
114
|
+
inFlightTools: [],
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
const fallbackRow = readTurnEndedRows(metricsPath).find(
|
|
118
|
+
(r) => r.ended_via === 'framework_fallback',
|
|
119
|
+
)
|
|
120
|
+
const duration = fallbackRow!.duration_ms as number
|
|
121
|
+
// Around 302 s, with generous slack for test-runtime jitter.
|
|
122
|
+
expect(duration).toBeGreaterThanOrEqual(302_000)
|
|
123
|
+
expect(duration).toBeLessThan(360_000)
|
|
124
|
+
})
|
|
125
|
+
})
|