switchroom 0.17.1 → 0.17.2

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.
@@ -28894,6 +28894,12 @@ function resolveOperatorUid() {
28894
28894
  if (uid > 0)
28895
28895
  return uid;
28896
28896
  }
28897
+ const hostdUid = process.env.SWITCHROOM_HOSTD_OPERATOR_UID;
28898
+ if (hostdUid !== undefined) {
28899
+ const parsed = parseInt(hostdUid, 10);
28900
+ if (Number.isFinite(parsed) && parsed > 0)
28901
+ return parsed;
28902
+ }
28897
28903
  return;
28898
28904
  }
28899
28905
  function operatorOwnedPaths(home2) {
@@ -63150,7 +63156,7 @@ var init_server4 = __esm(() => {
63150
63156
  },
63151
63157
  {
63152
63158
  name: "rollout",
63153
- description: "SAFELY roll the fleet to a pinned SEMVER version, staggered and " + "canary-gated (#2487). Unlike update_apply (a blunt all-at-once " + "recreate), this restarts agents one at a time canary-first, asserts " + "each agent's in-container `switchroom --version` matches the target, " + "and STOPS on the first mismatch \u2014 so a bad build fails on the canary " + "before touching the rest of the fleet. The durable release.pin is " + "persisted only AFTER the canary confirms (a failed canary never " + "strands a bad pin). `pin` MUST be a tagged semver (vX.Y.Z) \u2014 sha " + "pins are rejected because the version assert needs a semver to " + "compare against. The hostd/web self-refresh is DEFERRED on this path " + "(an agent-invoked rollout cannot recreate its own hostd container " + "without killing itself); run that host-side. By default downgrade pins " + "are rejected \u2014 pass `allow_downgrade: true` for the operator-approved " + "rollback path to a known-good earlier tag; all other safety rails " + "(canary order, version-assert, stop-on-mismatch) apply unchanged. " + "Admin-only at the wire layer AND deliberately NOT pre-approved \u2014 every " + "call surfaces a Telegram approval card for the operator to tap. " + "ALWAYS pass a one-line `reason` explaining why you're rolling the " + "fleet to this pin; it renders on the card's `why:` line so the " + "operator can decide in context. " + "Returns `started`; poll get_status for the structured outcome " + "(which agents rolled / where it stopped).",
63159
+ description: "SAFELY roll the fleet to a pinned SEMVER version, staggered and " + "canary-gated (#2487). Unlike update_apply (a blunt all-at-once " + "recreate), this restarts agents one at a time canary-first, asserts " + "each agent's in-container `switchroom --version` matches the target, " + "and STOPS on the first mismatch \u2014 so a bad build fails on the canary " + "before touching the rest of the fleet. The durable release.pin is " + "persisted only AFTER the canary confirms (a failed canary never " + "strands a bad pin). `pin` MUST be a tagged semver (vX.Y.Z) \u2014 sha " + "pins are rejected because the version assert needs a semver to " + "compare against. When the target pin is NEWER than hostd's own CLI " + "(every freshly tagged release), hostd SELF-BUMPS first (#2645): its " + "container restarts on the target image (~30-60s blip on this MCP " + "socket), then the roll resumes AUTOMATICALLY under the SAME " + "request_id \u2014 do NOT re-issue the rollout during the blip; poll " + "get_status with the returned request_id once the socket is back. " + "The web refresh stays DEFERRED on this path; the terminal warnings " + "name exactly what is still on the prior version (web, host operator " + "CLI) and the host-side commands to finish. By default downgrade pins " + "are rejected \u2014 pass `allow_downgrade: true` for the operator-approved " + "rollback path to a known-good earlier tag; all other safety rails " + "(canary order, version-assert, stop-on-mismatch) apply unchanged. " + "Admin-only at the wire layer AND deliberately NOT pre-approved \u2014 every " + "call surfaces a Telegram approval card for the operator to tap. " + "ALWAYS pass a one-line `reason` explaining why you're rolling the " + "fleet to this pin; it renders on the card's `why:` line so the " + "operator can decide in context. " + "Returns `started`; poll get_status for the structured outcome " + "(which agents rolled / where it stopped).",
63154
63160
  inputSchema: {
63155
63161
  type: "object",
63156
63162
  required: ["pin"],
@@ -63729,8 +63735,8 @@ import { existsSync, readFileSync } from "node:fs";
63729
63735
  import { dirname, join } from "node:path";
63730
63736
 
63731
63737
  // src/build-info.ts
63732
- var VERSION = "0.17.1";
63733
- var COMMIT_SHA = "d7162c0";
63738
+ var VERSION = "0.17.2";
63739
+ var COMMIT_SHA = "f06074f4";
63734
63740
 
63735
63741
  // src/cli/resolve-version.ts
63736
63742
  function readPackageVersion() {
@@ -84226,7 +84232,7 @@ function registerRolloutCommand(program3) {
84226
84232
  }
84227
84233
  if (shouldRefuseStaleCli(SWITCHROOM_VERSION, target)) {
84228
84234
  const cli = normalizeVersion(SWITCHROOM_VERSION);
84229
- const refusal = `rollout refused: the driving switchroom CLI is v${cli}, OLDER than ` + `the --pin target ${target}. An older CLI regenerates the compose file ` + `with its own (stale) generator and version stamp, which would boot ` + `agents on v${cli}, fail the canary, and leave compose downgraded ` + `below what's running. ` + (hostdCtx ? `Refresh hostd's CLI first \u2014 host-side: \`switchroom hostd install ` + `--tag ${target}\` \u2014 then re-run the roll.` : `Upgrade this CLI to >= ${target} first (e.g. \`switchroom update ` + `--pin ${target}\`, or rebuild your checkout), then re-run.`) + ` Nothing was changed.`;
84235
+ const refusal = `rollout refused: the driving switchroom CLI is v${cli}, OLDER than ` + `the --pin target ${target}. An older CLI regenerates the compose file ` + `with its own (stale) generator and version stamp, which would boot ` + `agents on v${cli}, fail the canary, and leave compose downgraded ` + `below what's running. ` + (hostdCtx ? `Normally hostd SELF-BUMPS before this child ever spawns ` + `(#2645) \u2014 seeing this refusal means the self-bump was ` + `skipped or resumed while still stale. Refresh hostd's CLI ` + `host-side: \`switchroom hostd install --tag ${target}\` \u2014 ` + `then re-run the roll.` : `Upgrade this CLI to >= ${target} first (e.g. \`switchroom update ` + `--pin ${target}\`, or rebuild your checkout), then re-run.`) + ` Nothing was changed.`;
84230
84236
  process.stderr.write(refusal + `
84231
84237
  `);
84232
84238
  if (hostdCtx) {
@@ -84320,7 +84326,7 @@ function registerRolloutCommand(program3) {
84320
84326
  `);
84321
84327
  const result = executeRollout(steps, target, deps, { hostdContext: hostdCtx });
84322
84328
  if (hostdCtx) {
84323
- result.warnings.push("hostd/web refresh deferred \u2014 run host-side (`switchroom webd install` " + "/ `switchroom hostd install`). An agent-invoked rollout cannot " + "recreate its own hostd container without killing itself.");
84329
+ result.warnings.push(`still on the PRIOR version after this roll: switchroom-web ` + `(host-side: \`switchroom webd install --tag ${target}\`) and the ` + `host operator CLI (\`sudo npm i -g switchroom@${normalizeVersion(target)}\`). ` + `hostd's compose was tag-bumped by the self-bump when one ran; a ` + `full hostd template regen (only needed when a release changes ` + `hostd's mounts/env) is \`switchroom hostd install --tag ${target}\` ` + `host-side. An agent-invoked rollout cannot recreate its own hostd ` + `container mid-roll without killing itself.`);
84324
84330
  process.stdout.write(encodeRolloutPhaseLine({ phase: "hostd-web-deferred", target }) + `
84325
84331
  `);
84326
84332
  }
@@ -17257,7 +17257,9 @@ import {
17257
17257
  ftruncateSync,
17258
17258
  writeSync as writeSync2,
17259
17259
  fsyncSync as fsyncSync2,
17260
- closeSync as closeSync3
17260
+ closeSync as closeSync3,
17261
+ copyFileSync,
17262
+ unlinkSync
17261
17263
  } from "node:fs";
17262
17264
  import { join as join5, dirname as dirname6, resolve as resolve6 } from "node:path";
17263
17265
  import { createHash as createHash5, randomUUID as randomUUID2, randomBytes } from "node:crypto";
@@ -22644,12 +22646,31 @@ var import_yaml3 = __toESM(require_dist(), 1);
22644
22646
  // src/cli/rollout.ts
22645
22647
  init_atomic();
22646
22648
 
22649
+ // src/config/release-resolve.ts
22650
+ function parseSemverTag(tag) {
22651
+ if (!tag)
22652
+ return null;
22653
+ const m = /^v(\d+)\.(\d+)\.(\d+)$/.exec(tag.trim());
22654
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
22655
+ }
22656
+ function compareReleaseTags(a, b) {
22657
+ const pa = parseSemverTag(a);
22658
+ const pb = parseSemverTag(b);
22659
+ if (!pa || !pb)
22660
+ return null;
22661
+ for (let i = 0;i < 3; i++) {
22662
+ if (pa[i] !== pb[i])
22663
+ return pa[i] < pb[i] ? -1 : 1;
22664
+ }
22665
+ return 0;
22666
+ }
22667
+
22647
22668
  // src/cli/resolve-version.ts
22648
22669
  import { existsSync as existsSync6, readFileSync as readFileSync4 } from "node:fs";
22649
22670
  import { dirname as dirname4, join as join2 } from "node:path";
22650
22671
 
22651
22672
  // src/build-info.ts
22652
- var VERSION = "0.17.1";
22673
+ var VERSION = "0.17.2";
22653
22674
 
22654
22675
  // src/cli/resolve-version.ts
22655
22676
  function readPackageVersion() {
@@ -22813,7 +22834,9 @@ function parseRolloutPhaseLine(line) {
22813
22834
  "agent-start",
22814
22835
  "agent-done",
22815
22836
  "persist-pin",
22816
- "hostd-web-deferred"
22837
+ "hostd-web-deferred",
22838
+ "self-bump",
22839
+ "self-bump-done"
22817
22840
  ]);
22818
22841
  if (typeof o.phase !== "string" || !PHASES.has(o.phase))
22819
22842
  return null;
@@ -22848,6 +22871,117 @@ function parseRolloutResultLine(stdout) {
22848
22871
  return null;
22849
22872
  }
22850
22873
 
22874
+ // src/host-control/self-bump.ts
22875
+ var SELF_BUMP_MARKER_FILENAME = "pending-rollout.json";
22876
+ var SELF_BUMP_HELPER_CONTAINER = "switchroom-hostd-selfbump";
22877
+ var SELF_BUMP_LOG_FILENAME = "selfbump.log";
22878
+ var SELF_BUMP_MARKER_MAX_AGE_MS = 15 * 60 * 1000;
22879
+ var SEMVER_PIN_RE = /^v\d+\.\d+\.\d+$/;
22880
+ function needsSelfBump(ownVersion, pin) {
22881
+ if (!ownVersion)
22882
+ return false;
22883
+ const cmp = compareReleaseTags(`v${ownVersion.trim().replace(/^v/, "")}`, `v${pin.trim().replace(/^v/, "")}`);
22884
+ return cmp !== null && cmp < 0;
22885
+ }
22886
+ function bumpHostdComposeImageTag(yaml, pin) {
22887
+ const re = /^(\s*image:\s*)(\S*switchroom-hostd):(\S+)\s*$/m;
22888
+ const m = yaml.match(re);
22889
+ if (!m)
22890
+ return null;
22891
+ const oldImageRef = `${m[2]}:${m[3]}`;
22892
+ const newImageRef = `${m[2]}:${pin}`;
22893
+ return {
22894
+ yaml: yaml.replace(re, `$1${newImageRef}`),
22895
+ oldImageRef,
22896
+ newImageRef
22897
+ };
22898
+ }
22899
+ function encodePendingRolloutMarker(m) {
22900
+ return JSON.stringify(m, null, 2) + `
22901
+ `;
22902
+ }
22903
+ function parsePendingRolloutMarker(raw) {
22904
+ let obj;
22905
+ try {
22906
+ obj = JSON.parse(raw);
22907
+ } catch {
22908
+ return null;
22909
+ }
22910
+ if (typeof obj !== "object" || obj === null)
22911
+ return null;
22912
+ const o = obj;
22913
+ if (o.v !== 1)
22914
+ return null;
22915
+ if (typeof o.request_id !== "string" || o.request_id.length === 0)
22916
+ return null;
22917
+ if (typeof o.pin !== "string" || !SEMVER_PIN_RE.test(o.pin))
22918
+ return null;
22919
+ if (typeof o.created_at !== "string" || !Number.isFinite(Date.parse(o.created_at))) {
22920
+ return null;
22921
+ }
22922
+ if (typeof o.prior_hostd_version !== "string")
22923
+ return null;
22924
+ const caller = o.caller;
22925
+ if (typeof caller !== "object" || caller === null)
22926
+ return null;
22927
+ if (caller.kind === "agent") {
22928
+ if (typeof caller.name !== "string" || caller.name.length === 0)
22929
+ return null;
22930
+ } else if (caller.kind !== "operator") {
22931
+ return null;
22932
+ }
22933
+ if (o.agents !== undefined) {
22934
+ if (!Array.isArray(o.agents) || o.agents.length === 0 || !o.agents.every((a) => typeof a === "string" && /^[\w-]+$/.test(a))) {
22935
+ return null;
22936
+ }
22937
+ }
22938
+ if (o.skip_web !== undefined && typeof o.skip_web !== "boolean")
22939
+ return null;
22940
+ if (o.allow_downgrade !== undefined && typeof o.allow_downgrade !== "boolean") {
22941
+ return null;
22942
+ }
22943
+ return {
22944
+ v: 1,
22945
+ request_id: o.request_id,
22946
+ pin: o.pin,
22947
+ ...o.agents !== undefined ? { agents: o.agents } : {},
22948
+ ...o.skip_web !== undefined ? { skip_web: o.skip_web } : {},
22949
+ ...o.allow_downgrade !== undefined ? { allow_downgrade: o.allow_downgrade } : {},
22950
+ caller: caller.kind === "agent" ? { kind: "agent", name: caller.name } : { kind: "operator" },
22951
+ created_at: o.created_at,
22952
+ prior_hostd_version: o.prior_hostd_version
22953
+ };
22954
+ }
22955
+ function isMarkerFresh(marker, nowMs) {
22956
+ const created = Date.parse(marker.created_at);
22957
+ if (!Number.isFinite(created))
22958
+ return false;
22959
+ return nowMs - created < SELF_BUMP_MARKER_MAX_AGE_MS;
22960
+ }
22961
+ function selfBumpHelperArgs(opts) {
22962
+ const composePath = `${opts.hostdDirHostPath}/docker-compose.yml`;
22963
+ const logPath = `${opts.hostdDirHostPath}/${SELF_BUMP_LOG_FILENAME}`;
22964
+ const script = `sleep 2; ` + `echo "selfbump $(date -u) -> ${opts.targetImage}" >> "${logPath}" 2>&1; ` + `docker compose -p switchroom-hostd -f "${composePath}" pull >> "${logPath}" 2>&1 && ` + `docker compose -p switchroom-hostd -f "${composePath}" up -d >> "${logPath}" 2>&1; ` + `rc=$?; echo "selfbump done rc=$rc" >> "${logPath}" 2>&1; exit $rc`;
22965
+ return [
22966
+ "run",
22967
+ "-d",
22968
+ "--rm",
22969
+ "--name",
22970
+ SELF_BUMP_HELPER_CONTAINER,
22971
+ "--label",
22972
+ "switchroom.hostd-selfbump=true",
22973
+ "-v",
22974
+ "/var/run/docker.sock:/var/run/docker.sock",
22975
+ "-v",
22976
+ `${opts.hostdDirHostPath}:${opts.hostdDirHostPath}`,
22977
+ "--entrypoint",
22978
+ "/bin/sh",
22979
+ opts.helperImage,
22980
+ "-c",
22981
+ script
22982
+ ];
22983
+ }
22984
+
22851
22985
  // node_modules/.bun/commander@13.1.0/node_modules/commander/esm.mjs
22852
22986
  var import__3 = __toESM(require_commander(), 1);
22853
22987
 
@@ -23503,6 +23637,10 @@ function phaseLine(s) {
23503
23637
  return "persisting pin";
23504
23638
  case "hostd-web-deferred":
23505
23639
  return "hostd/web refresh deferred (run host-side)";
23640
+ case "self-bump":
23641
+ return "hostd refreshing itself to the target (brief control blip)…";
23642
+ case "self-bump-done":
23643
+ return "hostd refreshed — resuming the roll";
23506
23644
  default:
23507
23645
  return "starting";
23508
23646
  }
@@ -23686,6 +23824,10 @@ class HostdServer {
23686
23824
  await chmod(hostdDir, 493).catch(() => {
23687
23825
  return;
23688
23826
  });
23827
+ await this.resumePendingSelfBumpRollout().catch((e) => {
23828
+ process.stderr.write(`hostd: self-bump resume failed (non-fatal): ${e.message}
23829
+ `);
23830
+ });
23689
23831
  const agentNames = Object.keys(this.opts.agentUids).sort();
23690
23832
  if (agentNames.length === 0) {
23691
23833
  return;
@@ -23936,7 +24078,7 @@ class HostdServer {
23936
24078
  resp = this.handleApply(req, caller, started);
23937
24079
  break;
23938
24080
  case "rollout":
23939
- resp = this.handleRollout(req, caller, started);
24081
+ resp = await this.handleRollout(req, caller, started);
23940
24082
  break;
23941
24083
  case "agent_start":
23942
24084
  resp = await this.handleAgentStart(req, started);
@@ -24208,21 +24350,34 @@ class HostdServer {
24208
24350
  duration_ms: Date.now() - started
24209
24351
  };
24210
24352
  }
24211
- handleRollout(req, caller, started) {
24353
+ async handleRollout(req, caller, started) {
24212
24354
  const denied = this.checkFleetMutationLock(req.op, req.request_id, started);
24213
24355
  if (denied)
24214
24356
  return denied;
24357
+ if (needsSelfBump(this.opts.selfVersion ?? SWITCHROOM_VERSION, req.args.pin)) {
24358
+ return this.beginSelfBump(req, caller, started);
24359
+ }
24215
24360
  const assetDenied = this.applyAssetPreflight(req.request_id, started);
24216
24361
  if (assetDenied)
24217
24362
  return assetDenied;
24218
- const args = ["rollout", "--pin", req.args.pin];
24219
- if (req.args.agents && req.args.agents.length > 0) {
24220
- args.push("--agents", req.args.agents.join(","));
24221
- }
24222
- if (req.args.skip_web)
24223
- args.push("--skip-web");
24224
- if (req.args.allow_downgrade)
24225
- args.push("--allow-downgrade");
24363
+ const entry = this.launchRollout(req.args, req.request_id, caller, started);
24364
+ return {
24365
+ v: 1,
24366
+ request_id: entry.request_id,
24367
+ result: "started",
24368
+ exit_code: null,
24369
+ duration_ms: Date.now() - started
24370
+ };
24371
+ }
24372
+ launchRollout(args, request_id, caller, started) {
24373
+ const argv = ["rollout", "--pin", args.pin];
24374
+ if (args.agents && args.agents.length > 0) {
24375
+ argv.push("--agents", args.agents.join(","));
24376
+ }
24377
+ if (args.skip_web)
24378
+ argv.push("--skip-web");
24379
+ if (args.allow_downgrade)
24380
+ argv.push("--allow-downgrade");
24226
24381
  const installCtx = readCachedInstallType(this.opts.bindRoot ?? this.opts.homeDir);
24227
24382
  let priorPin;
24228
24383
  try {
@@ -24233,16 +24388,16 @@ class HostdServer {
24233
24388
  }
24234
24389
  } catch {}
24235
24390
  const entry = {
24236
- request_id: req.request_id,
24391
+ request_id,
24237
24392
  caller,
24238
- op: req.op,
24393
+ op: "rollout",
24239
24394
  result: "started",
24240
24395
  exit_code: null,
24241
24396
  started_at: started,
24242
24397
  finished_at: null,
24243
24398
  stdout_tail: "",
24244
24399
  stderr_tail: "",
24245
- pin: req.args.pin,
24400
+ pin: args.pin,
24246
24401
  install_context: {
24247
24402
  install_type: installCtx.install_type,
24248
24403
  detected_at: installCtx.detected_at
@@ -24250,19 +24405,214 @@ class HostdServer {
24250
24405
  ...priorPin ? { prior_pin: priorPin } : {}
24251
24406
  };
24252
24407
  this.recordStatus(entry);
24408
+ this.fleetMutationInFlight = {
24409
+ op: "rollout",
24410
+ request_id,
24411
+ started_at: started
24412
+ };
24413
+ this.spawnRollout(argv, entry);
24414
+ return entry;
24415
+ }
24416
+ hostdDirPath() {
24417
+ return join5(this.opts.homeDir, ".switchroom", "hostd");
24418
+ }
24419
+ hostdDirHostPath() {
24420
+ const hostHome = this.opts.hostHomeDir ?? process.env.SWITCHROOM_HOST_HOME?.trim() ?? this.opts.homeDir;
24421
+ return join5(hostHome, ".switchroom", "hostd");
24422
+ }
24423
+ async beginSelfBump(req, caller, started) {
24424
+ const ownVersion = this.opts.selfVersion ?? SWITCHROOM_VERSION;
24425
+ const composePath = join5(this.hostdDirPath(), "docker-compose.yml");
24426
+ if (!existsSync8(composePath)) {
24427
+ return deniedResponse(req.request_id, `rollout: hostd's CLI (v${ownVersion}) is older than the target ` + `${req.args.pin} and needs a self-bump first, but its compose file ` + `is missing at ${composePath}. Run \`switchroom hostd install ` + `--tag ${req.args.pin}\` on the host, then re-run the roll. ` + `Nothing was changed.`, Date.now() - started);
24428
+ }
24429
+ const before = readFileSync6(composePath, "utf8");
24430
+ const bumped = bumpHostdComposeImageTag(before, req.args.pin);
24431
+ if (!bumped) {
24432
+ return deniedResponse(req.request_id, `rollout: hostd needs a self-bump to ${req.args.pin} but no ` + `switchroom-hostd image line was found in ${composePath} ` + `(hand-edited compose?). Run \`switchroom hostd install --tag ` + `${req.args.pin}\` on the host, then re-run the roll. Nothing ` + `was changed.`, Date.now() - started);
24433
+ }
24434
+ const probe = await this.runDocker([
24435
+ "manifest",
24436
+ "inspect",
24437
+ bumped.newImageRef
24438
+ ]);
24439
+ if (probe.exit_code !== 0) {
24440
+ return deniedResponse(req.request_id, `rollout: hostd needs a self-bump to ${req.args.pin} but the target ` + `image ${bumped.newImageRef} is not pullable yet (docker manifest ` + `inspect failed: ${tail(probe.stderr).trim() || "unknown error"}). ` + `The docker-images workflow may still be building — retry in a ` + `few minutes. Nothing was changed.`, Date.now() - started);
24441
+ }
24442
+ await this.runDocker(["rm", "-f", SELF_BUMP_HELPER_CONTAINER]).catch(() => {
24443
+ return;
24444
+ });
24445
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
24446
+ const bakPath = `${composePath}.bak-selfbump-${ts}`;
24447
+ try {
24448
+ const baks = readdirSync2(this.hostdDirPath()).filter((f) => f.startsWith("docker-compose.yml.bak-selfbump-")).sort();
24449
+ for (const f of baks.slice(0, Math.max(0, baks.length - 4))) {
24450
+ unlinkSync(join5(this.hostdDirPath(), f));
24451
+ }
24452
+ } catch {}
24453
+ const markerPath = join5(this.hostdDirPath(), SELF_BUMP_MARKER_FILENAME);
24454
+ const marker = {
24455
+ v: 1,
24456
+ request_id: req.request_id,
24457
+ pin: req.args.pin,
24458
+ ...req.args.agents && req.args.agents.length > 0 ? { agents: req.args.agents } : {},
24459
+ ...req.args.skip_web ? { skip_web: true } : {},
24460
+ ...req.args.allow_downgrade ? { allow_downgrade: true } : {},
24461
+ caller: caller.kind === "agent" ? { kind: "agent", name: caller.name } : { kind: "operator" },
24462
+ created_at: new Date().toISOString(),
24463
+ prior_hostd_version: ownVersion
24464
+ };
24465
+ copyFileSync(composePath, bakPath);
24466
+ writeFileSync3(composePath, bumped.yaml, "utf8");
24467
+ writeFileSync3(markerPath, encodePendingRolloutMarker(marker), {
24468
+ mode: 384
24469
+ });
24470
+ const entry = {
24471
+ request_id: req.request_id,
24472
+ caller,
24473
+ op: req.op,
24474
+ result: "started",
24475
+ exit_code: null,
24476
+ started_at: started,
24477
+ finished_at: null,
24478
+ stdout_tail: "",
24479
+ stderr_tail: "",
24480
+ pin: req.args.pin
24481
+ };
24482
+ this.recordStatus(entry);
24253
24483
  this.fleetMutationInFlight = {
24254
24484
  op: "rollout",
24255
24485
  request_id: req.request_id,
24256
24486
  started_at: started
24257
24487
  };
24258
- this.spawnRollout(args, entry);
24488
+ this.onRolloutPhase(entry, { phase: "self-bump", target: req.args.pin });
24489
+ const rollbackBump = () => {
24490
+ try {
24491
+ copyFileSync(bakPath, composePath);
24492
+ unlinkSync(markerPath);
24493
+ } catch (e) {
24494
+ process.stderr.write(`hostd: self-bump rollback failed (compose/marker may need manual ` + `restore from ${bakPath}): ${e.message}
24495
+ `);
24496
+ }
24497
+ };
24498
+ const helper = await this.runDocker(selfBumpHelperArgs({
24499
+ hostdDirHostPath: this.hostdDirHostPath(),
24500
+ helperImage: bumped.oldImageRef,
24501
+ targetImage: bumped.newImageRef
24502
+ })).catch((e) => ({
24503
+ exit_code: -1,
24504
+ stdout: "",
24505
+ stderr: e.message
24506
+ }));
24507
+ if (helper.exit_code !== 0) {
24508
+ rollbackBump();
24509
+ entry.result = "error";
24510
+ entry.finished_at = Date.now();
24511
+ entry.error = `self-bump helper spawn failed: ${tail(helper.stderr).trim()}`;
24512
+ this.fleetMutationInFlight = null;
24513
+ this.writeTerminalAudit(entry);
24514
+ return {
24515
+ v: 1,
24516
+ request_id: req.request_id,
24517
+ result: "error",
24518
+ exit_code: null,
24519
+ duration_ms: Date.now() - started,
24520
+ error: `rollout: could not spawn the hostd self-bump helper container ` + `(${tail(helper.stderr).trim() || "unknown error"}). The compose ` + `bump was rolled back — nothing is changed. Fallback: ` + `\`switchroom hostd install --tag ${req.args.pin}\` on the host, ` + `then re-run the roll.`
24521
+ };
24522
+ }
24523
+ this.runDocker(["wait", SELF_BUMP_HELPER_CONTAINER]).then((w) => {
24524
+ const code = Number.parseInt(w.stdout.trim(), 10);
24525
+ if (w.exit_code !== 0 || !Number.isFinite(code)) {
24526
+ process.stderr.write(`hostd: self-bump watcher could not read the helper's exit ` + `code (docker wait rc=${w.exit_code}): ${tail(w.stderr).trim()}
24527
+ `);
24528
+ return;
24529
+ }
24530
+ if (code === 0)
24531
+ return;
24532
+ rollbackBump();
24533
+ entry.result = "error";
24534
+ entry.finished_at = Date.now();
24535
+ entry.error = `self-bump helper failed (container exit ${Number.isFinite(code) ? code : "unknown"}). ` + `See ${join5(this.hostdDirHostPath(), SELF_BUMP_LOG_FILENAME)} on the host. ` + `Compose bump rolled back. Fallback: \`switchroom hostd install ` + `--tag ${req.args.pin}\` host-side, then re-run the roll.`;
24536
+ if (this.fleetMutationInFlight && this.fleetMutationInFlight.request_id === entry.request_id) {
24537
+ this.fleetMutationInFlight = null;
24538
+ }
24539
+ this.writeTerminalAudit(entry);
24540
+ this.pushRolloutTerminal(entry);
24541
+ }).catch(() => {
24542
+ return;
24543
+ });
24259
24544
  return {
24260
24545
  v: 1,
24261
24546
  request_id: req.request_id,
24262
24547
  result: "started",
24263
24548
  exit_code: null,
24264
- duration_ms: Date.now() - started
24549
+ duration_ms: Date.now() - started,
24550
+ payload: JSON.stringify({
24551
+ phase: "self-bump",
24552
+ pin: req.args.pin,
24553
+ rolled: [],
24554
+ note: `hostd's CLI (v${ownVersion}) is older than ${req.args.pin}, so ` + `hostd is refreshing itself first: its container restarts on the ` + `target image (~30-60s blip on this socket), then the roll resumes ` + `AUTOMATICALLY under this same request_id. Poll get_status with ` + `this request_id after the blip; do NOT re-issue the rollout.`
24555
+ })
24556
+ };
24557
+ }
24558
+ async resumePendingSelfBumpRollout() {
24559
+ const markerPath = join5(this.hostdDirPath(), SELF_BUMP_MARKER_FILENAME);
24560
+ if (!existsSync8(markerPath))
24561
+ return;
24562
+ let raw;
24563
+ try {
24564
+ raw = readFileSync6(markerPath, "utf8");
24565
+ } finally {
24566
+ try {
24567
+ unlinkSync(markerPath);
24568
+ } catch {}
24569
+ }
24570
+ const marker = parsePendingRolloutMarker(raw);
24571
+ if (!marker) {
24572
+ process.stderr.write(`hostd: pending-rollout marker at ${markerPath} was malformed — ` + `dropped without resuming. The originating roll will surface via ` + `the orphan sweep.
24573
+ `);
24574
+ return;
24575
+ }
24576
+ const caller = marker.caller.kind === "agent" ? { kind: "agent", name: marker.caller.name } : { kind: "operator" };
24577
+ const failResume = async (why) => {
24578
+ const entry2 = {
24579
+ request_id: marker.request_id,
24580
+ caller,
24581
+ op: "rollout",
24582
+ result: "error",
24583
+ exit_code: null,
24584
+ started_at: Date.parse(marker.created_at) || Date.now(),
24585
+ finished_at: Date.now(),
24586
+ stdout_tail: "",
24587
+ stderr_tail: "",
24588
+ pin: marker.pin,
24589
+ failed_step: "self-bump",
24590
+ error: why
24591
+ };
24592
+ this.recordStatus(entry2);
24593
+ process.stderr.write(`hostd: self-bump resume failed: ${why}
24594
+ `);
24595
+ await this.writeTerminalAudit(entry2);
24596
+ this.pushRolloutTerminal(entry2);
24265
24597
  };
24598
+ const ownVersion = this.opts.selfVersion ?? SWITCHROOM_VERSION;
24599
+ if (!isMarkerFresh(marker, Date.now())) {
24600
+ await failResume(`pending-rollout marker for ${marker.pin} (request_id ` + `${marker.request_id}) was older than the resume cutoff — the ` + `self-bump helper likely stalled. hostd is now on v${ownVersion}. ` + `Re-issue the rollout to try again.`);
24601
+ return;
24602
+ }
24603
+ if (needsSelfBump(ownVersion, marker.pin)) {
24604
+ await failResume(`hostd is still on v${ownVersion} after the self-bump to ` + `${marker.pin} (helper failed? see ` + `${join5(this.hostdDirHostPath(), SELF_BUMP_LOG_FILENAME)}). ` + `Fallback: \`switchroom hostd install --tag ${marker.pin}\` ` + `host-side, then re-issue the rollout.`);
24605
+ return;
24606
+ }
24607
+ process.stderr.write(`hostd: resuming rollout ${marker.request_id} → ${marker.pin} after ` + `self-bump (v${marker.prior_hostd_version} → v${ownVersion})
24608
+ `);
24609
+ const entry = this.launchRollout({
24610
+ pin: marker.pin,
24611
+ ...marker.agents ? { agents: marker.agents } : {},
24612
+ ...marker.skip_web ? { skip_web: true } : {},
24613
+ ...marker.allow_downgrade ? { allow_downgrade: true } : {}
24614
+ }, marker.request_id, caller, Date.now());
24615
+ this.onRolloutPhase(entry, { phase: "self-bump-done", target: marker.pin });
24266
24616
  }
24267
24617
  async handleAgentStart(req, started) {
24268
24618
  const res = await this.runSwitchroom(["agent", "start", req.args.name]);
@@ -25519,6 +25869,9 @@ class LogTailRolloutNarrator {
25519
25869
  }
25520
25870
  static seqFor(phase) {
25521
25871
  switch (phase.phase) {
25872
+ case "self-bump":
25873
+ case "self-bump-done":
25874
+ return 0;
25522
25875
  case "apply":
25523
25876
  return 0;
25524
25877
  case "canary-start":
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "switchroom",
3
3
  "//version": "NOT the release version — source of truth is the git tag, resolved by scripts/build.mjs:resolveVersion() (see CLAUDE.md > Standard release process). This field is stale by design and only the Layer-4 dev/non-tag fallback for build.mjs + src/cli/resolve-version.ts; do NOT bump it expecting a release to pick it up. npm-pack tarball naming needs a real version — do that as an UNCOMMITTED pack-time bump (see release step 6), never a committed one.",
4
- "version": "0.17.1",
4
+ "version": "0.17.2",
5
5
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
6
6
  "type": "module",
7
7
  "bin": {