@ouro.bot/cli 0.1.0-alpha.754 → 0.1.0-alpha.756

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.756",
6
+ "changes": [
7
+ "Recover private-runtime turns safely across container restarts and restore Sanctuary steward-policy context."
8
+ ]
9
+ },
10
+ {
11
+ "version": "0.1.0-alpha.755",
12
+ "changes": [
13
+ "Preserve canonical Telegram owner identity across restarts.",
14
+ "Validate and accept SAB array or object server stats without failing open on malformed or unsafe counts."
15
+ ]
16
+ },
4
17
  {
5
18
  "version": "0.1.0-alpha.754",
6
19
  "changes": [
@@ -88,13 +88,11 @@ if [ "$Q_STATUS" = "Idle" ] && [ "${Q_SLOTS:-0}" -gt 0 ]; then STALLED=1; fi
88
88
  MIN_ARTICLES=50000
89
89
  MIN_RATE=30
90
90
  TODAY=$(date +%Y-%m-%d)
91
- fetch_sab_json STATS 20 "http://localhost:8090/api?mode=server_stats&output=json" '(.servers | type) == "array"' "server-stats" || exit 1
92
- TRIED=$(echo "$STATS" | jq -r --arg d "$TODAY" '[.servers[].articles_tried[$d] // 0] | add // 0' 2>/dev/null)
93
- OKAY=$(echo "$STATS" | jq -r --arg d "$TODAY" '[.servers[].articles_success[$d] // 0] | add // 0' 2>/dev/null)
94
- BYTES=$(echo "$STATS" | jq -r --arg d "$TODAY" '[.servers[].daily[$d] // 0] | add // 0' 2>/dev/null)
95
- case "$TRIED" in ''|*[!0-9]*) TRIED=0 ;; esac
96
- case "$OKAY" in ''|*[!0-9]*) OKAY=0 ;; esac
97
- case "$BYTES" in ''|*[!0-9]*) BYTES=0 ;; esac
91
+ fetch_sab_json STATS 20 "http://localhost:8090/api?mode=server_stats&output=json" 'def valid_count: ((type == "number" and . >= 0 and . <= 9007199254740991 and floor == .) or (type == "string" and test("^[0-9]+$") and (tonumber <= 9007199254740991))); ((.servers | type) == "array" or (.servers | type) == "object") and (.servers | length) > 0 and all(.servers[]; (type == "object") and (.articles_tried | type) == "object" and (.articles_success | type) == "object" and (.daily | type) == "object" and all(.articles_tried[]; valid_count) and all(.articles_success[]; valid_count) and all(.daily[]; valid_count))' "server-stats" || exit 1
92
+ if ! IFS=$'\t' read -r TRIED OKAY BYTES < <(printf '%s' "$STATS" | jq -er --arg d "$TODAY" 'def count: if type == "number" and . >= 0 and . <= 9007199254740991 and floor == . then . elif type == "string" and test("^[0-9]+$") and (tonumber <= 9007199254740991) then tonumber else error("invalid server count") end; def total($field): ([.servers[] | (.[$field][$d] // 0) | count] | add) as $total | if $total >= 0 and $total <= 9007199254740991 and ($total | floor) == $total then $total else error("invalid aggregate server count") end; [total("articles_tried"), total("articles_success"), total("daily")] | @tsv' 2>/dev/null); then
93
+ emit_blind_observation "server-stats-response"
94
+ exit 1
95
+ fi
98
96
  GB=$(( ${BYTES:-0} / 1000000000 ))
99
97
 
100
98
  BASE_DAY=""
@@ -1,5 +1,5 @@
1
1
  {
2
- "runtimeVersion": "0.1.0-alpha.754",
2
+ "runtimeVersion": "0.1.0-alpha.756",
3
3
  "bundleSchemaVersion": 3,
4
4
  "lastUpdated": "2026-08-30T00:00:00.000Z"
5
5
  }
@@ -1,7 +1,7 @@
1
1
  <?xml version="1.0"?>
2
2
  <Container version="2">
3
3
  <Name>Mendelow Cloud Butler</Name>
4
- <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.754</Repository>
4
+ <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.756</Repository>
5
5
  <Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
6
6
  <Network>host</Network>
7
7
  <Shell>sh</Shell>
