switchroom 0.18.11 → 0.18.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-scheduler/index.js +37 -5
- package/dist/auth-broker/index.js +116 -78
- package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
- package/dist/cli/ms-365-write-pretool.mjs +31 -8
- package/dist/cli/notion-write-pretool.mjs +38 -6
- package/dist/cli/skill-validate-pretool.mjs +144 -2847
- package/dist/cli/switchroom.js +3529 -4543
- package/dist/cli/ui/index.html +163 -17
- package/dist/host-control/main.js +605 -2847
- package/dist/vault/approvals/kernel-server.js +120 -13
- package/dist/vault/broker/server.js +259 -157
- package/package.json +3 -4
- package/profiles/_base/start.sh.hbs +65 -0
- package/profiles/_shared/vault-protocol.md.hbs +3 -1
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md.hbs +2 -2
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/telegram-plugin/bridge/bridge.ts +37 -0
- package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
- package/telegram-plugin/dist/bridge/bridge.js +122 -4
- package/telegram-plugin/dist/gateway/gateway.js +4213 -3288
- package/telegram-plugin/dist/server.js +139 -5
- package/telegram-plugin/flood-circuit-breaker.ts +493 -21
- package/telegram-plugin/format.ts +19 -0
- package/telegram-plugin/gateway/approval-hold.ts +602 -0
- package/telegram-plugin/gateway/auth-command.ts +92 -2
- package/telegram-plugin/gateway/auth-loopback-relay.ts +670 -0
- package/telegram-plugin/gateway/boot-card.ts +12 -5
- package/telegram-plugin/gateway/callback-query-handlers.ts +88 -1
- package/telegram-plugin/gateway/config-approval-handler.ts +6 -1
- package/telegram-plugin/gateway/disconnect-flush.ts +19 -0
- package/telegram-plugin/gateway/dm-pin-sweep.test.ts +251 -0
- package/telegram-plugin/gateway/dm-pin-sweep.ts +178 -0
- package/telegram-plugin/gateway/gateway.ts +1695 -230
- package/telegram-plugin/gateway/hostd-dispatch.ts +23 -0
- package/telegram-plugin/gateway/idle-clear.ts +90 -6
- package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +26 -5
- package/telegram-plugin/gateway/inject-handler.ts +8 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +46 -3
- package/telegram-plugin/gateway/ipc-server.ts +43 -0
- package/telegram-plugin/gateway/mental-model-propose-resolve.ts +145 -37
- package/telegram-plugin/gateway/model-command.ts +9 -3
- package/telegram-plugin/gateway/pending-session-command.ts +13 -1
- package/telegram-plugin/gateway/permission-ttl-sweep.ts +66 -0
- package/telegram-plugin/gateway/pre-approval-check.ts +74 -0
- package/telegram-plugin/gateway/queued-card-store.ts +217 -0
- package/telegram-plugin/gateway/session-model-file.ts +26 -1
- package/telegram-plugin/gateway/turn-end-gate-backstop.ts +59 -0
- package/telegram-plugin/gateway/turn-end-gate.ts +95 -0
- package/telegram-plugin/gateway/turn-typing-loop.ts +10 -2
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +13 -0
- package/telegram-plugin/history.ts +51 -0
- package/telegram-plugin/hooks/dispatch-claim-scan.mjs +259 -0
- package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
- package/telegram-plugin/hooks/hooks.json +9 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +303 -2
- package/telegram-plugin/model-unavailable.ts +41 -11
- package/telegram-plugin/operator-events.ts +23 -0
- package/telegram-plugin/outbound-field-redact.ts +69 -0
- package/telegram-plugin/package.json +0 -1
- package/telegram-plugin/permission-rule.ts +1 -0
- package/telegram-plugin/permission-title.ts +1 -0
- package/telegram-plugin/render/render.ts +32 -14
- package/telegram-plugin/retry-api-call.ts +212 -2
- package/telegram-plugin/scoped-approval.ts +11 -2
- package/telegram-plugin/secret-detect/chunker.ts +18 -4
- package/telegram-plugin/secret-detect/index.ts +12 -56
- package/telegram-plugin/send-gate-degraded.test.ts +574 -0
- package/telegram-plugin/send-gate-observability.test.ts +470 -0
- package/telegram-plugin/send-gate-observability.ts +355 -0
- package/telegram-plugin/send-gate.test.ts +717 -0
- package/telegram-plugin/send-gate.ts +1056 -0
- package/telegram-plugin/session-tail.ts +82 -7
- package/telegram-plugin/shared/bot-runtime.ts +17 -5
- package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
- package/telegram-plugin/status-pin-driver.ts +52 -7
- package/telegram-plugin/status-pin.ts +81 -0
- package/telegram-plugin/subagent-watcher.ts +173 -18
- package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
- package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
- package/telegram-plugin/tests/approval-hold-outcome.test.ts +327 -0
- package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
- package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
- package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
- package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
- package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
- package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
- package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
- package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
- package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
- package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
- package/telegram-plugin/tests/history.test.ts +115 -0
- package/telegram-plugin/tests/idle-clear.test.ts +233 -3
- package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
- package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
- package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +448 -0
- package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
- package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
- package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
- package/telegram-plugin/tests/model-command.test.ts +14 -0
- package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
- package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
- package/telegram-plugin/tests/pending-session-command.test.ts +21 -0
- package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
- package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
- package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
- package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
- package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -0
- package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
- package/telegram-plugin/tests/render/render.test.ts +88 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
- package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
- package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
- package/telegram-plugin/tests/session-model-file.test.ts +50 -0
- package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
- package/telegram-plugin/tests/status-pin.test.ts +275 -1
- package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
- package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
- package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
- package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
- package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
- package/telegram-plugin/typing-emitter.ts +224 -0
- package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
- package/telegram-plugin/welcome-text.ts +42 -0
- package/telegram-plugin/worktree-watch-cwds.ts +194 -5
- package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
- package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
- package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
- package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
- package/vendor/hindsight-memory/scripts/session_end.py +14 -3
- package/vendor/hindsight-memory/scripts/session_start.py +21 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
- package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
- package/vendor/hindsight-memory/tests/test_pending.py +44 -0
- package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
- package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
- package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
- package/telegram-plugin/channel-envelope-safety.ts +0 -56
- package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
- package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
|
@@ -9,12 +9,26 @@
|
|
|
9
9
|
* Run with:
|
|
10
10
|
* bun test telegram-plugin/tests/worktree-watch-cwds.test.ts
|
|
11
11
|
*/
|
|
12
|
-
import { describe, it, expect, beforeEach } from "vitest";
|
|
12
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
13
|
+
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
|
|
14
|
+
import { join, dirname } from "node:path";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
13
17
|
import {
|
|
14
18
|
ownedWorktreeCwds,
|
|
19
|
+
refreshOwnedWorktreeHeartbeats,
|
|
20
|
+
makeWorktreeWatchProvider,
|
|
15
21
|
__resetIdentityEscalationForTests,
|
|
16
22
|
type WorktreeOwnershipRecord,
|
|
23
|
+
type WorktreeHeartbeatRecord,
|
|
17
24
|
} from "../worktree-watch-cwds.js";
|
|
25
|
+
import {
|
|
26
|
+
writeRecord,
|
|
27
|
+
readRecord,
|
|
28
|
+
listRecords as registryListRecords,
|
|
29
|
+
touchHeartbeat as registryTouchHeartbeat,
|
|
30
|
+
} from "../../src/worktree/registry.js";
|
|
31
|
+
import type { WorktreeRecord } from "../../src/worktree/types.js";
|
|
18
32
|
|
|
19
33
|
const idPath = (p: string) => p; // identity realpath for deterministic tests
|
|
20
34
|
|
|
@@ -196,3 +210,203 @@ describe("ownedWorktreeCwds", () => {
|
|
|
196
210
|
expect(provider()).toEqual(["/wt/b"]);
|
|
197
211
|
});
|
|
198
212
|
});
|
|
213
|
+
|
|
214
|
+
describe("refreshOwnedWorktreeHeartbeats", () => {
|
|
215
|
+
const records: WorktreeHeartbeatRecord[] = [
|
|
216
|
+
{ id: "mine-1", ownerAgent: "klanker" },
|
|
217
|
+
{ id: "mine-2", ownerAgent: "klanker" },
|
|
218
|
+
{ id: "theirs", ownerAgent: "reggie" },
|
|
219
|
+
{ id: "ownerless" }, // ownerAgent undefined
|
|
220
|
+
];
|
|
221
|
+
|
|
222
|
+
it("touches ONLY this agent's records, never ownerless or foreign ones", () => {
|
|
223
|
+
const touched: string[] = [];
|
|
224
|
+
const n = refreshOwnedWorktreeHeartbeats({
|
|
225
|
+
self: "klanker",
|
|
226
|
+
listRecords: () => records,
|
|
227
|
+
touchHeartbeat: (id) => touched.push(id),
|
|
228
|
+
minRefreshIntervalMs: 0, // always touch
|
|
229
|
+
});
|
|
230
|
+
expect(touched.sort()).toEqual(["mine-1", "mine-2"]);
|
|
231
|
+
expect(touched).not.toContain("theirs");
|
|
232
|
+
expect(touched).not.toContain("ownerless");
|
|
233
|
+
expect(n).toBe(2);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("fail-closed: unresolved identity touches NOTHING (no #1116 leak)", () => {
|
|
237
|
+
const touched: string[] = [];
|
|
238
|
+
const n = refreshOwnedWorktreeHeartbeats({
|
|
239
|
+
self: undefined,
|
|
240
|
+
listRecords: () => records,
|
|
241
|
+
touchHeartbeat: (id) => touched.push(id),
|
|
242
|
+
minRefreshIntervalMs: 0,
|
|
243
|
+
});
|
|
244
|
+
expect(touched).toEqual([]);
|
|
245
|
+
expect(n).toBe(0);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("resolves identity from agentDir when env is unset (durable fallback)", () => {
|
|
249
|
+
const touched: string[] = [];
|
|
250
|
+
refreshOwnedWorktreeHeartbeats({
|
|
251
|
+
self: undefined,
|
|
252
|
+
agentDir: "/home/x/.switchroom/agents/klanker",
|
|
253
|
+
listRecords: () => records,
|
|
254
|
+
touchHeartbeat: (id) => touched.push(id),
|
|
255
|
+
minRefreshIntervalMs: 0,
|
|
256
|
+
});
|
|
257
|
+
expect(touched.sort()).toEqual(["mine-1", "mine-2"]);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("throttles: skips a record whose heartbeat is younger than the interval", () => {
|
|
261
|
+
const now = 1_000_000;
|
|
262
|
+
const fresh = new Date(now - 30_000).toISOString(); // 30s ago
|
|
263
|
+
const aged = new Date(now - 5 * 60_000).toISOString(); // 5min ago
|
|
264
|
+
const touched: string[] = [];
|
|
265
|
+
refreshOwnedWorktreeHeartbeats({
|
|
266
|
+
self: "klanker",
|
|
267
|
+
listRecords: () => [
|
|
268
|
+
{ id: "fresh", ownerAgent: "klanker", heartbeatAt: fresh },
|
|
269
|
+
{ id: "aged", ownerAgent: "klanker", heartbeatAt: aged },
|
|
270
|
+
],
|
|
271
|
+
touchHeartbeat: (id) => touched.push(id),
|
|
272
|
+
minRefreshIntervalMs: 2 * 60_000, // 2 min
|
|
273
|
+
now: () => now,
|
|
274
|
+
});
|
|
275
|
+
// Only the aged one crosses the throttle window.
|
|
276
|
+
expect(touched).toEqual(["aged"]);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("a registry read failure is swallowed (never throws on the hot loop)", () => {
|
|
280
|
+
expect(() =>
|
|
281
|
+
refreshOwnedWorktreeHeartbeats({
|
|
282
|
+
self: "klanker",
|
|
283
|
+
listRecords: () => {
|
|
284
|
+
throw new Error("registry gone");
|
|
285
|
+
},
|
|
286
|
+
touchHeartbeat: () => {},
|
|
287
|
+
}),
|
|
288
|
+
).not.toThrow();
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("a per-record touch failure does not abort the remaining records", () => {
|
|
292
|
+
const touched: string[] = [];
|
|
293
|
+
const n = refreshOwnedWorktreeHeartbeats({
|
|
294
|
+
self: "klanker",
|
|
295
|
+
listRecords: () => records,
|
|
296
|
+
touchHeartbeat: (id) => {
|
|
297
|
+
if (id === "mine-1") throw new Error("write failed");
|
|
298
|
+
touched.push(id);
|
|
299
|
+
},
|
|
300
|
+
minRefreshIntervalMs: 0,
|
|
301
|
+
});
|
|
302
|
+
// mine-2 still touched despite mine-1 throwing.
|
|
303
|
+
expect(touched).toEqual(["mine-2"]);
|
|
304
|
+
expect(n).toBe(1);
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// ─── M1: the gateway's extraWatchCwds provider ──────────────────────────────
|
|
309
|
+
//
|
|
310
|
+
// The gateway wires a single closure (`makeWorktreeWatchProvider`) as the
|
|
311
|
+
// subagent-watcher's extraWatchCwdsProvider. The whole point of this PR is
|
|
312
|
+
// that that closure ALSO advances heartbeats on every tick — deleting the
|
|
313
|
+
// refresh call would leave the cwd behaviour (and every ownership test above)
|
|
314
|
+
// green while silently reintroducing the zero-caller `touchHeartbeat` bug.
|
|
315
|
+
// These tests are the deterministic guard: they assert the provider returns
|
|
316
|
+
// owned cwds AND advances a real registry record's heartbeatAt on the same
|
|
317
|
+
// call, and that the gateway actually installs it.
|
|
318
|
+
describe("makeWorktreeWatchProvider (gateway wiring)", () => {
|
|
319
|
+
const idPathLocal = (p: string) => p; // deterministic realpath
|
|
320
|
+
|
|
321
|
+
it("both returns owned cwds AND advances the claim's heartbeat (in-memory)", () => {
|
|
322
|
+
const now = 10_000_000;
|
|
323
|
+
const before = new Date(now - 60 * 60_000).toISOString(); // 1h ago (stale)
|
|
324
|
+
const store: (WorktreeOwnershipRecord & WorktreeHeartbeatRecord)[] = [
|
|
325
|
+
{ id: "mine", path: "/wt/mine", ownerAgent: "klanker", heartbeatAt: before },
|
|
326
|
+
{ id: "theirs", path: "/wt/theirs", ownerAgent: "reggie", heartbeatAt: before },
|
|
327
|
+
];
|
|
328
|
+
const touched: string[] = [];
|
|
329
|
+
const provider = makeWorktreeWatchProvider({
|
|
330
|
+
self: "klanker",
|
|
331
|
+
listRecords: () => store,
|
|
332
|
+
touchHeartbeat: (id) => touched.push(id),
|
|
333
|
+
realpath: idPathLocal,
|
|
334
|
+
minRefreshIntervalMs: 0,
|
|
335
|
+
now: () => now,
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
const cwds = provider();
|
|
339
|
+
|
|
340
|
+
// (1) returns exactly this agent's owned cwds
|
|
341
|
+
expect(cwds).toEqual(["/wt/mine"]);
|
|
342
|
+
// (2) AND advanced the heartbeat of the owned record only — never foreign
|
|
343
|
+
expect(touched).toEqual(["mine"]);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
describe("against a real temp registry", () => {
|
|
347
|
+
let tmpDir: string;
|
|
348
|
+
const origEnv = process.env.SWITCHROOM_WORKTREE_DIR;
|
|
349
|
+
|
|
350
|
+
beforeEach(() => {
|
|
351
|
+
tmpDir = mkdtempSync(join(tmpdir(), "sw-provider-test-"));
|
|
352
|
+
process.env.SWITCHROOM_WORKTREE_DIR = tmpDir;
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
afterEach(() => {
|
|
356
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
357
|
+
if (origEnv === undefined) delete process.env.SWITCHROOM_WORKTREE_DIR;
|
|
358
|
+
else process.env.SWITCHROOM_WORKTREE_DIR = origEnv;
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
function makeRecord(overrides: Partial<WorktreeRecord> = {}): WorktreeRecord {
|
|
362
|
+
const iso = new Date().toISOString();
|
|
363
|
+
return {
|
|
364
|
+
id: "prov001",
|
|
365
|
+
repo: "/fake/repo",
|
|
366
|
+
repoName: "fake",
|
|
367
|
+
branch: "task/prov001",
|
|
368
|
+
path: "/wt/prov001",
|
|
369
|
+
createdAt: iso,
|
|
370
|
+
heartbeatAt: iso,
|
|
371
|
+
ownerAgent: "klanker",
|
|
372
|
+
...overrides,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
it("provider drives the REAL registry: returns cwd AND advances heartbeatAt on disk", async () => {
|
|
377
|
+
const past = new Date(Date.now() - 5 * 60_000).toISOString(); // 5min ago
|
|
378
|
+
writeRecord(makeRecord({ heartbeatAt: past }));
|
|
379
|
+
const before = new Date(readRecord("prov001")!.heartbeatAt).getTime();
|
|
380
|
+
|
|
381
|
+
const provider = makeWorktreeWatchProvider({
|
|
382
|
+
self: "klanker",
|
|
383
|
+
listRecords: registryListRecords,
|
|
384
|
+
touchHeartbeat: registryTouchHeartbeat,
|
|
385
|
+
realpath: idPathLocal,
|
|
386
|
+
minRefreshIntervalMs: 0, // always touch
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
await new Promise((r) => setTimeout(r, 5)); // ensure a strictly newer ts
|
|
390
|
+
const cwds = provider();
|
|
391
|
+
|
|
392
|
+
// (1) returns the owned cwd from the real registry
|
|
393
|
+
expect(cwds).toEqual(["/wt/prov001"]);
|
|
394
|
+
// (2) AND the on-disk heartbeat actually advanced (the zero-caller guard)
|
|
395
|
+
const after = new Date(readRecord("prov001")!.heartbeatAt).getTime();
|
|
396
|
+
expect(after).toBeGreaterThan(before);
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it("gateway.ts installs makeWorktreeWatchProvider as extraWatchCwdsProvider", () => {
|
|
401
|
+
// Grep-pin (anchor MUST resolve so it can never pass vacuously — repo test
|
|
402
|
+
// convention, PR #3126): proves the gateway actually wires the extracted
|
|
403
|
+
// provider, so a behaviour test on the provider is not testing dead code.
|
|
404
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
405
|
+
const gatewaySrc = readFileSync(
|
|
406
|
+
join(here, "..", "gateway", "gateway.ts"),
|
|
407
|
+
"utf8",
|
|
408
|
+
);
|
|
409
|
+
const anchor = "extraWatchCwdsProvider: makeWorktreeWatchProvider(";
|
|
410
|
+
expect(gatewaySrc.indexOf(anchor)).toBeGreaterThan(-1);
|
|
411
|
+
});
|
|
412
|
+
});
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single gate every `sendChatAction` in the gateway passes through (#3084).
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS — the 2026-07-11 flood ban
|
|
5
|
+
* ------------------------------------------
|
|
6
|
+
* The typing indicator used to be "a 4 s interval", which sounds rate-limited
|
|
7
|
+
* and is not. Both typing loops (`startTypingLoop` in gateway.ts and the
|
|
8
|
+
* turn-level `createTurnTypingLoop`) are restart-safe by design: a re-start
|
|
9
|
+
* clears the old interval and fires ONE action immediately so "typing…" lands
|
|
10
|
+
* instantly. The tool-use wrapper restarts the loop on every tool call, so the
|
|
11
|
+
* ping rate tracked the AGENT'S TOOL-CALL RATE, not the 4 s cadence — the
|
|
12
|
+
* interval was decorative. On 2026-07-11 `overlord` emitted 8,729
|
|
13
|
+
* sendChatAction calls (55% of all outbound volume, bursting at 200-300/min
|
|
14
|
+
* into ONE DM) to deliver 203 messages, and earned a per-bot-token flood ban:
|
|
15
|
+
* `429 retry_after=16739s` — 4.6 hours with every outbound reply rejected.
|
|
16
|
+
*
|
|
17
|
+
* The old code comment said the redundant pings were "harmless — same action,
|
|
18
|
+
* and sendChatAction is cheap." They are not cheap. They spend the per-bot
|
|
19
|
+
* flood budget the REPLIES need.
|
|
20
|
+
*
|
|
21
|
+
* WHAT THIS ENFORCES
|
|
22
|
+
* ------------------
|
|
23
|
+
* 1. A per-chat-key emission FLOOR. At most one chat action per key per
|
|
24
|
+
* `floorMs` (~4 s refresh window), no matter how many loops restart, from
|
|
25
|
+
* which caller, on which surface. Both loops share ONE emitter, so the
|
|
26
|
+
* floor holds ACROSS them — they target the same chat key and neither can
|
|
27
|
+
* out-shout the other. This is what structurally decouples ping rate from
|
|
28
|
+
* tool-call rate.
|
|
29
|
+
* 2. The UX intent survives: a COLD start (no ping for this key inside the
|
|
30
|
+
* floor) still fires immediately, so "typing…" lands the moment a turn
|
|
31
|
+
* begins. Only the REDUNDANT restarts are dropped — and a dropped tick is
|
|
32
|
+
* never silently lost: it arms a COALESCED catch-up at the instant the
|
|
33
|
+
* window opens, so the worst-case gap between two emissions is `floorMs`
|
|
34
|
+
* (3.5 s), comfortably inside Telegram's ~5 s action expiry. Without it,
|
|
35
|
+
* an eaten tick on a fixed-cadence loop pushes the next emission out to
|
|
36
|
+
* `floorMs + refreshMs` = 7.5 s and the chat goes DARK mid-turn — trading
|
|
37
|
+
* a flood ban for a dead indicator, which is not a trade we make.
|
|
38
|
+
* 3. NON-ESSENTIAL by definition: while a flood-wait window is open
|
|
39
|
+
* (`isSuppressed`, wired to the #2923 circuit breaker) no typing is
|
|
40
|
+
* emitted at all. It cannot succeed, and sending into an open window can
|
|
41
|
+
* extend the ban.
|
|
42
|
+
*
|
|
43
|
+
* The window is claimed on every attempt that PASSES the floor, including one
|
|
44
|
+
* suppressed by a flood window. That is deliberate: it bounds the flood-state
|
|
45
|
+
* read to once per floor per chat instead of once per tool call.
|
|
46
|
+
*
|
|
47
|
+
* Pure + injectable (clock, send, chat-key, suppression) so the whole gate is
|
|
48
|
+
* unit-testable on a fake clock without a bot — `tests/typing-emitter.test.ts`.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
/** Refresh cadence of the typing loops. Telegram's `typing` expires at ~5 s. */
|
|
52
|
+
export const TYPING_REFRESH_MS = 4000
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Minimum gap between two chat actions on one chat key. Deliberately a hair
|
|
56
|
+
* UNDER `TYPING_REFRESH_MS` so a loop's own on-time refresh is never eaten by
|
|
57
|
+
* timer jitter (a 3999 ms tick would be dropped by a 4000 ms floor, and the
|
|
58
|
+
* indicator would go dark for a whole window). The gap between successive
|
|
59
|
+
* emissions therefore stays under Telegram's ~5 s action expiry, while the
|
|
60
|
+
* worst-case rate falls from the observed ~300/min to ~17/min per chat.
|
|
61
|
+
*/
|
|
62
|
+
export const TYPING_FLOOR_MS = 3500
|
|
63
|
+
|
|
64
|
+
export interface TypingEmitterDeps {
|
|
65
|
+
/** Perform the actual chat action. Errors are the caller's concern — the
|
|
66
|
+
* emitter never throws and never awaits. */
|
|
67
|
+
send: (chatId: string, threadId: number | null, action: string) => void
|
|
68
|
+
/** Canonical chat:thread key (the gateway's `chatKey`) — a supergroup topic
|
|
69
|
+
* is its own lane and gets its own floor. */
|
|
70
|
+
chatKey: (chatId: string, threadId: number | null) => string
|
|
71
|
+
/** True while a flood-wait window is open. Typing is non-essential: while
|
|
72
|
+
* this is true nothing is emitted. Defaults to "never suppressed". */
|
|
73
|
+
isSuppressed?: () => boolean
|
|
74
|
+
/** Injected clock (tests pass a fake). */
|
|
75
|
+
now?: () => number
|
|
76
|
+
/** Per-key emission floor in ms. */
|
|
77
|
+
floorMs?: number
|
|
78
|
+
/** Injected timer for the catch-up (tests pass fake timers). */
|
|
79
|
+
schedule?: (fn: () => void, ms: number) => unknown
|
|
80
|
+
/** Cancel a handle returned by `schedule`. */
|
|
81
|
+
cancel?: (handle: unknown) => void
|
|
82
|
+
/** Observability hook — fires for each dropped emission. */
|
|
83
|
+
onDrop?: (info: { key: string; reason: 'floor' | 'flood' }) => void
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface TypingEmitter {
|
|
87
|
+
/**
|
|
88
|
+
* Emit one chat action for (chatId, threadId), subject to the floor and the
|
|
89
|
+
* flood gate. Returns true iff the action was actually handed to `send`.
|
|
90
|
+
* A floor-dropped emission arms a coalesced catch-up (see below), so the
|
|
91
|
+
* caller's cadence is never silently lost.
|
|
92
|
+
*/
|
|
93
|
+
emit: (chatId: string, threadId?: number | null, action?: string) => boolean
|
|
94
|
+
/**
|
|
95
|
+
* Cancel any pending catch-up for a chat key. The canonical turn-end calls
|
|
96
|
+
* this so a dropped tick can't resurrect "typing…" after the turn is over.
|
|
97
|
+
* Does NOT clear the floor — the floor must survive loop stops, because the
|
|
98
|
+
* tool loop stops on every tool result and that is precisely the churn the
|
|
99
|
+
* floor exists to absorb.
|
|
100
|
+
*/
|
|
101
|
+
cancelPending: (chatId: string, threadId?: number | null) => void
|
|
102
|
+
/** Test/observability: number of chat keys currently tracked. */
|
|
103
|
+
trackedKeys: () => number
|
|
104
|
+
/** Test/observability: number of catch-ups currently armed (0 ⇒ none leaked). */
|
|
105
|
+
pendingCatchUps: () => number
|
|
106
|
+
/** Drop all floor state and cancel every catch-up (shutdown / tests). */
|
|
107
|
+
reset: () => void
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Keep the floor map from growing without bound in a long-lived gateway. */
|
|
111
|
+
const PRUNE_AFTER_FACTOR = 10
|
|
112
|
+
const PRUNE_SIZE_THRESHOLD = 64
|
|
113
|
+
|
|
114
|
+
export function createTypingEmitter(deps: TypingEmitterDeps): TypingEmitter {
|
|
115
|
+
const floorMs = deps.floorMs ?? TYPING_FLOOR_MS
|
|
116
|
+
const now = deps.now ?? Date.now
|
|
117
|
+
const isSuppressed = deps.isSuppressed ?? (() => false)
|
|
118
|
+
const schedule =
|
|
119
|
+
deps.schedule ??
|
|
120
|
+
((fn: () => void, ms: number) => {
|
|
121
|
+
const h = setTimeout(fn, ms)
|
|
122
|
+
;(h as { unref?: () => void }).unref?.()
|
|
123
|
+
return h
|
|
124
|
+
})
|
|
125
|
+
const cancel =
|
|
126
|
+
deps.cancel ?? ((h: unknown) => clearTimeout(h as ReturnType<typeof setTimeout>))
|
|
127
|
+
|
|
128
|
+
/** chat key → epoch ms at which the current emission window was claimed. */
|
|
129
|
+
const windowClaimedAt = new Map<string, number>()
|
|
130
|
+
/** chat key → the single armed catch-up timer (coalesced; never two). */
|
|
131
|
+
const catchUps = new Map<string, unknown>()
|
|
132
|
+
|
|
133
|
+
function prune(t: number): void {
|
|
134
|
+
if (windowClaimedAt.size <= PRUNE_SIZE_THRESHOLD) return
|
|
135
|
+
const cutoff = t - floorMs * PRUNE_AFTER_FACTOR
|
|
136
|
+
for (const [k, ts] of [...windowClaimedAt.entries()]) {
|
|
137
|
+
if (ts < cutoff && !catchUps.has(k)) windowClaimedAt.delete(k)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function clearCatchUp(key: string): void {
|
|
142
|
+
const h = catchUps.get(key)
|
|
143
|
+
if (h !== undefined) {
|
|
144
|
+
cancel(h)
|
|
145
|
+
catchUps.delete(key)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* A dropped tick MUST NOT become silence. Both loops re-arm a FIXED-cadence
|
|
151
|
+
* interval, so a tick eaten by the floor is simply lost: the next emission
|
|
152
|
+
* for that key could land as late as `floorMs + refreshMs` (7.5 s) after the
|
|
153
|
+
* last one — past Telegram's ~5 s chat-action expiry, and the chat goes dark
|
|
154
|
+
* mid-turn. That would trade a flood ban for a dead "typing…" indicator,
|
|
155
|
+
* which breaches `know-what-my-agent-is-doing` ("stays present from receipt
|
|
156
|
+
* to turn end").
|
|
157
|
+
*
|
|
158
|
+
* So a floor drop arms ONE coalesced catch-up at the exact moment the window
|
|
159
|
+
* opens (`claimedAt + floorMs`). Effect: the worst-case gap between two
|
|
160
|
+
* emissions is bounded by `floorMs`, while the emission CAP is untouched (the
|
|
161
|
+
* catch-up fires only once the floor has expired, so it can never emit early).
|
|
162
|
+
* A second drop inside the same window must not arm a second timer — hence
|
|
163
|
+
* the coalesce.
|
|
164
|
+
*/
|
|
165
|
+
function armCatchUp(
|
|
166
|
+
key: string,
|
|
167
|
+
chatId: string,
|
|
168
|
+
threadId: number | null,
|
|
169
|
+
action: string,
|
|
170
|
+
delayMs: number,
|
|
171
|
+
): void {
|
|
172
|
+
if (catchUps.has(key)) return // coalesced — one timer per key, always
|
|
173
|
+
const h = schedule(() => {
|
|
174
|
+
catchUps.delete(key)
|
|
175
|
+
// Re-enters `emit`, which re-checks the floor (now expired) and the flood
|
|
176
|
+
// gate. A send here cancels nothing — there is nothing left to cancel.
|
|
177
|
+
emit(chatId, threadId, action)
|
|
178
|
+
}, Math.max(0, delayMs))
|
|
179
|
+
catchUps.set(key, h)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function emit(chatId: string, threadId: number | null = null, action = 'typing'): boolean {
|
|
183
|
+
const key = deps.chatKey(chatId, threadId ?? null)
|
|
184
|
+
const t = now()
|
|
185
|
+
const claimed = windowClaimedAt.get(key)
|
|
186
|
+
// `t >= claimed` guards a backwards clock step (NTP): a stale future
|
|
187
|
+
// timestamp must not wedge the indicator off, so treat it as expired.
|
|
188
|
+
if (claimed != null && t >= claimed && t - claimed < floorMs) {
|
|
189
|
+
armCatchUp(key, chatId, threadId ?? null, action, claimed + floorMs - t)
|
|
190
|
+
deps.onDrop?.({ key, reason: 'floor' })
|
|
191
|
+
return false
|
|
192
|
+
}
|
|
193
|
+
// Claim the window BEFORE the flood check so a burst of restarts costs
|
|
194
|
+
// one flood-state read per floor, not one per restart.
|
|
195
|
+
windowClaimedAt.set(key, t)
|
|
196
|
+
prune(t)
|
|
197
|
+
if (isSuppressed()) {
|
|
198
|
+
// A flood window is open. Don't arm a catch-up — the goal is silence, not
|
|
199
|
+
// a deferred ping into the ban.
|
|
200
|
+
clearCatchUp(key)
|
|
201
|
+
deps.onDrop?.({ key, reason: 'flood' })
|
|
202
|
+
return false
|
|
203
|
+
}
|
|
204
|
+
// This send satisfies whatever a pending catch-up was going to deliver.
|
|
205
|
+
// Cancelling it here is what keeps a catch-up from re-arming itself into a
|
|
206
|
+
// self-sustaining heartbeat after the loops have stopped.
|
|
207
|
+
clearCatchUp(key)
|
|
208
|
+
deps.send(chatId, threadId ?? null, action)
|
|
209
|
+
return true
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
emit,
|
|
214
|
+
cancelPending(chatId, threadId = null) {
|
|
215
|
+
clearCatchUp(deps.chatKey(chatId, threadId ?? null))
|
|
216
|
+
},
|
|
217
|
+
trackedKeys: () => windowClaimedAt.size,
|
|
218
|
+
pendingCatchUps: () => catchUps.size,
|
|
219
|
+
reset: () => {
|
|
220
|
+
for (const key of [...catchUps.keys()]) clearCatchUp(key)
|
|
221
|
+
windowClaimedAt.clear()
|
|
222
|
+
},
|
|
223
|
+
}
|
|
224
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JTBD scenario — feel like a colleague, not a chatbot (DM).
|
|
3
|
+
*
|
|
4
|
+
* Serves: `reference/jobs/feel-like-a-colleague.md`. The colleague posture
|
|
5
|
+
* is shipped fleet-wide as lane-2 model-visible text (`~/.switchroom/fleet/
|
|
6
|
+
* CLAUDE.md`, seeded by `renderFleetDefaultsClaudeMd()` in
|
|
7
|
+
* `src/agents/fleet-defaults.ts`, epic #1850 / issue #1855). One bullet of
|
|
8
|
+
* "Good looks like" reads:
|
|
9
|
+
*
|
|
10
|
+
* "The agent asks at most one good clarifying question, and skips it when
|
|
11
|
+
* intent is clear, stating its assumption inline as it acts."
|
|
12
|
+
*
|
|
13
|
+
* This scenario sends a genuinely ambiguous request and asserts the agent
|
|
14
|
+
* asks EXACTLY ONE clarifying question rather than a question avalanche
|
|
15
|
+
* (the "Bad looks like" failure) or a confident guess that ignores the
|
|
16
|
+
* ambiguity.
|
|
17
|
+
*
|
|
18
|
+
* Gating: like every file under `telegram-plugin/uat/scenarios/`, this hits
|
|
19
|
+
* real Telegram + real Claude and only runs under `vitest.uat.config.ts`
|
|
20
|
+
* (`bun run test:uat`). The default vitest config excludes this directory,
|
|
21
|
+
* so CI stays green without live credentials.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { describe, it, expect } from "vitest";
|
|
25
|
+
import { spinUp } from "../harness.js";
|
|
26
|
+
|
|
27
|
+
const AGENT = "test-harness";
|
|
28
|
+
|
|
29
|
+
// Ambiguous on purpose: "the report" has no referent, no format, no
|
|
30
|
+
// destination. A colleague asks which report / what for; it does not
|
|
31
|
+
// silently invent one, and it does not fire three questions.
|
|
32
|
+
const AMBIGUOUS_PROMPT = "can you send over the report when you get a sec?";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Count clarifying questions the agent itself asks. We count sentences
|
|
36
|
+
* ending in a question mark AFTER stripping spans where a `?` is not a
|
|
37
|
+
* question the agent is asking: URLs (query strings), inline/fenced code,
|
|
38
|
+
* and quoted echoes of the user's own words. Rhetorical framing
|
|
39
|
+
* ("sure, which one?") counts as one question; the invariant is at most one.
|
|
40
|
+
*/
|
|
41
|
+
function countQuestions(text: string): number {
|
|
42
|
+
const stripped = text
|
|
43
|
+
// Fenced code blocks, then inline code spans.
|
|
44
|
+
.replace(/```[\s\S]*?```/g, " ")
|
|
45
|
+
.replace(/`[^`\n]*`/g, " ")
|
|
46
|
+
// URLs — `?` here is a query string, not a question.
|
|
47
|
+
.replace(/\bhttps?:\/\/\S+/gi, " ")
|
|
48
|
+
.replace(/\bwww\.\S+/gi, " ")
|
|
49
|
+
// Quoted spans — the agent echoing the user ("you said 'which report?'")
|
|
50
|
+
// is not the agent asking a question.
|
|
51
|
+
.replace(/"[^"\n]*"/g, " ")
|
|
52
|
+
// Single-quoted spans must OPEN at a word boundary so apostrophes in
|
|
53
|
+
// contractions ("don't", "user's") are not misread as quote delimiters.
|
|
54
|
+
.replace(/(^|[\s([{])'[^'\n]*'/g, "$1 ")
|
|
55
|
+
.replace(/[“”][^“”\n]*[“”]/g, " ")
|
|
56
|
+
// Markdown blockquote lines are quoted material, not the agent's voice.
|
|
57
|
+
.replace(/^\s*>.*$/gm, " ");
|
|
58
|
+
const matches = stripped.match(/[^.!?\n]*\?/g);
|
|
59
|
+
if (matches == null) return 0;
|
|
60
|
+
// Filter out trivial fragments (a lone "?" or whitespace) that are not
|
|
61
|
+
// real questions.
|
|
62
|
+
return matches.filter((m) => m.replace(/[^a-z0-9]/gi, "").length >= 3).length;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* `true` for bot messages that are infrastructure cards rather than a
|
|
67
|
+
* conversational reply: the boot/greeting card (always delivered with the
|
|
68
|
+
* Telegram `silent` flag — see boot-card.ts "Boot cards are ALWAYS
|
|
69
|
+
* delivered silently"), edits of earlier messages, and anything matching
|
|
70
|
+
* the known boot-card header shape (`✅ <agent> back up · <version>`).
|
|
71
|
+
*/
|
|
72
|
+
function isInfrastructureCard(m: {
|
|
73
|
+
text: string;
|
|
74
|
+
silent: boolean;
|
|
75
|
+
edited: boolean;
|
|
76
|
+
}): boolean {
|
|
77
|
+
if (m.silent || m.edited) return true;
|
|
78
|
+
return /back up ·|^✅ /u.test(m.text.trim());
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
describe("uat: feel like a colleague — one clarifying question when ambiguous", () => {
|
|
82
|
+
it(
|
|
83
|
+
"ambiguous ask → agent asks exactly one clarifying question",
|
|
84
|
+
async () => {
|
|
85
|
+
const sc = await spinUp({ agent: AGENT });
|
|
86
|
+
try {
|
|
87
|
+
await sc.sendDM(AMBIGUOUS_PROMPT);
|
|
88
|
+
|
|
89
|
+
// Substantive replies only: skip the boot/greeting card and any
|
|
90
|
+
// silent interim edits so the assertion runs against the agent's
|
|
91
|
+
// actual conversational answer to the prompt.
|
|
92
|
+
const reply = await sc.expectMessage(
|
|
93
|
+
(m) => /\S/.test(m.text) && !isInfrastructureCard(m),
|
|
94
|
+
{
|
|
95
|
+
from: "bot",
|
|
96
|
+
timeout: 90_000,
|
|
97
|
+
},
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
expect(reply.text.length).toBeGreaterThan(0);
|
|
101
|
+
|
|
102
|
+
const questionCount = countQuestions(reply.text);
|
|
103
|
+
|
|
104
|
+
// Invariant: at least one clarifying question (it did not guess
|
|
105
|
+
// blindly) and at most one (no question avalanche).
|
|
106
|
+
if (questionCount === 0) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
`[colleague] ambiguous ask got no clarifying question — the ` +
|
|
109
|
+
`agent either guessed a referent or ignored the ambiguity. ` +
|
|
110
|
+
`Reply: ${JSON.stringify(reply.text.slice(0, 300))}`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
if (questionCount > 1) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
`[colleague] question avalanche: ${questionCount} questions ` +
|
|
116
|
+
`where one would do. Reply: ${JSON.stringify(reply.text.slice(0, 300))}`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
expect(questionCount).toBe(1);
|
|
120
|
+
|
|
121
|
+
// Posture bonus: no sycophantic preamble. A soft forensic warn,
|
|
122
|
+
// not a hard fail (voice specifics are audited in
|
|
123
|
+
// fuzz-voice-scrub-dm).
|
|
124
|
+
if (/^(great question|i'?d be happy to|absolutely!|of course!)/i.test(reply.text.trim())) {
|
|
125
|
+
console.warn(
|
|
126
|
+
`[colleague] reply opens with sycophantic preamble: ` +
|
|
127
|
+
`${JSON.stringify(reply.text.slice(0, 120))}`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
} finally {
|
|
131
|
+
await sc.tearDown();
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
120_000,
|
|
135
|
+
);
|
|
136
|
+
});
|
|
@@ -93,6 +93,27 @@ export type AgentMetadata = {
|
|
|
93
93
|
* asked for current state, so terseness loses to completeness here.
|
|
94
94
|
*/
|
|
95
95
|
live?: StatusProbeRow[];
|
|
96
|
+
/**
|
|
97
|
+
* Send-gate state (#3084 PR 3, part3-design §6). Present only when the gate
|
|
98
|
+
* feature flag is ON; omitted entirely when off so `/status` looks exactly
|
|
99
|
+
* as it did before the gate existed.
|
|
100
|
+
*/
|
|
101
|
+
sendGate?: SendGateStatus;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* `/status` view of the deterministic send gate (#3084). Queued / shed totals
|
|
106
|
+
* plus any open flood windows with their expiry. Only populated when the gate
|
|
107
|
+
* is enabled.
|
|
108
|
+
*/
|
|
109
|
+
export type SendGateStatus = {
|
|
110
|
+
queued: number;
|
|
111
|
+
shed: number;
|
|
112
|
+
expired: number;
|
|
113
|
+
failedFast: number;
|
|
114
|
+
dropped: number;
|
|
115
|
+
/** Currently-open flood windows (expired already pruned). */
|
|
116
|
+
openWindows: { scopeKey: string; untilTs: number }[];
|
|
96
117
|
};
|
|
97
118
|
|
|
98
119
|
// Markdown escaper for dynamic values interpolated into bold/plain card
|
|
@@ -231,6 +252,27 @@ export function statusPairedText(params: {
|
|
|
231
252
|
}
|
|
232
253
|
}
|
|
233
254
|
|
|
255
|
+
// Send-gate block (#3084 PR 3) — only when the gate flag is on, so a fleet
|
|
256
|
+
// running with the gate OFF sees the identical pre-gate /status.
|
|
257
|
+
if (meta.sendGate) {
|
|
258
|
+
const sg = meta.sendGate;
|
|
259
|
+
lines.push("");
|
|
260
|
+
lines.push("**Send gate**");
|
|
261
|
+
lines.push(
|
|
262
|
+
`queued ${sg.queued} · shed ${sg.shed} · expired ${sg.expired} · ` +
|
|
263
|
+
`fail-fast ${sg.failedFast} · dropped ${sg.dropped}`,
|
|
264
|
+
);
|
|
265
|
+
if (sg.openWindows.length > 0) {
|
|
266
|
+
const now = Date.now();
|
|
267
|
+
for (const w of sg.openWindows) {
|
|
268
|
+
const secs = Math.max(0, Math.round((w.untilTs - now) / 1000));
|
|
269
|
+
lines.push(`⏳ flood window \`${escapeHtml(w.scopeKey)}\` — clears in ${secs}s`);
|
|
270
|
+
}
|
|
271
|
+
} else {
|
|
272
|
+
lines.push("no open flood windows");
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
234
276
|
const audit = meta.audit;
|
|
235
277
|
if (audit) {
|
|
236
278
|
// Blank separator before the audit block so the reply reads as two
|