switchroom 0.18.7 → 0.18.8

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 (54) hide show
  1. package/dist/cli/switchroom.js +905 -758
  2. package/dist/host-control/main.js +1 -1
  3. package/package.json +1 -1
  4. package/profiles/_base/start.sh.hbs +111 -34
  5. package/skills/switchroom-runtime/SKILL.md +2 -0
  6. package/telegram-plugin/dist/gateway/gateway.js +1403 -657
  7. package/telegram-plugin/flood-circuit-breaker.ts +123 -0
  8. package/telegram-plugin/gateway/activity-card-store.ts +63 -18
  9. package/telegram-plugin/gateway/boot-card.ts +27 -0
  10. package/telegram-plugin/gateway/busy-ack.ts +106 -0
  11. package/telegram-plugin/gateway/gateway.ts +564 -85
  12. package/telegram-plugin/gateway/mental-model-propose-diff.ts +61 -5
  13. package/telegram-plugin/gateway/model-command.ts +23 -11
  14. package/telegram-plugin/gateway/session-model-file.ts +198 -0
  15. package/telegram-plugin/gateway/status-pin-store.ts +82 -22
  16. package/telegram-plugin/gateway/worker-pin-reaper.ts +114 -0
  17. package/telegram-plugin/hooks/hooks.json +10 -10
  18. package/telegram-plugin/hooks/run-hook.sh +84 -0
  19. package/telegram-plugin/model-unavailable.ts +26 -0
  20. package/telegram-plugin/pty-partial-handler.ts +39 -0
  21. package/telegram-plugin/render/rich-render.ts +79 -1
  22. package/telegram-plugin/retry-api-call.ts +62 -0
  23. package/telegram-plugin/shared/bot-runtime.ts +8 -1
  24. package/telegram-plugin/silence-poke.ts +14 -0
  25. package/telegram-plugin/stream-controller.ts +156 -38
  26. package/telegram-plugin/tests/activity-card-store.test.ts +47 -2
  27. package/telegram-plugin/tests/approval-card-restart-outcome.test.ts +218 -0
  28. package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +111 -0
  29. package/telegram-plugin/tests/busy-ack-wiring.test.ts +118 -0
  30. package/telegram-plugin/tests/busy-ack.test.ts +121 -0
  31. package/telegram-plugin/tests/flood-circuit-breaker.test.ts +74 -0
  32. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +177 -25
  33. package/telegram-plugin/tests/mental-model-name-entity-corruption.test.ts +119 -0
  34. package/telegram-plugin/tests/model-command.test.ts +2 -2
  35. package/telegram-plugin/tests/model-unavailable.test.ts +41 -0
  36. package/telegram-plugin/tests/pty-partial-handler.test.ts +56 -0
  37. package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +98 -0
  38. package/telegram-plugin/tests/retry-api-call.test.ts +59 -0
  39. package/telegram-plugin/tests/run-hook-wrapper.test.ts +132 -0
  40. package/telegram-plugin/tests/session-model-file.test.ts +132 -0
  41. package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +3 -3
  42. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
  43. package/telegram-plugin/tests/status-pin-store.test.ts +62 -6
  44. package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +122 -0
  45. package/telegram-plugin/tests/voice-send.test.ts +308 -0
  46. package/telegram-plugin/tests/worker-pin-reaper.test.ts +132 -0
  47. package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +118 -0
  48. package/telegram-plugin/uat/scenarios/jtbd-midflight-busy-ack-dm.test.ts +201 -0
  49. package/telegram-plugin/uat/scenarios/jtbd-worker-pin-lifecycle-dm.test.ts +208 -0
  50. package/telegram-plugin/uat/scenarios/vault-card-survives-gateway-restart-dm.test.ts +140 -0
  51. package/telegram-plugin/uat/scenarios/vault-deny-resumes-turn-dm.test.ts +84 -0
  52. package/telegram-plugin/uat/scenarios/vault-timeout-wakes-agent-dm.test.ts +91 -0
  53. package/telegram-plugin/voice-ondemand.ts +25 -1
  54. package/telegram-plugin/voice-send.ts +154 -0
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Regression coverage for #2976 — HTML-entity corruption in mental-model
3
+ * fields, config-write-boundary layer.
4
+ *
5
+ * Reachability (stated plainly so these tests don't over-claim):
6
+ * - `source_query` decode is the REACHABLE/load-bearing half: a proposal's
7
+ * free-form query can carry escaped entities the model copied out of its
8
+ * Telegram-HTML context, and undecoded it steers recall on `R&D`
9
+ * instead of `R&D`.
10
+ * - `name` decode is REDUNDANT with the gateway slug gate
11
+ * (/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/ rejects an entity-bearing name before
12
+ * `buildMentalModelAppendDiff` ever runs). It's belt-and-suspenders; the
13
+ * name-path tests here exercise the boundary directly (bypassing the gate)
14
+ * to prove the normalization, NOT to imply the propose path could carry the
15
+ * bug.
16
+ * - The observed in-NAME corruption (`Nutrition Protocol & Deficit
17
+ * Status`, klanker 2026-07-06) actually arrived via the DIRECT
18
+ * `create_mental_model` Hindsight tool, which this PR does not touch — that
19
+ * vector is out of scope (steering.ts / Dockerfile.hindsight follow-ups).
20
+ *
21
+ * These tests pin the switchroom-owned WRITE-BOUNDARY normalization: name +
22
+ * source_query are decoded to their literal characters BEFORE synthesis into
23
+ * the config diff. Each assertion below FAILS against the pre-fix code (which
24
+ * serialized `spec.name` / `spec.source_query` verbatim) and passes after.
25
+ */
26
+
27
+ import { describe, it, expect } from "vitest";
28
+ import { parseDocument } from "yaml";
29
+ import {
30
+ buildMentalModelAppendDiff,
31
+ readDeclaredMentalModelNames,
32
+ decodeCanonicalEntities,
33
+ } from "../gateway/mental-model-propose-diff.js";
34
+
35
+ const BASE_CONFIG = `agents:
36
+ klanker:
37
+ memory:
38
+ backend: hindsight
39
+ `;
40
+
41
+ /** Pull the appended model node out of the synthesized `after` config. */
42
+ function appendedModel(after: string, agent = "klanker"): { name?: unknown; source_query?: unknown } {
43
+ const js = parseDocument(after).toJS() as {
44
+ agents: Record<string, { memory: { mental_models: Array<Record<string, unknown>> } }>;
45
+ };
46
+ const models = js.agents[agent]!.memory.mental_models;
47
+ return models[models.length - 1]!;
48
+ }
49
+
50
+ describe("decodeCanonicalEntities — single-pass, canonical six only", () => {
51
+ it("decodes each canonical entity to its literal character", () => {
52
+ expect(decodeCanonicalEntities("R&amp;D")).toBe("R&D");
53
+ expect(decodeCanonicalEntities("a&lt;b&gt;c")).toBe("a<b>c");
54
+ expect(decodeCanonicalEntities("say &quot;hi&quot;")).toBe('say "hi"');
55
+ expect(decodeCanonicalEntities("Ken&apos;s")).toBe("Ken's");
56
+ expect(decodeCanonicalEntities("Ken&#39;s")).toBe("Ken's");
57
+ });
58
+
59
+ it("undoes exactly ONE layer of escaping (double-escaped → single-escaped)", () => {
60
+ // We only reverse the escaping switchroom applied when rendering the model's
61
+ // own prior output — never the user's literal intent.
62
+ expect(decodeCanonicalEntities("R&amp;amp;D")).toBe("R&amp;D");
63
+ expect(decodeCanonicalEntities("&amp;lt;")).toBe("&lt;");
64
+ });
65
+
66
+ it("leaves entity-free text untouched (idempotent on clean input)", () => {
67
+ expect(decodeCanonicalEntities("Nutrition Protocol & Deficit")).toBe("Nutrition Protocol & Deficit");
68
+ });
69
+ });
70
+
71
+ describe("#2976 — propose write-boundary normalizes the mental-model name", () => {
72
+ it("stores the LITERAL '&' name, not the escaped '&amp;' the model emitted", () => {
73
+ const built = buildMentalModelAppendDiff({
74
+ configText: BASE_CONFIG,
75
+ agentName: "klanker",
76
+ spec: { name: "Nutrition Protocol &amp; Deficit Status", source_query: "how is the deficit?" },
77
+ });
78
+ expect(built.ok).toBe(true);
79
+ if (!built.ok) return;
80
+ expect(appendedModel(built.after).name).toBe("Nutrition Protocol & Deficit Status");
81
+ // And there is no residual entity anywhere in the persisted config.
82
+ expect(built.after).not.toContain("&amp;");
83
+ // Reading the declared names back yields the decoded literal.
84
+ expect(readDeclaredMentalModelNames(built.after, "klanker")).toContain(
85
+ "Nutrition Protocol & Deficit Status",
86
+ );
87
+ });
88
+
89
+ it("normalizes the recall-steering source_query too (not just the name)", () => {
90
+ const built = buildMentalModelAppendDiff({
91
+ configText: BASE_CONFIG,
92
+ agentName: "klanker",
93
+ spec: { name: "rd-budget", source_query: "what is the user&apos;s R&amp;D budget?" },
94
+ });
95
+ expect(built.ok).toBe(true);
96
+ if (!built.ok) return;
97
+ expect(appendedModel(built.after).source_query).toBe("what is the user's R&D budget?");
98
+ });
99
+
100
+ it("rejects an entity-escaped re-propose of an already-declared literal name (dup guard on decoded name)", () => {
101
+ const configWithModel = `agents:
102
+ klanker:
103
+ memory:
104
+ backend: hindsight
105
+ mental_models:
106
+ - name: Q3 R&D Plan
107
+ source_query: what is the plan?
108
+ `;
109
+ const built = buildMentalModelAppendDiff({
110
+ configText: configWithModel,
111
+ agentName: "klanker",
112
+ // Escaped variant of the same logical name — must collide, not create a twin.
113
+ spec: { name: "Q3 R&amp;D Plan", source_query: "different query" },
114
+ });
115
+ expect(built.ok).toBe(false);
116
+ if (built.ok) return;
117
+ expect(built.error).toBe("duplicate");
118
+ });
119
+ });
@@ -201,7 +201,7 @@ describe("handleModelCommand — set", () => {
201
201
  const reply = await handleModelCommand({ kind: "set", model: "opus" }, deps);
202
202
  expect(calls).toEqual([{ agent: "klanker", command: "/model opus" }]);
203
203
  expect(reply.text).toContain("<pre>⏺ Set model to sonnet</pre>");
204
- expect(reply.text).toContain("Session-only");
204
+ expect(reply.text).toContain("Sticky across switchroom-managed relaunches");
205
205
  expect(reply.html).toBe(true);
206
206
  // A verified confirmation records the live model so /status stays honest
207
207
  // (bug 1: the typed path never recorded the switch before).
@@ -444,7 +444,7 @@ describe("handleModelCommand — busy gate + honest unverified reporting", () =>
444
444
  const reply = await handleModelCommand({ kind: "set", model: "claude-bogus" }, deps);
445
445
  expect(reply.text).toContain("did not take");
446
446
  expect(reply.text).toContain("Model not found");
447
- expect(reply.text).not.toContain("Session-only");
447
+ expect(reply.text).not.toContain("Sticky across switchroom-managed relaunches");
448
448
  expect(reply.selectedModel).toBeUndefined();
449
449
  });
450
450
 
@@ -76,6 +76,47 @@ describe('detectModelUnavailable — overload / 429 / 5xx strings', () => {
76
76
  })
77
77
  })