@@ -51,6 +51,7 @@ const fs = __importStar(require("node:fs"));
51
51
  const path = __importStar(require("node:path"));
52
52
  const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
53
53
  const runtime_1 = require("../nerves/runtime");
54
+ const habit_lifecycle_1 = require("../heart/habits/habit-lifecycle");
54
55
  class SessionTurnBusyError extends Error {
55
56
  retryable = true;
56
57
  constructor(message = "session turn is busy") {
@@ -101,34 +102,71 @@ function openLeaseDatabase(lockPath, busyTimeoutMs = 0) {
101
102
  owner_token TEXT NOT NULL
102
103
  )
103
104
  `);
105
+ const columns = new Set(database.prepare(`PRAGMA table_info(session_turn_lease)`).all().map((column) => column.name));
106
+ if (!columns.has("boot_identity"))
107
+ database.exec(`ALTER TABLE session_turn_lease ADD COLUMN boot_identity TEXT`);
108
+ if (!columns.has("process_started_at"))
109
+ database.exec(`ALTER TABLE session_turn_lease ADD COLUMN process_started_at TEXT`);
104
110
  return database;
105
111
  }
106
- function claimLeaseRecord(lockPath, record, isProcessAlive) {
112
+ function acquisitionRecord(options) {
113
+ const pid = options.pid ?? process.pid;
114
+ const bootIdentity = (options.getBootIdentity ?? habit_lifecycle_1.probeHabitBootIdentity)();
115
+ const processStartedAt = (options.getProcessStartedAt ?? habit_lifecycle_1.probeHabitProcessStartedAt)(pid);
116
+ if (!bootIdentity || !processStartedAt)
117
+ throw new SessionTransactionError("current process identity is unavailable");
118
+ return {
119
+ pid,
120
+ ownerId: options.ownerId ?? (0, node_crypto_1.randomUUID)(),
121
+ ownerToken: options.ownerToken ?? (0, node_crypto_1.randomUUID)(),
122
+ bootIdentity,
123
+ processStartedAt,
124
+ };
125
+ }
126
+ function claimLeaseRecord(lockPath, record, isProcessAlive, getProcessStartedAt) {
107
127
  const database = openLeaseDatabase(lockPath);
108
128
  try {
109
129
  return database.transaction(() => {
110
130
  const current = database.prepare(`
111
- SELECT pid, owner_id, owner_token FROM session_turn_lease WHERE singleton = 1
131
+ SELECT pid, owner_id, owner_token, boot_identity, process_started_at FROM session_turn_lease WHERE singleton = 1
112
132
  `).get();
113
133
  if (!current) {
114
134
  database.prepare(`
115
- INSERT INTO session_turn_lease (singleton, pid, owner_id, owner_token) VALUES (1, ?, ?, ?)
116
- `).run(record.pid, record.ownerId, record.ownerToken);
135
+ INSERT INTO session_turn_lease (singleton, pid, owner_id, owner_token, boot_identity, process_started_at) VALUES (1, ?, ?, ?, ?, ?)
136
+ `).run(record.pid, record.ownerId, record.ownerToken, record.bootIdentity, record.processStartedAt);
117
137
  return { acquired: true, stale: null };
118
138
  }
119
139
  if (!Number.isSafeInteger(current.pid) || current.pid <= 0
120
140
  || typeof current.owner_id !== "string" || current.owner_id.length === 0
121
- || typeof current.owner_token !== "string" || current.owner_token.length === 0) {
141
+ || typeof current.owner_token !== "string" || current.owner_token.length === 0
142
+ || !((current.boot_identity === null && current.process_started_at === null)
143
+ || (typeof current.boot_identity === "string" && current.boot_identity.length > 0
144
+ && typeof current.process_started_at === "string" && current.process_started_at.length > 0))) {
122
145
  throw new SessionTransactionError("invalid session lease record");
123
146
  }
