switchroom 0.19.30 β†’ 0.19.32

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.
Files changed (51) hide show
  1. package/dist/cli/switchroom.js +1121 -584
  2. package/dist/host-control/main.js +151 -73
  3. package/package.json +1 -1
  4. package/profiles/_base/cron-session.sh.hbs +5 -1
  5. package/profiles/_base/start.sh.hbs +15 -1
  6. package/telegram-plugin/dist/bridge/bridge.js +3 -0
  7. package/telegram-plugin/dist/gateway/gateway.js +1660 -677
  8. package/telegram-plugin/dist/server.js +3 -0
  9. package/telegram-plugin/edit-flood-fuse.ts +70 -20
  10. package/telegram-plugin/gateway/boot-beacon.ts +364 -0
  11. package/telegram-plugin/gateway/boot-sweep-gate.ts +20 -15
  12. package/telegram-plugin/gateway/gateway.ts +87 -88
  13. package/telegram-plugin/gateway/inbound-spool.ts +39 -0
  14. package/telegram-plugin/gateway/narrative-lane.ts +12 -0
  15. package/telegram-plugin/gateway/obligation-store.ts +28 -0
  16. package/telegram-plugin/gateway/stale-pin-sweep-store.ts +221 -0
  17. package/telegram-plugin/gateway/stale-pin-sweep-wiring.ts +211 -0
  18. package/telegram-plugin/gateway/stale-pin-sweep.test.ts +804 -0
  19. package/telegram-plugin/gateway/stale-pin-sweep.ts +1146 -0
  20. package/telegram-plugin/gateway/status-pin-retarget.ts +15 -2
  21. package/telegram-plugin/gateway/status-pin-store.ts +33 -11
  22. package/telegram-plugin/gateway/stream-render.ts +578 -321
  23. package/telegram-plugin/registry/turns-schema.ts +21 -1
  24. package/telegram-plugin/retry-api-call.ts +46 -21
  25. package/telegram-plugin/session-tail.ts +13 -0
  26. package/telegram-plugin/shared/bot-runtime.ts +61 -17
  27. package/telegram-plugin/shared/gw-trace-gate.ts +18 -2
  28. package/telegram-plugin/tests/activity-card-wiring.test.ts +7 -7
  29. package/telegram-plugin/tests/activity-drain-fuse-drop-not-failure.test.ts +324 -0
  30. package/telegram-plugin/tests/agent-card-result-footer.test.ts +193 -0
  31. package/telegram-plugin/tests/boot-beacon.test.ts +462 -0
  32. package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +6 -6
  33. package/telegram-plugin/tests/boot-sweep-gate.test.ts +42 -31
  34. package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +2 -0
  35. package/telegram-plugin/tests/inbound-spool-progress.test.ts +2 -0
  36. package/telegram-plugin/tests/inbound-spool.test.ts +134 -5
  37. package/telegram-plugin/tests/narrative-lane-golden.test.ts +28 -0
  38. package/telegram-plugin/tests/obligation-determinism.test.ts +2 -0
  39. package/telegram-plugin/tests/obligation-store.test.ts +67 -1
  40. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
  41. package/telegram-plugin/tests/status-pin-store.test.ts +26 -5
  42. package/telegram-plugin/tests/stream-render-golden.test.ts +20 -5
  43. package/telegram-plugin/tests/tg-post-logger-error-shape.test.ts +161 -0
  44. package/telegram-plugin/tests/turn-mint-defers-until-dequeue.test.ts +196 -0
  45. package/telegram-plugin/tests/turn-mint-harness.ts +155 -0
  46. package/telegram-plugin/tests/turn-supersede-finalizes-prior-card.test.ts +124 -0
  47. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +30 -0
  48. package/telegram-plugin/tool-activity-summary.ts +104 -38
  49. package/telegram-plugin/worker-activity-feed.ts +33 -16
  50. package/telegram-plugin/gateway/dm-pin-sweep.test.ts +0 -251
  51. package/telegram-plugin/gateway/dm-pin-sweep.ts +0 -178
