@ouro.bot/cli 0.1.0-alpha.947 → 0.1.0-alpha.950

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/changelog.json CHANGED
@@ -1,6 +1,19 @@
1
1
  {
2
2
  "_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
3
3
  "versions": [
4
+ {
5
+ "version": "0.1.0-alpha.950",
6
+ "changes": [
7
+ "Slugger's model calls work again: Ouro now identifies as a current Claude Code release, so the API stops refusing claude-opus-5-5, and Shepherd's check-ins and notices resume.",
8
+ "Saving an operator trust file twice in the same millisecond no longer fails: the second backup takes the next free name, and no backup is ever overwritten."
9
+ ]
10
+ },
11
+ {
12
+ "version": "0.1.0-alpha.948",
13
+ "changes": [
14
+ "A Butler upgrade's automatic rollback no longer fails when the stopping gateway removes its lock file mid-read, and host supervision now stays paused for the whole upgrade, replay gate and rollback included, instead of waking up after 30 minutes."
15
+ ]
16
+ },
4
17
  {
5
18
  "version": "0.1.0-alpha.947",
6
19
  "changes": [
@@ -40,7 +40,7 @@
40
40
  // Usage: sanctuary-butler-upgrade.mjs <preflight|prepare|install|verify|upgrade> <version> [--rehearse <step>] [--no-gate] [--plant <case>]
41
41
  // sanctuary-butler-upgrade.mjs verify [--gate]
42
42
 
43
- import { execFileSync, spawnSync } from "node:child_process"
43
+ import { execFileSync, spawn, spawnSync } from "node:child_process"
44
44
  import { createHash } from "node:crypto"
45
45
  import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync, statSync, lstatSync, chmodSync, realpathSync, readdirSync } from "node:fs"
46
46
  import { fileURLToPath } from "node:url"
@@ -67,7 +67,8 @@ const INCOMING_MANIFEST = `${ROOT}/incoming-package-manifest.json`
67
67
  const INCOMING_REQUEST = `${ROOT}/incoming-request.json`
68
68
  const UPGRADE_JOURNAL = `${ROOT}/upgrade.json`
69
69
  // Host supervision skips its work while this flag is fresh (< 30 min), so a crashed
70
- // upgrade can never leave self-heal off for longer than that.
70
+ // upgrade can never leave self-heal off for longer than that. A paused upgrade keeps it
71
+ // fresh with a heartbeat (holdSupervision), because the replay gate alone takes about an hour.
71
72
  const MAINTENANCE = "/run/ouro-authority-maintenance"
72
73
  // The stop hook's flag: every keeper watchdog version stands down while it exists.
73
74
  const SHUTDOWN_FLAG = "/run/ouro-authority-shutdown"
@@ -958,6 +959,20 @@ function buildFinalProof(version, out) {
958
959
 
959
960
  // ---- upgrade (in place, installed authority) --------------------------------
960
961
 
962
+ /**
963
+ * Keeps host supervision paused for as long as this upgrade process lives, gate and rollback included: a detached
964
+ * shell touches the maintenance flag every interval while the parent is alive. It never recreates a released flag
965
+ * (touch -c). Once the parent is gone, however it ended, the shell drops the shutdown flag (which never expires) and
966
+ * stops, so the maintenance flag ages out and self-heal returns within about 30 minutes of a crash.
967
+ */
968
+ export const SUPERVISION_HOLD_SH = 'while kill -0 "$1" 2>/dev/null; do touch -c "$2"; sleep "$4"; done; rm -f "$3"'
969
+ export function holdSupervision({ spawnImpl = spawn, parentPid = process.pid, flag = MAINTENANCE, shutdownFlag = SHUTDOWN_FLAG, intervalSec = 60 } = {}) {
970
+ const child = spawnImpl("/bin/sh", ["-c", SUPERVISION_HOLD_SH, "ouro-supervision-hold", String(parentPid), flag, shutdownFlag, String(intervalSec)], { detached: true, stdio: "ignore" })
971
+ child.unref()
972
+ return child
973
+ }
974
+ let HOLD = null
975
+
961
976
  // A host still running an older keeper has a cron watchdog that ignores the maintenance
962
977
  // flag: it resurrected the paused supervisor, which restarted the resident mid-upgrade
963
978
  // (the first 830 -> 835 rehearsal). So put this version's keeper scripts in place first
@@ -966,6 +981,7 @@ function buildFinalProof(version, out) {
966
981
  function pauseSupervision() {
967
982
  writeFileSync(MAINTENANCE, `${new Date().toISOString()} ${process.pid}\n`)
968
983
  writeFileSync(SHUTDOWN_FLAG, `upgrade ${process.pid}\n`)
984
+ HOLD ??= holdSupervision()
969
985
  for (const [path, body] of [[GATEWAY_SUPERVISOR, GATEWAY_SUPERVISOR_SH], [GATEWAY_WATCHDOG, GATEWAY_WATCHDOG_SH]]) {
970
986
  writeFileSync(path, body)
971
987
  sh("/bin/chown", ["0:0", path]); chmodSync(path, 0o700)
@@ -975,6 +991,7 @@ function pauseSupervision() {
975
991
  ok("host supervision paused (keeper scripts current; flags expire on their own)")
976
992
  }
977
993
  function resumeSupervision() {
994
+ if (HOLD) { try { process.kill(-HOLD.pid, "SIGTERM") } catch { /* already gone; touch -c cannot recreate the flag */ } HOLD = null }
978
995
  rmSync(MAINTENANCE, { force: true })
979
996
  rmSync(SHUTDOWN_FLAG, { force: true })
980
997
  installGatewaySupervisor()
@@ -1005,6 +1022,10 @@ function pruneButlerImages() {
1005
1022
  ok(`pruned ${removed} old Butler image(s); kept the live and rollback images`)
1006
1023
  }
1007
1024
 
1025
+ // A failed rollback exits with supervision still paused: the keeper's boot would retry the rollback alongside a manual
1026
+ // retry (the 945 rollback on 2026-10-25). The flag then ages out and the keeper's boot retries it on its own.
1027
+ const ROLLBACK_FAILED = (e, lifecycle) => `ROLLBACK FAILED: ${lifecycleFailure(e)}\nThe journal is kept. Host supervision stays paused for about 30 minutes; after that the keeper's authority boot retries the rollback. To retry by hand sooner, keep it paused while you do: \`touch ${MAINTENANCE}; node ${lifecycle} upgrade-rollback\` (repeat the touch if the retry is slow).`
1028
+
1008
1029
  function lifecycleFailure(e) {
1009
1030
  const detail = [e.stderr, e.stdout].map((x) => (x ? String(x).trim() : "")).filter(Boolean).join(" | ")
1010
1031
  let last = ""
@@ -1131,12 +1152,10 @@ async function upgrade(version, rehearse, { noGate = false, plant } = {}) {
1131
1152
  if (failure) {
1132
1153
  console.log(`\n!! ${planned ? "rehearsal stopped as planned" : "upgrade FAILED"}: ${reason}\n!! rolling back to the predecessor`)
1133
1154
  try { ok(`rollback: ${sh("/usr/local/bin/node", [lifecycle, "upgrade-rollback"], { stdio: ["ignore", "pipe", "pipe"] }).trim()}`) }
1134
- catch (e) {
1135
- resumeSupervision()
1136
- fail(`ROLLBACK FAILED: ${lifecycleFailure(e)}\nThe journal is kept; the next authority boot (or \`upgrade-rollback\`) retries it.`)
1137
- }
1155
+ catch (e) { fail(ROLLBACK_FAILED(e, lifecycle)) }
1138
1156
  } else if (!gated) { pinTemplate(version); pruneButlerImages() }
1139
- resumeSupervision()
1157
+ // A held upgrade stays paused through the gate; commit or rollback resumes it.
1158
+ if (!gated || failure) resumeSupervision()
1140
1159
  say("preservation")
1141
1160
  const preserved = []
1142
1161
  const jellyfinNow = docker(["inspect", "jellyfin", "--format", "{{.Id}}|{{.Image}}|{{.RestartCount}}|{{.State.StartedAt}}"]).trim()
@@ -1158,17 +1177,18 @@ async function upgrade(version, rehearse, { noGate = false, plant } = {}) {
1158
1177
  return problem ? { ok: false, detail: problem } : result
1159
1178
  },
1160
1179
  commit: () => {
1161
- const problem = stillHeld()
1162
- if (problem) throw new Error(`refusing to commit: ${problem}`)
1163
1180
  pauseSupervision()
1164
- try { ok(`commit: ${sh("/usr/local/bin/node", [lifecycle, "upgrade", id, image(version)], { stdio: ["ignore", "pipe", "pipe"] }).trim()}`) }
1165
- finally { resumeSupervision() }
1181
+ try {
1182
+ const problem = stillHeld()
1183
+ if (problem) throw new Error(`refusing to commit: ${problem}`)
1184
+ ok(`commit: ${sh("/usr/local/bin/node", [lifecycle, "upgrade", id, image(version)], { stdio: ["ignore", "pipe", "pipe"] }).trim()}`)
1185
+ } finally { resumeSupervision() }
1166
1186
  },
1167
1187
  rollback: () => {
1168
1188
  pauseSupervision()
1169
1189
  try { ok(`rollback: ${sh("/usr/local/bin/node", [lifecycle, "upgrade-rollback"], { stdio: ["ignore", "pipe", "pipe"] }).trim()}`) }
1170
- catch (e) { fail(`ROLLBACK FAILED: ${lifecycleFailure(e)}\nThe journal is kept; the next authority boot (or \`upgrade-rollback\`) retries it.`) }
1171
- finally { resumeSupervision() }
1190
+ catch (e) { fail(ROLLBACK_FAILED(e, lifecycle)) }
1191
+ resumeSupervision()
1172
1192
  },
1173
1193
  confirmRollback: () => {
1174
1194
  if (!waitForButler(priorImage, 300)) fail(`rolled back, but ${priorImage} is not running healthy; inspect \`docker logs ${CONTAINER}\``)
@@ -1,5 +1,5 @@
1
1
  {
2
- "runtimeVersion": "0.1.0-alpha.947",
2
+ "runtimeVersion": "0.1.0-alpha.950",
3
3
  "bundleSchemaVersion": 3,
4
4
  "lastUpdated": "2026-09-03T00:00:00.000Z"
5
5
  }
@@ -1,7 +1,7 @@
1
1
  <?xml version="1.0"?>
2
2
  <Container version="2">
3
3
  <Name>ouro-butler</Name>
4
- <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.947</Repository>
4
+ <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.950</Repository>
5
5
  <Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
6
6
  <Network>host</Network>
7
7
  <Shell>sh</Shell>
@@ -329,6 +329,24 @@ function createExclusive(file, body) {
329
329
  fs.closeSync(fd);
330
330
  }
331
331
  }
332
+ const MAX_BACKUP_SUFFIX = 99;
333
+ /**
334
+ * Keeps the previous bytes under the timestamped name, or under the first free `-1`, `-2`, ... variant when two writes
335
+ * land in the same millisecond. Every attempt is an exclusive create, so an existing backup is never overwritten.
336
+ */
337
+ function createBackup(base, previous) {
338
+ for (let suffix = 0;; suffix++) {
339
+ const candidate = suffix === 0 ? base : `${base}-${suffix}`;
340
+ try {
341
+ createExclusive(candidate, previous);
342
+ return candidate;
343
+ }
344
+ catch (error) {
345
+ if (error.code !== "EEXIST" || suffix >= MAX_BACKUP_SUFFIX)
346
+ throw error;
347
+ }
348
+ }
349
+ }
332
350
  function rawBytes(file) {
333
351
  let fd;
334
352
  try {
@@ -345,7 +363,7 @@ function rawBytes(file) {
345
363
  }
346
364
  /**
347
365
  * Replaces one file in the trust directory. The previous bytes are kept beside it as a timestamped backup, created the
348
- * same safe way. The new file is written to an exclusive random temporary name, owned and moded through its descriptor,
366
+ * same safe way and never overwriting an earlier one. The new file is written to an exclusive random temporary name, owned and moded through its descriptor,
349
367
  * fsynced and renamed into place, so a reader never sees a half-written file and nothing already on disk is followed.
350
368
  */
351
369
  function writeTrustedFile(agentRoot, fileName, body, now) {
@@ -355,8 +373,7 @@ function writeTrustedFile(agentRoot, fileName, body, now) {
355
373
  const previous = rawBytes(file);
356
374
  let backup = null;
357
375
  if (previous) {
358
- backup = `${file}.bak-${now.toISOString().replace(/[:.]/gu, "-")}`;
359
- createExclusive(backup, previous);
376
+ backup = createBackup(`${file}.bak-${now.toISOString().replace(/[:.]/gu, "-")}`, previous);
360
377
  fsyncDirectory(dir);
361
378
  }
362
379
  const tmp = path.join(dir, `.${fileName}.${(0, node_crypto_1.randomBytes)(8).toString("hex")}.tmp`);
@@ -75,6 +75,11 @@ const DIGEST = /^sha256:[a-f0-9]{64}$/u;
75
75
  // page cache on Unraid's array that takes minutes, so readiness gets a generous budget
76
76
  // (a 120 s budget failed a real rollback on 2026-09-24).
77
77
  const GATEWAY_READY_TIMEOUT_MS = 900_000;
78
+ const RESIDENT_READY_TIMEOUT_MS = 300_000;
79
+ // A rolled-back predecessor older than 0.1.0-alpha.947 starts its senses behind a startup external-event turn and took about 8 minutes to become ready on 2026-10-25.
80
+ const PREDECESSOR_READY_TIMEOUT_MS = 900_000;
81
+ // How long an operator upgrade or rollback waits for the deployment lease while a keeper boot holds it. A boot can be running the whole rollback: halt, bundle finalize, gateway readiness and the predecessor's 15 minutes.
82
+ const OPERATOR_LEASE_WAIT_MS = 1_800_000;
78
83
  const GATEWAY_START_BACKOFF_MS = [2_000, 10_000, 30_000];
79
84
  const GATEWAY_LOG_TAIL_CHARS = 2_000;
80
85
  // Read more than is shown, so masking sees whole tokens before the tail is cut.
@@ -495,7 +500,17 @@ class SanctuaryAuthorityRootLifecycle {
495
500
  const lock = `${this.#epochRoot()}/authority.lock`;
496
501
  if (!fs.existsSync(this.#p(lock)))
497
502
  return null;
498
- const pid = Number(this.#private(lock));
503
+ let text;
504
+ try {
505
+ text = this.#private(lock);
506
+ }
507
+ catch (error) {
508
+ // An exiting gateway unlinks its lock, which can land mid-read (ENOENT, or nlink 0). A lock that is gone means no gateway; one still present stays strict.
509
+ if (!fs.existsSync(this.#p(lock)))
510
+ return null;
511
+ throw error;
512
+ }
513
+ const pid = Number(text);
499
514
  if (!Number.isSafeInteger(pid) || pid <= 0)
500
515
  throw new Error("Sanctuary gateway process lock is invalid");
501
516
  if (!fs.existsSync(this.#p(`/proc/${pid}`)))
@@ -861,7 +876,7 @@ class SanctuaryAuthorityRootLifecycle {
861
876
  if (this.#containers().find((container) => container.Name === "/ouro-butler")?.Image !== journal.from.imageId) {
862
877
  predecessor.#recreateResident(journal.from.imageReference, journal.from.imageId);
863
878
  }
864
- await predecessor.#startUpgraded();
879
+ await predecessor.#startUpgraded(PREDECESSOR_READY_TIMEOUT_MS);
865
880
  this.#remove(UPGRADE);
866
881
  this.#removeTree(PREVIOUS);
867
882
  (0, runtime_1.emitNervesEvent)({ level: "warn", component: "daemon", event: "daemon.sanctuary_upgrade_rolled_back", message: "Sanctuary upgrade rolled back to its predecessor", meta: { from: journal.to.imageReference, to: journal.from.imageReference } });
@@ -1039,11 +1054,11 @@ class SanctuaryAuthorityRootLifecycle {
1039
1054
  fs.rmSync(path.join(directory, "window.json"), { force: true, recursive: true });
1040
1055
  }
1041
1056
  /** Start the gateway, then the resident, and prove both. */
1042
- async #startUpgraded() {
1057
+ async #startUpgraded(readyTimeoutMs = RESIDENT_READY_TIMEOUT_MS) {
1043
1058
  this.#runtimeDirectories();
1044
1059
  await this.#startGateway();
1045
1060
  this.#docker(["start", "ouro-butler"]);
1046
- await this.#wait(async () => this.#target().State.Running && this.#target().State.Health?.Status === "healthy" && await this.#ready(), 300_000);
1061
+ await this.#wait(async () => this.#target().State.Running && this.#target().State.Health?.Status === "healthy" && await this.#ready(), readyTimeoutMs);
1047
1062
  }
1048
1063
  #removeTree(absolute) {
1049
1064
  fs.rmSync(this.#p(absolute), { recursive: true, force: true });
@@ -1257,7 +1272,7 @@ async function runSanctuaryAuthorityRootCli(argv, write = (text) => process.stdo
1257
1272
  }
1258
1273
  else
1259
1274
  await lifecycle.boot();
1260
- }, { timeoutMs: upgrade || rollback ? 600_000 : 0, confinementRoot: "/boot/config/custom/ouro-butler" });
1275
+ }, { timeoutMs: upgrade || rollback ? OPERATOR_LEASE_WAIT_MS : 0, confinementRoot: "/boot/config/custom/ouro-butler" });
1261
1276
  }
1262
1277
  if (process.argv[1] && fs.existsSync(process.argv[1]) && fs.realpathSync(process.argv[1]) === __filename) {
1263
1278
  void runSanctuaryAuthorityRootCli(process.argv.slice(2)).catch((error) => {
@@ -49,6 +49,9 @@ const error_classification_1 = require("./error-classification");
49
49
  const ANTHROPIC_SETUP_TOKEN_PREFIX = "sk-ant-oat01-";
50
50
  const ANTHROPIC_SETUP_TOKEN_MIN_LENGTH = 80;
51
51
  const ANTHROPIC_OAUTH_BETA_HEADER = "claude-code-20250219,oauth-2025-04-20,fine-grained-tool-streaming-2025-05-14,interleaved-thinking-2025-05-14";
52
+ // The API refuses newer models to Claude Code versions it considers too old (claude-opus-5-5 needs 2.1.280 or newer),
53
+ // so this names a current release. Raise it when the API asks for a newer one.
54
+ const CLAUDE_CLI_USER_AGENT = "claude-cli/2.1.310 (external, cli)";
52
55
  function getAnthropicAgentNameForGuidance() {
53
56
  return (0, identity_1.getAgentName)();
54
57
  }
@@ -455,7 +458,7 @@ function createAnthropicProviderRuntime(model, anthropicConfig = (0, config_1.ge
455
458
  defaultHeaders: {
456
459
  "anthropic-beta": ANTHROPIC_OAUTH_BETA_HEADER,
457
460
  "anthropic-dangerous-direct-browser-access": "true",
458
- "user-agent": "claude-cli/2.1.2 (external, cli)",
461
+ "user-agent": CLAUDE_CLI_USER_AGENT,
459
462
  "x-app": "cli",
460
463
  },
461
464
  });
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.947",
3
+ "version": "0.1.0-alpha.950",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@ouro.bot/cli",
9
- "version": "0.1.0-alpha.947",
9
+ "version": "0.1.0-alpha.950",
10
10
  "dependencies": {
11
11
  "@anthropic-ai/sdk": "^0.78.0",
12
12
  "@azure/identity": "^4.13.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.947",
3
+ "version": "0.1.0-alpha.950",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },