switchroom 0.12.4 → 0.12.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.12.4",
3
+ "version": "0.12.6",
4
4
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -233,9 +233,11 @@ import {
233
233
  hostdRequestId,
234
234
  hostdWillBeUsed,
235
235
  pollHostdStatus,
236
+ hostdGetStatusOnce,
236
237
  warnLegacySpawnIfHostdDisabled,
237
238
  _resetHostdEnabledCache,
238
239
  } from './hostd-dispatch.js'
240
+ import { formatUpdateStatusLine } from './update-status-line.js'
239
241
  import type { HostdRequest } from '../../src/host-control/protocol.js'
240
242
  import type { AgentAudit } from '../welcome-text.js'
241
243
  import { shouldSweepChatAtBoot } from './boot-sweep-filter.js'
@@ -2567,22 +2569,50 @@ function ensureIssuesCard(chatId: string, threadId: number | undefined): void {
2567
2569
  // the framework itself sends a user-visible "still working… / still
2568
2570
  // thinking…" message. Honours SWITCHROOM_DISABLE_SILENCE_POKE=1 kill
2569
2571
  // switch (no-op if set).
2572
+ // Set when this gateway dispatches an `update_apply` to hostd that
2573
+ // returns `started`; cleared when the dispatch poll resolves (terminal
2574
+ // / not-configured / timeout). While set, the framework silence
2575
+ // fallback substitutes hostd's real phase + elapsed for the generic
2576
+ // "still working…" text — deterministic, model-free, the klanker
2577
+ // incident fix. In-memory only; a gateway recreate naturally resets it.
2578
+ let inFlightUpdate: { requestId: string; startedAt: number } | null = null
2579
+
2570
2580
  silencePoke.startTimer({
2571
2581
  emitMetric: (event) => {
2572
2582
  // Re-emit through the unified runtime-metrics fan-out (PostHog + JSONL).
2573
2583
  emitRuntimeMetric(event)
2574
2584
  },
2575
2585
  onFrameworkFallback: async (ctx) => {
2576
- // Wording is load-bearing extracted to `formatFrameworkFallbackText`
2577
- // in `silence-poke.ts` so it can be snapshot-tested in isolation
2578
- // (CC-4 in `docs/status-ask-cause-classes.md`). Derives "N min" suffix
2579
- // from `ctx.silenceMs` so the wording stays honest if the 300s
2580
- // threshold is tuned.
2581
- const text = silencePoke.formatFrameworkFallbackText(
2582
- ctx.fallbackKind,
2583
- ctx.silenceMs,
2584
- ctx.inFlightTools,
2585
- )
2586
+ // Deterministic in-flight update status (klanker incident). If this
2587
+ // gateway dispatched an update_apply that's still running, the
2588
+ // recurring framework fallback carries hostd's REAL phase + elapsed
2589
+ // instead of the content-free "still working…". No model: pure
2590
+ // get_status snapshot → pure formatter. Any hostd unavailability
2591
+ // degrades silently to the existing generic text (zero regression).
2592
+ let text: string | null = null
2593
+ const upd = inFlightUpdate
2594
+ if (upd != null) {
2595
+ try {
2596
+ const st = await hostdGetStatusOnce(getMyAgentName(), upd.requestId)
2597
+ if (st !== 'not-configured' && st !== 'unavailable') {
2598
+ text = formatUpdateStatusLine(st, upd.startedAt, Date.now())
2599
+ }
2600
+ } catch {
2601
+ /* degrade to generic fallback below */
2602
+ }
2603
+ }
2604
+ if (text == null) {
2605
+ // Wording is load-bearing — extracted to `formatFrameworkFallbackText`
2606
+ // in `silence-poke.ts` so it can be snapshot-tested in isolation
2607
+ // (CC-4 in `docs/status-ask-cause-classes.md`). Derives "N min" suffix
2608
+ // from `ctx.silenceMs` so the wording stays honest if the 300s
2609
+ // threshold is tuned.
2610
+ text = silencePoke.formatFrameworkFallbackText(
2611
+ ctx.fallbackKind,
2612
+ ctx.silenceMs,
2613
+ ctx.inFlightTools,
2614
+ )
2615
+ }
2586
2616
  try {
2587
2617
  await robustApiCall(
2588
2618
  () => bot.api.sendMessage(ctx.chatId, text, {
@@ -8594,6 +8624,10 @@ bot.command('update', async ctx => {
8594
8624
  return
8595
8625
  }
8596
8626
  if (hostdResp.result === 'started') {
8627
+ // Mark in-flight so the framework silence fallback renders hostd's
8628
+ // real phase + elapsed (deterministic, model-free) instead of the
8629
+ // content-free "still working…" — the klanker incident fix.
8630
+ inFlightUpdate = { requestId: updateRequestId, startedAt: Date.now() }
8597
8631
  // RFC C §5.3: long-running mutation. Poll get_status until terminal
8598
8632
  // or until the recreate kills this gateway (whichever happens first).
8599
8633
  // The success signal is the post-restart greeting card edited into
@@ -8602,6 +8636,7 @@ bot.command('update', async ctx => {
8602
8636
  // doesn't leave the operator staring at the orphan "🚀 update started"
8603
8637
  // ack indefinitely. Live repro: PR #1305.
8604
8638
  void (async () => {
8639
+ try {
8605
8640
  // 60s budget: RFC C §5.3 specs `apply` at 30s and `update_apply`
8606
8641
  // at 60s. Image pulls + scaffold regeneration dominate the wall
8607
8642
  // clock for update_apply, hence the larger budget. The poll
@@ -8648,6 +8683,11 @@ bot.command('update', async ctx => {
8648
8683
  await switchroomReply(ctx, editedText, { html: true })
8649
8684
  } catch {}
8650
8685
  }
8686
+ } finally {
8687
+ // Poll resolved (terminal / completed / not-configured /
8688
+ // timeout) — stop substituting in-flight update status.
8689
+ inFlightUpdate = null
8690
+ }
8651
8691
  })()
8652
8692
  return
8653
8693
  }
@@ -124,6 +124,44 @@ export function hostdRequestId(prefix: string): string {
124
124
  return `${prefix}-${Date.now()}-${randomBytes(4).toString("hex")}`;
125
125
  }
126
126
 
127
+ /**
128
+ * Single-shot `get_status` snapshot for `targetRequestId` (NOT a
129
+ * poll-until-terminal — {@link pollHostdStatus} does that). Used by
130
+ * the framework silence-fallback to render a deterministic in-flight
131
+ * status line. Returns `"not-configured"` (hostd off / socket absent)
132
+ * or `"unavailable"` (wire error / hostd couldn't answer) so the
133
+ * caller can cleanly fall back to the generic fallback text — never
134
+ * throws, never blocks the fallback.
135
+ */
136
+ export async function hostdGetStatusOnce(
137
+ agentName: string,
138
+ targetRequestId: string,
139
+ ): Promise<HostdResponse | "not-configured" | "unavailable"> {
140
+ if (!isHostdEnabled()) return "not-configured";
141
+ const sockPath = hostdSocketPath(agentName);
142
+ if (!existsSync(sockPath)) return "not-configured";
143
+ try {
144
+ const resp = await hostdRequest(
145
+ { socketPath: sockPath, timeoutMs: 3000 },
146
+ {
147
+ v: 1,
148
+ op: "get_status",
149
+ request_id: hostdRequestId("gw-poke-status"),
150
+ args: { target_request_id: targetRequestId },
151
+ },
152
+ );
153
+ // hostd answered but couldn't resolve the entry → treat as
154
+ // unavailable so we degrade to the generic fallback, not a
155
+ // confusing "error" status line.
156
+ if (resp.result === "denied" || resp.result === "error") {
157
+ return "unavailable";
158
+ }
159
+ return resp;
160
+ } catch {
161
+ return "unavailable";
162
+ }
163
+ }
164
+
127
165
  /**
128
166
  * Poll hostd's `get_status` verb until the target request reaches a
129
167
  * terminal state (`completed` / `error` / `denied`) or the caller's
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Deterministic, model-free status line for an in-flight hostd
3
+ * `update_apply`. Pure function over hostd's `get_status` response —
4
+ * no model, no I/O. Used by the gateway's framework silence-fallback
5
+ * so the recurring "is it still going?" message carries hostd's
6
+ * actual phase + elapsed instead of the content-free
7
+ * "still working… no update from agent in N min" (the klanker
8
+ * incident: a multi-minute fleet update with zero visible status).
9
+ *
10
+ * The hostd CLI prints step banners as `▸ <step>` lines (pull-images,
11
+ * apply-config, recreate-containers, doctor, …) into stdout; hostd
12
+ * surfaces the last 4 KiB as `stdout_tail`. The phase is the LAST such
13
+ * banner — a deterministic string parse, no interpretation.
14
+ */
15
+
16
+ export interface UpdateStatusResponse {
17
+ result: string;
18
+ stdout_tail?: string;
19
+ stderr_tail?: string;
20
+ }
21
+
22
+ /** Latest `▸ <step>` banner in the tail, or null if none yet. */
23
+ export function latestHostdPhase(tail: string | undefined): string | null {
24
+ if (!tail) return null;
25
+ let phase: string | null = null;
26
+ for (const raw of tail.split("\n")) {
27
+ const m = /^\s*▸\s*(.+?)\s*$/.exec(raw);
28
+ if (m && m[1]) phase = m[1];
29
+ }
30
+ return phase;
31
+ }
32
+
33
+ function elapsedMin(startedAt: number, now: number): number {
34
+ return Math.max(1, Math.round((now - startedAt) / 60_000));
35
+ }
36
+
37
+ /**
38
+ * Compose the deterministic status line. `resp` is hostd's get_status
39
+ * for the in-flight update_apply; `startedAt` is when the gateway
40
+ * dispatched it; `now` is the current time.
41
+ */
42
+ export function formatUpdateStatusLine(
43
+ resp: UpdateStatusResponse,
44
+ startedAt: number,
45
+ now: number,
46
+ ): string {
47
+ const mins = elapsedMin(startedAt, now);
48
+ const tail = `Recreating the fleet (including me) — I'll report the result here when it's done.`;
49
+
50
+ // Terminal-but-gateway-still-alive: recreate hasn't killed us yet, or
51
+ // it failed before recreate. The dedicated terminal/boot path owns the
52
+ // final verdict; here we just stop claiming "in progress".
53
+ if (resp.result !== "started") {
54
+ return `⏳ Fleet update finishing — hostd reported \`${resp.result}\` (~${mins}m). ${tail}`;
55
+ }
56
+
57
+ const phase = latestHostdPhase(resp.stdout_tail);
58
+ return phase
59
+ ? `⏳ Fleet update in progress — phase: ${phase} (~${mins}m). ${tail}`
60
+ : `⏳ Fleet update in progress — starting (~${mins}m). ${tail}`;
61
+ }
@@ -0,0 +1,70 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ latestHostdPhase,
4
+ formatUpdateStatusLine,
5
+ } from '../gateway/update-status-line.js'
6
+
7
+ const T0 = 1_000_000_000_000
8
+
9
+ describe('latestHostdPhase', () => {
10
+ it('returns null for empty / no-banner tail', () => {
11
+ expect(latestHostdPhase(undefined)).toBeNull()
12
+ expect(latestHostdPhase('')).toBeNull()
13
+ expect(latestHostdPhase('Applying switchroom config...\nWrote compose')).toBeNull()
14
+ })
15
+
16
+ it('returns the LAST ▸ banner (deterministic, last wins)', () => {
17
+ const tail =
18
+ '▸ pull-images\n some docker output\n▸ apply-config\n▸ recreate-containers\n'
19
+ expect(latestHostdPhase(tail)).toBe('recreate-containers')
20
+ })
21
+
22
+ it('tolerates leading/trailing whitespace around the banner', () => {
23
+ expect(latestHostdPhase(' ▸ doctor \n')).toBe('doctor')
24
+ })
25
+ })
26
+
27
+ describe('formatUpdateStatusLine', () => {
28
+ it('in-progress with a phase: shows phase + elapsed + report-back tail', () => {
29
+ const line = formatUpdateStatusLine(
30
+ { result: 'started', stdout_tail: '▸ pull-images\n▸ apply-config\n' },
31
+ T0,
32
+ T0 + 2 * 60_000 + 5_000, // ~2m
33
+ )
34
+ expect(line).toContain('Fleet update in progress')
35
+ expect(line).toContain('phase: apply-config')
36
+ expect(line).toContain('~2m')
37
+ expect(line).toMatch(/report the result here/i)
38
+ expect(line).not.toMatch(/still working|no update from agent/i)
39
+ })
40
+
41
+ it('in-progress before any banner: "starting"', () => {
42
+ const line = formatUpdateStatusLine(
43
+ { result: 'started', stdout_tail: 'Image ... Pulling' },
44
+ T0,
45
+ T0 + 30_000,
46
+ )
47
+ expect(line).toContain('Fleet update in progress')
48
+ expect(line).toContain('starting')
49
+ expect(line).not.toContain('phase:')
50
+ expect(line).toContain('~1m') // min 1m floor
51
+ })
52
+
53
+ it('elapsed rounds to nearest minute, floored at 1', () => {
54
+ expect(formatUpdateStatusLine({ result: 'started' }, T0, T0 + 5_000)).toContain('~1m')
55
+ expect(
56
+ formatUpdateStatusLine({ result: 'started' }, T0, T0 + 7 * 60_000 + 40_000),
57
+ ).toContain('~8m')
58
+ })
59
+
60
+ it('terminal-but-gateway-alive: stops claiming in-progress, defers to verdict path', () => {
61
+ const line = formatUpdateStatusLine(
62
+ { result: 'error', stderr_tail: 'boom' },
63
+ T0,
64
+ T0 + 3 * 60_000,
65
+ )
66
+ expect(line).toContain('finishing')
67
+ expect(line).toContain('`error`')
68
+ expect(line).not.toContain('in progress')
69
+ })
70
+ })