switchroom 0.17.10 → 0.18.6
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/bin/workspace-dynamic-hook.sh +12 -13
- package/dist/agent-scheduler/index.js +29 -2
- package/dist/auth-broker/index.js +6163 -152
- package/dist/cli/notion-write-pretool.mjs +31 -3
- package/dist/cli/switchroom.js +695 -526
- package/dist/host-control/main.js +6184 -173
- package/dist/vault/approvals/kernel-server.js +5893 -165
- package/dist/vault/broker/server.js +6666 -921
- package/package.json +1 -1
- package/profiles/_base/settings.json.hbs +2 -2
- package/profiles/_base/start.sh.hbs +170 -21
- 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/skills/switchroom-release/SKILL.md +78 -0
- package/telegram-plugin/auth-snapshot-format.ts +37 -25
- package/telegram-plugin/context-exhaustion.ts +124 -0
- package/telegram-plugin/dist/gateway/gateway.js +25025 -9203
- package/telegram-plugin/gateway/activity-card-store.ts +76 -0
- package/telegram-plugin/gateway/gateway.ts +740 -106
- package/telegram-plugin/gateway/inbound-delivery-gate.ts +26 -0
- package/telegram-plugin/gateway/model-command.ts +70 -10
- package/telegram-plugin/gateway/resolve-person.ts +304 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +21 -1
- package/telegram-plugin/hooks/silent-end-scan.mjs +164 -40
- package/telegram-plugin/operator-events.ts +21 -0
- package/telegram-plugin/package.json +6 -0
- package/telegram-plugin/pending-work-progress.ts +42 -7
- package/telegram-plugin/quota-bar-format.ts +360 -0
- package/telegram-plugin/quota-watch.ts +4 -6
- package/telegram-plugin/registry/turns-schema.test.ts +97 -0
- package/telegram-plugin/registry/turns-schema.ts +78 -0
- package/telegram-plugin/render/ir.ts +209 -0
- package/telegram-plugin/render/parse.ts +363 -0
- package/telegram-plugin/render/render.ts +440 -0
- package/telegram-plugin/render/rich-render.ts +72 -0
- package/telegram-plugin/stream-controller.ts +14 -3
- package/telegram-plugin/subagent-watcher.ts +27 -9
- package/telegram-plugin/tests/activity-card-store.test.ts +94 -0
- package/telegram-plugin/tests/auth-command-format2.test.ts +1 -1
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +51 -16
- package/telegram-plugin/tests/claude-code-event-contract.test.ts +48 -0
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +11 -0
- package/telegram-plugin/tests/feed-survival.test.ts +39 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +81 -0
- package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +82 -0
- package/telegram-plugin/tests/liveness-tracker.test.ts +228 -0
- package/telegram-plugin/tests/model-command.test.ts +193 -16
- package/telegram-plugin/tests/narrative-render.test.ts +125 -0
- package/telegram-plugin/tests/operator-events.test.ts +16 -0
- package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +123 -163
- package/telegram-plugin/tests/pending-work-progress.test.ts +116 -3
- package/telegram-plugin/tests/quota-bar-format.test.ts +444 -0
- package/telegram-plugin/tests/quota-watch.test.ts +1 -4
- package/telegram-plugin/tests/rapid-fire-delivery-ordering.test.ts +149 -0
- package/telegram-plugin/tests/render/parse-torture.test.ts +136 -0
- package/telegram-plugin/tests/render/parse.test.ts +393 -0
- package/telegram-plugin/tests/render/render.test.ts +436 -0
- package/telegram-plugin/tests/render/rich-render.test.ts +85 -0
- package/telegram-plugin/tests/resolve-person.test.ts +290 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +53 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +138 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +61 -0
- package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +155 -1
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +19 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +97 -0
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +98 -3
- package/telegram-plugin/turn-liveness-floor.ts +35 -1
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +99 -7
- package/telegram-plugin/worker-activity-feed.ts +220 -15
- package/telegram-plugin/worktree-watch-cwds.ts +92 -17
- package/vendor/hindsight-memory/scripts/lib/client.py +11 -1
- package/vendor/hindsight-memory/scripts/lib/config.py +9 -2
- package/vendor/hindsight-memory/scripts/recall.py +64 -6
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +1 -0
- package/vendor/hindsight-memory/tests/test_client.py +43 -0
- package/vendor/hindsight-memory/tests/test_recall_precision.py +114 -0
- package/profiles/default/CLAUDE.md +0 -116
- package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
- package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quota-bar block — a compact per-account ASCII-bar rendering of the 5-hour
|
|
3
|
+
* and 7-day utilization windows, for a live-refreshing Telegram card.
|
|
4
|
+
*
|
|
5
|
+
* JTBD: "at a glance, how much headroom does each account have, and how far
|
|
6
|
+
* through its reset window are we" — denser than the `/auth` table (Format 2,
|
|
7
|
+
* `auth-snapshot-format.ts`), meant for a small always-visible strip rather
|
|
8
|
+
* than a full snapshot.
|
|
9
|
+
*
|
|
10
|
+
* Locked output shape (operator-confirmed via live Telegram iteration —
|
|
11
|
+
* do not reformat without re-confirming):
|
|
12
|
+
*
|
|
13
|
+
* ```
|
|
14
|
+
* - **you@example.com** (active)
|
|
15
|
+
* - 🟢 5h `[┃░░░░░░░░░] 0% / 4h20m left`
|
|
16
|
+
* - 🟡 7d `[████┃█░░░░] 47% / 3d1h left`
|
|
17
|
+
* - **alice@example.com** (exhausted)
|
|
18
|
+
* - 🟢 5h `[┃░░░░░░░░░] 0% / resets now`
|
|
19
|
+
* - 🔴 7d `[██████┃███] 100% / 2d16h left`
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* Rules:
|
|
23
|
+
* 1. GFM `- ` bullet marker on EVERY line, including the account title
|
|
24
|
+
* line — a tight one-item-per-line list, no blank lines between rows
|
|
25
|
+
* (a no-bullet hard-break variant was tried live and rejected as
|
|
26
|
+
* "worse" — bullets are the final mechanism).
|
|
27
|
+
* 2. Title line: `- **<email>** (<status>)`, status derived from data
|
|
28
|
+
* (`active` | `exhausted` | `idle` — see `accountStatus`).
|
|
29
|
+
* 3. One row per window (5h, 7d): `- <dot> <window> \`[<bar>] <pct>% /
|
|
30
|
+
* <time-left>\``.
|
|
31
|
+
* 4. Bar is 10 cells of `█` (filled, proportional to utilization) /
|
|
32
|
+
* `░` (empty), with a single `┃` "pace" tick marking how far through
|
|
33
|
+
* the reset window we currently are. The tick ALWAYS renders at its
|
|
34
|
+
* computed position, overriding whatever fill character is there —
|
|
35
|
+
* even at 100% utilization — since the pace signal must stay visible
|
|
36
|
+
* (operator-confirmed; see `buildBar`).
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import type { QuotaUtilization } from './quota-check.js';
|
|
40
|
+
import { refillNormalizedUtils, isProbeThin } from '../src/auth/quota.js';
|
|
41
|
+
import type { AccountState, ListStateData } from '../src/auth/broker/client.js';
|
|
42
|
+
import { reviveLastQuota, recommendation, type AccountSnapshot } from './auth-snapshot-format.js';
|
|
43
|
+
import { escapeMarkdown } from './card-format.js';
|
|
44
|
+
import { maskEmail } from './demo-mask.js';
|
|
45
|
+
|
|
46
|
+
// ── dot thresholds ───────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Per-row status dot, purely a function of THAT window's own utilization
|
|
50
|
+
* percentage (not the account's overall exhausted flag — an exhausted
|
|
51
|
+
* account can still show a green 5h row if that window is fresh).
|
|
52
|
+
*
|
|
53
|
+
* - < 50% → 🟢 healthy
|
|
54
|
+
* - 50-89% → 🟡 getting close
|
|
55
|
+
* - >= 90% → 🔴 at/near the wall
|
|
56
|
+
*/
|
|
57
|
+
export function pickDot(pct: number): '🟢' | '🟡' | '🔴' {
|
|
58
|
+
const clamped = Math.max(0, Math.min(100, pct));
|
|
59
|
+
if (clamped >= 90) return '🔴';
|
|
60
|
+
if (clamped >= 50) return '🟡';
|
|
61
|
+
return '🟢';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── time-left formatting ─────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Compact time-left string for the quota-bar row: `4h20m left`,
|
|
68
|
+
* `3d1h left`, `45m left`, or `resets now` when the reset has already
|
|
69
|
+
* passed (or is unknown — no reset timestamp to count down to).
|
|
70
|
+
*
|
|
71
|
+
* Deliberately more compact than `formatRelative` in
|
|
72
|
+
* `auth-snapshot-format.ts` (no space between the number and unit) to
|
|
73
|
+
* keep the fixed-width code-span row from wrapping on a phone screen.
|
|
74
|
+
*/
|
|
75
|
+
export function formatTimeLeft(target: Date | null, now: Date = new Date()): string {
|
|
76
|
+
if (!target) return 'resets now';
|
|
77
|
+
const deltaMs = target.getTime() - now.getTime();
|
|
78
|
+
// A malformed reset timestamp (invalid Date → NaN delta) must not leak a
|
|
79
|
+
// `"NaNm left"` string; treat a non-finite delta as "resets now".
|
|
80
|
+
if (!Number.isFinite(deltaMs) || deltaMs <= 0) return 'resets now';
|
|
81
|
+
const totalMin = Math.round(deltaMs / 60_000);
|
|
82
|
+
if (totalMin < 60) return `${totalMin}m left`;
|
|
83
|
+
const totalHours = Math.floor(totalMin / 60);
|
|
84
|
+
const m = totalMin % 60;
|
|
85
|
+
if (totalHours < 24) return `${totalHours}h${m}m left`;
|
|
86
|
+
const d = Math.floor(totalHours / 24);
|
|
87
|
+
const h = totalHours % 24;
|
|
88
|
+
return h > 0 ? `${d}d${h}h left` : `${d}d left`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── pace (elapsed-through-window) fraction ───────────────────────────
|
|
92
|
+
|
|
93
|
+
const FIVE_HOUR_MS = 5 * 60 * 60 * 1000;
|
|
94
|
+
const SEVEN_DAY_MS = 7 * 24 * 60 * 60 * 1000;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* How far through the reset window we currently are, as a 0..1 fraction,
|
|
98
|
+
* derived from the time LEFT to reset and the window's nominal duration
|
|
99
|
+
* (we're never told the window's start time, only its end/reset — so this
|
|
100
|
+
* is an approximation, not an exact elapsed-time read).
|
|
101
|
+
*
|
|
102
|
+
* - no reset timestamp, or reset already passed → 0 (treat as a freshly
|
|
103
|
+
* started window rather than claiming we're at the very end of one we
|
|
104
|
+
* have no data for)
|
|
105
|
+
* - otherwise → `1 - timeLeftMs / windowDurationMs`, clamped to [0, 1]
|
|
106
|
+
*/
|
|
107
|
+
export function elapsedFraction(
|
|
108
|
+
target: Date | null,
|
|
109
|
+
windowDurationMs: number,
|
|
110
|
+
now: Date = new Date(),
|
|
111
|
+
): number {
|
|
112
|
+
if (!target) return 0;
|
|
113
|
+
const timeLeftMs = target.getTime() - now.getTime();
|
|
114
|
+
// A malformed reset timestamp (invalid Date → NaN delta) must not corrupt
|
|
115
|
+
// the tick placement; treat a non-finite delta as a freshly-started window.
|
|
116
|
+
if (!Number.isFinite(timeLeftMs) || timeLeftMs <= 0) return 0;
|
|
117
|
+
return Math.max(0, Math.min(1, 1 - timeLeftMs / windowDurationMs));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── bar rendering ─────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
const BAR_WIDTH = 10;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Build the 10-cell `[█…░…]` bar body (no surrounding brackets — callers
|
|
126
|
+
* add those) for one window row.
|
|
127
|
+
*
|
|
128
|
+
* - `fillCount = round(pct / 100 * 10)` cells are `█` from the left.
|
|
129
|
+
* - a single `┃` pace tick is placed at
|
|
130
|
+
* `round(elapsedFrac * (width - 1))`, and ALWAYS overwrites whatever
|
|
131
|
+
* cell is there — including a filled `█` cell, even at 100% utilization
|
|
132
|
+
* (rule 4 above; the pace signal must always be visible).
|
|
133
|
+
*/
|
|
134
|
+
export function buildBar(pct: number, elapsedFrac: number): string {
|
|
135
|
+
const clampedPct = Math.max(0, Math.min(100, pct));
|
|
136
|
+
const fillCount = Math.round((clampedPct / 100) * BAR_WIDTH);
|
|
137
|
+
const cells: string[] = new Array(BAR_WIDTH).fill('░');
|
|
138
|
+
for (let i = 0; i < fillCount; i++) cells[i] = '█';
|
|
139
|
+
const tickIndex = Math.max(
|
|
140
|
+
0,
|
|
141
|
+
Math.min(BAR_WIDTH - 1, Math.round(elapsedFrac * (BAR_WIDTH - 1))),
|
|
142
|
+
);
|
|
143
|
+
cells[tickIndex] = '┃';
|
|
144
|
+
return cells.join('');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── account status (title-line suffix) ───────────────────────────────
|
|
148
|
+
|
|
149
|
+
export type QuotaBarAccountStatus = 'active' | 'exhausted' | 'idle';
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Title-line status word. `active` wins over `exhausted` (the fleet's
|
|
153
|
+
* pinned account is reported as active even if the broker also flags it
|
|
154
|
+
* exhausted — matches the locked example where the operator wants to know
|
|
155
|
+
* WHICH account is live first, and its health second, from the two window
|
|
156
|
+
* rows underneath). Otherwise: `exhausted` if the broker's own flag says
|
|
157
|
+
* so, else `idle` (present, healthy, just not the current pick).
|
|
158
|
+
*/
|
|
159
|
+
export function accountStatus(isActive: boolean, exhausted: boolean): QuotaBarAccountStatus {
|
|
160
|
+
if (isActive) return 'active';
|
|
161
|
+
if (exhausted) return 'exhausted';
|
|
162
|
+
return 'idle';
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── row / block assembly ─────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
/** One `- <dot> <window> \`[<bar>] <pct>% / <time-left>\`` line. */
|
|
168
|
+
export function formatWindowRow(
|
|
169
|
+
window: '5h' | '7d',
|
|
170
|
+
pct: number,
|
|
171
|
+
resetAt: Date | null,
|
|
172
|
+
now: Date = new Date(),
|
|
173
|
+
): string {
|
|
174
|
+
const windowMs = window === '5h' ? FIVE_HOUR_MS : SEVEN_DAY_MS;
|
|
175
|
+
const dot = pickDot(pct);
|
|
176
|
+
const bar = buildBar(pct, elapsedFraction(resetAt, windowMs, now));
|
|
177
|
+
const pctStr = `${Math.round(Math.max(0, Math.min(100, pct)))}%`;
|
|
178
|
+
const timeLeft = formatTimeLeft(resetAt, now);
|
|
179
|
+
return `- ${dot} ${window} \`[${bar}] ${pctStr} / ${timeLeft}\``;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** One account's title line + its two window rows (5h then 7d). */
|
|
183
|
+
export function renderQuotaBarAccount(
|
|
184
|
+
label: string,
|
|
185
|
+
isActive: boolean,
|
|
186
|
+
exhausted: boolean,
|
|
187
|
+
quota: QuotaUtilization | null,
|
|
188
|
+
now: Date = new Date(),
|
|
189
|
+
demo = false,
|
|
190
|
+
): string[] {
|
|
191
|
+
const status = accountStatus(isActive, exhausted);
|
|
192
|
+
// Title line wraps `label` in GFM `**bold**`, NOT a code span — so this
|
|
193
|
+
// needs `escapeMarkdown` (backslash-escapes *, _, [, ], etc.), not
|
|
194
|
+
// `codeSpanSafe` (which only defuses backticks and is only correct
|
|
195
|
+
// inside literal `code spans` — see format.ts). Using codeSpanSafe here
|
|
196
|
+
// was a bug: a label containing e.g. `**` or `[x](url)` would break the
|
|
197
|
+
// bold run or inject a markdown link into the card.
|
|
198
|
+
const displayLabel = demo ? maskEmail(label) : label;
|
|
199
|
+
const lines: string[] = [`- **${escapeMarkdown(displayLabel)}** (${status})`];
|
|
200
|
+
if (!quota || isProbeThin(quota)) {
|
|
201
|
+
// Data-quality gap. A failed / thin probe carries NO real utilization
|
|
202
|
+
// signal, so it must NOT render as a healthy 🟢 0% bar — that's
|
|
203
|
+
// indistinguishable from a fresh account with full headroom, which was
|
|
204
|
+
// the #2959 review's blocking bug. Emit a distinct ⚠️ warning row per
|
|
205
|
+
// window with a "no data" label (thin vs failed) instead of a fake bar,
|
|
206
|
+
// keeping two rows so the block stays shape-stable.
|
|
207
|
+
const reason = !quota ? 'no data — probe failed' : 'no data — thin probe';
|
|
208
|
+
lines.push(`- ⚠️ 5h \`${reason}\``);
|
|
209
|
+
lines.push(`- ⚠️ 7d \`${reason}\``);
|
|
210
|
+
return lines;
|
|
211
|
+
}
|
|
212
|
+
const norm = refillNormalizedUtils(quota, now);
|
|
213
|
+
lines.push(formatWindowRow('5h', norm.fiveHourUtilizationPct, quota.fiveHourResetAt, now));
|
|
214
|
+
lines.push(formatWindowRow('7d', norm.sevenDayUtilizationPct, quota.sevenDayResetAt, now));
|
|
215
|
+
return lines;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export interface QuotaBarRenderOpts {
|
|
219
|
+
now?: Date;
|
|
220
|
+
/** Demo mode (the `/usage demo` suffix) — masks account-email labels. */
|
|
221
|
+
demo?: boolean;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Relative-age stamp for the freshness footer: "0s ago", "3m ago". Measured
|
|
226
|
+
* against `now` so tests with an injected clock get deterministic output.
|
|
227
|
+
* (Mirrors `formatAgeStamp` in auth-snapshot-format.ts, which is not exported.)
|
|
228
|
+
*/
|
|
229
|
+
function formatAgeStamp(atMs: number, now: Date = new Date()): string {
|
|
230
|
+
const ageSec = Math.max(0, Math.round((now.getTime() - atMs) / 1000));
|
|
231
|
+
return ageSec < 60 ? `${ageSec}s ago` : `${Math.round(ageSec / 60)}m ago`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export interface UsageCardRenderOpts extends QuotaBarRenderOpts {
|
|
235
|
+
/**
|
|
236
|
+
* The probe-on-open attempted a live refresh but it FAILED (or hit the TTL),
|
|
237
|
+
* so the card is served off the durable cache. When set, the footer shows an
|
|
238
|
+
* explicit "⚠ cached Nm ago" warning (age from this `capturedAt`) instead of
|
|
239
|
+
* a false live stamp. Takes precedence over `liveProbedAtMs`. Restores the
|
|
240
|
+
* signal `renderAuthSnapshotFormat2` carried before /usage went bar-only.
|
|
241
|
+
*/
|
|
242
|
+
staleCachedAtMs?: number;
|
|
243
|
+
/** Timestamp of the most recent live probe; renders "Live · refreshed Nm
|
|
244
|
+
* ago" when no stale-cache marker applies. Omit to render a bare "Live".
|
|
245
|
+
* Do NOT set this when the probe failed and no live data was obtained —
|
|
246
|
+
* set `probeFailed: true` instead so the footer doesn't claim "Live". */
|
|
247
|
+
liveProbedAtMs?: number;
|
|
248
|
+
/**
|
|
249
|
+
* True when the live probe returned no usable data for ANY account (the
|
|
250
|
+
* probe threw / timed out / returned zero rows, AND nothing was served
|
|
251
|
+
* from cache either). The footer then renders an explicit `⚠ probe failed
|
|
252
|
+
* — no live data` instead of a false "Live" stamp. Subscription-honesty:
|
|
253
|
+
* the /usage card exists to tell the operator the truth about quota state,
|
|
254
|
+
* so a footer that says "Live" while every row shows "⚠️ no data" is the
|
|
255
|
+
* exact lie this flag closes. Takes precedence over `liveProbedAtMs`;
|
|
256
|
+
* `staleCachedAtMs` (cache-served data) still takes precedence over this
|
|
257
|
+
* because in that case there IS real data, just stale.
|
|
258
|
+
*/
|
|
259
|
+
probeFailed?: boolean;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Render the full multi-account quota-bar block from `AccountSnapshot[]`
|
|
264
|
+
* (the same shape `auth-snapshot-format.ts` builds — reuse
|
|
265
|
+
* `buildSnapshotsFromCachedState` / `buildSnapshotsFromState` /
|
|
266
|
+
* `quotaBarSnapshotsFromListState` to get one).
|
|
267
|
+
*
|
|
268
|
+
* Additionally needs each account's `exhausted` flag (not carried on
|
|
269
|
+
* `AccountSnapshot`), passed as a parallel lookup keyed by label.
|
|
270
|
+
*/
|
|
271
|
+
export function renderQuotaBarBlock(
|
|
272
|
+
snapshots: AccountSnapshot[],
|
|
273
|
+
exhaustedByLabel: ReadonlyMap<string, boolean>,
|
|
274
|
+
opts: QuotaBarRenderOpts = {},
|
|
275
|
+
): string {
|
|
276
|
+
const now = opts.now ?? new Date();
|
|
277
|
+
const demo = opts.demo ?? false;
|
|
278
|
+
const lines: string[] = [];
|
|
279
|
+
for (const snap of snapshots) {
|
|
280
|
+
const exhausted = exhaustedByLabel.get(snap.label) ?? false;
|
|
281
|
+
lines.push(
|
|
282
|
+
...renderQuotaBarAccount(snap.label, snap.isActive, exhausted, snap.quota, now, demo),
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
return lines.join('\n');
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Convenience one-shot: build the quota-bar block directly from the shape
|
|
290
|
+
* `switchroom auth list --json` prints (`ListStateData`), using each
|
|
291
|
+
* account's cached `last_quota` (no live probe — same cache path
|
|
292
|
+
* `buildSnapshotsFromCachedState` uses). This is what the CLI script
|
|
293
|
+
* entrypoint (`scripts/print-quota-bar.ts`) calls.
|
|
294
|
+
*/
|
|
295
|
+
export function renderQuotaBarBlockFromListState(
|
|
296
|
+
state: ListStateData,
|
|
297
|
+
opts: QuotaBarRenderOpts = {},
|
|
298
|
+
): string {
|
|
299
|
+
const now = opts.now ?? new Date();
|
|
300
|
+
const exhaustedByLabel = new Map<string, boolean>(
|
|
301
|
+
state.accounts.map((a: AccountState) => [a.label, a.exhausted]),
|
|
302
|
+
);
|
|
303
|
+
const snapshots: AccountSnapshot[] = state.accounts.map((acc: AccountState) => ({
|
|
304
|
+
label: acc.label,
|
|
305
|
+
isActive: acc.label === state.active,
|
|
306
|
+
quota: reviveLastQuota(acc.last_quota ?? null),
|
|
307
|
+
quotaError: acc.last_quota ? undefined : 'no cached quota (no probe since broker start)',
|
|
308
|
+
expiresAtMs: acc.expiresAt,
|
|
309
|
+
capturedAtMs: acc.last_quota?.capturedAt,
|
|
310
|
+
}));
|
|
311
|
+
return renderQuotaBarBlock(snapshots, exhaustedByLabel, { now });
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* The live `/usage` card — the compact quota-bar block, and ONLY the bar
|
|
316
|
+
* block. `/usage` used to append the full Format 2 health-grouped table
|
|
317
|
+
* (`renderAuthSnapshotFormat2`) underneath; the operator asked for the bar
|
|
318
|
+
* card alone (2026-07-10) since the two views were redundant and the table
|
|
319
|
+
* doubled the message length. Every field the table carried per-account
|
|
320
|
+
* (5h/7d utilization + reset) has an equivalent in the bar rows — the `pct%
|
|
321
|
+
* / <time-left>` segment of each window row is the reset info, just
|
|
322
|
+
* relative instead of absolute. `opts.now` is shared with the bar block;
|
|
323
|
+
* `opts.demo` masks account-email labels in the title line the same way the
|
|
324
|
+
* table used to.
|
|
325
|
+
*
|
|
326
|
+
* Footer: after the bar rows, two footer lines are appended —
|
|
327
|
+
* 1. the synthesized cross-account "switch now" verdict (`recommendation`,
|
|
328
|
+
* shared with the /auth snapshot) — the single most actionable line,
|
|
329
|
+
* dropped when /usage went bar-only and restored here (#2959 review).
|
|
330
|
+
* 2. a freshness marker — `⚠ cached Nm ago` when the data was served stale
|
|
331
|
+
* from cache (`staleCachedAtMs`), else `Live · refreshed Nm ago` /
|
|
332
|
+
* `Live` — so a cache-served card is never mistaken for a live one.
|
|
333
|
+
*/
|
|
334
|
+
export function renderUsageCard(
|
|
335
|
+
snapshots: AccountSnapshot[],
|
|
336
|
+
exhaustedByLabel: ReadonlyMap<string, boolean>,
|
|
337
|
+
opts: UsageCardRenderOpts = {},
|
|
338
|
+
): string {
|
|
339
|
+
const now = opts.now ?? new Date();
|
|
340
|
+
const demo = opts.demo ?? false;
|
|
341
|
+
const bar = renderQuotaBarBlock(snapshots, exhaustedByLabel, { now, demo });
|
|
342
|
+
const lines = [bar];
|
|
343
|
+
// Actionable cross-account verdict — restored from renderAuthSnapshotFormat2.
|
|
344
|
+
lines.push(`_${recommendation(snapshots, now, demo)}_`);
|
|
345
|
+
// Freshness signal: stale-cache warning takes precedence over a live stamp,
|
|
346
|
+
// which takes precedence over an explicit probe-failed marker (no live data
|
|
347
|
+
// AND no cache — the card is showing "⚠️ no data" rows, so "Live" would be
|
|
348
|
+
// a lie). The probeFailed branch is the honesty backstop: without it, a
|
|
349
|
+
// total probe failure rendered a bare `_Live_` footer next to no-data rows.
|
|
350
|
+
if (opts.staleCachedAtMs != null) {
|
|
351
|
+
lines.push(`_⚠ cached ${formatAgeStamp(opts.staleCachedAtMs, now)}_`);
|
|
352
|
+
} else if (opts.liveProbedAtMs != null) {
|
|
353
|
+
lines.push(`_Live · refreshed ${formatAgeStamp(opts.liveProbedAtMs, now)}_`);
|
|
354
|
+
} else if (opts.probeFailed) {
|
|
355
|
+
lines.push('_⚠ probe failed — no live data_');
|
|
356
|
+
} else {
|
|
357
|
+
lines.push('_Live_');
|
|
358
|
+
}
|
|
359
|
+
return lines.join('\n');
|
|
360
|
+
}
|
|
@@ -97,9 +97,10 @@ export function emptyAccountState(): QuotaWatchAccountState {
|
|
|
97
97
|
* SWITCHROOM_QUOTA_WATCH_FLEET_DEDUP "0" disables the broker claim
|
|
98
98
|
* (every agent sends, pre-incident
|
|
99
99
|
* behaviour)
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
100
|
+
*
|
|
101
|
+
* When the pre-send validation probe fails, the alert is unconditionally
|
|
102
|
+
* suppressed (a quota notification must never carry numbers we could not
|
|
103
|
+
* verify live); the transition re-evaluates on the next poll tick.
|
|
103
104
|
*/
|
|
104
105
|
export interface QuotaWatchTuning {
|
|
105
106
|
/** Cached snapshots older than this are treated as unknown (no opinion). 0 = off. */
|
|
@@ -108,8 +109,6 @@ export interface QuotaWatchTuning {
|
|
|
108
109
|
lateRecoveryMs: number;
|
|
109
110
|
/** Route sends through the broker's claim-notification dedup. */
|
|
110
111
|
fleetDedup: boolean;
|
|
111
|
-
/** Legacy: send from cached data when the validation probe fails. */
|
|
112
|
-
sendOnProbeFail: boolean;
|
|
113
112
|
}
|
|
114
113
|
|
|
115
114
|
export const DEFAULT_QUOTA_WATCH_MAX_STALE_MS = 60 * 60_000;
|
|
@@ -133,7 +132,6 @@ export function resolveQuotaWatchTuning(
|
|
|
133
132
|
maxStaleMs: num(env.SWITCHROOM_QUOTA_WATCH_MAX_STALE_MS, DEFAULT_QUOTA_WATCH_MAX_STALE_MS),
|
|
134
133
|
lateRecoveryMs: num(env.SWITCHROOM_QUOTA_WATCH_LATE_RECOVERY_MS, DEFAULT_QUOTA_WATCH_LATE_RECOVERY_MS),
|
|
135
134
|
fleetDedup: env.SWITCHROOM_QUOTA_WATCH_FLEET_DEDUP !== "0",
|
|
136
|
-
sendOnProbeFail: env.SWITCHROOM_QUOTA_WATCH_SEND_ON_PROBE_FAIL === "1",
|
|
137
135
|
};
|
|
138
136
|
}
|
|
139
137
|
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
recordTurnEnd,
|
|
22
22
|
findRecentTurnsForChat,
|
|
23
23
|
getTurnByKey,
|
|
24
|
+
reapStaleOpenTurns,
|
|
24
25
|
} from './turns-schema.js'
|
|
25
26
|
|
|
26
27
|
// ---------------------------------------------------------------------------
|
|
@@ -157,3 +158,99 @@ describe('getTurnByKey', () => {
|
|
|
157
158
|
db.close()
|
|
158
159
|
})
|
|
159
160
|
})
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// reapStaleOpenTurns — mid-session periodic orphan sweep (#2918)
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
describe('reapStaleOpenTurns (#2918 mid-session sweep)', () => {
|
|
167
|
+
// Helper: force a row's started_at into the past so the TTL gate is met.
|
|
168
|
+
function ageRow(db: ReturnType<typeof openTurnsDbInMemory>, turnKey: string, startedAt: number): void {
|
|
169
|
+
db.prepare('UPDATE turns SET started_at = ? WHERE turn_key = ?').run(startedAt, turnKey)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
it('stamps an ownerless open row aged past the TTL as restart', () => {
|
|
173
|
+
const db = openTurnsDbInMemory()
|
|
174
|
+
const now = 1_000_000_000_000
|
|
175
|
+
recordTurnStart(db, { turnKey: 'dm:dead', chatId: '111' })
|
|
176
|
+
ageRow(db, 'dm:dead', now - 30 * 60_000) // 30 min old
|
|
177
|
+
const res = reapStaleOpenTurns(db, {
|
|
178
|
+
activeTurnKeys: new Set<string>(), // process gone → no live owner
|
|
179
|
+
ttlMs: 15 * 60_000,
|
|
180
|
+
now,
|
|
181
|
+
})
|
|
182
|
+
expect(res.reaped).toBe(1)
|
|
183
|
+
expect(res.reapedTurnKeys).toEqual(['dm:dead'])
|
|
184
|
+
const turn = getTurnByKey(db, 'dm:dead')
|
|
185
|
+
expect(turn?.ended_at).toBe(now)
|
|
186
|
+
expect(turn?.ended_via).toBe('restart')
|
|
187
|
+
db.close()
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('NEVER touches a healthy in-flight turn (turn_key in activeTurnKeys)', () => {
|
|
191
|
+
const db = openTurnsDbInMemory()
|
|
192
|
+
const now = 1_000_000_000_000
|
|
193
|
+
recordTurnStart(db, { turnKey: 'dm:live', chatId: '222' })
|
|
194
|
+
// Even though it is aged well past the TTL, a live owner protects it.
|
|
195
|
+
ageRow(db, 'dm:live', now - 6 * 60 * 60_000) // 6h "long-running" turn
|
|
196
|
+
const res = reapStaleOpenTurns(db, {
|
|
197
|
+
activeTurnKeys: new Set(['dm:live']),
|
|
198
|
+
ttlMs: 15 * 60_000,
|
|
199
|
+
now,
|
|
200
|
+
})
|
|
201
|
+
expect(res.reaped).toBe(0)
|
|
202
|
+
const turn = getTurnByKey(db, 'dm:live')
|
|
203
|
+
expect(turn?.ended_at).toBeNull()
|
|
204
|
+
expect(turn?.ended_via).toBeNull()
|
|
205
|
+
db.close()
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('does not reap an ownerless row younger than the TTL (race guard)', () => {
|
|
209
|
+
const db = openTurnsDbInMemory()
|
|
210
|
+
const now = 1_000_000_000_000
|
|
211
|
+
recordTurnStart(db, { turnKey: 'dm:fresh', chatId: '333' })
|
|
212
|
+
ageRow(db, 'dm:fresh', now - 60_000) // 1 min old, ownerless
|
|
213
|
+
const res = reapStaleOpenTurns(db, {
|
|
214
|
+
activeTurnKeys: new Set<string>(),
|
|
215
|
+
ttlMs: 15 * 60_000,
|
|
216
|
+
now,
|
|
217
|
+
})
|
|
218
|
+
expect(res.reaped).toBe(0)
|
|
219
|
+
expect(getTurnByKey(db, 'dm:fresh')?.ended_at).toBeNull()
|
|
220
|
+
db.close()
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('reaps the dead orphan while sparing a concurrently-live turn', () => {
|
|
224
|
+
const db = openTurnsDbInMemory()
|
|
225
|
+
const now = 1_000_000_000_000
|
|
226
|
+
recordTurnStart(db, { turnKey: 'dm:dead', chatId: '111' })
|
|
227
|
+
recordTurnStart(db, { turnKey: 'dm:live', chatId: '222' })
|
|
228
|
+
ageRow(db, 'dm:dead', now - 30 * 60_000)
|
|
229
|
+
ageRow(db, 'dm:live', now - 30 * 60_000)
|
|
230
|
+
const res = reapStaleOpenTurns(db, {
|
|
231
|
+
activeTurnKeys: new Set(['dm:live']),
|
|
232
|
+
ttlMs: 15 * 60_000,
|
|
233
|
+
now,
|
|
234
|
+
})
|
|
235
|
+
expect(res.reapedTurnKeys).toEqual(['dm:dead'])
|
|
236
|
+
expect(getTurnByKey(db, 'dm:dead')?.ended_via).toBe('restart')
|
|
237
|
+
expect(getTurnByKey(db, 'dm:live')?.ended_at).toBeNull()
|
|
238
|
+
db.close()
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('leaves already-ended rows alone (idempotent)', () => {
|
|
242
|
+
const db = openTurnsDbInMemory()
|
|
243
|
+
const now = 1_000_000_000_000
|
|
244
|
+
recordTurnStart(db, { turnKey: 'dm:done', chatId: '444' })
|
|
245
|
+
ageRow(db, 'dm:done', now - 30 * 60_000)
|
|
246
|
+
recordTurnEnd(db, { turnKey: 'dm:done', endedVia: 'stop' })
|
|
247
|
+
const res = reapStaleOpenTurns(db, {
|
|
248
|
+
activeTurnKeys: new Set<string>(),
|
|
249
|
+
ttlMs: 15 * 60_000,
|
|
250
|
+
now,
|
|
251
|
+
})
|
|
252
|
+
expect(res.reaped).toBe(0)
|
|
253
|
+
expect(getTurnByKey(db, 'dm:done')?.ended_via).toBe('stop')
|
|
254
|
+
db.close()
|
|
255
|
+
})
|
|
256
|
+
})
|
|
@@ -497,6 +497,84 @@ export function markOrphanedWithTimeoutClassification(
|
|
|
497
497
|
return { reaped: (timeoutTurnKey ? 1 : 0) + rest.changes, timeoutTurnKey }
|
|
498
498
|
}
|
|
499
499
|
|
|
500
|
+
export interface ReapStaleOpenTurnsOpts {
|
|
501
|
+
/**
|
|
502
|
+
* The set of turn_keys that belong to a turn still LIVE in this process's
|
|
503
|
+
* memory (the gateway's `currentTurnMap` registry keys plus the singleton
|
|
504
|
+
* `currentTurn` mirror). A row whose turn_key is in this set is NEVER
|
|
505
|
+
* reaped — it is a genuinely in-flight turn whose spinner must keep
|
|
506
|
+
* spinning, however long it runs. This is the load-bearing liveness
|
|
507
|
+
* predicate: age alone must never reap; only an open row with NO live
|
|
508
|
+
* owner qualifies.
|
|
509
|
+
*/
|
|
510
|
+
activeTurnKeys: ReadonlySet<string>
|
|
511
|
+
/**
|
|
512
|
+
* Minimum age (ms, measured from `started_at`) before an ownerless open row
|
|
513
|
+
* is swept. A secondary guard against races — a turn that has JUST started
|
|
514
|
+
* but not yet populated the live set (or a row recorded microseconds ago) is
|
|
515
|
+
* protected until it ages past this. Liveness (activeTurnKeys) does the real
|
|
516
|
+
* work; the TTL only closes the "recorded-but-not-yet-tracked" window.
|
|
517
|
+
*/
|
|
518
|
+
ttlMs: number
|
|
519
|
+
/** Injectable clock for tests. */
|
|
520
|
+
now?: number
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
export interface ReapStaleOpenTurnsResult {
|
|
524
|
+
/** Rows stamped `ended_via='restart'` by this sweep. */
|
|
525
|
+
reaped: number
|
|
526
|
+
/** The turn_keys that were stamped, for logging / card finalization. */
|
|
527
|
+
reapedTurnKeys: string[]
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Mid-session periodic reaper (#2918). The boot-time
|
|
532
|
+
* `markOrphanedWithTimeoutClassification` only runs once, right after
|
|
533
|
+
* `openTurnsDb` — so a turn whose owning process dies MID-session (the SDK
|
|
534
|
+
* subprocess is SIGKILLed / OOMs / crashes) without a clean `recordTurnEnd`
|
|
535
|
+
* leaves its row `ended_at IS NULL`, and its activity card keeps spinning
|
|
536
|
+
* until the NEXT gateway boot (often many hours later). This sweep runs on a
|
|
537
|
+
* periodic timer inside the live gateway and stamps those ownerless open rows
|
|
538
|
+
* `ended_via='restart'` (the same clean-interrupt classification the boot
|
|
539
|
+
* reaper uses for a non-hung orphan) so the stale card can be finalized
|
|
540
|
+
* without waiting for a restart.
|
|
541
|
+
*
|
|
542
|
+
* CORRECTNESS: only rows that are BOTH (a) not owned by any live turn
|
|
543
|
+
* (`turn_key ∉ activeTurnKeys`) AND (b) older than `ttlMs` are swept. A
|
|
544
|
+
* healthy in-flight turn — however long it runs — is always in
|
|
545
|
+
* `activeTurnKeys` and is never touched. Never invents a new state; reuses
|
|
546
|
+
* `'restart'` so the existing resume/report policy applies unchanged.
|
|
547
|
+
*/
|
|
548
|
+
export function reapStaleOpenTurns(
|
|
549
|
+
db: SqliteDatabase,
|
|
550
|
+
opts: ReapStaleOpenTurnsOpts,
|
|
551
|
+
): ReapStaleOpenTurnsResult {
|
|
552
|
+
const now = opts.now ?? Date.now()
|
|
553
|
+
const cutoff = now - opts.ttlMs
|
|
554
|
+
// Candidate = open row aged past the TTL. Liveness is filtered in JS against
|
|
555
|
+
// the injected active set (avoids brittle SQL IN-list binding).
|
|
556
|
+
const candidates = db.prepare(`
|
|
557
|
+
SELECT turn_key FROM turns
|
|
558
|
+
WHERE ended_at IS NULL AND started_at <= ?
|
|
559
|
+
`).all(cutoff) as { turn_key: string }[]
|
|
560
|
+
|
|
561
|
+
const stamp = db.prepare(`
|
|
562
|
+
UPDATE turns
|
|
563
|
+
SET ended_at = ?,
|
|
564
|
+
ended_via = 'restart',
|
|
565
|
+
updated_at = ?
|
|
566
|
+
WHERE turn_key = ? AND ended_at IS NULL
|
|
567
|
+
`)
|
|
568
|
+
|
|
569
|
+
const reapedTurnKeys: string[] = []
|
|
570
|
+
for (const { turn_key } of candidates) {
|
|
571
|
+
if (opts.activeTurnKeys.has(turn_key)) continue // live — never reap
|
|
572
|
+
const r = stamp.run(now, now, turn_key) as { changes: number }
|
|
573
|
+
if (r.changes > 0) reapedTurnKeys.push(turn_key)
|
|
574
|
+
}
|
|
575
|
+
return { reaped: reapedTurnKeys.length, reapedTurnKeys }
|
|
576
|
+
}
|
|
577
|
+
|
|
500
578
|
/**
|
|
501
579
|
* Return the most recent N turns for `chatId` (any state — running or ended),
|
|
502
580
|
* ordered by started_at DESC. Used by the idle-footer renderer to decide
|