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 +1 -1
- package/telegram-plugin/gateway/gateway.ts +50 -10
- package/telegram-plugin/gateway/hostd-dispatch.ts +38 -0
- package/telegram-plugin/gateway/update-status-line.ts +61 -0
- package/telegram-plugin/tests/update-status-line.test.ts +70 -0
- package/dist/agent-scheduler/index.js +0 -12657
- package/dist/auth-broker/index.js +0 -14133
- package/dist/cli/autoaccept-poll.js +0 -128
- package/dist/cli/drive-write-pretool.mjs +0 -5451
- package/dist/cli/skill-validate-pretool.mjs +0 -7209
- package/dist/cli/switchroom.js +0 -76015
- package/dist/cli/ui/index.html +0 -1281
- package/dist/host-control/main.js +0 -16041
- package/dist/vault/approvals/kernel-server.js +0 -13121
- package/dist/vault/broker/server.js +0 -17177
- package/telegram-plugin/dist/bridge/bridge.js +0 -24996
- package/telegram-plugin/dist/gateway/gateway.js +0 -55480
- package/telegram-plugin/dist/server.js +0 -24786
package/package.json
CHANGED
|
@@ -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
|
-
//
|
|
2577
|
-
//
|
|
2578
|
-
//
|
|
2579
|
-
//
|
|
2580
|
-
//
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
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
|
+
})
|