78
78
 
79
+ describe('detectModelUnavailable — transient upstream 429 vs account quota (#2922)', () => {
80
+ // The load-bearing regression: a server-side transient 429 whose message
81
+ // explicitly negates the account-quota reading ("not your usage limit")
82
+ // was misclassified as `quota_exhausted` by the negation-blind "usage limit"
83
+ // substring, firing a phantom fleet failover + dead turn. It must classify
84
+ // as `overload` (the calm rate-limit path Claude Code retries internally).
85
+ it("classifies the live-incident 'not your usage limit' 429 as overload, NOT quota_exhausted", () => {
86
+ const raw =
87
+ "API Error: Server is temporarily limiting requests (not your usage limit) · " +
88
+ 'b\'{"type":"error","error":{"type":"rate_limit_error","message":' +
89
+ '"This request would exceed your account\'s rate limit. Please try again later."}}\''
90
+ const d = detectModelUnavailable(raw)
91
+ expect(d?.kind).toBe('overload')
92
+ expect(d?.kind).not.toBe('quota_exhausted')
93
+ })
94
+
95
+ it("classifies bare 'temporarily limiting requests' as overload", () => {
96
+ expect(
97
+ detectModelUnavailable('Server is temporarily limiting requests')?.kind,
98
+ ).toBe('overload')
99
+ })
100
+
101
+ it("classifies \"would exceed your account's rate limit\" as overload", () => {
102
+ expect(
103
+ detectModelUnavailable(
104
+ "This request would exceed your account's rate limit. Please try again later.",
105
+ )?.kind,
106
+ ).toBe('overload')
107
+ })
108
+
109
+ it('still classifies a genuine account usage-limit hit as quota_exhausted', () => {
110
+ // Guard against over-correction: real quota exhaustion must stay quota.
111
+ expect(
112
+ detectModelUnavailable("You've hit your limit · resets 8:50am (Australia/Melbourne)")?.kind,
113
+ ).toBe('quota_exhausted')
114
+ expect(detectModelUnavailable('Reached usage limit for the 5h window')?.kind).toBe(
115
+ 'quota_exhausted',
116
+ )
117
+ })
118
+ })
119
+
79
120
  describe('detectModelUnavailable — network failures', () => {
80
121
  it('classifies ECONNREFUSED', () => {
81
122
  expect(detectModelUnavailable('connect ECONNREFUSED 1.2.3.4:443')?.kind).toBe('network')
@@ -9,6 +9,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
9
9
  import {
10
10
  createPtyPartialHandler,
11
11
  handlePtyPartialPure,
12
+ looksLikeRawApiError,
12
13
  type PtyHandlerState,
13
14
  type PtyHandlerDeps,
14
15
  } from '../pty-partial-handler.js'
@@ -65,6 +66,35 @@ describe('handlePtyPartialPure', () => {
65
66
  expect(bot.api.sendMessage).not.toHaveBeenCalled()
66
67
  })
67
68
 
69
+ it('suppresses a raw API-error TUI line so it never leaks to chat (#2922 Bug 3)', async () => {
70
+ const state = makeState({ currentSessionChatId: '1' })
71
+ const deps = makeDeps(bot)
72
+ // The exact shape Claude Code's TUI renders on a transient 429.
73
+ const raw =
74
+ "API Error: Server is temporarily limiting requests (not your usage limit) · " +
75
+ "b'{\"type\":\"error\",\"error\":{\"type\":\"rate_limit_error\"}}'"
76
+ const action = handlePtyPartialPure(raw, state, deps)
77
+ expect(action).toBe('error-suppressed')
78
+ await microtaskFlush()
79
+ // Nothing sent — the operator-event pipeline owns the user-facing card.
80
+ expect(bot.api.sendMessage).not.toHaveBeenCalled()
81
+ expect(state.activeDraftStreams.size).toBe(0)
82
+ // Not recorded as a preview, so it can't poison later dedup either.
83
+ expect(state.lastPtyPreviewByChat.size).toBe(0)
84
+ })
85
+
86
+ it('does NOT suppress ordinary assistant text that merely mentions errors', async () => {
87
+ const state = makeState({ currentSessionChatId: '1' })
88
+ const action = handlePtyPartialPure(
89
+ "Here's how to handle an error in your retry loop:",
90
+ state,
91
+ makeDeps(bot),
92
+ )
93
+ expect(action).toBe('update-new')
94
+ await microtaskFlush()
95
+ expect(bot.api.sendMessage).toHaveBeenCalledTimes(1)
96
+ })
97
+
68
98
  it('dedups when same text arrives twice in a row', async () => {
69
99
  const state = makeState({ currentSessionChatId: '1' })
70
100
  const deps = makeDeps(bot)
@@ -324,3 +354,29 @@ describe('createPtyPartialHandler — session + buffer replay', () => {
324
354
  expect(state.activeDraftStreams.size).toBe(0)
325
355
  })
326
356
  })
357
+
358
+ describe('looksLikeRawApiError (#2922 Bug 3)', () => {
359
+ it('flags the CLI "API Error: … · b\'{…}\'" line', () => {
360
+ expect(
361
+ looksLikeRawApiError(
362
+ "API Error: Server is temporarily limiting requests · b'{\"type\":\"error\"}'",
363
+ ),
364
+ ).toBe(true)
365
+ })
366
+
367
+ it('flags a bare rate_limit_error JSON body', () => {
368
+ expect(
369
+ looksLikeRawApiError('{"type":"error","error":{"type":"rate_limit_error"}}'),
370
+ ).toBe(true)
371
+ })
372
+
373
+ it('flags overloaded_error and is_error markers', () => {
374
+ expect(looksLikeRawApiError('{"type":"overloaded_error"}')).toBe(true)
375
+ expect(looksLikeRawApiError('{"is_error":true,"content":"boom"}')).toBe(true)
376
+ })
377
+
378
+ it('does not flag ordinary prose mentioning "error"', () => {
379
+ expect(looksLikeRawApiError('I hit an error handling that request')).toBe(false)
380
+ expect(looksLikeRawApiError('')).toBe(false)
381
+ })
382
+ })
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Regression tests for the chunk-boundary cap bug (fix/rich-render-chunk-boundary-cap).
3
+ *
4
+ * THE BUG: the outbound safe renderer (`renderSafe`) re-escapes GFM-special
5
+ * characters, which GROWS a body. A raw chunk sized just under the wire cap can
6
+ * escape PAST it. `renderSafe`'s only oversize recourse is to degrade the WHOLE
7
+ * document to `mode:"plain"` (raw source, no rich wrapper) — it never re-splits
8
+ * a multi-block body. The send path then ships that plain body through the
9
+ * 4096-char plain `sendMessage` endpoint, so a ~32k plain body is rejected by
10
+ * Telegram (`message is too long`) and the answer is dropped.
11
+ *
12
+ * `renderOutboundChunks` closes the gap: every returned piece fits its own wire
13
+ * cap (rich `<= maxLen`, plain `<= plainMax`) and is cut only at
14
+ * `splitMarkdownChunks`' safe boundaries (never bisecting a fence / table row).
15
+ */
16
+ import { describe, it, expect } from "vitest";
17
+ import { renderOutboundChunks, PLAIN_TEXT_MAX_CHARS } from "../../render/rich-render.js";
18
+ import { RICH_MESSAGE_MAX_CHARS } from "../../format.js";
19
+
20
+ const ON = { SWITCHROOM_RICH_RENDER: "1" } as NodeJS.ProcessEnv;
21
+ const OFF = {} as NodeJS.ProcessEnv;
22
+
23
+ /** Count fenced-code delimiter lines (```) in a body. A piece that bisects a
24
+ * fenced block has an ODD count. */
25
+ function fenceCount(s: string): number {
26
+ return (s.match(/^```/gm) ?? []).length;
27
+ }
28
+
29
+ describe("renderOutboundChunks", () => {
30
+ it("flag OFF is a single passthrough piece (byte-for-byte)", () => {
31
+ const raw = "**bold** and _italic_ | a | table |";
32
+ const pieces = renderOutboundChunks(raw, OFF);
33
+ expect(pieces).toHaveLength(1);
34
+ expect(pieces[0].text).toBe(raw);
35
+ expect(pieces[0].mode).toBe("markdown");
36
+ });
37
+
38
+ it("flag ON, body that fits is a single piece (common case)", () => {
39
+ const pieces = renderOutboundChunks("just some plain prose", ON);
40
+ expect(pieces).toHaveLength(1);
41
+ expect(pieces[0].text.length).toBeLessThanOrEqual(RICH_MESSAGE_MAX_CHARS);
42
+ });
43
+
44
+ it("REGRESSION: a near-cap escapable body splits into cap-respecting pieces", () => {
45
+ // Prose whose escaping (`_ * |` each gain a leading `\`) grows it past the
46
+ // cap. Use small caps so the test is fast; the invariant is cap-agnostic.
47
+ const maxLen = 200;
48
+ const plainMax = 80;
49
+ const unit = "a_b*c|d ";
50
+ const raw = unit.repeat(40); // 320 raw chars, ~1.5x after escaping
51
+ const pieces = renderOutboundChunks(raw, ON, maxLen, plainMax);
52
+
53
+ // The whole body did NOT fit as one message — it was re-split.
54
+ expect(pieces.length).toBeGreaterThan(1);
55
+ for (const p of pieces) {
56
+ if (p.mode === "plain") {
57
+ // A plain piece rides the plain `sendMessage` endpoint — must fit its cap.
58
+ expect(p.text.length).toBeLessThanOrEqual(plainMax);
59
+ } else {
60
+ expect(p.text.length).toBeLessThanOrEqual(maxLen);
61
+ }
62
+ }
63
+ });
64
+
65
+ it("REGRESSION: never bisects a fenced code block when splitting", () => {
66
+ const maxLen = 300;
67
+ const plainMax = 250; // >= the fence size, so a fence never needs a hard slice.
68
+ // A fenced block big enough that a naive length cut would land inside it,
69
+ // wrapped in prose so the whole document overflows and must be re-split.
70
+ const fence = "```\n" + "code line here\n".repeat(8) + "```";
71
+ const prose = "word ".repeat(40);
72
+ const raw = `${prose}\n\n${fence}\n\n${prose}`;
73
+ const pieces = renderOutboundChunks(raw, ON, maxLen, plainMax);
74
+
75
+ expect(pieces.length).toBeGreaterThan(1);
76
+ for (const p of pieces) {
77
+ // Every emitted piece has BALANCED fence delimiters — no piece opens a
78
+ // fence it doesn't close (which would swallow the next piece's text).
79
+ expect(fenceCount(p.text) % 2).toBe(0);
80
+ const cap = p.mode === "plain" ? plainMax : maxLen;
81
+ expect(p.text.length).toBeLessThanOrEqual(cap);
82
+ }
83
+ });
84
+
85
+ it("REAL-CAP: a ~32k escapable body never yields an over-4096 plain piece", () => {
86
+ // The production failure: raw just under RICH_MESSAGE_MAX_CHARS, escaping
87
+ // pushes the rendered form over it. On the buggy path this became ONE
88
+ // ~32k plain body sent through the 4096 plain endpoint. Here every plain
89
+ // piece is <= 4096 and every rich piece <= 32768.
90
+ const unit = "a_b*c|d ";
91
+ const raw = unit.repeat(Math.floor((RICH_MESSAGE_MAX_CHARS - 20) / unit.length));
92
+ const pieces = renderOutboundChunks(raw, ON);
93
+ for (const p of pieces) {
94
+ const cap = p.mode === "plain" ? PLAIN_TEXT_MAX_CHARS : RICH_MESSAGE_MAX_CHARS;
95
+ expect(p.text.length).toBeLessThanOrEqual(cap);
96
+ }
97
+ }, 30000);
98
+ });
@@ -15,6 +15,8 @@ import {
15
15
  createSwallowingRetryApiCall,
16
16
  retryWithThreadFallback,
17
17
  isHtmlParseRejectError,
18
+ isLocalResourceError,
19
+ LOCAL_RESOURCE_EXHAUSTED,
18
20
  type RetryObserver,
19
21
  } from '../retry-api-call.js'
20
22
  import { errors, makeGrammyError } from './fake-bot-api.js'
@@ -512,3 +514,60 @@ describe('isHtmlParseRejectError', () => {
512
514
  ).toBe(true)
513
515
  })
514
516
  })
517
+
518
+ describe('#2923 — LOCAL resource exhaustion is NOT retried (avoids flood ban)', () => {
519
+ it('classifies ENOSPC / EDQUOT / EIO / ENOMEM by errno code', () => {
520
+ expect(isLocalResourceError(Object.assign(new Error('x'), { code: 'ENOSPC' }))).toBe(true)
521
+ expect(isLocalResourceError(Object.assign(new Error('x'), { code: 'EDQUOT' }))).toBe(true)
522
+ expect(isLocalResourceError(Object.assign(new Error('x'), { code: 'EIO' }))).toBe(true)
523
+ expect(isLocalResourceError(Object.assign(new Error('x'), { code: 'ENOMEM' }))).toBe(true)
524
+ })
525
+
526
+ it('classifies by message when no code is present (incl. EIO, word-boundaried)', () => {
527
+ expect(isLocalResourceError(new Error('ENOSPC: no space left on device, write'))).toBe(true)
528
+ expect(isLocalResourceError(new Error('disk quota exceeded'))).toBe(true)
529
+ expect(isLocalResourceError(new Error('EIO: i/o error, write'))).toBe(true)
530
+ // No false match on a substring (e.g. a word containing the letters).
531
+ expect(isLocalResourceError(new Error('DENOSPCX not a real code'))).toBe(false)
532
+ })
533
+
534
+ it('does NOT classify a remote GrammyError or ordinary error', () => {
535
+ expect(isLocalResourceError(errors.floodWait(10))).toBe(false)
536
+ expect(isLocalResourceError(new Error('fetch failed'))).toBe(false)
537
+ })
538
+
539
+ it('throws LOCAL_RESOURCE_EXHAUSTED immediately without retrying', async () => {
540
+ // Before the fix: an ENOSPC thrown by the send-staging step fell through
541
+ // to the network-retry branch pattern OR was rethrown but only after the
542
+ // caller kept re-driving sends — the storm that tripped the flood ban.
543
+ // Now it must fail FAST on the first attempt with a distinct marker.
544
+ const sleep = vi.fn(async () => {})
545
+ let calls = 0
546
+ const retry = createRetryApiCall({ maxRetries: 3, sleep })
547
+ await expect(
548
+ retry(async () => {
549
+ calls++
550
+ throw Object.assign(new Error('ENOSPC: no space left on device'), { code: 'ENOSPC' })
551
+ }),
552
+ ).rejects.toThrow(LOCAL_RESOURCE_EXHAUSTED)
553
+ expect(calls).toBe(1) // no retry
554
+ expect(sleep).not.toHaveBeenCalled() // no backoff-into-flood
555
+ })
556
+
557
+ it('fires onFloodWait with the retry_after when a 429 is seen', async () => {
558
+ const seen: number[] = []
559
+ const sleep = vi.fn(async () => {})
560
+ let n = 0
561
+ const retry = createRetryApiCall({
562
+ maxRetries: 3,
563
+ sleep,
564
+ onFloodWait: (s) => seen.push(s),
565
+ })
566
+ const out = await retry(async () => {
567
+ if (n++ === 0) throw errors.floodWait(42)
568
+ return 'ok'
569
+ })
570
+ expect(out).toBe('ok')
571
+ expect(seen).toEqual([42])
572
+ })
573
+ })
@@ -0,0 +1,132 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
+ import { spawnSync } from 'node:child_process'
3
+ import { mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
5
+ import { join, resolve } from 'node:path'
6
+ import { fileURLToPath } from 'node:url'
7
+
8
+ /**
9
+ * #2555 — run-hook.sh must tolerate a Node exit-134 (uv_thread_create abort
10
+ * under memory pressure): retry once, and if it still aborts, skip cleanly
11
+ * (exit 0) rather than propagating 134. Real non-134 statuses pass through.
12
+ *
13
+ * We drive the wrapper with a fake command (a tiny sh script) whose exit code
14
+ * is scripted via a counter file, so we exercise the exact control flow
15
+ * without needing a real memory-pressured Node.
16
+ */
17
+ const wrapper = resolve(
18
+ fileURLToPath(new URL('../hooks/run-hook.sh', import.meta.url)),
19
+ )
20
+
21
+ describe('#2555 run-hook.sh exit-134 tolerance', () => {
22
+ let dir: string
23
+ let fake: string
24
+ let counter: string
25
+
26
+ beforeEach(() => {
27
+ dir = mkdtempSync(join(tmpdir(), 'run-hook-'))
28
+ fake = join(dir, 'fake.sh')
29
+ counter = join(dir, 'n')
30
+ writeFileSync(counter, '0')
31
+ })
32
+ afterEach(() => rmSync(dir, { recursive: true, force: true }))
33
+
34
+ /** Fake command: exits `codes[attemptIndex]`, records each invocation. */
35
+ function writeFake(codes: number[]): void {
36
+ writeFileSync(
37
+ fake,
38
+ [
39
+ '#!/bin/sh',
40
+ `n=$(cat "${counter}")`,
41
+ `echo "$((n + 1))" > "${counter}"`,
42
+ 'case "$n" in',
43
+ ...codes.map((c, i) => ` ${i}) exit ${c} ;;`),
44
+ ` *) exit ${codes[codes.length - 1]} ;;`,
45
+ 'esac',
46
+ ].join('\n'),
47
+ )
48
+ }
49
+
50
+ const run = (input = '') =>
51
+ spawnSync('sh', [wrapper, 'sh', fake], { encoding: 'utf-8', input })
52
+
53
+ const attempts = () => Number(readFileSync(counter, 'utf-8').trim())
54
+
55
+ it('passes a clean exit 0 through without retrying', () => {
56
+ writeFake([0])
57
+ const r = run()
58
+ expect(r.status).toBe(0)
59
+ expect(attempts()).toBe(1)
60
+ })
61
+
62
+ it('passes a real non-134 failure through unchanged (no retry, not masked)', () => {
63
+ writeFake([2])
64
+ const r = run()
65
+ expect(r.status).toBe(2)
66
+ expect(attempts()).toBe(1)
67
+ })
68
+
69
+ it('retries ONCE on a 134 abort and succeeds on the second attempt', () => {
70
+ writeFake([134, 0])
71
+ const r = run()
72
+ expect(r.status).toBe(0)
73
+ expect(attempts()).toBe(2)
74
+ })
75
+
76
+ it('skips cleanly (exit 0) when it aborts 134 twice — no crash card', () => {
77
+ writeFake([134, 134])
78
+ const r = run()
79
+ expect(r.status).toBe(0) // skipped cleanly, NOT 134
80
+ expect(attempts()).toBe(2)
81
+ expect(r.stderr).toMatch(/skipping hook cleanly/)
82
+ })
83
+
84
+ it('exports a shrunk UV_THREADPOOL_SIZE to the child', () => {
85
+ writeFileSync(fake, `#!/bin/sh\necho "$UV_THREADPOOL_SIZE"\nexit 0\n`)
86
+ const r = run()
87
+ expect(r.stdout.trim()).toBe('1')
88
+ })
89
+
90
+ it('replays the SAME stdin payload on the retry (scanner never sees empty input)', () => {
91
+ // Fake: attempt 0 reads stdin then aborts 134; attempt 1 reads stdin and
92
+ // records it, then exits 0. If stdin were not preserved, the recorded
93
+ // payload on the retry would be empty.
94
+ const seen = join(dir, 'seen-stdin')
95
+ writeFileSync(
96
+ fake,
97
+ [
98
+ '#!/bin/sh',
99
+ `n=$(cat "${counter}")`,
100
+ `echo "$((n + 1))" > "${counter}"`,
101
+ 'data=$(cat)', // drain stdin (the abort-after-read scenario)
102
+ `echo "$data" > "${seen}.$n"`,
103
+ '[ "$n" = "0" ] && exit 134',
104
+ 'exit 0',
105
+ ].join('\n'),
106
+ )
107
+ const r = run('SECRET-PAYLOAD-123')
108
+ expect(r.status).toBe(0)
109
+ expect(attempts()).toBe(2)
110
+ // The RETRY (attempt 1) must have received the full payload, not empty.
111
+ expect(readFileSync(`${seen}.1`, 'utf-8').trim()).toBe('SECRET-PAYLOAD-123')
112
+ })
113
+
114
+ it('FAILS CLOSED (propagates 134) for a security hook that aborts twice', () => {
115
+ // A genuinely broken secret scanner must NOT silently pass — exit 0 after
116
+ // two aborts would be a silent security bypass.
117
+ const secFake = join(dir, 'secret-guard-pretool.mjs')
118
+ writeFileSync(
119
+ secFake,
120
+ [
121
+ '#!/bin/sh',
122
+ `n=$(cat "${counter}")`,
123
+ `echo "$((n + 1))" > "${counter}"`,
124
+ 'exit 134',
125
+ ].join('\n'),
126
+ )
127
+ const r = spawnSync('sh', [wrapper, 'sh', secFake], { encoding: 'utf-8', input: '{}' })
128
+ expect(r.status).toBe(134) // fail closed — NOT skipped
129
+ expect(attempts()).toBe(2)
130
+ expect(r.stderr).toMatch(/FAILING CLOSED/)
131
+ })
132
+ })