switchroom 0.12.3 → 0.12.5
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/cli/switchroom.js +3 -2
- package/dist/host-control/main.js +28 -8
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +92 -27
- 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/cli/switchroom.js
CHANGED
|
@@ -46894,8 +46894,8 @@ var {
|
|
|
46894
46894
|
} = import__.default;
|
|
46895
46895
|
|
|
46896
46896
|
// src/build-info.ts
|
|
46897
|
-
var VERSION = "0.12.
|
|
46898
|
-
var COMMIT_SHA = "
|
|
46897
|
+
var VERSION = "0.12.5";
|
|
46898
|
+
var COMMIT_SHA = "bab28d7e";
|
|
46899
46899
|
|
|
46900
46900
|
// src/cli/agent.ts
|
|
46901
46901
|
init_source();
|
|
@@ -48276,6 +48276,7 @@ function buildWorkspaceContext(args) {
|
|
|
48276
48276
|
botToken: resolvedBotToken ?? rawBotToken,
|
|
48277
48277
|
forumChatId: telegramConfig.forum_chat_id,
|
|
48278
48278
|
dangerousMode: agentConfig.dangerous_mode === true,
|
|
48279
|
+
admin: agentConfig.admin === true,
|
|
48279
48280
|
useSwitchroomPlugin: usesSwitchroomTelegramPlugin(agentConfig),
|
|
48280
48281
|
useHotReloadStable: agentConfig.channels?.telegram?.hotReloadStable === true,
|
|
48281
48282
|
telegramEnabledFlag: agentConfig.channels?.telegram?.enabled === false ? "false" : "true",
|
|
@@ -14693,7 +14693,7 @@ import {
|
|
|
14693
14693
|
renameSync,
|
|
14694
14694
|
mkdirSync
|
|
14695
14695
|
} from "node:fs";
|
|
14696
|
-
import { join as join2, dirname as dirname2 } from "node:path";
|
|
14696
|
+
import { join as join2, dirname as dirname2, resolve as resolve5 } from "node:path";
|
|
14697
14697
|
|
|
14698
14698
|
// src/host-control/protocol.ts
|
|
14699
14699
|
var MAX_FRAME_BYTES = 64 * 1024;
|
|
@@ -15355,8 +15355,8 @@ class HostdServer {
|
|
|
15355
15355
|
process.stderr.write(`hostd: server error on ${sockPath}: ${err.message}
|
|
15356
15356
|
`);
|
|
15357
15357
|
});
|
|
15358
|
-
await new Promise((
|
|
15359
|
-
server.listen(sockPath, () =>
|
|
15358
|
+
await new Promise((resolve6, reject) => {
|
|
15359
|
+
server.listen(sockPath, () => resolve6());
|
|
15360
15360
|
server.once("error", reject);
|
|
15361
15361
|
});
|
|
15362
15362
|
await chmod(sockPath, 432).catch(() => {
|
|
@@ -15378,7 +15378,7 @@ class HostdServer {
|
|
|
15378
15378
|
async stop() {
|
|
15379
15379
|
const paths = [...this.servers.keys()];
|
|
15380
15380
|
for (const [, server] of this.servers) {
|
|
15381
|
-
await new Promise((
|
|
15381
|
+
await new Promise((resolve6) => server.close(() => resolve6()));
|
|
15382
15382
|
}
|
|
15383
15383
|
this.servers.clear();
|
|
15384
15384
|
for (const s of paths) {
|
|
@@ -15604,10 +15604,27 @@ class HostdServer {
|
|
|
15604
15604
|
stderr_tail: tail(res.stderr)
|
|
15605
15605
|
};
|
|
15606
15606
|
}
|
|
15607
|
+
missingApplyAssets() {
|
|
15608
|
+
const root = this.opts.applyAssetsRoot ?? resolve5(import.meta.dirname, "../..");
|
|
15609
|
+
return [
|
|
15610
|
+
join2(root, "profiles"),
|
|
15611
|
+
join2(root, "profiles", "default"),
|
|
15612
|
+
join2(root, "vendor", "hindsight-memory")
|
|
15613
|
+
].filter((p) => !existsSync5(p));
|
|
15614
|
+
}
|
|
15615
|
+
applyAssetPreflight(request_id, started) {
|
|
15616
|
+
const missing = this.missingApplyAssets();
|
|
15617
|
+
if (missing.length === 0)
|
|
15618
|
+
return null;
|
|
15619
|
+
return deniedResponse(request_id, `refused: this switchroom-hostd image is missing apply-time ` + `assets [${missing.join(", ")}]. hostd's in-container ` + `\`switchroom apply\` cannot scaffold agents without them and ` + `would pull images then strand the fleet on the old image ` + `(the klanker incident). Nothing was pulled or changed. Fix: ` + `rebuild/refresh the hostd image — \`switchroom update\` ` + `(refreshes hostd) or \`switchroom hostd install\` on the ` + `host; meanwhile run \`switchroom update\` host-side.`, Date.now() - started);
|
|
15620
|
+
}
|
|
15607
15621
|
handleUpdateApply(req, caller, started) {
|
|
15608
15622
|
const denied = this.checkFleetMutationLock(req.op, req.request_id, started);
|
|
15609
15623
|
if (denied)
|
|
15610
15624
|
return denied;
|
|
15625
|
+
const assetDenied = this.applyAssetPreflight(req.request_id, started);
|
|
15626
|
+
if (assetDenied)
|
|
15627
|
+
return assetDenied;
|
|
15611
15628
|
if (req.args?.channel && req.args?.pin) {
|
|
15612
15629
|
return deniedResponse(req.request_id, "update_apply: `channel` and `pin` are mutually exclusive — pass at most one.", Date.now() - started);
|
|
15613
15630
|
}
|
|
@@ -15663,6 +15680,9 @@ class HostdServer {
|
|
|
15663
15680
|
const denied = this.checkFleetMutationLock(req.op, req.request_id, started);
|
|
15664
15681
|
if (denied)
|
|
15665
15682
|
return denied;
|
|
15683
|
+
const assetDenied = this.applyAssetPreflight(req.request_id, started);
|
|
15684
|
+
if (assetDenied)
|
|
15685
|
+
return assetDenied;
|
|
15666
15686
|
const args = ["apply", "--non-interactive"];
|
|
15667
15687
|
const entry = {
|
|
15668
15688
|
request_id: req.request_id,
|
|
@@ -15755,7 +15775,7 @@ class HostdServer {
|
|
|
15755
15775
|
};
|
|
15756
15776
|
}
|
|
15757
15777
|
runDocker(args) {
|
|
15758
|
-
return new Promise((
|
|
15778
|
+
return new Promise((resolve6, reject) => {
|
|
15759
15779
|
const bin = this.opts.dockerBin ?? "docker";
|
|
15760
15780
|
const child = spawn(bin, args, {
|
|
15761
15781
|
stdio: ["ignore", "pipe", "pipe"],
|
|
@@ -15770,7 +15790,7 @@ class HostdServer {
|
|
|
15770
15790
|
stderr += d.toString("utf8");
|
|
15771
15791
|
});
|
|
15772
15792
|
child.on("error", (err) => reject(err));
|
|
15773
|
-
child.on("close", (code) =>
|
|
15793
|
+
child.on("close", (code) => resolve6({ exit_code: code ?? -1, stdout, stderr }));
|
|
15774
15794
|
});
|
|
15775
15795
|
}
|
|
15776
15796
|
imageRefsForDigestCapture() {
|
|
@@ -15919,7 +15939,7 @@ class HostdServer {
|
|
|
15919
15939
|
});
|
|
15920
15940
|
}
|
|
15921
15941
|
runSwitchroom(args) {
|
|
15922
|
-
return new Promise((
|
|
15942
|
+
return new Promise((resolve6, reject) => {
|
|
15923
15943
|
const bin = this.opts.switchroomBin ?? "switchroom";
|
|
15924
15944
|
const child = spawn(bin, args, {
|
|
15925
15945
|
stdio: ["ignore", "pipe", "pipe"],
|
|
@@ -15934,7 +15954,7 @@ class HostdServer {
|
|
|
15934
15954
|
stderr += d.toString("utf8");
|
|
15935
15955
|
});
|
|
15936
15956
|
child.on("error", (err) => reject(err));
|
|
15937
|
-
child.on("close", (code) =>
|
|
15957
|
+
child.on("close", (code) => resolve6({ exit_code: code ?? -1, stdout, stderr }));
|
|
15938
15958
|
});
|
|
15939
15959
|
}
|
|
15940
15960
|
}
|
package/package.json
CHANGED
|
@@ -42575,6 +42575,27 @@ async function tryHostdDispatch(agentName3, req) {
|
|
|
42575
42575
|
function hostdRequestId(prefix) {
|
|
42576
42576
|
return `${prefix}-${Date.now()}-${randomBytes3(4).toString("hex")}`;
|
|
42577
42577
|
}
|
|
42578
|
+
async function hostdGetStatusOnce(agentName3, targetRequestId) {
|
|
42579
|
+
if (!isHostdEnabled())
|
|
42580
|
+
return "not-configured";
|
|
42581
|
+
const sockPath = hostdSocketPath(agentName3);
|
|
42582
|
+
if (!existsSync19(sockPath))
|
|
42583
|
+
return "not-configured";
|
|
42584
|
+
try {
|
|
42585
|
+
const resp = await hostdRequest({ socketPath: sockPath, timeoutMs: 3000 }, {
|
|
42586
|
+
v: 1,
|
|
42587
|
+
op: "get_status",
|
|
42588
|
+
request_id: hostdRequestId("gw-poke-status"),
|
|
42589
|
+
args: { target_request_id: targetRequestId }
|
|
42590
|
+
});
|
|
42591
|
+
if (resp.result === "denied" || resp.result === "error") {
|
|
42592
|
+
return "unavailable";
|
|
42593
|
+
}
|
|
42594
|
+
return resp;
|
|
42595
|
+
} catch {
|
|
42596
|
+
return "unavailable";
|
|
42597
|
+
}
|
|
42598
|
+
}
|
|
42578
42599
|
async function pollHostdStatus(agentName3, targetRequestId, opts) {
|
|
42579
42600
|
if (!isHostdEnabled())
|
|
42580
42601
|
return "not-configured";
|
|
@@ -42622,6 +42643,32 @@ function warnLegacySpawnIfHostdDisabled(verb) {
|
|
|
42622
42643
|
`);
|
|
42623
42644
|
}
|
|
42624
42645
|
|
|
42646
|
+
// gateway/update-status-line.ts
|
|
42647
|
+
function latestHostdPhase(tail) {
|
|
42648
|
+
if (!tail)
|
|
42649
|
+
return null;
|
|
42650
|
+
let phase = null;
|
|
42651
|
+
for (const raw of tail.split(`
|
|
42652
|
+
`)) {
|
|
42653
|
+
const m = /^\s*\u25b8\s*(.+?)\s*$/.exec(raw);
|
|
42654
|
+
if (m && m[1])
|
|
42655
|
+
phase = m[1];
|
|
42656
|
+
}
|
|
42657
|
+
return phase;
|
|
42658
|
+
}
|
|
42659
|
+
function elapsedMin(startedAt, now) {
|
|
42660
|
+
return Math.max(1, Math.round((now - startedAt) / 60000));
|
|
42661
|
+
}
|
|
42662
|
+
function formatUpdateStatusLine(resp, startedAt, now) {
|
|
42663
|
+
const mins = elapsedMin(startedAt, now);
|
|
42664
|
+
const tail = `Recreating the fleet (including me) \u2014 I'll report the result here when it's done.`;
|
|
42665
|
+
if (resp.result !== "started") {
|
|
42666
|
+
return `\u23f3 Fleet update finishing \u2014 hostd reported \`${resp.result}\` (~${mins}m). ${tail}`;
|
|
42667
|
+
}
|
|
42668
|
+
const phase = latestHostdPhase(resp.stdout_tail);
|
|
42669
|
+
return phase ? `\u23f3 Fleet update in progress \u2014 phase: ${phase} (~${mins}m). ${tail}` : `\u23f3 Fleet update in progress \u2014 starting (~${mins}m). ${tail}`;
|
|
42670
|
+
}
|
|
42671
|
+
|
|
42625
42672
|
// gateway/boot-sweep-filter.ts
|
|
42626
42673
|
function shouldSweepChatAtBoot(chatId) {
|
|
42627
42674
|
const n = Number(chatId);
|
|
@@ -46559,11 +46606,11 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
|
|
|
46559
46606
|
}
|
|
46560
46607
|
|
|
46561
46608
|
// ../src/build-info.ts
|
|
46562
|
-
var VERSION = "0.12.
|
|
46563
|
-
var COMMIT_SHA = "
|
|
46564
|
-
var COMMIT_DATE = "2026-05-
|
|
46565
|
-
var LATEST_PR =
|
|
46566
|
-
var COMMITS_AHEAD_OF_TAG =
|
|
46609
|
+
var VERSION = "0.12.5";
|
|
46610
|
+
var COMMIT_SHA = "bab28d7e";
|
|
46611
|
+
var COMMIT_DATE = "2026-05-18T12:38:38Z";
|
|
46612
|
+
var LATEST_PR = 1514;
|
|
46613
|
+
var COMMITS_AHEAD_OF_TAG = 3;
|
|
46567
46614
|
|
|
46568
46615
|
// gateway/boot-version.ts
|
|
46569
46616
|
function formatRelativeAgo(iso) {
|
|
@@ -48168,12 +48215,25 @@ function ensureIssuesCard(chatId, threadId) {
|
|
|
48168
48215
|
}
|
|
48169
48216
|
}
|
|
48170
48217
|
}
|
|
48218
|
+
var inFlightUpdate = null;
|
|
48171
48219
|
startTimer({
|
|
48172
48220
|
emitMetric: (event) => {
|
|
48173
48221
|
emitRuntimeMetric(event);
|
|
48174
48222
|
},
|
|
48175
48223
|
onFrameworkFallback: async (ctx) => {
|
|
48176
|
-
|
|
48224
|
+
let text = null;
|
|
48225
|
+
const upd = inFlightUpdate;
|
|
48226
|
+
if (upd != null) {
|
|
48227
|
+
try {
|
|
48228
|
+
const st = await hostdGetStatusOnce(getMyAgentName(), upd.requestId);
|
|
48229
|
+
if (st !== "not-configured" && st !== "unavailable") {
|
|
48230
|
+
text = formatUpdateStatusLine(st, upd.startedAt, Date.now());
|
|
48231
|
+
}
|
|
48232
|
+
} catch {}
|
|
48233
|
+
}
|
|
48234
|
+
if (text == null) {
|
|
48235
|
+
text = formatFrameworkFallbackText(ctx.fallbackKind, ctx.silenceMs, ctx.inFlightTools);
|
|
48236
|
+
}
|
|
48177
48237
|
try {
|
|
48178
48238
|
await robustApiCall(() => bot.api.sendMessage(ctx.chatId, text, {
|
|
48179
48239
|
...ctx.threadId != null ? { message_thread_id: ctx.threadId } : {},
|
|
@@ -52078,33 +52138,38 @@ The gateway will restart as part of the recreate step; watch for the post-restar
|
|
|
52078
52138
|
return;
|
|
52079
52139
|
}
|
|
52080
52140
|
if (hostdResp.result === "started") {
|
|
52141
|
+
inFlightUpdate = { requestId: updateRequestId, startedAt: Date.now() };
|
|
52081
52142
|
(async () => {
|
|
52082
|
-
|
|
52083
|
-
|
|
52084
|
-
|
|
52085
|
-
|
|
52086
|
-
|
|
52087
|
-
|
|
52088
|
-
|
|
52089
|
-
|
|
52090
|
-
|
|
52091
|
-
|
|
52143
|
+
try {
|
|
52144
|
+
const terminal = await pollHostdStatus(getMyAgentName(), updateRequestId, {
|
|
52145
|
+
timeoutMs: 60000
|
|
52146
|
+
});
|
|
52147
|
+
if (terminal === "not-configured")
|
|
52148
|
+
return;
|
|
52149
|
+
if (terminal.result === "completed")
|
|
52150
|
+
return;
|
|
52151
|
+
clearRestartMarker();
|
|
52152
|
+
const errBody = terminal.error ?? terminal.stderr_tail ?? terminal.stdout_tail ?? "(no error tail returned)";
|
|
52153
|
+
const editedText = `\uD83D\uDE80 <b>update started</b> \u2014 <b>FAILED</b> via hostd ` + `(result=${escapeHtmlForTg(terminal.result)}):
|
|
52092
52154
|
` + preBlock(errBody);
|
|
52093
|
-
|
|
52094
|
-
|
|
52095
|
-
|
|
52096
|
-
|
|
52097
|
-
|
|
52098
|
-
|
|
52099
|
-
|
|
52155
|
+
if (ackId != null) {
|
|
52156
|
+
try {
|
|
52157
|
+
await robustApiCall(() => lockedBot.api.editMessageText(chatId, ackId, editedText, {
|
|
52158
|
+
parse_mode: "HTML",
|
|
52159
|
+
link_preview_options: { is_disabled: true }
|
|
52160
|
+
}), { verb: "update.poll.editAck" });
|
|
52161
|
+
} catch {
|
|
52162
|
+
try {
|
|
52163
|
+
await switchroomReply(ctx, editedText, { html: true });
|
|
52164
|
+
} catch {}
|
|
52165
|
+
}
|
|
52166
|
+
} else {
|
|
52100
52167
|
try {
|
|
52101
52168
|
await switchroomReply(ctx, editedText, { html: true });
|
|
52102
52169
|
} catch {}
|
|
52103
52170
|
}
|
|
52104
|
-
}
|
|
52105
|
-
|
|
52106
|
-
await switchroomReply(ctx, editedText, { html: true });
|
|
52107
|
-
} catch {}
|
|
52171
|
+
} finally {
|
|
52172
|
+
inFlightUpdate = null;
|
|
52108
52173
|
}
|
|
52109
52174
|
})();
|
|
52110
52175
|
return;
|
|
@@ -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
|
+
})
|