@@ -0,0 +1,193 @@
1
+ /**
2
+ * The πŸ€– agent card and the πŸ›  worker card must end the SAME way: a `─────`
3
+ * rule then `βœ… _<final summary sentence>_`.
4
+ *
5
+ * Before this suite the two surfaces had already been unified for the card
6
+ * BODY (#3844 β€” one `renderStatusCard`/`card-layout` core), but the terminal
7
+ * RESULT footer was still forked: the derivation (clean the raw summary, pick
8
+ * βœ…/⚠️ from the state, omit on empty, never emit one for `incomplete`) lived
9
+ * inline in `renderWorkerActivity`, so the agent card had no result block at
10
+ * all and ended on `βœ“ N steps`. `deriveCardResult` is now the single shared
11
+ * derivation and both adapters call it.
12
+ *
13
+ * These assertions fail on the forked behaviour: (1) the agent card had no
14
+ * `βœ…` footer to end with, and (2) there was no shared symbol whose output
15
+ * both cards' footers could be compared against byte for byte.
16
+ */
17
+ import { describe, it, expect } from 'vitest'
18
+ import {
19
+ deriveCardResult,
20
+ renderActivityFeed,
21
+ renderActivityFeedWithNested,
22
+ type SessionActivityHeader,
23
+ } from '../tool-activity-summary.js'
24
+ import { renderWorkerActivity, type WorkerActivityView } from '../worker-activity-feed.js'
25
+
26
+ /** A realistic multi-line, markdown-authored final summary. */
27
+ const RAW_SUMMARY = '## Result\nStanding by for the **background merge waiter** to complete.'
28
+ /** What the shared derivation cleans it down to. */
29
+ const CLEAN_SUMMARY = 'Result Standing by for the background merge waiter to complete.'
30
+ /** The exact two trailing card lines both surfaces must end with. */
31
+ const EXPECTED_FOOTER_TAIL = `─────`
32
+
33
+ function agentHeader(over: Partial<SessionActivityHeader> = {}): SessionActivityHeader {
34
+ return {
35
+ label: 'Agent',
36
+ elapsedMs: 64_000,
37
+ toolCount: 6,
38
+ state: 'done',
39
+ model: 'claude-opus-5',
40
+ totalTokens: 3000,
41
+ ...over,
42
+ }
43
+ }
44
+
45
+ function workerView(over: Partial<WorkerActivityView> = {}): WorkerActivityView {
46
+ return {
47
+ description: 'Merge 3922 and cut v0.19.30',
48
+ lastTool: null,
49
+ toolCount: 6,
50
+ totalTokens: 3000,
51
+ latestSummary: RAW_SUMMARY,
52
+ narrativeLines: ['Reading a.ts', 'Running tests'],
53
+ elapsedMs: 64_000,
54
+ state: 'done',
55
+ model: 'claude-opus-5',
56
+ ...over,
57
+ }
58
+ }
59
+
60
+ /** The card's trailing result block: the `─────` rule line and everything after. */
61
+ function footerOf(card: string): string {
62
+ const idx = card.lastIndexOf(EXPECTED_FOOTER_TAIL)
63
+ return idx === -1 ? '' : card.slice(idx)
64
+ }
65
+
66
+ describe('deriveCardResult β€” the ONE terminal-footer derivation', () => {
67
+ it('done β†’ βœ… + the cleaned single-paragraph summary', () => {
68
+ expect(deriveCardResult('done', RAW_SUMMARY)).toEqual({ emoji: 'βœ…', text: CLEAN_SUMMARY })
69
+ })
70
+
71
+ it('failed β†’ ⚠️ (never the green tick)', () => {
72
+ expect(deriveCardResult('failed', RAW_SUMMARY)).toEqual({ emoji: '⚠️', text: CLEAN_SUMMARY })
73
+ })
74
+
75
+ it('running β†’ no block (the card is not finished)', () => {
76
+ expect(deriveCardResult('running', RAW_SUMMARY)).toBeUndefined()
77
+ })
78
+
79
+ it('incomplete β†’ NEVER a block, even with summary text (truthful-no-result)', () => {
80
+ expect(deriveCardResult('incomplete', RAW_SUMMARY)).toBeUndefined()
81
+ })
82
+
83
+ it('empty / whitespace / markdown-only summary β†’ no block, never a fabricated line', () => {
84
+ expect(deriveCardResult('done', '')).toBeUndefined()
85
+ expect(deriveCardResult('done', undefined)).toBeUndefined()
86
+ expect(deriveCardResult('done', ' \n---\n ')).toBeUndefined()
87
+ })
88
+
89
+ it('redacts a secret in the summary β€” cards bypass the outbound redact chokepoint', () => {
90
+ // Status cards go out via sendRichMessage, which skips
91
+ // `normalizeOutboundBody β†’ redact` (outbound-send-path.ts). The agent
92
+ // card's summary is the turn's PRE-redaction reply text, so the scrub has
93
+ // to happen in this derivation or a token reaches Telegram verbatim.
94
+ const token = 'sk-ant-' + 'api03-' + 'A'.repeat(48)
95
+ const out = deriveCardResult('done', `Deployed with ${token} and all is green.`)!
96
+ expect(out.text).not.toContain(token)
97
+ expect(out.text).toContain('[REDACTED:')
98
+ expect(out.text).toContain('Deployed with')
99
+ expect(out.text).toContain('and all is green.')
100
+ })
101
+ })
102
+
103
+ describe('πŸ€– agent card ends with the βœ… + final-summary footer', () => {
104
+ it('final render with resultText appends the rule + βœ… summary as the LAST line', () => {
105
+ const card = renderActivityFeed(
106
+ ['Reading a.ts', 'Running tests'],
107
+ true,
108
+ '',
109
+ 6,
110
+ agentHeader({ resultText: RAW_SUMMARY }),
111
+ )!
112
+ expect(card).toContain('─────')
113
+ // The βœ… summary is the final line of the card, exactly like the worker card.
114
+ expect(card.endsWith(`βœ… _${CLEAN_SUMMARY}_`)).toBe(true)
115
+ })
116
+
117
+ it('the nested (foreground sub-agent) render gets the identical footer', () => {
118
+ const flat = renderActivityFeed(
119
+ ['Delegating: x'],
120
+ true,
121
+ '',
122
+ 6,
123
+ agentHeader({ resultText: RAW_SUMMARY }),
124
+ )!
125
+ const nested = renderActivityFeedWithNested(
126
+ ['Delegating: x'],
127
+ ['child step'],
128
+ true,
129
+ '',
130
+ 6,
131
+ agentHeader({ resultText: RAW_SUMMARY }),
132
+ )!
133
+ expect(footerOf(nested)).toBe(footerOf(flat))
134
+ expect(nested.endsWith(`βœ… _${CLEAN_SUMMARY}_`)).toBe(true)
135
+ })
136
+
137
+ it('no resultText β†’ no footer block (worker fallback), card still ends on βœ“ N steps', () => {
138
+ const card = renderActivityFeed(['Reading a.ts'], true, '', 6, agentHeader())!
139
+ expect(card).not.toContain('─────')
140
+ expect(card).not.toContain('βœ…')
141
+ expect(card.endsWith('_βœ“ 6 steps_')).toBe(true)
142
+ })
143
+
144
+ it('a LIVE (non-final) render never shows the footer, even if resultText is set', () => {
145
+ const card = renderActivityFeed(
146
+ ['Reading a.ts'],
147
+ false,
148
+ '',
149
+ undefined,
150
+ agentHeader({ state: 'running', resultText: RAW_SUMMARY }),
151
+ )!
152
+ expect(card).not.toContain('─────')
153
+ expect(card).not.toContain('βœ…')
154
+ })
155
+ })
156
+
157
+ describe('both cards render their footer through the SAME shared derivation', () => {
158
+ it('agent + worker footers are byte-identical for the same raw summary', () => {
159
+ const agent = renderActivityFeed(
160
+ ['Reading a.ts', 'Running tests'],
161
+ true,
162
+ '',
163
+ 6,
164
+ agentHeader({ resultText: RAW_SUMMARY }),
165
+ )!
166
+ const worker = renderWorkerActivity(workerView())
167
+ const footer = footerOf(agent)
168
+ expect(footer).not.toBe('')
169
+ expect(footer).toBe(footerOf(worker))
170
+ // …and that shared footer is exactly what `deriveCardResult` produced.
171
+ const derived = deriveCardResult('done', RAW_SUMMARY)!
172
+ expect(footer.endsWith(`${derived.emoji} _${derived.text}_`)).toBe(true)
173
+ })
174
+
175
+ it('the shared body (stats line + struck step list) is identical too', () => {
176
+ // Line 2 (the `done Β· N tools Β· N tok Β· elapsed Β· model` stats run) and the
177
+ // struck-through step lines come from the one card core β€” the only
178
+ // legitimate difference is the title line (icon + label + task).
179
+ const agent = renderActivityFeed(
180
+ ['Reading a.ts', 'Running tests'],
181
+ true,
182
+ '',
183
+ undefined,
184
+ agentHeader({ resultText: RAW_SUMMARY }),
185
+ )!
186
+ const worker = renderWorkerActivity(workerView())
187
+ const dropTitle = (card: string) => card.split(' \n').slice(1).join(' \n')
188
+ expect(dropTitle(agent)).toBe(dropTitle(worker))
189
+ // The title lines DO differ β€” that is the one parameterised difference.
190
+ expect(agent.split(' \n')[0]).toContain('πŸ€–')
191
+ expect(worker.split(' \n')[0]).toContain('πŸ› ')
192
+ })
193
+ })
@@ -0,0 +1,462 @@
1
+ /**
2
+ * Gateway boot beacon β€” the write-only forensic record a later boot-cause
3
+ * classifier will read.
4
+ *
5
+ * Two things are pinned here, and they are the two that can silently break:
6
+ *
7
+ * 1. **Graceful degradation.** Every cgroup / `/proc` read is optional. A
8
+ * cgroup v1 host, a kernel with no `random/boot_id`, a truncated or
9
+ * garbage file β€” each must OMIT that one field and still produce a
10
+ * usable beacon. This module runs on a 5s tick inside every gateway in
11
+ * the fleet; a throw here would crash-loop every agent on rollout, so the
12
+ * malformed-input cases assert "no throw + field absent", not just a
13
+ * happy-path parse.
14
+ *
15
+ * 2. **The absent/unlimited distinction.** `memory.max` of the literal
16
+ * `max` (unlimited cgroup) must survive as `'max'`, NOT collapse to the
17
+ * same "field missing" shape as an unreadable file. The classifier needs
18
+ * to tell "a limit-triggered OOM was impossible" from "we don't know".
19
+ */
20
+
21
+ import { describe, it, expect } from 'vitest'
22
+ import { mkdtempSync, readFileSync, writeFileSync, rmSync, existsSync, statSync } from 'node:fs'
23
+ import { join } from 'node:path'
24
+ import { tmpdir } from 'node:os'
25
+ import {
26
+ attachBootBeacon,
27
+ BOOT_BEACON_FILE,
28
+ BOOT_BEACON_INTERVAL_MS,
29
+ buildBootBeacon,
30
+ parseHostBootId,
31
+ parseMemoryBytes,
32
+ parseMemoryMax,
33
+ parseOomKillCount,
34
+ sampleBeaconHost,
35
+ serializeBootBeacon,
36
+ tickBootBeacon,
37
+ writeBootBeaconFile,
38
+ type BootBeacon,
39
+ } from '../gateway/boot-beacon.js'
40
+
41
+ const REAL_MEMORY_EVENTS = 'low 0\nhigh 0\nmax 0\noom 0\noom_kill 7\noom_group_kill 0\n'
42
+
43
+ describe('boot-beacon parsers', () => {
44
+ describe('parseHostBootId', () => {
45
+ it('extracts the boot_id uuid from the /proc file contents', () => {
46
+ expect(parseHostBootId('8917e33b-05c6-4509-8b4b-7e39d98c4c7f\n'))
47
+ .toBe('8917e33b-05c6-4509-8b4b-7e39d98c4c7f')
48
+ })
49
+
50
+ it('returns undefined for a missing file (null read), empty, or blank contents', () => {
51
+ expect(parseHostBootId(null)).toBeUndefined()
52
+ expect(parseHostBootId(undefined)).toBeUndefined()
53
+ expect(parseHostBootId('')).toBeUndefined()
54
+ expect(parseHostBootId(' \n ')).toBeUndefined()
55
+ })
56
+
57
+ it('rejects multi-token junk rather than poisoning the host-reboot comparison', () => {
58
+ // A garbage boot_id is WORSE than an absent one: the classifier compares
59
+ // this value across boots to decide "host rebooted" vs "our process was
60
+ // killed". Junk that happens to differ between reads would fabricate a
61
+ // host reboot, so anything unexpected must degrade to absent.
62
+ expect(parseHostBootId('<html>not a boot id</html>')).toBeUndefined()
63
+ expect(parseHostBootId('abc def')).toBeUndefined()
64
+ expect(parseHostBootId('x'.repeat(500))).toBeUndefined()
65
+ })
66
+ })
67
+
68
+ describe('parseOomKillCount', () => {
69
+ it('reads oom_kill out of a real cgroup v2 memory.events table', () => {
70
+ expect(parseOomKillCount(REAL_MEMORY_EVENTS)).toBe(7)
71
+ })
72
+
73
+ it('reads oom_kill, not oom β€” reclaim-resolved OOM events are not kills', () => {
74
+ expect(parseOomKillCount('oom 42\noom_kill 0\n')).toBe(0)
75
+ })
76
+
77
+ it('tolerates oom_group_kill sharing the oom_kill prefix', () => {
78
+ expect(parseOomKillCount('oom_group_kill 9\noom_kill 3\n')).toBe(3)
79
+ })
80
+
81
+ it('returns undefined when the key is absent (older kernel) or the file is unreadable', () => {
82
+ expect(parseOomKillCount('low 0\nhigh 0\n')).toBeUndefined()
83
+ expect(parseOomKillCount(null)).toBeUndefined()
84
+ expect(parseOomKillCount('')).toBeUndefined()
85
+ })
86
+
87
+ it('returns undefined for a malformed value instead of NaN or a throw', () => {
88
+ expect(parseOomKillCount('oom_kill notanumber\n')).toBeUndefined()
89
+ expect(parseOomKillCount('oom_kill -1\n')).toBeUndefined()
90
+ expect(parseOomKillCount('oom_kill\n')).toBeUndefined()
91
+ expect(parseOomKillCount('oom_kill 1 2\n')).toBeUndefined()
92
+ // Binary garbage (a cgroup v1 host serving something else at this path).
93
+ expect(() => parseOomKillCount('\x00\x01\x02')).not.toThrow()
94
+ expect(parseOomKillCount('\x00\x01\x02')).toBeUndefined()
95
+ })
96
+ })
97
+
98
+ describe('parseMemoryBytes', () => {
99
+ it('parses memory.current', () => {
100
+ expect(parseMemoryBytes('2846650368\n')).toBe(2846650368)
101
+ expect(parseMemoryBytes('0')).toBe(0)
102
+ })
103
+
104
+ it('degrades to undefined for unreadable / non-numeric / non-integer contents', () => {
105
+ expect(parseMemoryBytes(null)).toBeUndefined()
106
+ expect(parseMemoryBytes('')).toBeUndefined()
107
+ expect(parseMemoryBytes('max')).toBeUndefined()
108
+ expect(parseMemoryBytes('12.5')).toBeUndefined()
109
+ expect(parseMemoryBytes('-5')).toBeUndefined()
110
+ // Beyond Number.MAX_SAFE_INTEGER β€” a lossy parse is worse than absent.
111
+ expect(parseMemoryBytes('99999999999999999999')).toBeUndefined()
112
+ })
113
+ })
114
+
115
+ describe('parseMemoryMax', () => {
116
+ it('parses a byte limit', () => {
117
+ expect(parseMemoryMax('17179869184\n')).toBe(17179869184)
118
+ })
119
+
120
+ it('preserves the literal "max" β€” unlimited is NOT the same as unknown', () => {
121
+ expect(parseMemoryMax('max\n')).toBe('max')
122
+ // An unreadable file omits the field; an unlimited cgroup reports 'max'.
123
+ expect(parseMemoryMax(null)).toBeUndefined()
124
+ expect(parseMemoryMax('garbage')).toBeUndefined()
125
+ })
126
+ })
127
+ })
128
+
129
+ describe('buildBootBeacon', () => {
130
+ it('carries every field through when the full host sample is available', () => {
131
+ const beacon = buildBootBeacon({
132
+ bootId: 'boot-1',
133
+ pid: 4242,
134
+ wallMs: 1_700_000_000_000,
135
+ monotonicMs: 12_345,
136
+ sample: {
137
+ hostBootId: 'host-1',
138
+ oomKillCount: 7,
139
+ memCurrent: 100,
140
+ memMax: 200,
141
+ },
142
+ })
143
+ expect(beacon).toEqual({
144
+ bootId: 'boot-1',
145
+ pid: 4242,
146
+ wallMs: 1_700_000_000_000,
147
+ monotonicMs: 12_345,
148
+ hostBootId: 'host-1',
149
+ oomKillCount: 7,
150
+ memCurrent: 100,
151
+ memMax: 200,
152
+ })
153
+ })
154
+
155
+ it('OMITS unavailable fields rather than emitting undefined/null keys', () => {
156
+ // A non-cgroup-v2 host degrades to the process-local core. "Absent" must
157
+ // be one unambiguous shape for the future reader, so the keys must not
158
+ // exist at all.
159
+ const beacon = buildBootBeacon({
160
+ bootId: 'boot-1',
161
+ pid: 1,
162
+ wallMs: 5,
163
+ monotonicMs: 6,
164
+ sample: {},
165
+ })
166
+ expect(beacon).toEqual({ bootId: 'boot-1', pid: 1, wallMs: 5, monotonicMs: 6 })
167
+ expect(Object.keys(beacon).sort()).toEqual(['bootId', 'monotonicMs', 'pid', 'wallMs'])
168
+ expect(JSON.parse(serializeBootBeacon(beacon))).not.toHaveProperty('hostBootId')
169
+ expect(JSON.parse(serializeBootBeacon(beacon))).not.toHaveProperty('oomKillCount')
170
+ })
171
+
172
+ it('keeps a zero oomKillCount β€” 0 is a real reading the classifier diffs against', () => {
173
+ const beacon = buildBootBeacon({
174
+ bootId: 'b', pid: 1, wallMs: 1, monotonicMs: 1,
175
+ sample: { oomKillCount: 0, memCurrent: 0 },
176
+ })
177
+ expect(beacon.oomKillCount).toBe(0)
178
+ expect(beacon.memCurrent).toBe(0)
179
+ expect(serializeBootBeacon(beacon)).toContain('"oomKillCount":0')
180
+ })
181
+
182
+ it('serialises to one JSON line with a trailing newline', () => {
183
+ const text = serializeBootBeacon(buildBootBeacon({
184
+ bootId: 'b', pid: 1, wallMs: 2, monotonicMs: 3,
185
+ }))
186
+ expect(text.endsWith('\n')).toBe(true)
187
+ expect(text.trimEnd().includes('\n')).toBe(false)
188
+ expect(JSON.parse(text)).toEqual({ bootId: 'b', pid: 1, wallMs: 2, monotonicMs: 3 })
189
+ })
190
+ })
191
+
192
+ describe('sampleBeaconHost', () => {
193
+ it('reads a cgroup v2 layout end to end from injected paths', () => {
194
+ const dir = mkdtempSync(join(tmpdir(), 'beacon-cg-'))
195
+ try {
196
+ writeFileSync(join(dir, 'boot_id'), 'host-boot-abc\n')
197
+ writeFileSync(join(dir, 'memory.events'), REAL_MEMORY_EVENTS)
198
+ writeFileSync(join(dir, 'memory.current'), '2846650368\n')
199
+ writeFileSync(join(dir, 'memory.max'), '17179869184\n')
200
+ expect(sampleBeaconHost({
201
+ hostBootId: join(dir, 'boot_id'),
202
+ memoryEvents: join(dir, 'memory.events'),
203
+ memoryCurrent: join(dir, 'memory.current'),
204
+ memoryMax: join(dir, 'memory.max'),
205
+ })).toEqual({
206
+ hostBootId: 'host-boot-abc',
207
+ oomKillCount: 7,
208
+ memCurrent: 2846650368,
209
+ memMax: 17179869184,
210
+ })
211
+ } finally {
212
+ rmSync(dir, { recursive: true, force: true })
213
+ }
214
+ })
215
+
216
+ it('degrades to an empty sample when NOTHING is readable (cgroup v1 host)', () => {
217
+ const dir = mkdtempSync(join(tmpdir(), 'beacon-cg1-'))
218
+ try {
219
+ const missing = {
220
+ hostBootId: join(dir, 'nope-boot_id'),
221
+ memoryEvents: join(dir, 'nope-memory.events'),
222
+ memoryCurrent: join(dir, 'nope-memory.current'),
223
+ memoryMax: join(dir, 'nope-memory.max'),
224
+ }
225
+ let sample: ReturnType<typeof sampleBeaconHost> | undefined
226
+ expect(() => { sample = sampleBeaconHost(missing) }).not.toThrow()
227
+ expect(sample).toEqual({})
228
+ } finally {
229
+ rmSync(dir, { recursive: true, force: true })
230
+ }
231
+ })
232
+
233
+ it('degrades PER FIELD β€” one unreadable file does not take the others down', () => {
234
+ const dir = mkdtempSync(join(tmpdir(), 'beacon-partial-'))
235
+ try {
236
+ writeFileSync(join(dir, 'boot_id'), 'host-boot-abc\n')
237
+ // memory.events missing entirely; memory.current garbage; memory.max unlimited.
238
+ writeFileSync(join(dir, 'memory.current'), 'not-a-number\n')
239
+ writeFileSync(join(dir, 'memory.max'), 'max\n')
240
+ const sample = sampleBeaconHost({
241
+ hostBootId: join(dir, 'boot_id'),
242
+ memoryEvents: join(dir, 'memory.events'),
243
+ memoryCurrent: join(dir, 'memory.current'),
244
+ memoryMax: join(dir, 'memory.max'),
245
+ })
246
+ expect(sample).toEqual({ hostBootId: 'host-boot-abc', memMax: 'max' })
247
+ } finally {
248
+ rmSync(dir, { recursive: true, force: true })
249
+ }
250
+ })
251
+
252
+ it('does not throw when a cgroup path resolves to a DIRECTORY (EISDIR)', () => {
253
+ const dir = mkdtempSync(join(tmpdir(), 'beacon-eisdir-'))
254
+ try {
255
+ expect(() => sampleBeaconHost({
256
+ hostBootId: dir,
257
+ memoryEvents: dir,
258
+ memoryCurrent: dir,
259
+ memoryMax: dir,
260
+ })).not.toThrow()
261
+ expect(sampleBeaconHost({ hostBootId: dir, memoryEvents: dir, memoryCurrent: dir, memoryMax: dir }))
262
+ .toEqual({})
263
+ } finally {
264
+ rmSync(dir, { recursive: true, force: true })
265
+ }
266
+ })
267
+ })
268
+
269
+ describe('writeBootBeaconFile', () => {
270
+ const beacon: BootBeacon = {
271
+ bootId: 'boot-1', pid: 99, wallMs: 1234, monotonicMs: 56, hostBootId: 'host-1',
272
+ }
273
+
274
+ it('writes the beacon under the state dir, creating the dir 0700 if needed', () => {
275
+ const root = mkdtempSync(join(tmpdir(), 'beacon-w-'))
276
+ try {
277
+ const stateDir = join(root, 'nested', 'state')
278
+ expect(writeBootBeaconFile(stateDir, beacon)).toBe(true)
279
+ const path = join(stateDir, BOOT_BEACON_FILE)
280
+ expect(JSON.parse(readFileSync(path, 'utf-8'))).toEqual(beacon)
281
+ // STATE_DIR also holds access.json / .env. A 5s tick that recreated it
282
+ // at a default mode would loosen permissions on all of that.
283
+ expect(statSync(stateDir).mode & 0o777).toBe(0o700)
284
+ expect(statSync(path).mode & 0o777).toBe(0o600)
285
+ } finally {
286
+ rmSync(root, { recursive: true, force: true })
287
+ }
288
+ })
289
+
290
+ it('leaves no tmp file behind after a successful write (rename, not copy)', () => {
291
+ const dir = mkdtempSync(join(tmpdir(), 'beacon-w-'))
292
+ try {
293
+ writeBootBeaconFile(dir, beacon)
294
+ expect(existsSync(join(dir, `${BOOT_BEACON_FILE}.tmp-${process.pid}`))).toBe(false)
295
+ } finally {
296
+ rmSync(dir, { recursive: true, force: true })
297
+ }
298
+ })
299
+
300
+ it('overwrites the previous beacon in place on the next tick', () => {
301
+ const dir = mkdtempSync(join(tmpdir(), 'beacon-w-'))
302
+ try {
303
+ writeBootBeaconFile(dir, beacon)
304
+ writeBootBeaconFile(dir, { ...beacon, wallMs: 9999, oomKillCount: 3 })
305
+ const read = JSON.parse(readFileSync(join(dir, BOOT_BEACON_FILE), 'utf-8'))
306
+ expect(read.wallMs).toBe(9999)
307
+ expect(read.oomKillCount).toBe(3)
308
+ } finally {
309
+ rmSync(dir, { recursive: true, force: true })
310
+ }
311
+ })
312
+
313
+ it('returns false instead of throwing when the state dir cannot be created', () => {
314
+ const root = mkdtempSync(join(tmpdir(), 'beacon-bad-'))
315
+ try {
316
+ // A regular FILE where the state dir should be β€” mkdirSync throws ENOTDIR.
317
+ const notADir = join(root, 'occupied')
318
+ writeFileSync(notADir, 'i am a file')
319
+ let result: boolean | undefined
320
+ expect(() => { result = writeBootBeaconFile(join(notADir, 'state'), beacon) }).not.toThrow()
321
+ expect(result).toBe(false)
322
+ } finally {
323
+ rmSync(root, { recursive: true, force: true })
324
+ }
325
+ })
326
+ })
327
+
328
+ describe('tickBootBeacon', () => {
329
+ it('writes a well-formed beacon carrying this process pid and the given boot id', () => {
330
+ const dir = mkdtempSync(join(tmpdir(), 'beacon-tick-'))
331
+ try {
332
+ expect(tickBootBeacon(dir, 'boot-under-test')).toBe(true)
333
+ const read = JSON.parse(readFileSync(join(dir, BOOT_BEACON_FILE), 'utf-8')) as BootBeacon
334
+ expect(read.bootId).toBe('boot-under-test')
335
+ expect(read.pid).toBe(process.pid)
336
+ expect(read.wallMs).toBeGreaterThan(1_700_000_000_000)
337
+ expect(read.monotonicMs).toBeGreaterThanOrEqual(0)
338
+ // Host/cgroup fields are environment-dependent: assert TYPE when present,
339
+ // and that their absence is a clean omission, never null/NaN.
340
+ if ('hostBootId' in read) expect(typeof read.hostBootId).toBe('string')
341
+ if ('oomKillCount' in read) expect(Number.isInteger(read.oomKillCount)).toBe(true)
342
+ if ('memCurrent' in read) expect(Number.isInteger(read.memCurrent)).toBe(true)
343
+ if ('memMax' in read) expect(['number', 'string']).toContain(typeof read.memMax)
344
+ for (const value of Object.values(read)) expect(value).not.toBeNull()
345
+ } finally {
346
+ rmSync(dir, { recursive: true, force: true })
347
+ }
348
+ })
349
+
350
+ it('advances wallMs across ticks so the next boot can bound the time of death', async () => {
351
+ const dir = mkdtempSync(join(tmpdir(), 'beacon-tick-'))
352
+ try {
353
+ tickBootBeacon(dir, 'b')
354
+ const first = JSON.parse(readFileSync(join(dir, BOOT_BEACON_FILE), 'utf-8')) as BootBeacon
355
+ await new Promise(resolve => setTimeout(resolve, 25))
356
+ tickBootBeacon(dir, 'b')
357
+ const second = JSON.parse(readFileSync(join(dir, BOOT_BEACON_FILE), 'utf-8')) as BootBeacon
358
+ expect(second.wallMs).toBeGreaterThanOrEqual(first.wallMs)
359
+ expect(second.monotonicMs).toBeGreaterThan(first.monotonicMs)
360
+ } finally {
361
+ rmSync(dir, { recursive: true, force: true })
362
+ }
363
+ })
364
+
365
+ it('never throws on an unwritable state dir β€” a tick failure must not reach the gateway interval', () => {
366
+ const root = mkdtempSync(join(tmpdir(), 'beacon-tick-bad-'))
367
+ try {
368
+ const notADir = join(root, 'occupied')
369
+ writeFileSync(notADir, 'i am a file')
370
+ let result: boolean | undefined
371
+ expect(() => { result = tickBootBeacon(join(notADir, 'state'), 'b') }).not.toThrow()
372
+ expect(result).toBe(false)
373
+ } finally {
374
+ rmSync(root, { recursive: true, force: true })
375
+ }
376
+ })
377
+
378
+ })
379
+
380
+ describe('attachBootBeacon', () => {
381
+ it('writes a beacon IMMEDIATELY, before the first interval elapses', () => {
382
+ const dir = mkdtempSync(join(tmpdir(), 'beacon-attach-'))
383
+ try {
384
+ attachBootBeacon(dir, () => {})
385
+ // Not "after the returned tick runs" β€” at attach time. A gateway that
386
+ // dies in its first 5s must still have recorded this host's boot_id.
387
+ expect(existsSync(join(dir, BOOT_BEACON_FILE))).toBe(true)
388
+ } finally {
389
+ rmSync(dir, { recursive: true, force: true })
390
+ }
391
+ })
392
+
393
+ it('refreshes the beacon and runs the host tick, beacon first', () => {
394
+ const dir = mkdtempSync(join(tmpdir(), 'beacon-attach-'))
395
+ try {
396
+ const order: string[] = []
397
+ const tick = attachBootBeacon(dir, () => {
398
+ order.push(`next:${JSON.parse(readFileSync(join(dir, BOOT_BEACON_FILE), 'utf-8')).bootId}`)
399
+ })
400
+ // Stamp a sentinel over the attach-time beacon so "the tick refreshed it"
401
+ // is observable without depending on the clock advancing between writes.
402
+ writeFileSync(join(dir, BOOT_BEACON_FILE), '{"bootId":"stale-sentinel"}\n')
403
+ tick()
404
+ const after = JSON.parse(readFileSync(join(dir, BOOT_BEACON_FILE), 'utf-8')) as BootBeacon
405
+ expect(after.bootId).not.toBe('stale-sentinel')
406
+ expect(after.pid).toBe(process.pid)
407
+ // The host tick observed the ALREADY-refreshed beacon β€” proving the
408
+ // beacon write precedes it, so a throw from `next` cannot starve it.
409
+ expect(order).toEqual([`next:${after.bootId}`])
410
+ } finally {
411
+ rmSync(dir, { recursive: true, force: true })
412
+ }
413
+ })
414
+
415
+ it('does not swallow the host tick\'s errors β€” existing behaviour is unchanged', () => {
416
+ const dir = mkdtempSync(join(tmpdir(), 'beacon-attach-'))
417
+ try {
418
+ const tick = attachBootBeacon(dir, () => { throw new Error('host tick blew up') })
419
+ expect(() => tick()).toThrow('host tick blew up')
420
+ // …and the beacon still landed, because it is written first.
421
+ expect(existsSync(join(dir, BOOT_BEACON_FILE))).toBe(true)
422
+ } finally {
423
+ rmSync(dir, { recursive: true, force: true })
424
+ }
425
+ })
426
+
427
+ it('still runs the host tick when the beacon write fails', () => {
428
+ // The beacon is forensic garnish; it must never gate the tick it rides on.
429
+ const root = mkdtempSync(join(tmpdir(), 'beacon-attach-bad-'))
430
+ try {
431
+ const notADir = join(root, 'occupied')
432
+ writeFileSync(notADir, 'i am a file')
433
+ let ran = false
434
+ const tick = attachBootBeacon(join(notADir, 'state'), () => { ran = true })
435
+ expect(() => tick()).not.toThrow()
436
+ expect(ran).toBe(true)
437
+ } finally {
438
+ rmSync(root, { recursive: true, force: true })
439
+ }
440
+ })
441
+ })
442
+
443
+ describe('gateway wiring', () => {
444
+ // Source-scrape rather than booting the 24k-line gateway module (which has
445
+ // load-bearing module-eval ordering β€” see the #3392 TDZ scar). The outcome
446
+ // that matters is that the beacon is actually DRIVEN: an unwired writer is
447
+ // an empty forensic record, and nothing else in the tree would go red.
448
+ const gatewaySrc = readFileSync(
449
+ new URL('../gateway/gateway.ts', import.meta.url), 'utf-8',
450
+ )
451
+
452
+ it('rides the existing shared 5s approval tick rather than adding a timer', () => {
453
+ expect(gatewaySrc).toContain("import { attachBootBeacon } from './boot-beacon.js'")
454
+ expect(gatewaySrc).toContain(
455
+ 'setInterval(attachBootBeacon(STATE_DIR, checkApprovals), 5000)',
456
+ )
457
+ // Exactly one wiring site, and it is the pre-existing interval β€” a second
458
+ // `attachBootBeacon` call would mean a second beacon writer.
459
+ expect(gatewaySrc.match(/attachBootBeacon\(STATE_DIR/g)?.length).toBe(1)
460
+ expect(BOOT_BEACON_INTERVAL_MS).toBe(5_000)
461
+ })
462
+ })