124
- const observed = { pid: current.pid, ownerId: current.owner_id, ownerToken: current.owner_token };
125
- if (isProcessAlive(observed.pid))
147
+ const observed = {
148
+ pid: current.pid,
149
+ ownerId: current.owner_id,
150
+ ownerToken: current.owner_token,
151
+ bootIdentity: current.boot_identity,
152
+ processStartedAt: current.process_started_at,
153
+ };
154
+ const differentBoot = observed.bootIdentity !== null && observed.bootIdentity !== record.bootIdentity;
155
+ let differentProcess = false;
156
+ if (observed.bootIdentity !== null && !differentBoot) {
157
+ const liveStartedAt = getProcessStartedAt(observed.pid);
158
+ differentProcess = liveStartedAt !== null && liveStartedAt !== observed.processStartedAt;
159
+ if (liveStartedAt === observed.processStartedAt)
160
+ return { acquired: false, stale: null };
161
+ }
162
+ if (!differentBoot && !differentProcess && isProcessAlive(observed.pid))
126
163
  return { acquired: false, stale: null };
127
- database.prepare(`
128
- UPDATE session_turn_lease SET pid = ?, owner_id = ?, owner_token = ?
164
+ const changed = database.prepare(`
165
+ UPDATE session_turn_lease SET pid = ?, owner_id = ?, owner_token = ?, boot_identity = ?, process_started_at = ?
129
166
  WHERE singleton = 1 AND pid = ? AND owner_id = ? AND owner_token = ?
130
- `).run(record.pid, record.ownerId, record.ownerToken, observed.pid, observed.ownerId, observed.ownerToken);
131
- return { acquired: true, stale: observed };
167
+ AND boot_identity IS ? AND process_started_at IS ?
168
+ `).run(record.pid, record.ownerId, record.ownerToken, record.bootIdentity, record.processStartedAt, observed.pid, observed.ownerId, observed.ownerToken, observed.bootIdentity, observed.processStartedAt).changes;
169
+ return changed === 1 ? { acquired: true, stale: observed } : { acquired: false, stale: null };
132
170
  })();
133
171
  }
