switchroom 0.20.11 → 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 +4 -2
- package/dist/auth-broker/index.js +27 -24
- package/dist/cli/notion-write-pretool.mjs +4 -2
- package/dist/cli/switchroom.js +1608 -1068
- package/dist/host-control/main.js +28 -25
- package/dist/vault/approvals/kernel-server.js +27 -24
- package/dist/vault/broker/server.js +27 -24
- package/examples/personal-google-workspace-mcp/compose.yaml +1 -1
- package/package.json +1 -1
- 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/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 +54 -63
- package/telegram-plugin/dist/server.js +0 -12
- package/telegram-plugin/gateway/gateway.ts +24 -59
- package/telegram-plugin/gateway/liveness-wiring.ts +6 -1
- package/telegram-plugin/gateway/stale-pin-sweep.ts +4 -3
- package/telegram-plugin/gateway/status-pin-store.ts +10 -9
- package/telegram-plugin/gateway/stream-render.ts +4 -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/status-pin.ts +2 -5
- 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/pin-message-tool-retired.test.ts +64 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +38 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +40 -1
- package/telegram-plugin/worker-activity-feed.ts +1 -1
- package/vendor/hindsight-memory/scripts/recall.py +140 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_latency_instrumentation.py +277 -0
|
@@ -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` /
|
|
@@ -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>()
|
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pin_message tool retirement (#4452).
|
|
3
|
+
*
|
|
4
|
+
* The agent-facing `pin_message` MCP tool was removed: agents may no longer
|
|
5
|
+
* hand-pin arbitrary messages. The framework's OWN auto-pin
|
|
6
|
+
* (`pin_status_while_working` — the status/activity card and the 🛠 Worker
|
|
7
|
+
* card) is unaffected; that is the one sanctioned pin and is exercised by
|
|
8
|
+
* status-pin-lifecycle.test.ts.
|
|
9
|
+
*
|
|
10
|
+
* These are OUTCOME assertions on the actual offered surface — they fail if the
|
|
11
|
+
* tool is ever re-registered in the bridge schema, re-wired into the gateway
|
|
12
|
+
* dispatch, or re-granted in the agent scaffold. bridge.ts and gateway.ts each
|
|
13
|
+
* run boot side-effects at import (a top-level `await main()` / the gateway boot
|
|
14
|
+
* IIFE), so they cannot be imported here; we assert against their source, which
|
|
15
|
+
* IS the registration surface.
|
|
16
|
+
*/
|
|
17
|
+
import { describe, it, expect } from 'vitest'
|
|
18
|
+
import { readFileSync } from 'node:fs'
|
|
19
|
+
import { fileURLToPath } from 'node:url'
|
|
20
|
+
|
|
21
|
+
const here = fileURLToPath(new URL('.', import.meta.url))
|
|
22
|
+
const read = (rel: string) => readFileSync(new URL(rel, import.meta.url), 'utf8')
|
|
23
|
+
|
|
24
|
+
describe('pin_message MCP tool is retired (#4452)', () => {
|
|
25
|
+
it('is NOT registered as a bridge tool schema', () => {
|
|
26
|
+
const bridge = read('../bridge/bridge.ts')
|
|
27
|
+
// The tool-schema registration form is `name: 'pin_message'`. Its absence
|
|
28
|
+
// means ListTools no longer offers it to the agent.
|
|
29
|
+
expect(bridge).not.toMatch(/name:\s*['"]pin_message['"]/)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('is NOT in the gateway IPC tool allowlist and has no dispatch case', () => {
|
|
33
|
+
const gateway = read('../gateway/gateway.ts')
|
|
34
|
+
// ALLOWED_TOOLS gate: a bridge could not invoke it even by name.
|
|
35
|
+
expect(gateway).not.toMatch(/['"]pin_message['"]/)
|
|
36
|
+
// No dispatch arm and no handler.
|
|
37
|
+
expect(gateway).not.toMatch(/case\s+['"]pin_message['"]/)
|
|
38
|
+
expect(gateway).not.toContain('executePinMessage')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('is NOT granted in the agent scaffold permission surface', () => {
|
|
42
|
+
const scaffold = read('../../src/agents/scaffold.ts')
|
|
43
|
+
expect(scaffold).not.toContain('mcp__switchroom-telegram__pin_message')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
// Sanity: prove the assertions above are meaningful by confirming a tool that
|
|
47
|
+
// SURVIVED is still present in each surface (a test that can't fail is not a
|
|
48
|
+
// test — this pins the read paths to real content).
|
|
49
|
+
it('a surviving tool (delete_message) is still registered — guards false-green', () => {
|
|
50
|
+
expect(read('../bridge/bridge.ts')).toMatch(/name:\s*['"]delete_message['"]/)
|
|
51
|
+
expect(read('../gateway/gateway.ts')).toContain('executeDeleteMessage')
|
|
52
|
+
expect(read('../../src/agents/scaffold.ts')).toContain(
|
|
53
|
+
'mcp__switchroom-telegram__delete_message',
|
|
54
|
+
)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('the framework auto status-pin machinery is untouched', () => {
|
|
58
|
+
// Change 1 must not disturb pin_status_while_working (the ONE sanctioned pin).
|
|
59
|
+
const gateway = read('../gateway/gateway.ts')
|
|
60
|
+
expect(gateway).toContain('PIN_STATUS_WHILE_WORKING')
|
|
61
|
+
expect(gateway).toContain('runStatusPinBootCleanup')
|
|
62
|
+
void here
|
|
63
|
+
})
|
|
64
|
+
})
|
|
@@ -259,6 +259,44 @@ describe("status-pin boot recovery (gateway wiring)", () => {
|
|
|
259
259
|
expect(idOnly(loadStatusPins(PATH, fs))).toEqual([]);
|
|
260
260
|
});
|
|
261
261
|
|
|
262
|
+
it("(SAFETY) boot cleanup NEVER unpins a message the framework did not record (a user's manual pin survives)", async () => {
|
|
263
|
+
// The hard safety constraint of the boot reap: it reaps ONLY the framework's
|
|
264
|
+
// own pins (the status/activity card, the 🛠 Worker card — the rows this
|
|
265
|
+
// process itself wrote to status-pins.json). A message the USER deliberately
|
|
266
|
+
// pinned is never recorded there, so the reap must leave it completely
|
|
267
|
+
// untouched. A boot that nuked a user's real pin is a regression worse than a
|
|
268
|
+
// stranded orphan. This test makes that structural guarantee an assertion:
|
|
269
|
+
// the store never sees the user's id, so the reap can never target it.
|
|
270
|
+
const { fs } = memFs();
|
|
271
|
+
const tg = fakeTelegram();
|
|
272
|
+
|
|
273
|
+
// Session 1: the framework pins its OWN work-scoped status card (recorded).
|
|
274
|
+
const gw1 = makeGateway(fs, tg);
|
|
275
|
+
await gw1.reconcileStatusPin("fg:c:3", "-100123", { pinned: true, messageId: 715 });
|
|
276
|
+
|
|
277
|
+
// A user manually pins their own message in the SAME chat. It lives in the
|
|
278
|
+
// Telegram pin stack but is NOT in status-pins.json — the framework has no
|
|
279
|
+
// record of it and no business touching it.
|
|
280
|
+
tg.pinned.add("-100123:999");
|
|
281
|
+
|
|
282
|
+
// The framework's pin is the ONLY recorded row; the user's is not present.
|
|
283
|
+
expect(idOnly(loadStatusPins(PATH, fs))).toEqual([
|
|
284
|
+
{ pinKey: "fg:c:3", chatId: "-100123", messageId: 715 },
|
|
285
|
+
]);
|
|
286
|
+
expect(loadStatusPins(PATH, fs).some((r) => r.messageId === 999)).toBe(false);
|
|
287
|
+
|
|
288
|
+
// ── CRASH, then a fresh boot runs the reap. ──
|
|
289
|
+
const gw2 = makeGateway(fs, tg);
|
|
290
|
+
const res = await gw2.bootCleanup();
|
|
291
|
+
|
|
292
|
+
// Exactly the framework's OWN pin was cleared …
|
|
293
|
+
expect(res).toEqual({ cleared: 1, retained: 0, kept: 0, total: 1 });
|
|
294
|
+
expect(tg.pinned.has("-100123:715")).toBe(false);
|
|
295
|
+
// … and the user's manual pin is untouched — never unpinned.
|
|
296
|
+
expect(tg.pinned.has("-100123:999")).toBe(true);
|
|
297
|
+
expect(idOnly(loadStatusPins(PATH, fs))).toEqual([]);
|
|
298
|
+
});
|
|
299
|
+
|
|
262
300
|
it("clean shutdown (sweep DID run) leaves nothing for boot cleanup to do", async () => {
|
|
263
301
|
// Contrast: when the SIGTERM sweep runs (unpin each key), the store is
|
|
264
302
|
// emptied and the pin removed — boot cleanup is a no-op. This guards the
|
|
@@ -304,6 +304,45 @@ describe('createWorkerActivityFeed', () => {
|
|
|
304
304
|
expect(feed.has('w1')).toBe(true)
|
|
305
305
|
})
|
|
306
306
|
|
|
307
|
+
it('defaults firstPaintMin to 4000ms (no explicit firstPaintMinMs)', async () => {
|
|
308
|
+
// Guards the module default. A prose-silent worker's first card must paint
|
|
309
|
+
// once it has run ≥4000ms and NOT before — asserting the actual default so
|
|
310
|
+
// this fails on the old 8000. #fix/worker-feed-first-paint.
|
|
311
|
+
const bot = makeFakeBot()
|
|
312
|
+
let clock = 0
|
|
313
|
+
const feed = createWorkerActivityFeed({ bot, now: () => clock })
|
|
314
|
+
// Just below the 4000 default: still held.
|
|
315
|
+
clock = 3999
|
|
316
|
+
await feed.update('w1', 'chat', view({ elapsedMs: 3999 }))
|
|
317
|
+
expect(bot.sent).toHaveLength(0)
|
|
318
|
+
expect(feed.has('w1')).toBe(false)
|
|
319
|
+
// At/above 4000: paints. (On the old 8000 default this would still be held
|
|
320
|
+
// and bot.sent would be empty — so this assertion pins the new value.)
|
|
321
|
+
clock = 4000
|
|
322
|
+
await feed.update('w1', 'chat', view({ elapsedMs: 4000 }))
|
|
323
|
+
expect(bot.sent).toHaveLength(1)
|
|
324
|
+
expect(feed.has('w1')).toBe(true)
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
it('honors an explicit firstPaintMinMs override (env plumbing shape)', async () => {
|
|
328
|
+
// The gateway threads SWITCHROOM_TG_WORKER_FEED_FIRST_PAINT_MS through as
|
|
329
|
+
// firstPaintMinMs; an operator override (e.g. reverting to 8000) must hold
|
|
330
|
+
// first paint until that value, overriding the 4000 default.
|
|
331
|
+
const bot = makeFakeBot()
|
|
332
|
+
let clock = 0
|
|
333
|
+
const feed = createWorkerActivityFeed({ bot, now: () => clock, firstPaintMinMs: 8000 })
|
|
334
|
+
// Past the 4000 default but below the override: still held.
|
|
335
|
+
clock = 5000
|
|
336
|
+
await feed.update('w1', 'chat', view({ elapsedMs: 5000 }))
|
|
337
|
+
expect(bot.sent).toHaveLength(0)
|
|
338
|
+
expect(feed.has('w1')).toBe(false)
|
|
339
|
+
// At the override threshold: paints.
|
|
340
|
+
clock = 8000
|
|
341
|
+
await feed.update('w1', 'chat', view({ elapsedMs: 8000 }))
|
|
342
|
+
expect(bot.sent).toHaveLength(1)
|
|
343
|
+
expect(feed.has('w1')).toBe(true)
|
|
344
|
+
})
|
|
345
|
+
|
|
307
346
|
it('messageIdOf exposes the posted message id (for status-pin) and is null before paint / after finish', async () => {
|
|
308
347
|
const bot = makeFakeBot()
|
|
309
348
|
let clock = 0
|
|
@@ -706,7 +745,7 @@ describe('createWorkerActivityFeed — heartbeat', () => {
|
|
|
706
745
|
setInterval: () => 1,
|
|
707
746
|
clearInterval: () => {},
|
|
708
747
|
})
|
|
709
|
-
// First paint at elapsed 0 (firstPaintMin default
|
|
748
|
+
// First paint at elapsed 0 (firstPaintMin default 4000 — use 9000). The
|
|
710
749
|
// narrative line 'pulling data' lands here, so the current step starts now.
|
|
711
750
|
clock = 19_000
|
|
712
751
|
await feed.update('w1', 'chat', view({ elapsedMs: 9000, latestSummary: 'pulling data' }))
|
|
@@ -800,7 +800,7 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
|
|
|
800
800
|
const floodWaitRemainingMs = opts.floodWaitRemainingMs ?? (() => 0)
|
|
801
801
|
const minEditInterval = opts.minEditIntervalMs ?? 2500
|
|
802
802
|
const elapsedRefreshMs = Math.max(minEditInterval, Math.floor(opts.elapsedRefreshMs ?? 15000))
|
|
803
|
-
const firstPaintMin = opts.firstPaintMinMs ??
|
|
803
|
+
const firstPaintMin = opts.firstPaintMinMs ?? 4000
|
|
804
804
|
const heartbeatTickMs = opts.heartbeatTickMs ?? 6000
|
|
805
805
|
const maxRows = Math.max(1, Math.floor(opts.maxRows ?? 8))
|
|
806
806
|
const staleWorkerTtlMs = Math.max(1, Math.floor(opts.staleWorkerTtlMs ?? 50 * 60_000))
|
|
@@ -1037,6 +1037,116 @@ def _write_recall_log(entry: dict) -> None:
|
|
|
1037
1037
|
pass
|
|
1038
1038
|
|
|
1039
1039
|
|
|
1040
|
+
# Switchroom recall-latency instrumentation — full-hook wall-time.
|
|
1041
|
+
#
|
|
1042
|
+
# recall.py is the UserPromptSubmit hook that sits in front of EVERY reply
|
|
1043
|
+
# (pre-first-token), yet it was the one hook with no wall-time record: it is a
|
|
1044
|
+
# DIRECT Claude Code plugin hook (hooks/hooks.json), NOT wrapped by
|
|
1045
|
+
# bin/run-hook.sh, so it never emitted a `hook-timings-<Ddd>.log` row the way
|
|
1046
|
+
# every wrapped hook does, and `recall_log.jsonl` measured only the recall
|
|
1047
|
+
# critical path (`total_elapsed_ms`, from `recall_start_monotonic`) — never the
|
|
1048
|
+
# hook's own import + stdin + cache-check + gate overhead.
|
|
1049
|
+
#
|
|
1050
|
+
# Routing it through run-hook.sh was rejected as the mechanism: the vendored
|
|
1051
|
+
# hooks.json is re-copied verbatim into every agent's plugin dir on `switchroom
|
|
1052
|
+
# apply`, and run-hook.sh lives in the switchroom repo's bin/, not under
|
|
1053
|
+
# CLAUDE_PLUGIN_ROOT — coupling the vendor snapshot to switchroom's bin layout is
|
|
1054
|
+
# fragile, and the os._exit(0) fast-path (which skips atexit / thread-join to
|
|
1055
|
+
# return control the instant stdout is flushed) would have to be reconciled with
|
|
1056
|
+
# the wrapper. Instead the hook emits the SAME JSON line, into the SAME weekday-
|
|
1057
|
+
# ring file, honouring the SAME env knobs, from inside the process at exit — so
|
|
1058
|
+
# `grep duration_ms hook-timings-*.log` sees this hook next to every other one.
|
|
1059
|
+
HOOK_TIMING_SOURCE = "hook:hindsight-recall"
|
|
1060
|
+
HOOK_TIMING_CODE = "recall.py"
|
|
1061
|
+
|
|
1062
|
+
|
|
1063
|
+
def _hook_duration_ms() -> int:
|
|
1064
|
+
"""Milliseconds since this hook process began doing work.
|
|
1065
|
+
|
|
1066
|
+
Anchored at import start (`_IMPORT_START_MONOTONIC`, taken before any of this
|
|
1067
|
+
hook's own imports ran — recall.py line ~48), so the number spans the WHOLE
|
|
1068
|
+
hook: dependency import, the stdin read, the cache check, the gate
|
|
1069
|
+
short-circuits, and — when it got that far — the recall round-trips. That is
|
|
1070
|
+
deliberately WIDER than `total_elapsed_ms` (the recall critical path only,
|
|
1071
|
+
measured from `recall_start_monotonic`): `duration_ms - total_elapsed_ms` is
|
|
1072
|
+
therefore the pre-recall LOCAL overhead, and the per-bank
|
|
1073
|
+
`bank_timings[].elapsed_ms` remain the Hindsight server round-trips — so the
|
|
1074
|
+
log already separates server time from local overhead without a new field.
|
|
1075
|
+
"""
|
|
1076
|
+
return int((time.monotonic() - _IMPORT_START_MONOTONIC) * 1000)
|
|
1077
|
+
|
|
1078
|
+
|
|
1079
|
+
def _emit_hook_timing_log(duration_ms: int, status: int) -> None:
|
|
1080
|
+
"""Append one timing line to `hook-timings-<Ddd>.log`, matching run-hook.sh.
|
|
1081
|
+
|
|
1082
|
+
STDOUT-SAFE by construction: writes only to a log file, NEVER to the hook's
|
|
1083
|
+
stdout contract that Claude Code consumes. Failure-tolerant — any error is
|
|
1084
|
+
swallowed so instrumentation can never take the hook down. Honours the same
|
|
1085
|
+
env knobs as bin/run-hook.sh (`SWITCHROOM_HOOK_TIMING`,
|
|
1086
|
+
`SWITCHROOM_HOOK_TIMING_DIR`, `SWITCHROOM_HOOK_TIMING_MIN_MS`) and reproduces
|
|
1087
|
+
its 7-day self-truncating weekday ring and JSON line shape exactly, so a
|
|
1088
|
+
consumer cannot tell this row apart from a wrapped hook's.
|
|
1089
|
+
|
|
1090
|
+
NOTE on the 12s ceiling: if Claude Code kills this hook at the
|
|
1091
|
+
UserPromptSubmit timeout, the process is terminated before any exit path runs
|
|
1092
|
+
and NO timing line (nor recall_log row) is written — the MISSING line is the
|
|
1093
|
+
breach signal, consistent with the two-signal baseline documented at the
|
|
1094
|
+
recall_log write. This records every invocation that returns under budget,
|
|
1095
|
+
including a fully-degraded/timed-out recall that still exits cleanly.
|
|
1096
|
+
"""
|
|
1097
|
+
try:
|
|
1098
|
+
if os.environ.get("SWITCHROOM_HOOK_TIMING", "1") == "0":
|
|
1099
|
+
return
|
|
1100
|
+
timing_dir = os.environ.get("SWITCHROOM_HOOK_TIMING_DIR") or os.environ.get(
|
|
1101
|
+
"TELEGRAM_STATE_DIR", ""
|
|
1102
|
+
)
|
|
1103
|
+
if not timing_dir or not os.path.isdir(timing_dir):
|
|
1104
|
+
return
|
|
1105
|
+
try:
|
|
1106
|
+
duration_ms = int(duration_ms)
|
|
1107
|
+
except (TypeError, ValueError):
|
|
1108
|
+
return
|
|
1109
|
+
if duration_ms < 0:
|
|
1110
|
+
duration_ms = 0
|
|
1111
|
+
try:
|
|
1112
|
+
min_ms = int(os.environ.get("SWITCHROOM_HOOK_TIMING_MIN_MS", "0"))
|
|
1113
|
+
except (TypeError, ValueError):
|
|
1114
|
+
min_ms = 0
|
|
1115
|
+
if duration_ms < min_ms:
|
|
1116
|
+
return
|
|
1117
|
+
# Local wall clock, matching run-hook.sh's builtin `%(...)T` formatting so
|
|
1118
|
+
# both writers agree on which weekday-ring file today lands in.
|
|
1119
|
+
now = time.localtime()
|
|
1120
|
+
today = time.strftime("%Y-%m-%d", now)
|
|
1121
|
+
dow = time.strftime("%a", now)
|
|
1122
|
+
ts = time.strftime("%Y-%m-%dT%H:%M:%S%z", now)
|
|
1123
|
+
logfile = os.path.join(timing_dir, f"hook-timings-{dow}.log")
|
|
1124
|
+
# 7-day self-truncating ring: the weekday-named file is either today's or
|
|
1125
|
+
# exactly a week stale. If its first line does not carry today's date,
|
|
1126
|
+
# reset it before appending (same rule as run-hook.sh).
|
|
1127
|
+
try:
|
|
1128
|
+
if os.path.getsize(logfile) > 0:
|
|
1129
|
+
with open(logfile, encoding="utf-8") as f:
|
|
1130
|
+
first = f.readline()
|
|
1131
|
+
if f'"date":"{today}"' not in first:
|
|
1132
|
+
open(logfile, "w", encoding="utf-8").close()
|
|
1133
|
+
except OSError:
|
|
1134
|
+
pass
|
|
1135
|
+
# HOOK_TIMING_SOURCE / HOOK_TIMING_CODE are fixed constants with no JSON
|
|
1136
|
+
# metacharacters, so no escape pass is needed (unlike run-hook.sh, whose
|
|
1137
|
+
# source/code are caller-supplied).
|
|
1138
|
+
line = (
|
|
1139
|
+
'{"ts":"%s","date":"%s","source":"%s","code":"%s",'
|
|
1140
|
+
'"duration_ms":%d,"status":%d}\n'
|
|
1141
|
+
% (ts, today, HOOK_TIMING_SOURCE, HOOK_TIMING_CODE, duration_ms, status)
|
|
1142
|
+
)
|
|
1143
|
+
with open(logfile, "a", encoding="utf-8") as f:
|
|
1144
|
+
f.write(line)
|
|
1145
|
+
except Exception:
|
|
1146
|
+
# Instrumentation is never load-bearing — swallow everything.
|
|
1147
|
+
pass
|
|
1148
|
+
|
|
1149
|
+
|
|
1040
1150
|
def _read_transcript_lines(transcript_path: str, tail_bytes: int):
|
|
1041
1151
|
"""Yield the transcript's trailing lines, byte-bounded.
|
|
1042
1152
|
|
|
@@ -1824,6 +1934,12 @@ def main():
|
|
|
1824
1934
|
# timed out"; `deadline_hit is None` means "no banks ran"
|
|
1825
1935
|
# (review finding 3).
|
|
1826
1936
|
"total_elapsed_ms": None,
|
|
1937
|
+
# Switchroom recall-latency instrumentation — full-hook wall time
|
|
1938
|
+
# (import + stdin + cache check), measured to this log write. A
|
|
1939
|
+
# cache hit issues no bank HTTP, so `total_elapsed_ms` is None and
|
|
1940
|
+
# this is pure local overhead — the cheap path this cache exists
|
|
1941
|
+
# to create, now visible per-row.
|
|
1942
|
+
"duration_ms": _hook_duration_ms(),
|
|
1827
1943
|
"directives_elapsed_ms": None,
|
|
1828
1944
|
"bank_timings": [],
|
|
1829
1945
|
"deadline_hit": None,
|
|
@@ -2542,6 +2658,16 @@ def main():
|
|
|
2542
2658
|
# parallelism change measures against; the 17-26% figure it replaces is
|
|
2543
2659
|
# the stale 2026-05-24 pre-fix audit.
|
|
2544
2660
|
"total_elapsed_ms": int((time.monotonic() - recall_start_monotonic) * 1000),
|
|
2661
|
+
# Switchroom recall-latency instrumentation — FULL-HOOK wall time to this
|
|
2662
|
+
# log write: dependency import + stdin read + cache check + the gate
|
|
2663
|
+
# short-circuits + the whole recall critical path. `total_elapsed_ms`
|
|
2664
|
+
# above is the recall critical path ONLY, so `duration_ms -
|
|
2665
|
+
# total_elapsed_ms` is the pre-recall LOCAL overhead this row could not
|
|
2666
|
+
# see before, while `bank_timings[].elapsed_ms` stay the server round-
|
|
2667
|
+
# trips — the log now separates server time from local overhead. Written
|
|
2668
|
+
# here (with the rest of the row, before the empty-block return) so even a
|
|
2669
|
+
# fully-timed-out / degraded recall that reaches this line gets a duration.
|
|
2670
|
+
"duration_ms": _hook_duration_ms(),
|
|
2545
2671
|
"directives_elapsed_ms": directives_elapsed_ms,
|
|
2546
2672
|
"bank_timings": bank_timings,
|
|
2547
2673
|
# Switchroom hindsight-leverage A3 — FINALIZED `deadline_hit` semantics
|
|
@@ -2795,6 +2921,12 @@ if __name__ == "__main__":
|
|
|
2795
2921
|
sys.stdout.flush()
|
|
2796
2922
|
except Exception:
|
|
2797
2923
|
pass
|
|
2924
|
+
# Switchroom recall-latency instrumentation — emit the full-hook timing
|
|
2925
|
+
# line AFTER stdout is flushed (Claude Code already has the bytes) and
|
|
2926
|
+
# BEFORE os._exit, which skips atexit and would otherwise drop it. Adds
|
|
2927
|
+
# ~0.5ms (one file append) before the process exits — the same budget
|
|
2928
|
+
# run-hook.sh spends per wrapped hook. Stdout is untouched.
|
|
2929
|
+
_emit_hook_timing_log(_hook_duration_ms(), 0)
|
|
2798
2930
|
os._exit(0)
|
|
2799
2931
|
except Exception as e:
|
|
2800
2932
|
# Switchroom #1070 (redo per #1085 review).
|
|
@@ -2844,6 +2976,10 @@ if __name__ == "__main__":
|
|
|
2844
2976
|
import traceback
|
|
2845
2977
|
|
|
2846
2978
|
traceback.print_exc(file=sys.stderr)
|
|
2979
|
+
# Instrumentation: record the failed invocation's wall time too
|
|
2980
|
+
# (status 2, the debug-mode block behaviour) so a crash-looping
|
|
2981
|
+
# hook is visible in the timing log, not just a silent gap.
|
|
2982
|
+
_emit_hook_timing_log(_hook_duration_ms(), 2)
|
|
2847
2983
|
# Debug-mode exit 2 is intentional and unchanged —
|
|
2848
2984
|
# operators with HINDSIGHT_DEBUG=1 are chasing a broken
|
|
2849
2985
|
# recall and want the hook to surface its failure.
|
|
@@ -2853,4 +2989,8 @@ if __name__ == "__main__":
|
|
|
2853
2989
|
# 0 with no stdout (agent's prompt assembly treats absent
|
|
2854
2990
|
# additionalContext as "no recall this turn").
|
|
2855
2991
|
_record_issue_safely(_detail, _class)
|
|
2992
|
+
# Instrumentation: the non-debug exit code is 0 (the safe-empty stdout
|
|
2993
|
+
# posture), so log status 0 — the accompanying issue-sink record is where
|
|
2994
|
+
# the failure detail lives; this row just makes the latency observable.
|
|
2995
|
+
_emit_hook_timing_log(_hook_duration_ms(), 0)
|
|
2856
2996
|
sys.exit(0)
|