switchroom 0.19.30 → 0.19.31
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/cli/switchroom.js +1121 -584
- package/dist/host-control/main.js +151 -73
- package/package.json +1 -1
- package/profiles/_base/cron-session.sh.hbs +5 -1
- package/profiles/_base/start.sh.hbs +15 -1
- package/telegram-plugin/dist/gateway/gateway.js +1435 -551
- package/telegram-plugin/edit-flood-fuse.ts +70 -20
- package/telegram-plugin/gateway/boot-beacon.ts +364 -0
- package/telegram-plugin/gateway/boot-sweep-gate.ts +20 -15
- package/telegram-plugin/gateway/gateway.ts +87 -88
- package/telegram-plugin/gateway/inbound-spool.ts +39 -0
- package/telegram-plugin/gateway/narrative-lane.ts +12 -0
- package/telegram-plugin/gateway/obligation-store.ts +28 -0
- package/telegram-plugin/gateway/stale-pin-sweep-store.ts +221 -0
- package/telegram-plugin/gateway/stale-pin-sweep-wiring.ts +211 -0
- package/telegram-plugin/gateway/stale-pin-sweep.test.ts +804 -0
- package/telegram-plugin/gateway/stale-pin-sweep.ts +1146 -0
- package/telegram-plugin/gateway/status-pin-retarget.ts +15 -2
- package/telegram-plugin/gateway/status-pin-store.ts +33 -11
- package/telegram-plugin/registry/turns-schema.ts +21 -1
- package/telegram-plugin/retry-api-call.ts +46 -21
- package/telegram-plugin/shared/bot-runtime.ts +61 -17
- package/telegram-plugin/shared/gw-trace-gate.ts +18 -2
- package/telegram-plugin/tests/activity-card-wiring.test.ts +7 -7
- package/telegram-plugin/tests/activity-drain-fuse-drop-not-failure.test.ts +324 -0
- package/telegram-plugin/tests/agent-card-result-footer.test.ts +193 -0
- package/telegram-plugin/tests/boot-beacon.test.ts +462 -0
- package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +6 -6
- package/telegram-plugin/tests/boot-sweep-gate.test.ts +42 -31
- package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +2 -0
- package/telegram-plugin/tests/inbound-spool-progress.test.ts +2 -0
- package/telegram-plugin/tests/inbound-spool.test.ts +134 -5
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +28 -0
- package/telegram-plugin/tests/obligation-determinism.test.ts +2 -0
- package/telegram-plugin/tests/obligation-store.test.ts +67 -1
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
- package/telegram-plugin/tests/status-pin-store.test.ts +26 -5
- package/telegram-plugin/tests/tg-post-logger-error-shape.test.ts +161 -0
- package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +30 -0
- package/telegram-plugin/tool-activity-summary.ts +104 -38
- package/telegram-plugin/worker-activity-feed.ts +33 -16
- package/telegram-plugin/gateway/dm-pin-sweep.test.ts +0 -251
- package/telegram-plugin/gateway/dm-pin-sweep.ts +0 -178
|
@@ -33,20 +33,33 @@ function msg(over: Partial<InboundMessage> = {}): InboundMessage {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
/** In-memory fake fs keyed by path. Models append, full rewrite, and
|
|
36
|
-
* atomic rename (so the tmp→rename compaction path is exercised).
|
|
37
|
-
|
|
36
|
+
* atomic rename (so the tmp→rename compaction path is exercised).
|
|
37
|
+
* `ops` records the mutating/durability calls in order, so tests can pin
|
|
38
|
+
* the fsync ORDERING the crash-durability guarantee depends on. */
|
|
39
|
+
function fakeFs(): InboundSpoolFsSeam & { dump(p?: string): string; ops: string[] } {
|
|
38
40
|
const files = new Map<string, string>()
|
|
41
|
+
const ops: string[] = []
|
|
39
42
|
return {
|
|
40
|
-
appendFileSync: (p, d) =>
|
|
43
|
+
appendFileSync: (p, d) => {
|
|
44
|
+
ops.push(`append:${p}`)
|
|
45
|
+
files.set(p, (files.get(p) ?? '') + d)
|
|
46
|
+
},
|
|
41
47
|
readFileSync: (p) => files.get(p) ?? '',
|
|
42
|
-
writeFileSync: (p, d) =>
|
|
48
|
+
writeFileSync: (p, d) => {
|
|
49
|
+
ops.push(`write:${p}`)
|
|
50
|
+
files.set(p, d)
|
|
51
|
+
},
|
|
43
52
|
renameSync: (from, to) => {
|
|
53
|
+
ops.push(`rename:${from}->${to}`)
|
|
44
54
|
files.set(to, files.get(from) ?? '')
|
|
45
55
|
files.delete(from)
|
|
46
56
|
},
|
|
47
57
|
existsSync: (p) => files.has(p),
|
|
48
58
|
statSizeSync: (p) => Buffer.byteLength(files.get(p) ?? ''),
|
|
59
|
+
fsyncFileSync: (p) => ops.push(`fsyncFile:${p}`),
|
|
60
|
+
fsyncDirSync: (p) => ops.push(`fsyncDir:${p}`),
|
|
49
61
|
dump: (p = PATH) => files.get(p) ?? '',
|
|
62
|
+
ops,
|
|
50
63
|
}
|
|
51
64
|
}
|
|
52
65
|
|
|
@@ -478,6 +491,92 @@ describe('inbound-spool — robustness', () => {
|
|
|
478
491
|
})
|
|
479
492
|
})
|
|
480
493
|
|
|
494
|
+
describe('inbound-spool — power-cut durability (fsync)', () => {
|
|
495
|
+
it('fsyncs the spool file on every appended record', () => {
|
|
496
|
+
const fs = fakeFs()
|
|
497
|
+
const s = createInboundSpool({ path: PATH, fs, log: () => {} })
|
|
498
|
+
s.put('a', msg({ messageId: 1 }))
|
|
499
|
+
s.ack(msg({ messageId: 1 }))
|
|
500
|
+
// Both the put record and the ack tombstone are durability-critical:
|
|
501
|
+
// an un-synced ack replays an already-answered message after a power
|
|
502
|
+
// cut, an un-synced put loses it entirely.
|
|
503
|
+
expect(fs.ops).toEqual([
|
|
504
|
+
`append:${PATH}`,
|
|
505
|
+
`fsyncFile:${PATH}`,
|
|
506
|
+
`append:${PATH}`,
|
|
507
|
+
`fsyncFile:${PATH}`,
|
|
508
|
+
])
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
it('compaction fsyncs the tmp file BEFORE the rename and the directory AFTER', () => {
|
|
512
|
+
const fs = fakeFs()
|
|
513
|
+
const s = createInboundSpool({ path: PATH, fs, compactAtBytes: 200, log: () => {} })
|
|
514
|
+
for (let i = 1; i <= 10; i++) s.put('a', msg({ messageId: i, text: 'y'.repeat(50) }))
|
|
515
|
+
// The bound triggers several compactions across the 10 puts; each one
|
|
516
|
+
// must follow the same four-step sequence, so pin the last.
|
|
517
|
+
expect(fs.ops.slice(-4)).toEqual([
|
|
518
|
+
`write:${PATH}.compact.tmp`,
|
|
519
|
+
`fsyncFile:${PATH}.compact.tmp`,
|
|
520
|
+
`rename:${PATH}.compact.tmp->${PATH}`,
|
|
521
|
+
'fsyncDir:/state/agent/telegram',
|
|
522
|
+
])
|
|
523
|
+
})
|
|
524
|
+
|
|
525
|
+
it('a failed compaction directory fsync is not reported as a failed compaction', () => {
|
|
526
|
+
// The rename already landed, so the compacted log IS on disk; only the
|
|
527
|
+
// power-cut upgrade is missing. Calling it "compact FAILED" would send an
|
|
528
|
+
// operator hunting a rewrite that actually worked.
|
|
529
|
+
const fs = fakeFs()
|
|
530
|
+
const logs: string[] = []
|
|
531
|
+
fs.fsyncDirSync = () => {
|
|
532
|
+
throw new Error('EIO: i/o error, fsync dir')
|
|
533
|
+
}
|
|
534
|
+
const s = createInboundSpool({ path: PATH, fs, compactAtBytes: 200, log: (l) => logs.push(l) })
|
|
535
|
+
for (let i = 1; i <= 10; i++) s.put('a', msg({ messageId: i, text: 'y'.repeat(50) }))
|
|
536
|
+
expect(logs.join('')).toContain('compact directory fsync FAILED')
|
|
537
|
+
expect(logs.join('')).toContain('compacted path=')
|
|
538
|
+
// And the compacted log is intact and readable — nothing was rolled back.
|
|
539
|
+
const s2 = createInboundSpool({ path: PATH, fs, log: () => {} })
|
|
540
|
+
expect(s2.liveCount()).toBe(10)
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
it('an un-acked entry survives a POWER CUT, not just a process kill', () => {
|
|
544
|
+
// Models the distinction the fsync exists for: `appendFileSync` returning
|
|
545
|
+
// puts the bytes in the page cache (enough to survive SIGKILL — the
|
|
546
|
+
// process dies, the kernel still holds them), but only fsync puts them on
|
|
547
|
+
// stable storage. `powerCut()` discards everything not fsync'd, which is
|
|
548
|
+
// what a host power loss actually does.
|
|
549
|
+
const cache = new Map<string, string>()
|
|
550
|
+
const platter = new Map<string, string>()
|
|
551
|
+
const fs: InboundSpoolFsSeam = {
|
|
552
|
+
appendFileSync: (p, d) => cache.set(p, (cache.get(p) ?? '') + d),
|
|
553
|
+
readFileSync: (p) => cache.get(p) ?? '',
|
|
554
|
+
writeFileSync: (p, d) => cache.set(p, d),
|
|
555
|
+
renameSync: (from, to) => {
|
|
556
|
+
cache.set(to, cache.get(from) ?? '')
|
|
557
|
+
cache.delete(from)
|
|
558
|
+
},
|
|
559
|
+
existsSync: (p) => cache.has(p),
|
|
560
|
+
statSizeSync: (p) => Buffer.byteLength(cache.get(p) ?? ''),
|
|
561
|
+
fsyncFileSync: (p) => {
|
|
562
|
+
if (cache.has(p)) platter.set(p, cache.get(p)!)
|
|
563
|
+
},
|
|
564
|
+
fsyncDirSync: () => {},
|
|
565
|
+
}
|
|
566
|
+
const powerCut = (): void => {
|
|
567
|
+
cache.clear()
|
|
568
|
+
for (const [p, d] of platter) cache.set(p, d)
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const s = createInboundSpool({ path: PATH, fs, log: () => {} })
|
|
572
|
+
s.put('a', msg({ messageId: 7, text: 'answer me' }))
|
|
573
|
+
powerCut()
|
|
574
|
+
|
|
575
|
+
const rebooted = createInboundSpool({ path: PATH, fs, log: () => {} })
|
|
576
|
+
expect(rebooted.liveEntries().map((e) => e.msg.messageId)).toEqual([7])
|
|
577
|
+
})
|
|
578
|
+
})
|
|
579
|
+
|
|
481
580
|
describe('inbound-spool — #2789 C: multi-message drop notice reports the real count', () => {
|
|
482
581
|
it('passes the true per-chat dropped count to the coalesced notice', () => {
|
|
483
582
|
const fs = fakeFs()
|
|
@@ -549,10 +648,14 @@ describe('inbound-spool — #2789 B: spool write failure surfaces a health signa
|
|
|
549
648
|
// An fs whose appendFileSync can be toggled to fail, modelling a full /
|
|
550
649
|
// unwritable persistent volume. The append swallows the error (delivery
|
|
551
650
|
// must not break) but the degradation must be SURFACED, not silent.
|
|
552
|
-
function toggleableFs(): InboundSpoolFsSeam & { fail: boolean } {
|
|
651
|
+
function toggleableFs(): InboundSpoolFsSeam & { fail: boolean; failFsync: boolean } {
|
|
553
652
|
const files = new Map<string, string>()
|
|
554
653
|
const seam = {
|
|
555
654
|
fail: false,
|
|
655
|
+
// A failing fsync is its own degradation mode: the bytes reached the
|
|
656
|
+
// page cache but NOT stable storage, so the durable promise is gone
|
|
657
|
+
// even though appendFileSync returned cleanly.
|
|
658
|
+
failFsync: false,
|
|
556
659
|
appendFileSync(p: string, d: string) {
|
|
557
660
|
if (seam.fail) throw new Error('ENOSPC: no space left on device')
|
|
558
661
|
files.set(p, (files.get(p) ?? '') + d)
|
|
@@ -565,6 +668,10 @@ describe('inbound-spool — #2789 B: spool write failure surfaces a health signa
|
|
|
565
668
|
},
|
|
566
669
|
existsSync: (p: string) => files.has(p),
|
|
567
670
|
statSizeSync: (p: string) => Buffer.byteLength(files.get(p) ?? ''),
|
|
671
|
+
fsyncFileSync: (_p: string) => {
|
|
672
|
+
if (seam.failFsync) throw new Error('EIO: i/o error, fsync')
|
|
673
|
+
},
|
|
674
|
+
fsyncDirSync: (_p: string) => {},
|
|
568
675
|
}
|
|
569
676
|
return seam
|
|
570
677
|
}
|
|
@@ -588,6 +695,28 @@ describe('inbound-spool — #2789 B: spool write failure surfaces a health signa
|
|
|
588
695
|
expect(events).toEqual([{ degraded: true, consecutiveFailures: 1 }])
|
|
589
696
|
})
|
|
590
697
|
|
|
698
|
+
it('a failing fsync degrades too — a cached-but-unsynced append is NOT durable', () => {
|
|
699
|
+
// The subtle one: appendFileSync succeeds, so the old code would have
|
|
700
|
+
// reported a healthy spool while the record was one power cut from gone.
|
|
701
|
+
const fs = toggleableFs()
|
|
702
|
+
const events: { degraded: boolean }[] = []
|
|
703
|
+
const logs: string[] = []
|
|
704
|
+
const s = createInboundSpool({
|
|
705
|
+
path: PATH,
|
|
706
|
+
fs,
|
|
707
|
+
log: (l) => logs.push(l),
|
|
708
|
+
onDegraded: (info) => events.push({ degraded: info.degraded }),
|
|
709
|
+
})
|
|
710
|
+
fs.failFsync = true
|
|
711
|
+
expect(() => s.put('a', msg({ messageId: 1, ts: 1 }))).not.toThrow()
|
|
712
|
+
expect(s.isDegraded()).toBe(true)
|
|
713
|
+
expect(events).toEqual([{ degraded: true }])
|
|
714
|
+
expect(logs.join('')).toContain('durability degraded')
|
|
715
|
+
// Live delivery is unaffected — degradation is a durability claim, not a
|
|
716
|
+
// delivery failure.
|
|
717
|
+
expect(s.liveCount()).toBe(1)
|
|
718
|
+
})
|
|
719
|
+
|
|
591
720
|
it('does not re-fire the degraded signal on every failing append (latched)', () => {
|
|
592
721
|
const fs = toggleableFs()
|
|
593
722
|
const events: boolean[] = []
|
|
@@ -211,6 +211,34 @@ describe('narrative-lane golden — SHOW path opens then edits ONE feed card', (
|
|
|
211
211
|
expect(final!).toMatch(/3 steps/)
|
|
212
212
|
expect(live!).not.toMatch(/3 steps/)
|
|
213
213
|
})
|
|
214
|
+
|
|
215
|
+
it('composeTurnActivity: the FINAL render ends with the ✅ + delivered-answer footer', () => {
|
|
216
|
+
// Parity with the 🛠 worker card, which has always closed on `✅ <summary>`.
|
|
217
|
+
// The agent card's analogue of the worker's `latestSummary` is the answer
|
|
218
|
+
// the turn actually delivered (`turn.lastReplyText`); the lane threads it
|
|
219
|
+
// into the header as `resultText` and the SHARED `deriveCardResult` renders
|
|
220
|
+
// it. Fails on the pre-fix lane, which passed no result text at all.
|
|
221
|
+
const { lane } = makeLane()
|
|
222
|
+
const turn = makeLaneTurn(lane, { labeledToolCount: 2 })
|
|
223
|
+
turn.mirrorLines.push('Read the file', 'Ran the tests')
|
|
224
|
+
turn.lastReplyText = 'Standing by for the background merge waiter to complete.'
|
|
225
|
+
const live = lane.composeTurnActivity(turn)
|
|
226
|
+
const final = lane.composeTurnActivity(turn, true)
|
|
227
|
+
expect(final!).toContain('─────')
|
|
228
|
+
expect(final!.endsWith('✅ _Standing by for the background merge waiter to complete._')).toBe(true)
|
|
229
|
+
// The LIVE render must not leak the footer — the turn is not finished.
|
|
230
|
+
expect(live!).not.toContain('✅')
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('composeTurnActivity: a turn that delivered no answer renders NO footer (no fabrication)', () => {
|
|
234
|
+
const { lane } = makeLane()
|
|
235
|
+
const turn = makeLaneTurn(lane, { labeledToolCount: 2 })
|
|
236
|
+
turn.mirrorLines.push('Read the file', 'Ran the tests')
|
|
237
|
+
turn.lastReplyText = ''
|
|
238
|
+
const final = lane.composeTurnActivity(turn, true)
|
|
239
|
+
expect(final!).not.toContain('─────')
|
|
240
|
+
expect(final!).not.toContain('✅')
|
|
241
|
+
})
|
|
214
242
|
})
|
|
215
243
|
|
|
216
244
|
describe('narrative-lane golden — the narrative-dedup gate at turn end', () => {
|
|
@@ -27,6 +27,12 @@ function memFs(seed: Record<string, string> = {}) {
|
|
|
27
27
|
files.delete(a);
|
|
28
28
|
},
|
|
29
29
|
existsSync: (p) => files.has(p),
|
|
30
|
+
fsyncFileSync: (p) => {
|
|
31
|
+
calls.push(`fsyncFile:${p}`);
|
|
32
|
+
},
|
|
33
|
+
fsyncDirSync: (p) => {
|
|
34
|
+
calls.push(`fsyncDir:${p}`);
|
|
35
|
+
},
|
|
30
36
|
};
|
|
31
37
|
return { fs, files, calls };
|
|
32
38
|
}
|
|
@@ -63,7 +69,12 @@ describe("obligation-store", () => {
|
|
|
63
69
|
it("persists atomically: writes a sibling .tmp then renames over the path", () => {
|
|
64
70
|
const { fs, calls, files } = memFs();
|
|
65
71
|
persistObligations(PATH, fs, [ob("c:3#1")]);
|
|
66
|
-
expect(calls).toEqual([
|
|
72
|
+
expect(calls).toEqual([
|
|
73
|
+
`write:${PATH}.tmp`,
|
|
74
|
+
`fsyncFile:${PATH}.tmp`,
|
|
75
|
+
`rename:${PATH}.tmp->${PATH}`,
|
|
76
|
+
"fsyncDir:/state/agent/telegram",
|
|
77
|
+
]);
|
|
67
78
|
// The tmp is gone (renamed); only the real path remains.
|
|
68
79
|
expect(files.has(PATH)).toBe(true);
|
|
69
80
|
expect(files.has(`${PATH}.tmp`)).toBe(false);
|
|
@@ -170,8 +181,63 @@ describe("obligation-store", () => {
|
|
|
170
181
|
},
|
|
171
182
|
renameSync: () => {},
|
|
172
183
|
existsSync: () => false,
|
|
184
|
+
fsyncFileSync: () => {},
|
|
185
|
+
fsyncDirSync: () => {},
|
|
173
186
|
};
|
|
174
187
|
expect(() => persistObligations(PATH, fs, [ob("c:3#1")], (l) => logs.push(l))).not.toThrow();
|
|
175
188
|
expect(logs.join("")).toContain("persist FAILED");
|
|
176
189
|
});
|
|
177
190
|
});
|
|
191
|
+
|
|
192
|
+
describe("obligation-store — power-cut durability (fsync ordering)", () => {
|
|
193
|
+
it("fsyncs the tmp file BEFORE the rename and the directory AFTER it", () => {
|
|
194
|
+
const { fs, calls } = memFs();
|
|
195
|
+
persistObligations(PATH, fs, [ob("c:3#1")]);
|
|
196
|
+
// The exact sequence IS the guarantee: syncing the directory before the
|
|
197
|
+
// rename, or renaming before the tmp file's data is on stable storage,
|
|
198
|
+
// publishes a name that can point at bytes that were never written.
|
|
199
|
+
expect(calls).toEqual([
|
|
200
|
+
`write:${PATH}.tmp`,
|
|
201
|
+
`fsyncFile:${PATH}.tmp`,
|
|
202
|
+
`rename:${PATH}.tmp->${PATH}`,
|
|
203
|
+
"fsyncDir:/state/agent/telegram",
|
|
204
|
+
]);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("a failed tmp fsync aborts BEFORE the rename — the previous snapshot is left intact", () => {
|
|
208
|
+
// Publishing an unsynced tmp over a good snapshot would be strictly worse
|
|
209
|
+
// than not writing at all, so the rename must not happen.
|
|
210
|
+
const prior = JSON.stringify({ v: 1, obligations: [ob("c:3#1")] });
|
|
211
|
+
const { fs, files } = memFs({ [PATH]: prior });
|
|
212
|
+
const logs: string[] = [];
|
|
213
|
+
const failing: ObligationStoreFsSeam = {
|
|
214
|
+
...fs,
|
|
215
|
+
fsyncFileSync: () => {
|
|
216
|
+
throw new Error("EIO: i/o error, fsync");
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
expect(() => persistObligations(PATH, failing, [ob("c:3#2")], (l) => logs.push(l))).not.toThrow();
|
|
220
|
+
expect(logs.join("")).toContain("persist FAILED");
|
|
221
|
+
expect(files.get(PATH)).toBe(prior);
|
|
222
|
+
expect(loadObligations(PATH, fs)[0]!.originTurnId).toBe("c:3#1");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("a failed DIRECTORY fsync keeps the written snapshot and is not reported as a failed persist", () => {
|
|
226
|
+
// The rename already landed, so the snapshot is correct for a process
|
|
227
|
+
// crash; only the power-cut upgrade is missing. Mislabelling it
|
|
228
|
+
// "persist FAILED" would send an operator hunting a write that worked.
|
|
229
|
+
const { fs, files } = memFs();
|
|
230
|
+
const logs: string[] = [];
|
|
231
|
+
const failing: ObligationStoreFsSeam = {
|
|
232
|
+
...fs,
|
|
233
|
+
fsyncDirSync: () => {
|
|
234
|
+
throw new Error("EIO: i/o error, fsync dir");
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
expect(() => persistObligations(PATH, failing, [ob("c:3#1")], (l) => logs.push(l))).not.toThrow();
|
|
238
|
+
expect(logs.join("")).toContain("directory fsync FAILED");
|
|
239
|
+
expect(logs.join("")).not.toContain("persist FAILED");
|
|
240
|
+
expect(files.has(PATH)).toBe(true);
|
|
241
|
+
expect(loadObligations(PATH, fs)).toHaveLength(1);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
@@ -311,7 +311,7 @@ describe("status-pin boot cleanup is mutex-gated (structural)", () => {
|
|
|
311
311
|
|
|
312
312
|
it("every boot pin-cleanup call site follows the won-lock check", () => {
|
|
313
313
|
// #3026: boot cleanup is now invoked via the mutex-gated orchestrator
|
|
314
|
-
//
|
|
314
|
+
// runBootPinCleanupAndStalePinSweep(), which awaits statusPinBootCleanup()
|
|
315
315
|
// (+ the other reapers) and then runs the DM stale-pin sweep. The
|
|
316
316
|
// invariant is unchanged: cleanup must only run after the lock is won.
|
|
317
317
|
const lines = gatewaySrc.split("\n");
|
|
@@ -344,7 +344,7 @@ describe("status-pin boot cleanup is mutex-gated (structural)", () => {
|
|
|
344
344
|
);
|
|
345
345
|
expect(declIdx).toBeGreaterThan(-1);
|
|
346
346
|
const orchestratorIdx = lines.findIndex((l) =>
|
|
347
|
-
/function
|
|
347
|
+
/function runBootPinCleanupAndStalePinSweep/.test(l),
|
|
348
348
|
);
|
|
349
349
|
expect(orchestratorIdx).toBeGreaterThan(-1);
|
|
350
350
|
const invocationIdxs = lines
|
|
@@ -365,7 +365,7 @@ describe("status-pin boot cleanup is mutex-gated (structural)", () => {
|
|
|
365
365
|
|
|
366
366
|
it("the blocked (losing) boot exits before reaching cleanup", () => {
|
|
367
367
|
// In the mutex block, `outcome.status === 'blocked'` must lead to
|
|
368
|
-
// process.exit(1) BEFORE the winner's
|
|
368
|
+
// process.exit(1) BEFORE the winner's runBootPinCleanupAndStalePinSweep() call
|
|
369
369
|
// in that block — so a loser never touches the shared store.
|
|
370
370
|
const blockedIdx = gatewaySrc.indexOf("outcome.status === 'blocked'");
|
|
371
371
|
expect(blockedIdx).toBeGreaterThan(-1);
|
|
@@ -117,8 +117,8 @@ describe("status-pin-store", () => {
|
|
|
117
117
|
});
|
|
118
118
|
|
|
119
119
|
it("fail-open: wrong envelope version / shape loads as []", () => {
|
|
120
|
-
//
|
|
121
|
-
const { fs: f1 } = memFs({ [PATH]: JSON.stringify({ v:
|
|
120
|
+
// v5 is an unknown FUTURE version (v1–v4 are the supported set).
|
|
121
|
+
const { fs: f1 } = memFs({ [PATH]: JSON.stringify({ v: 5, pins: [pin()] }) });
|
|
122
122
|
expect(loadStatusPins(PATH, f1)).toEqual([]);
|
|
123
123
|
const { fs: f2 } = memFs({ [PATH]: JSON.stringify({ v: 1, pins: "nope" }) });
|
|
124
124
|
expect(loadStatusPins(PATH, f2)).toEqual([]);
|
|
@@ -548,13 +548,34 @@ describe("status-pin-store — envelope version compat", () => {
|
|
|
548
548
|
expect(loaded[0].messageId).toBe(715);
|
|
549
549
|
});
|
|
550
550
|
|
|
551
|
-
it("round-trips a v2 pending flag and writes
|
|
551
|
+
it("round-trips a v2 pending flag and writes v4 envelopes", () => {
|
|
552
552
|
const { fs, files } = memFs();
|
|
553
553
|
persistStatusPins(PATH, fs, [pin({ pending: true })]);
|
|
554
|
-
expect(JSON.parse(files.get(PATH)!).v).toBe(
|
|
554
|
+
expect(JSON.parse(files.get(PATH)!).v).toBe(4);
|
|
555
555
|
expect(loadStatusPins(PATH, fs)[0].pending).toBe(true);
|
|
556
556
|
});
|
|
557
557
|
|
|
558
|
+
it("v4 persists the forum topic a pin lives in, so a later sweep can aim at it", () => {
|
|
559
|
+
// Telegram's pin stack is chat-wide and `unpinChatMessage` takes no
|
|
560
|
+
// message_thread_id, so `unpinAllForumTopicMessages(chat, thread)` is the
|
|
561
|
+
// only topic-scoped drain a bot has — and it needs this field. Without it a
|
|
562
|
+
// boot after a crash knows a forum chat has orphans but not where.
|
|
563
|
+
const { fs, files } = memFs();
|
|
564
|
+
persistStatusPins(PATH, fs, [pin({ threadId: 77 })]);
|
|
565
|
+
expect(JSON.parse(files.get(PATH)!).v).toBe(4);
|
|
566
|
+
expect(loadStatusPins(PATH, fs)[0].threadId).toBe(77);
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
it("drops a row with a non-numeric threadId", () => {
|
|
570
|
+
const { fs } = memFs({
|
|
571
|
+
[PATH]: JSON.stringify({
|
|
572
|
+
v: 4,
|
|
573
|
+
pins: [pin({ messageId: 715 }), { pinKey: "k", chatId: "c", messageId: 1, threadId: "5" }],
|
|
574
|
+
}),
|
|
575
|
+
});
|
|
576
|
+
expect(loadStatusPins(PATH, fs)).toHaveLength(1);
|
|
577
|
+
});
|
|
578
|
+
|
|
558
579
|
it("#3810: a v3 row round-trips its pinnedAt claim age; a v1/v2 row loads without one", () => {
|
|
559
580
|
const { fs } = memFs({
|
|
560
581
|
[PATH]: JSON.stringify({
|
|
@@ -584,7 +605,7 @@ describe("status-pin-store — envelope version compat", () => {
|
|
|
584
605
|
});
|
|
585
606
|
|
|
586
607
|
it("rejects an unknown future envelope version (fail-open [])", () => {
|
|
587
|
-
const { fs } = memFs({ [PATH]: JSON.stringify({ v:
|
|
608
|
+
const { fs } = memFs({ [PATH]: JSON.stringify({ v: 5, pins: [pin()] }) });
|
|
588
609
|
expect(idOnly(loadStatusPins(PATH, fs))).toEqual([]);
|
|
589
610
|
});
|
|
590
611
|
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire-level outcome tests for the `tg-post` logger's error shape (#3927).
|
|
3
|
+
*
|
|
4
|
+
* The bug: grammY resolves its transformer chain with the raw `ApiResponse`
|
|
5
|
+
* and only converts `{ok:false}` into a thrown `GrammyError` AFTER the chain
|
|
6
|
+
* returns (grammy `out/core/client.js`, `callApi`). Every Telegram-level
|
|
7
|
+
* rejection — 429 flood-wait, 400, 403 — therefore reached
|
|
8
|
+
* `installTgPostLogger` as a RESOLVED value and was logged
|
|
9
|
+
* `status=ok err=- code=- desc=-`. Only transport `HttpError`s ever hit the
|
|
10
|
+
* `catch`. A rate-limited `unpinAllChatMessages` on clerk logged as SUCCESS
|
|
11
|
+
* 3ms before the retry policy logged `429 rate limited, waiting 3s`.
|
|
12
|
+
*
|
|
13
|
+
* These tests MUST go through a REAL grammy `Bot` with a stubbed transport
|
|
14
|
+
* (the same reason `rich-markdown-guard-transformer.test.ts` does): the
|
|
15
|
+
* mock-`api` harnesses sit ABOVE the transformer layer, so `api.config.use`
|
|
16
|
+
* transformers never run there and a test written through them would be a
|
|
17
|
+
* false guard. Stubbing `client.fetch` with an `ok:false` envelope is the
|
|
18
|
+
* only way to reproduce the exact resolved-rejection path.
|
|
19
|
+
*/
|
|
20
|
+
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
21
|
+
import { Bot } from 'grammy'
|
|
22
|
+
import { installTgPostLogger } from '../shared/bot-runtime.js'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A real Bot whose transport always answers with the given Telegram
|
|
26
|
+
* envelope. HTTP status stays 200 — a Bot API rejection is a 200 response
|
|
27
|
+
* carrying `ok:false`, which is precisely why it resolves the chain.
|
|
28
|
+
*/
|
|
29
|
+
function makeBot(envelope: Record<string, unknown>): Bot {
|
|
30
|
+
const fakeFetch = (async () =>
|
|
31
|
+
({
|
|
32
|
+
ok: true,
|
|
33
|
+
status: 200,
|
|
34
|
+
json: async () => envelope,
|
|
35
|
+
}) as unknown as Response) as unknown as typeof fetch
|
|
36
|
+
|
|
37
|
+
const bot = new Bot('123456:TEST_TOKEN', {
|
|
38
|
+
botInfo: {
|
|
39
|
+
id: 123456,
|
|
40
|
+
is_bot: true,
|
|
41
|
+
first_name: 'Test',
|
|
42
|
+
username: 'test_bot',
|
|
43
|
+
can_join_groups: false,
|
|
44
|
+
can_read_all_group_messages: false,
|
|
45
|
+
supports_inline_queries: false,
|
|
46
|
+
can_connect_to_business: false,
|
|
47
|
+
has_main_web_app: false,
|
|
48
|
+
},
|
|
49
|
+
client: { fetch: fakeFetch },
|
|
50
|
+
})
|
|
51
|
+
installTgPostLogger(bot)
|
|
52
|
+
return bot
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Run one API call against the stub and return every tg-post line emitted. */
|
|
56
|
+
async function tgPostLinesFor(envelope: Record<string, unknown>): Promise<string[]> {
|
|
57
|
+
const written: string[] = []
|
|
58
|
+
const spy = vi
|
|
59
|
+
.spyOn(process.stderr, 'write')
|
|
60
|
+
.mockImplementation(((chunk: unknown) => {
|
|
61
|
+
written.push(String(chunk))
|
|
62
|
+
return true
|
|
63
|
+
}) as unknown as typeof process.stderr.write)
|
|
64
|
+
try {
|
|
65
|
+
const bot = makeBot(envelope)
|
|
66
|
+
// grammy converts the ok:false envelope into a thrown GrammyError once
|
|
67
|
+
// the transformer chain has returned — expected, and not what we assert.
|
|
68
|
+
await bot.api.sendMessage(4242, 'hello').catch(() => undefined)
|
|
69
|
+
} finally {
|
|
70
|
+
spy.mockRestore()
|
|
71
|
+
}
|
|
72
|
+
return written.filter(l => l.startsWith('tg-post '))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
afterEach(() => {
|
|
76
|
+
vi.restoreAllMocks()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe('tg-post logger — resolved ok:false responses (#3927)', () => {
|
|
80
|
+
it('logs a 429 flood-wait as status=err code=429 (FAILS before the fix: logged status=ok)', async () => {
|
|
81
|
+
const lines = await tgPostLinesFor({
|
|
82
|
+
ok: false,
|
|
83
|
+
error_code: 429,
|
|
84
|
+
description: 'Too Many Requests: retry after 3',
|
|
85
|
+
parameters: { retry_after: 3 },
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
expect(lines).toHaveLength(1)
|
|
89
|
+
const line = lines[0]
|
|
90
|
+
expect(line).toContain('status=err')
|
|
91
|
+
expect(line).toContain('code=429')
|
|
92
|
+
expect(line).toContain('err=telegram_429')
|
|
93
|
+
expect(line).toContain('desc=Too Many Requests: retry after 3')
|
|
94
|
+
// The precise regression: it must NOT claim success.
|
|
95
|
+
expect(line).not.toContain('status=ok')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('logs a non-benign 400 as status=err carrying the rejection reason', async () => {
|
|
99
|
+
const lines = await tgPostLinesFor({
|
|
100
|
+
ok: false,
|
|
101
|
+
error_code: 400,
|
|
102
|
+
description: "Bad Request: can't parse entities: unsupported start tag",
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
expect(lines).toHaveLength(1)
|
|
106
|
+
expect(lines[0]).toContain('status=err')
|
|
107
|
+
expect(lines[0]).toContain('code=400')
|
|
108
|
+
expect(lines[0]).toContain("desc=Bad Request: can't parse entities")
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('logs a 403 as status=err', async () => {
|
|
112
|
+
const lines = await tgPostLinesFor({
|
|
113
|
+
ok: false,
|
|
114
|
+
error_code: 403,
|
|
115
|
+
description: 'Forbidden: bot was blocked by the user',
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
expect(lines).toHaveLength(1)
|
|
119
|
+
expect(lines[0]).toContain('status=err')
|
|
120
|
+
expect(lines[0]).toContain('code=403')
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
describe('tg-post logger — benign 400s do not become error noise (#3927)', () => {
|
|
125
|
+
// Promoting every ok:false to status=err would flood the log with the
|
|
126
|
+
// high-volume no-ops the retry policy already swallows, burying the signal
|
|
127
|
+
// this change exists to create. They get their own `benign` tier instead:
|
|
128
|
+
// still logged (same volume as the status=ok line they replace), never
|
|
129
|
+
// matched by `grep status=err`.
|
|
130
|
+
const benign: Array<[string, string]> = [
|
|
131
|
+
['not modified', 'Bad Request: message is not modified'],
|
|
132
|
+
['edit target gone', 'Bad Request: message to edit not found'],
|
|
133
|
+
['delete target gone', 'Bad Request: message to delete not found'],
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
for (const [label, description] of benign) {
|
|
137
|
+
it(`logs "${label}" as status=benign, never status=err`, async () => {
|
|
138
|
+
const lines = await tgPostLinesFor({ ok: false, error_code: 400, description })
|
|
139
|
+
|
|
140
|
+
expect(lines).toHaveLength(1)
|
|
141
|
+
expect(lines[0]).toContain('status=benign')
|
|
142
|
+
expect(lines[0]).not.toContain('status=err')
|
|
143
|
+
// Still honest about what happened — the reason is on the line.
|
|
144
|
+
expect(lines[0]).toContain('code=400')
|
|
145
|
+
expect(lines[0]).toContain('desc=Bad Request: message')
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
describe('tg-post logger — success is unchanged', () => {
|
|
151
|
+
it('still logs a genuine ok:true response as status=ok with empty error fields', async () => {
|
|
152
|
+
const lines = await tgPostLinesFor({
|
|
153
|
+
ok: true,
|
|
154
|
+
result: { message_id: 7, date: 0, chat: { id: 4242, type: 'private' } },
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
expect(lines).toHaveLength(1)
|
|
158
|
+
expect(lines[0]).toContain('status=ok')
|
|
159
|
+
expect(lines[0]).toContain('err=- code=- desc=-')
|
|
160
|
+
})
|
|
161
|
+
})
|
|
@@ -217,6 +217,36 @@ function makePersistingPinHarness(path: string) {
|
|
|
217
217
|
describe('worker-feed pin persistence — durable status-pins.json survives steady-state edits (F1)', () => {
|
|
218
218
|
const PATH = '/state/agent/telegram/status-pins.json'
|
|
219
219
|
|
|
220
|
+
it('writes an UNAMBIGUOUS (chat, thread) pin key — no trailing-space topic slot', async () => {
|
|
221
|
+
// The key is persisted verbatim into status-pins.json. It used to be
|
|
222
|
+
// `${chatId} ${threadId ?? ''}`, so a topic-less group wrote
|
|
223
|
+
// `"pinKey": "wk:group:-100123 "` — trailing whitespace that survives a
|
|
224
|
+
// JSON round-trip and is invisible in every log and grep.
|
|
225
|
+
const bot = makeFakeBot()
|
|
226
|
+
const pin = makePersistingPinHarness(PATH)
|
|
227
|
+
let clock = 0
|
|
228
|
+
const feed = createWorkerActivityFeed({
|
|
229
|
+
bot,
|
|
230
|
+
now: () => clock,
|
|
231
|
+
firstPaintMinMs: 0,
|
|
232
|
+
minEditIntervalMs: 0,
|
|
233
|
+
reconcilePin: pin.reconcilePinFn,
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
clock = 1000
|
|
237
|
+
await feed.update('w-topicless', '-100123', view({ elapsedMs: 1000, toolCount: 1 }))
|
|
238
|
+
await flush()
|
|
239
|
+
clock = 2000
|
|
240
|
+
await feed.update('w-topic', '-100123', view({ elapsedMs: 1000, toolCount: 1 }), 7)
|
|
241
|
+
await flush()
|
|
242
|
+
|
|
243
|
+
const keys = pin.rows().map((r) => r.pinKey).sort()
|
|
244
|
+
expect(keys).toEqual(['wk:group:-100123:-', 'wk:group:-100123:7'])
|
|
245
|
+
// Same chat, different topic ⇒ genuinely different rows, and no key can be
|
|
246
|
+
// mistaken for another by an operator or a tool reconciling by chat.
|
|
247
|
+
for (const k of keys) expect(k).toBe(k.trim())
|
|
248
|
+
})
|
|
249
|
+
|
|
220
250
|
it('preserves the wk:group row across many steady-state edits (noop-clear must NOT delete it)', async () => {
|
|
221
251
|
const bot = makeFakeBot()
|
|
222
252
|
const pin = makePersistingPinHarness(PATH)
|