134
172
  finally {
@@ -140,7 +178,8 @@ function releaseLeaseRecord(lockPath, record) {
140
178
  try {
141
179
  return database.prepare(`
142
180
  DELETE FROM session_turn_lease WHERE singleton = 1 AND pid = ? AND owner_id = ? AND owner_token = ?
143
- `).run(record.pid, record.ownerId, record.ownerToken).changes === 1;
181
+ AND boot_identity IS ? AND process_started_at IS ?
182
+ `).run(record.pid, record.ownerId, record.ownerToken, record.bootIdentity, record.processStartedAt).changes === 1;
144
183
  }
145
184
  finally {
146
185
  database.close();
@@ -180,9 +219,8 @@ async function acquireSessionTurnLease(sessionPath, options = {}) {
180
219
  return makeLease(existing);
181
220
  }
182
221
  }
183
- const ownerId = options.ownerId ?? (0, node_crypto_1.randomUUID)();
184
- const ownerToken = options.ownerToken ?? (0, node_crypto_1.randomUUID)();
185
- const pid = options.pid ?? process.pid;
222
+ const record = acquisitionRecord(options);
223
+ const { ownerId } = record;
186
224
  const timeoutMs = options.timeoutMs ?? 5_000;
187
225
  const pollIntervalMs = options.pollIntervalMs ?? 10;
188
226
  const isProcessAlive = options.isProcessAlive ?? processAlive;
@@ -192,14 +230,14 @@ async function acquireSessionTurnLease(sessionPath, options = {}) {
192
230
  for (;;) {
193
231
  let claim = null;
194
232
  try {
195
- claim = claimLeaseRecord(lockPath, { pid, ownerId, ownerToken }, isProcessAlive);
233
+ claim = claimLeaseRecord(lockPath, record, isProcessAlive, options.getProcessStartedAt ?? habit_lifecycle_1.probeHabitProcessStartedAt);
196
234
  }
197
235
  catch (error) {
198
236
  if (!isSqliteBusy(error))
199
237
  throw error;
200
238
  }
201
239
  if (claim?.acquired) {
202
- const held = { sessionPath: canonical, lockPath, pid, ownerId, ownerToken, depth: 1, released: false };
240
+ const held = { sessionPath: canonical, lockPath, ...record, depth: 1, released: false };
203
241
  heldLeases.set(canonical, held);
204
242
  (0, runtime_1.emitNervesEvent)({
205
243
  component: "mind",
@@ -246,15 +284,13 @@ function withImmediateSessionTurnLease(sessionPath, work, options = {}) {
246
284
  const contextual = currentSessionTurnLease(canonical);
247
285
  if (contextual)
248
286
  return work(contextual);
249
- const ownerId = options.ownerId ?? (0, node_crypto_1.randomUUID)();
250
- const ownerToken = options.ownerToken ?? (0, node_crypto_1.randomUUID)();
251
- const pid = options.pid ?? process.pid;
287
+ const record = acquisitionRecord(options);
252
288
  const lockPath = `${canonical}.turn.lock`;
253
289
  fs.mkdirSync(path.dirname(canonical), { recursive: true });
254
290
  const tryAcquire = () => {
255
291
  let claim;
256
292
  try {
257
- claim = claimLeaseRecord(lockPath, { pid, ownerId, ownerToken }, options.isProcessAlive ?? processAlive);
293
+ claim = claimLeaseRecord(lockPath, record, options.isProcessAlive ?? processAlive, options.getProcessStartedAt ?? habit_lifecycle_1.probeHabitProcessStartedAt);
258
294
  }
259
295
  catch (error) {
260
296
  if (isSqliteBusy(error))
@@ -262,7 +298,7 @@ function withImmediateSessionTurnLease(sessionPath, work, options = {}) {
262
298
  throw error;
263
299
  }
264
300
  if (claim.acquired) {
265
- const held = { sessionPath: canonical, lockPath, pid, ownerId, ownerToken, depth: 1, released: false };
301
+ const held = { sessionPath: canonical, lockPath, ...record, depth: 1, released: false };
266
302
  heldLeases.set(canonical, held);
267
303
  if (claim.stale) {
268
304
  options.onStaleLease?.(claim.stale);
@@ -515,6 +515,7 @@ function createPrivateRuntimeWorker(runTurn = (options) => (0, private_runtime_1
515
515
  const currentHabitName = nextHabitName;
516
516
  const currentTrigger = nextTrigger ?? "overdue";
517
517
  const currentNoSend = nextNoSend;
518
+ const currentExternalEvent = nextExternalEvent;
518
519
  const currentExternalEventHeartbeat = nextExternalEventHeartbeat;
519
520
  nextExternalEventHeartbeat = undefined;
520
521
  const currentHabitRun = currentReason === "habit" && currentHabitName
@@ -655,6 +656,11 @@ function createPrivateRuntimeWorker(runTurn = (options) => (0, private_runtime_1
655
656
  // would cause hasPendingWork() to be true here, producing a
656
657
  // self-sustaining "instinct" loop with no external input. Cap it.
657
658
  if (hasPendingWork(currentHabitRun?.paths.pendingDir)) {
659
+ if (currentExternalEvent && (turnErrors.length > 0
660
+ || (turnResult && typeof turnResult === "object" && "turnOutcome" in turnResult && turnResult.turnOutcome === "errored"))) {
661
+ finalizeCurrentHabitRun();
662
+ break;
663
+ }
658
664
  clearHeartbeatRestShield();
659
665
  if (consecutiveInstinctTurns >= exports.MAX_CONSECUTIVE_INSTINCT_TURNS) {
660
666
  (0, runtime_1.emitNervesEvent)({
@@ -120,10 +120,10 @@ function persistAttempt(filePath, attempt) {
120
120
  fs.writeFileSync(temporary, `${JSON.stringify(attempt)}\n`, { mode: 0o600 });
121
121
  fs.renameSync(temporary, filePath);
122
122
  }
123
- async function appendAcceptanceAttempt(agentName, attempt) {
123
+ async function appendAcceptanceAttempt(agentRoot, attempt) {
124
124
  if (!attempt.scenarioHandleDigest)
125
125
  return;
126
- const filePath = path.join((0, identity_1.getAgentRoot)(agentName), "state", "acceptance", "restart-attempts.ndjson");
126
+ const filePath = path.join(agentRoot, "state", "acceptance", "restart-attempts.ndjson");
127
127
  const previous = acceptanceLedgerTails.get(filePath) ?? Promise.resolve();
128
128
  const current = previous.catch(() => undefined).then(() => {
129
129
  fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
@@ -187,6 +187,7 @@ function createSanctuaryToolContext(agentName) {
187
187
  message: "creating typed Sanctuary tool context",
188
188
  meta: { agentName },
189
189
  });
190
+ const agentRoot = (0, identity_1.getAgentRoot)(agentName);
190
191
  const initial = machineConfig(agentName);
191
192
  const endpoint = required(initial, "unraidGraphqlUrl");
192
193
  const readClient = new unraid_client_1.UnraidClient({ endpoint, apiKey: required(initial, "unraidReadApiKey") });
@@ -216,15 +217,16 @@ function createSanctuaryToolContext(agentName) {
216
217
  listContainers: reads.listContainers,
217
218
  loadWriteApiKey: async () => required(machineConfig(agentName), "unraidWriteApiKey"),
218
219
  persistAttempt: async (attempt) => {
219
- persistAttempt(path.join((0, identity_1.getAgentRoot)(agentName), "state", "approvals", "unraid-restart-attempt.json"), attempt);
220
- await appendAcceptanceAttempt(agentName, attempt);
220
+ persistAttempt(path.join(agentRoot, "state", "approvals", "unraid-restart-attempt.json"), attempt);
221
+ await appendAcceptanceAttempt(agentRoot, attempt);
221
222
  },
222
223
  acceptanceScenarioHandleDigest: () => (0, sanctuary_acceptance_marker_1.readSanctuaryAcceptanceMarker)(agentName)?.scenarioHandleDigest,
223
224
  acceptanceApproval: sanctuary_acceptance_marker_1.readSanctuaryAcceptanceApproval,
224
- reserveRoutineAction: (input) => (0, steward_policy_1.consumeRoutineActionGrant)((0, identity_1.getAgentRoot)(agentName), input),
225
- transitionRoutineAction: (input) => (0, steward_policy_1.transitionRoutineActionReceipt)((0, identity_1.getAgentRoot)(agentName), input),
225
+ reserveRoutineAction: (input) => (0, steward_policy_1.consumeRoutineActionGrant)(agentRoot, input),
226
+ transitionRoutineAction: (input) => (0, steward_policy_1.transitionRoutineActionReceipt)(agentRoot, input),
226
227
  });
227
228
  return {
229
+ agentRoot,
228
230
  sanctuary: {
229
231
  listContainers: acceptanceRead("unraid_list_containers", reads.listContainers),
230
232
  getContainerLogs: acceptanceRead("unraid_get_container_logs", reads.getContainerLogs),
@@ -255,7 +257,7 @@ function createSanctuaryToolContext(agentName) {
255
257
  collectToolResult(result);
256
258
  return result;
257
259
  },
258
- recoverRoutineActions: () => (0, steward_policy_1.recoverRoutineActionReceipts)((0, identity_1.getAgentRoot)(agentName), {
260
+ recoverRoutineActions: () => (0, steward_policy_1.recoverRoutineActionReceipts)(agentRoot, {
259
261
  observeTarget: async (target) => {
260
262
  const observed = await reads.listContainers();
261
263
  if (!observed.ok)
@@ -924,12 +924,14 @@ function createTelegramSenseApp(options) {
924
924
  acceptanceAuditBarrier();
925
925
  migrateTelegramSessionIdentity(agentRoot, legacySubject, legacySubject, subject);
926
926
  acceptanceAuditBarrier();
927
- await migrateTelegramFriendIdentity(agentRoot, legacySubject, subject);
927
+ if (!options.admission)
928
+ await migrateTelegramFriendIdentity(agentRoot, legacySubject, subject);
928
929
  }
929
930
  acceptanceAuditBarrier();
930
931
  migrateTelegramSessionIdentity(agentRoot, authorizedUserId, authorizedChatId, subject);
931
932
  acceptanceAuditBarrier();
932
- await migrateTelegramFriendIdentity(agentRoot, authorizedUserId, subject);
933
+ if (!options.admission)
934
+ await migrateTelegramFriendIdentity(agentRoot, authorizedUserId, subject);
933
935
  acceptanceAuditBarrier();
934
936
  approvalRuntime?.migrateIdentity?.(migrationSubjects);
935
937
  acceptanceAuditBarrier();
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.754",
3
+ "version": "0.1.0-alpha.756",
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.754",
9
+ "version": "0.1.0-alpha.756",
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.754",
3
+ "version": "0.1.0-alpha.756",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },