@ouro.bot/cli 0.1.0-alpha.900 → 0.1.0-alpha.903

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,18 @@
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.903",
6
+ "changes": [
7
+ "Shepherd on Herdr now watches a pane opened after it started. Herdr sends a new pane's `pane_created` event with the pane under `data.pane` and no top-level `pane_id`, so the Herdr host ignored it and never subscribed to the new pane's agent status; it now resubscribes on every `pane_created`, and the test fake sends that event in Herdr's real shape. The Unraid template points at the 0.1.0-alpha.903 Butler image."
8
+ ]
9
+ },
10
+ {
11
+ "version": "0.1.0-alpha.901",
12
+ "changes": [
13
+ "Bundle sync never commits a file over 50 MiB, so one oversized file can no longer block every later push (GitHub rejects files over 100 MiB). Sync leaves such a file uncommitted in the working tree, keeps the last committed version of a tracked file, holds back the deletion of the old path when a big file is renamed, and records skipped files in state/sync-skipped-files.json. The rebase retry uses --autostash, so an uncommitted oversized file no longer blocks it. A failed sync now stays visible until it is fixed: state/pending-sync.json is written for every failure (including a failed git status), carries the date the failures began and how many attempts have failed in a row, and is no longer deleted after one turn. When nothing changed, sync clears the record if the bundle matches its upstream (or has no remote) and otherwise pushes the commits an earlier failure left behind. `ouro status` shows a failing bundle as 'failing' with that date and count and lists skipped files, and the daemon health checks report both as a bundle-sync warning for sync-enabled bundles. Sync commits with -q and gives git a 256 MiB output buffer and a 120-second timeout for add, commit, push and pull, and the context-loss sentinel's git status gets the same buffer, because a bundle with about 30,000 changed files overflowed the 1 MiB default (ENOBUFS) and took 17 seconds to stage."
14
+ ]
15
+ },
4
16
  {
5
17
  "version": "0.1.0-alpha.900",
6
18
  "changes": [
@@ -1,5 +1,5 @@
1
1
  {
2
- "runtimeVersion": "0.1.0-alpha.900",
2
+ "runtimeVersion": "0.1.0-alpha.903",
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.900</Repository>
4
+ <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.903</Repository>
5
5
  <Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
6
6
  <Network>host</Network>
7
7
  <Shell>sh</Shell>
@@ -42,6 +42,7 @@ exports.formatContextLossSentinelJson = formatContextLossSentinelJson;
42
42
  const fs = __importStar(require("fs"));
43
43
  const path = __importStar(require("path"));
44
44
  const child_process_1 = require("child_process");
45
+ const sync_1 = require("./sync");
45
46
  const crypto_1 = require("crypto");
46
47
  const flight_recorder_1 = require("../arc/flight-recorder");
47
48
  const runtime_1 = require("../nerves/runtime");
@@ -528,6 +529,7 @@ function defaultGitStatus(agentRoot) {
528
529
  encoding: "utf-8",
529
530
  stdio: ["ignore", "pipe", "pipe"],
530
531
  timeout: CONTEXT_LOSS_SENTINEL_GIT_STATUS_TIMEOUT_MS,
532
+ maxBuffer: sync_1.SYNC_GIT_MAX_BUFFER_BYTES,
531
533
  });
532
534
  return { ok: true, porcelain };
533
535
  }
@@ -49,6 +49,7 @@ const child_process_1 = require("child_process");
49
49
  const identity_1 = require("../identity");
50
50
  const machine_identity_1 = require("../machine-identity");
51
51
  const runtime_1 = require("../../nerves/runtime");
52
+ const sync_health_1 = require("../sync-health");
52
53
  /**
53
54
  * True when the value is the string `"library"`. Library bundles are
54
55
  * content-only resources — never run as agents, never appear in sync surfaces.
@@ -347,6 +348,11 @@ function listBundleSyncRows(options = {}) {
347
348
  catch {
348
349
  // No remote configured or git missing — leave remoteUrl undefined.
349
350
  }
351
+ const health = (0, sync_health_1.readBundleSyncHealth)(bundleRoot, { readFileSync });
352
+ if (health.failure)
353
+ row.failure = health.failure;
354
+ if (health.skippedFiles.length > 0)
355
+ row.skippedFiles = health.skippedFiles;
350
356
  }
351
357
  }
352
358
  rows.push(row);
@@ -57,6 +57,7 @@ const identity_1 = require("../identity");
57
57
  const daemon_tombstone_1 = require("./daemon-tombstone");
58
58
  const daemon_health_1 = require("./daemon-health");
59
59
  const agent_discovery_1 = require("./agent-discovery");
60
+ const sync_health_1 = require("../sync-health");
60
61
  // ── Field extractors ──
61
62
  function stringField(value) {
62
63
  return typeof value === "string" ? value : null;
@@ -267,6 +268,12 @@ function parseStatusPayload(data) {
267
268
  const gitInitialized = booleanField(row.gitInitialized);
268
269
  if (gitInitialized !== null)
269
270
  parsed.gitInitialized = gitInitialized;
271
+ const failure = (0, sync_health_1.parseBundleSyncFailure)(row.failure);
272
+ if (failure)
273
+ parsed.failure = failure;
274
+ const skippedFiles = (0, sync_health_1.parseBundleSyncSkippedFiles)(row.skippedFiles);
275
+ if (skippedFiles.length > 0)
276
+ parsed.skippedFiles = skippedFiles;
270
277
  return parsed;
271
278
  });
272
279
  const parsedAgents = (agents ?? []).map((entry) => {
@@ -584,6 +591,11 @@ function formatDaemonStatusOutput(response, fallback) {
584
591
  stateText = "error ".padEnd(10);
585
592
  detail = "not a git repo — run `git init` to enable sync";
586
593
  }
594
+ else if (row.failure) {
595
+ dot = red("●");
596
+ stateText = "failing ".padEnd(10);
597
+ detail = (0, sync_health_1.describeSyncFailure)(row.failure);
598
+ }
587
599
  else if (row.remoteUrl !== undefined) {
588
600
  dot = green("●");
589
601
  stateText = "enabled ".padEnd(10);
@@ -595,6 +607,9 @@ function formatDaemonStatusOutput(response, fallback) {
595
607
  detail = "local only";
596
608
  }
597
609
  lines.push(` ${name} ${dot} ${stateText} ${dim(detail)}`);
610
+ if (row.enabled && row.skippedFiles && row.skippedFiles.length > 0) {
611
+ lines.push(` ${" ".repeat(agentNameWidth)} ${yellow("●")} ${"warn".padEnd(10)} ${dim((0, sync_health_1.describeSkippedFiles)(row.skippedFiles))}`);
612
+ }
598
613
  }
599
614
  lines.push("");
600
615
  }
@@ -83,6 +83,7 @@ const sanctuary_acceptance_marker_1 = require("./sanctuary-acceptance-marker");
83
83
  const sanctuary_scheduler_liveness_1 = require("./sanctuary-scheduler-liveness");
84
84
  const sanctuary_scheduler_origin_1 = require("./sanctuary-scheduler-origin");
85
85
  const telegram_1 = require("../../senses/telegram");
86
+ const sync_health_1 = require("../sync-health");
86
87
  function parseSocketPath(argv) {
87
88
  const socketIndex = argv.indexOf("--socket");
88
89
  if (socketIndex >= 0) {
@@ -343,6 +344,7 @@ const healthMonitor = new health_monitor_1.HealthMonitor({
343
344
  }
344
345
  return [...sentinels, homes.health];
345
346
  },
347
+ bundleSyncChecker: () => [(0, sync_health_1.checkBundleSyncHealth)((0, agent_discovery_1.listBundleSyncRows)({ bundlesRoot: (0, identity_1.getAgentBundlesRoot)() }).filter((row) => managedAgents.includes(row.agent)))],
346
348
  senseProbeProvider: () => [
347
349
  ...senseManager.listHealthProbes(),
348
350
  ...managedAgents.map((agent) => (0, mcp_canary_1.createMcpStatusCanaryProbe)({
@@ -8,6 +8,7 @@ class HealthMonitor {
8
8
  alertSink;
9
9
  diskUsagePercent;
10
10
  sentinelChecker;
11
+ bundleSyncChecker;
11
12
  onCriticalAgent;
12
13
  onCriticalSense;
13
14
  senseProbes;
@@ -20,6 +21,7 @@ class HealthMonitor {
20
21
  this.alertSink = options.alertSink ?? (() => undefined);
21
22
  this.diskUsagePercent = options.diskUsagePercent ?? (() => 0);
22
23
  this.sentinelChecker = options.sentinelChecker ?? (() => []);
24
+ this.bundleSyncChecker = options.bundleSyncChecker ?? (() => []);
23
25
  this.onCriticalAgent = options.onCriticalAgent ?? (() => undefined);
24
26
  this.onCriticalSense = options.onCriticalSense ?? (() => undefined);
25
27
  this.senseProbes = options.senseProbes ?? [];
@@ -148,6 +150,16 @@ class HealthMonitor {
148
150
  message: `disk usage healthy (${diskPercent}%)`,
149
151
  });
150
152
  }
153
+ try {
154
+ results.push(...this.bundleSyncChecker());
155
+ }
156
+ catch (error) {
157
+ results.push({
158
+ name: "bundle-sync",
159
+ status: "warn",
160
+ message: `bundle sync check failed: ${error instanceof Error ? error.message : String(error)}`,
161
+ });
162
+ }
151
163
  const senseProbes = [...this.senseProbes];
152
164
  try {
153
165
  senseProbes.push(...this.senseProbeProvider());
@@ -0,0 +1,152 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.parseBundleSyncFailure = parseBundleSyncFailure;
37
+ exports.parseBundleSyncSkippedFiles = parseBundleSyncSkippedFiles;
38
+ exports.readBundleSyncHealth = readBundleSyncHealth;
39
+ exports.summarizeSyncError = summarizeSyncError;
40
+ exports.formatMegabytes = formatMegabytes;
41
+ exports.describeSyncFailure = describeSyncFailure;
42
+ exports.describeSkippedFiles = describeSkippedFiles;
43
+ exports.checkBundleSyncHealth = checkBundleSyncHealth;
44
+ /**
45
+ * Reads the two bundle sync records that post-turn sync leaves on disk and
46
+ * turns them into one view for `ouro status` and the daemon health check:
47
+ *
48
+ * - `state/pending-sync.json`: the last sync failed and nothing has
49
+ * succeeded since (written and cleared by `postTurnPush`).
50
+ * - `state/sync-skipped-files.json`: files sync left uncommitted because
51
+ * they are over `SYNC_FILE_SIZE_LIMIT_BYTES`.
52
+ *
53
+ * Reading never throws: an unreadable or malformed record reads as absent.
54
+ */
55
+ const fs = __importStar(require("fs"));
56
+ const path = __importStar(require("path"));
57
+ const runtime_1 = require("../nerves/runtime");
58
+ function readJson(filePath, readFile) {
59
+ try {
60
+ return JSON.parse(readFile(filePath, "utf-8"));
61
+ }
62
+ catch {
63
+ return null;
64
+ }
65
+ }
66
+ function asRecord(value) {
67
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
68
+ }
69
+ /**
70
+ * Accepts both the on-disk `pending-sync.json` shape (`failedAt`) and the
71
+ * `BundleSyncFailure` shape the daemon sends in status (`lastFailedAt`).
72
+ */
73
+ function parseBundleSyncFailure(value) {
74
+ const record = asRecord(value);
75
+ if (!record || typeof record.error !== "string")
76
+ return null;
77
+ const lastFailedAt = typeof record.failedAt === "string"
78
+ ? record.failedAt
79
+ : typeof record.lastFailedAt === "string" ? record.lastFailedAt : "unknown";
80
+ return {
81
+ error: record.error,
82
+ classification: typeof record.classification === "string" ? record.classification : "unknown",
83
+ firstFailedAt: typeof record.firstFailedAt === "string" ? record.firstFailedAt : lastFailedAt,
84
+ lastFailedAt,
85
+ consecutiveFailures: typeof record.consecutiveFailures === "number" && record.consecutiveFailures > 0
86
+ ? record.consecutiveFailures
87
+ : 1,
88
+ };
89
+ }
90
+ /** Accepts the `sync-skipped-files.json` record or a bare array of files. */
91
+ function parseBundleSyncSkippedFiles(value) {
92
+ const files = Array.isArray(value) ? value : asRecord(value)?.files;
93
+ if (!Array.isArray(files))
94
+ return [];
95
+ return files.flatMap((entry) => {
96
+ const file = asRecord(entry);
97
+ if (!file || typeof file.path !== "string" || typeof file.bytes !== "number")
98
+ return [];
99
+ return [{ path: file.path, bytes: file.bytes }];
100
+ });
101
+ }
102
+ function readBundleSyncHealth(agentRoot, deps = {}) {
103
+ const readFile = deps.readFileSync ?? ((p, encoding) => fs.readFileSync(p, encoding));
104
+ return {
105
+ failure: parseBundleSyncFailure(readJson(path.join(agentRoot, "state", "pending-sync.json"), readFile)),
106
+ skippedFiles: parseBundleSyncSkippedFiles(readJson(path.join(agentRoot, "state", "sync-skipped-files.json"), readFile)),
107
+ };
108
+ }
109
+ /** First line of a git error, capped so one status line stays readable. */
110
+ function summarizeSyncError(error) {
111
+ const firstLine = error.split("\n").map((line) => line.trim()).find((line) => line.length > 0) ?? "unknown";
112
+ return firstLine.length > 160 ? `${firstLine.slice(0, 157)}...` : firstLine;
113
+ }
114
+ function formatMegabytes(bytes) {
115
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
116
+ }
117
+ function describeSyncFailure(failure) {
118
+ return `sync failing since ${failure.firstFailedAt} (${failure.consecutiveFailures} attempt(s)): ${summarizeSyncError(failure.error)}`;
119
+ }
120
+ function describeSkippedFiles(files) {
121
+ const listed = files.slice(0, 3).map((file) => `${file.path} (${formatMegabytes(file.bytes)})`).join(", ");
122
+ const more = files.length > 3 ? `, and ${files.length - 3} more` : "";
123
+ return `${files.length} file(s) too large to sync left uncommitted: ${listed}${more}`;
124
+ }
125
+ /**
126
+ * Daemon health check over the managed agents' bundle sync rows. Bundles with
127
+ * sync disabled are ignored. Sync trouble is a warning, never critical: the
128
+ * agent still runs, but its bundle is not being backed up, which must stay
129
+ * visible until someone fixes it.
130
+ */
131
+ function checkBundleSyncHealth(rows) {
132
+ const problems = [];
133
+ for (const row of rows) {
134
+ if (!row.enabled)
135
+ continue;
136
+ if (row.failure)
137
+ problems.push(`${row.agent}: ${describeSyncFailure(row.failure)}`);
138
+ if (row.skippedFiles && row.skippedFiles.length > 0)
139
+ problems.push(`${row.agent}: ${describeSkippedFiles(row.skippedFiles)}`);
140
+ }
141
+ if (problems.length === 0) {
142
+ return { name: "bundle-sync", status: "ok", message: "bundle sync healthy" };
143
+ }
144
+ (0, runtime_1.emitNervesEvent)({
145
+ level: "warn",
146
+ component: "heart",
147
+ event: "heart.bundle_sync_unhealthy",
148
+ message: "bundle sync health check found problems",
149
+ meta: { problems },
150
+ });
151
+ return { name: "bundle-sync", status: "warn", message: problems.join("; ") };
152
+ }
@@ -33,7 +33,10 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.STALE_INDEX_LOCK_MS = void 0;
36
+ exports.STALE_INDEX_LOCK_MS = exports.SYNC_GIT_TIMEOUT_MS = exports.SYNC_GIT_MAX_BUFFER_BYTES = exports.SYNC_FILE_SIZE_LIMIT_BYTES = void 0;
37
+ exports.pendingSyncPath = pendingSyncPath;
38
+ exports.skippedSyncFilesPath = skippedSyncFilesPath;
39
+ exports.clearPendingSync = clearPendingSync;
37
40
  exports.preTurnPull = preTurnPull;
38
41
  exports.postTurnPush = postTurnPush;
39
42
  exports.preTurnPullAsync = preTurnPullAsync;
@@ -41,16 +44,172 @@ const child_process_1 = require("child_process");
41
44
  const fs = __importStar(require("fs"));
42
45
  const path = __importStar(require("path"));
43
46
  const runtime_1 = require("../nerves/runtime");
47
+ const sync_health_1 = require("./sync-health");
48
+ /**
49
+ * Sync never commits a file larger than this. GitHub rejects any push that
50
+ * contains a file over 100 MiB and warns above 50 MiB; one such file in an
51
+ * unpushed commit blocks every later push from the bundle. The guard leaves
52
+ * the file in the working tree, uncommitted, and records it.
53
+ */
54
+ exports.SYNC_FILE_SIZE_LIMIT_BYTES = 50 * 1024 * 1024;
55
+ /**
56
+ * Output buffer for git commands whose output grows with the number of
57
+ * changed files. Node's 1 MiB default made `git status` fail with ENOBUFS
58
+ * once a bundle held about 30,000 changed files, which stopped sync.
59
+ */
60
+ exports.SYNC_GIT_MAX_BUFFER_BYTES = 256 * 1024 * 1024;
61
+ /**
62
+ * Timeout for sync's add, commit, push and pull. `git add -A` took 17 s on a
63
+ * bundle with 30,000 changed files, past the old 10 s limit.
64
+ */
65
+ exports.SYNC_GIT_TIMEOUT_MS = 120_000;
66
+ function pendingSyncPath(agentRoot) {
67
+ return path.join(agentRoot, "state", "pending-sync.json");
68
+ }
69
+ function skippedSyncFilesPath(agentRoot) {
70
+ return path.join(agentRoot, "state", "sync-skipped-files.json");
71
+ }
72
+ function readPreviousFailure(agentRoot) {
73
+ try {
74
+ return (0, sync_health_1.parseBundleSyncFailure)(JSON.parse(fs.readFileSync(pendingSyncPath(agentRoot), "utf-8")));
75
+ }
76
+ catch {
77
+ return null;
78
+ }
79
+ }
44
80
  function writePendingSync(agentRoot, error, classification, conflictFiles) {
45
- const pendingSyncPath = path.join(agentRoot, "state", "pending-sync.json");
81
+ const previous = readPreviousFailure(agentRoot);
82
+ const failedAt = new Date().toISOString();
83
+ const previousFirst = previous && previous.firstFailedAt !== "unknown" ? previous.firstFailedAt : null;
46
84
  fs.mkdirSync(path.join(agentRoot, "state"), { recursive: true });
47
85
  const record = {
48
86
  error,
49
- failedAt: new Date().toISOString(),
87
+ failedAt,
50
88
  classification,
51
89
  conflictFiles,
90
+ firstFailedAt: previousFirst ?? failedAt,
91
+ consecutiveFailures: (previous?.consecutiveFailures ?? 0) + 1,
52
92
  };
53
- fs.writeFileSync(pendingSyncPath, JSON.stringify(record, null, 2), "utf-8");
93
+ fs.writeFileSync(pendingSyncPath(agentRoot), JSON.stringify(record, null, 2), "utf-8");
94
+ }
95
+ function recordPendingSync(agentRoot, error, classification, conflictFiles) {
96
+ try {
97
+ writePendingSync(agentRoot, error, classification, conflictFiles);
98
+ }
99
+ catch {
100
+ // Best effort: the caller's nerves event still records the failure.
101
+ }
102
+ }
103
+ /**
104
+ * Remove `state/pending-sync.json` after a sync that reached the remote (or
105
+ * committed, for a local-only bundle). The record otherwise stays, so the
106
+ * start-of-turn packet, `ouro status` and the daemon health check keep
107
+ * reporting the failure until it is fixed.
108
+ */
109
+ function clearPendingSync(agentRoot) {
110
+ try {
111
+ fs.rmSync(pendingSyncPath(agentRoot), { force: true });
112
+ }
113
+ catch {
114
+ // Best effort: a stale record is reported again and cleared next time.
115
+ }
116
+ }
117
+ function writeSkippedSyncFiles(agentRoot, files) {
118
+ try {
119
+ if (files.length === 0) {
120
+ fs.rmSync(skippedSyncFilesPath(agentRoot), { force: true });
121
+ return;
122
+ }
123
+ fs.mkdirSync(path.join(agentRoot, "state"), { recursive: true });
124
+ const record = {
125
+ updatedAt: new Date().toISOString(),
126
+ limitBytes: exports.SYNC_FILE_SIZE_LIMIT_BYTES,
127
+ files,
128
+ };
129
+ fs.writeFileSync(skippedSyncFilesPath(agentRoot), JSON.stringify(record, null, 2), "utf-8");
130
+ }
131
+ catch {
132
+ // Best effort: the nerves event emitted by the caller names the files.
133
+ }
134
+ }
135
+ /** Parse `git diff --cached --name-status -z` output. */
136
+ function parseStagedChanges(output) {
137
+ const tokens = output.split("\0").filter((entry) => entry.length > 0);
138
+ const changes = [];
139
+ for (let index = 0; index < tokens.length;) {
140
+ const status = tokens[index];
141
+ if (status.startsWith("R") || status.startsWith("C")) {
142
+ changes.push({ status, fromPath: tokens[index + 1], path: tokens[index + 2] });
143
+ index += 3;
144
+ }
145
+ else {
146
+ changes.push({ status, path: tokens[index + 1] });
147
+ index += 2;
148
+ }
149
+ }
150
+ return changes;
151
+ }
152
+ /**
153
+ * Unstage every staged file larger than `SYNC_FILE_SIZE_LIMIT_BYTES`. The
154
+ * file stays in the working tree and the previous committed version, if any,
155
+ * stays in the index, so nothing is deleted on other machines. For a rename,
156
+ * the deletion of the old path is unstaged too; otherwise the remote would
157
+ * lose the old path while never receiving the new one.
158
+ */
159
+ function unstageOversizedFiles(agentRoot) {
160
+ const changes = parseStagedChanges((0, child_process_1.execFileSync)("git", ["diff", "--cached", "--name-status", "-M", "-z"], {
161
+ cwd: agentRoot,
162
+ stdio: "pipe",
163
+ timeout: exports.SYNC_GIT_TIMEOUT_MS,
164
+ maxBuffer: exports.SYNC_GIT_MAX_BUFFER_BYTES,
165
+ }).toString());
166
+ const oversized = [];
167
+ const unstage = [];
168
+ for (const change of changes) {
169
+ if (change.status.startsWith("D"))
170
+ continue;
171
+ let bytes;
172
+ try {
173
+ bytes = fs.lstatSync(path.join(agentRoot, change.path)).size;
174
+ }
175
+ catch {
176
+ continue;
177
+ }
178
+ if (bytes <= exports.SYNC_FILE_SIZE_LIMIT_BYTES)
179
+ continue;
180
+ oversized.push({ path: change.path, bytes });
181
+ unstage.push(change.path);
182
+ if (change.status.startsWith("R") && change.fromPath)
183
+ unstage.push(change.fromPath);
184
+ }
185
+ if (oversized.length === 0)
186
+ return oversized;
187
+ (0, child_process_1.execFileSync)("git", ["--literal-pathspecs", "reset", "-q", "--", ...unstage], {
188
+ cwd: agentRoot,
189
+ stdio: "pipe",
190
+ timeout: exports.SYNC_GIT_TIMEOUT_MS,
191
+ });
192
+ (0, runtime_1.emitNervesEvent)({
193
+ level: "warn",
194
+ component: "heart",
195
+ event: "heart.sync_oversized_files_skipped",
196
+ message: "post-turn push: left files over the sync size limit uncommitted",
197
+ meta: { agentRoot, limitBytes: exports.SYNC_FILE_SIZE_LIMIT_BYTES, files: oversized },
198
+ });
199
+ return oversized;
200
+ }
201
+ function hasStagedChanges(agentRoot) {
202
+ try {
203
+ (0, child_process_1.execFileSync)("git", ["diff", "--cached", "--quiet"], {
204
+ cwd: agentRoot,
205
+ stdio: "pipe",
206
+ timeout: exports.SYNC_GIT_TIMEOUT_MS,
207
+ });
208
+ return false;
209
+ }
210
+ catch {
211
+ return true;
212
+ }
54
213
  }
55
214
  function collectRebaseConflictFiles(agentRoot) {
56
215
  try {
@@ -58,6 +217,7 @@ function collectRebaseConflictFiles(agentRoot) {
58
217
  cwd: agentRoot,
59
218
  stdio: "pipe",
60
219
  timeout: 5000,
220
+ maxBuffer: exports.SYNC_GIT_MAX_BUFFER_BYTES,
61
221
  }).toString();
62
222
  const files = [];
63
223
  for (const line of output.split("\n")) {
@@ -248,11 +408,13 @@ function postTurnPush(agentRoot, config) {
248
408
  statusOutput = (0, child_process_1.execFileSync)("git", ["status", "--porcelain"], {
249
409
  cwd: agentRoot,
250
410
  stdio: "pipe",
251
- timeout: 10000,
411
+ timeout: exports.SYNC_GIT_TIMEOUT_MS,
412
+ maxBuffer: exports.SYNC_GIT_MAX_BUFFER_BYTES,
252
413
  }).toString().trim();
253
414
  }
254
415
  catch (err) {
255
416
  const error = err instanceof Error ? err.message : String(err);
417
+ recordPendingSync(agentRoot, error, "unknown", []);
256
418
  (0, runtime_1.emitNervesEvent)({
257
419
  component: "heart",
258
420
  event: "heart.sync_push_error",
@@ -262,13 +424,9 @@ function postTurnPush(agentRoot, config) {
262
424
  return { ok: false, error };
263
425
  }
264
426
  if (statusOutput.length === 0) {
265
- (0, runtime_1.emitNervesEvent)({
266
- component: "heart",
267
- event: "heart.sync_push_end",
268
- message: "post-turn push: no changes to sync",
269
- meta: { agentRoot },
270
- });
271
- return { ok: true };
427
+ // Nothing to commit, so nothing is skipped for size any more.
428
+ writeSkippedSyncFiles(agentRoot, []);
429
+ return syncCleanTree(agentRoot, config);
272
430
  }
273
431
  const changedCount = statusOutput.split("\n").length;
274
432
  clearStaleIndexLock(agentRoot);
@@ -276,116 +434,179 @@ function postTurnPush(agentRoot, config) {
276
434
  (0, child_process_1.execFileSync)("git", ["add", "-A"], {
277
435
  cwd: agentRoot,
278
436
  stdio: "pipe",
279
- timeout: 10000,
437
+ timeout: exports.SYNC_GIT_TIMEOUT_MS,
438
+ maxBuffer: exports.SYNC_GIT_MAX_BUFFER_BYTES,
280
439
  });
281
- (0, child_process_1.execFileSync)("git", ["commit", "-m", "sync: post-turn update"], {
440
+ const skippedFiles = unstageOversizedFiles(agentRoot);
441
+ writeSkippedSyncFiles(agentRoot, skippedFiles);
442
+ if (skippedFiles.length > 0 && !hasStagedChanges(agentRoot)) {
443
+ (0, runtime_1.emitNervesEvent)({
444
+ component: "heart",
445
+ event: "heart.sync_push_end",
446
+ message: "post-turn push: only oversized files changed, nothing committed",
447
+ meta: { agentRoot, changedCount, skippedCount: skippedFiles.length },
448
+ });
449
+ return syncCleanTree(agentRoot, config);
450
+ }
451
+ // -q: without it git prints a create/delete line per file, which overran
452
+ // the output buffer on a 30,000-file commit after the commit had landed.
453
+ (0, child_process_1.execFileSync)("git", ["commit", "-q", "-m", "sync: post-turn update"], {
282
454
  cwd: agentRoot,
283
455
  stdio: "pipe",
284
- timeout: 10000,
456
+ timeout: exports.SYNC_GIT_TIMEOUT_MS,
457
+ maxBuffer: exports.SYNC_GIT_MAX_BUFFER_BYTES,
285
458
  });
286
- // Check if a remote exists
287
- const remoteOutput = (0, child_process_1.execFileSync)("git", ["remote"], {
288
- cwd: agentRoot,
289
- stdio: "pipe",
290
- timeout: 5000,
291
- }).toString().trim();
292
- if (remoteOutput.length === 0) {
459
+ if (!hasRemote(agentRoot)) {
293
460
  (0, runtime_1.emitNervesEvent)({
294
461
  component: "heart",
295
462
  event: "heart.sync_push_end",
296
463
  message: "post-turn push: committed locally, no remote configured",
297
464
  meta: { agentRoot, changedCount },
298
465
  });
466
+ clearPendingSync(agentRoot);
299
467
  return { ok: true };
300
468
  }
469
+ return pushWithRebaseRetry(agentRoot, config, changedCount);
470
+ }
471
+ catch (err) {
472
+ const error = err instanceof Error ? err.message : String(err);
473
+ // Record it so the start-of-turn packet surfaces the failure; otherwise a
474
+ // failing local commit is visible only in nerves logs.
475
+ recordPendingSync(agentRoot, error, "unknown", []);
476
+ (0, runtime_1.emitNervesEvent)({
477
+ component: "heart",
478
+ event: "heart.sync_push_error",
479
+ message: "post-turn push failed",
480
+ meta: { agentRoot, error },
481
+ });
482
+ return { ok: false, error };
483
+ }
484
+ }
485
+ function hasRemote(agentRoot) {
486
+ return (0, child_process_1.execFileSync)("git", ["remote"], {
487
+ cwd: agentRoot,
488
+ stdio: "pipe",
489
+ timeout: 5000,
490
+ }).toString().trim().length > 0;
491
+ }
492
+ /**
493
+ * Nothing new to commit. A failure record must not outlive the problem, and
494
+ * commits left behind by an earlier failed push still need to reach the
495
+ * remote: with no remote, or nothing ahead of the upstream, the record is
496
+ * cleared; with unpushed commits, the push is retried.
497
+ */
498
+ function syncCleanTree(agentRoot, config) {
499
+ try {
500
+ if (!hasRemote(agentRoot)) {
501
+ clearPendingSync(agentRoot);
502
+ }
503
+ else {
504
+ const ahead = Number.parseInt((0, child_process_1.execFileSync)("git", ["rev-list", "--count", "@{upstream}..HEAD"], {
505
+ cwd: agentRoot,
506
+ stdio: "pipe",
507
+ timeout: 5000,
508
+ }).toString().trim(), 10);
509
+ if (ahead > 0)
510
+ return pushWithRebaseRetry(agentRoot, config, 0);
511
+ if (ahead === 0)
512
+ clearPendingSync(agentRoot);
513
+ }
514
+ }
515
+ catch (err) {
516
+ // No upstream tracked (or git failed): nothing to compare against, so
517
+ // leave any failure record as it is rather than guess.
518
+ (0, runtime_1.emitNervesEvent)({
519
+ component: "heart",
520
+ event: "heart.sync_upstream_unknown",
521
+ message: "post-turn push: could not compare with upstream",
522
+ meta: { agentRoot, error: err instanceof Error ? err.message : /* v8 ignore next -- defensive non-Error catch @preserve */ String(err) },
523
+ });
524
+ }
525
+ (0, runtime_1.emitNervesEvent)({
526
+ component: "heart",
527
+ event: "heart.sync_push_end",
528
+ message: "post-turn push: no changes to sync",
529
+ meta: { agentRoot },
530
+ });
531
+ return { ok: true };
532
+ }
533
+ function pushWithRebaseRetry(agentRoot, config, changedCount) {
534
+ try {
535
+ (0, child_process_1.execFileSync)("git", ["push", config.remote], {
536
+ cwd: agentRoot,
537
+ stdio: "pipe",
538
+ timeout: exports.SYNC_GIT_TIMEOUT_MS,
539
+ maxBuffer: exports.SYNC_GIT_MAX_BUFFER_BYTES,
540
+ });
541
+ }
542
+ catch {
543
+ // Push rejected -- try pull-rebase-push. --autostash: an oversized file
544
+ // the size guard left uncommitted is an unstaged change, and a plain
545
+ // rebase refuses to start while one exists.
546
+ let rebaseError = null;
301
547
  try {
302
- (0, child_process_1.execFileSync)("git", ["push", config.remote], {
548
+ (0, child_process_1.execFileSync)("git", ["pull", "--rebase", "--autostash", config.remote], {
303
549
  cwd: agentRoot,
304
550
  stdio: "pipe",
305
- timeout: 30000,
551
+ timeout: exports.SYNC_GIT_TIMEOUT_MS,
552
+ maxBuffer: exports.SYNC_GIT_MAX_BUFFER_BYTES,
306
553
  });
307
554
  }
308
- catch {
309
- // Push rejected -- try pull-rebase-push
310
- let rebaseError = null;
555
+ catch (err) {
556
+ rebaseError = err instanceof Error ? err.message : String(err);
557
+ }
558
+ if (rebaseError === null) {
311
559
  try {
312
- (0, child_process_1.execFileSync)("git", ["pull", "--rebase", config.remote], {
560
+ (0, child_process_1.execFileSync)("git", ["push", config.remote], {
313
561
  cwd: agentRoot,
314
562
  stdio: "pipe",
315
- timeout: 30000,
563
+ timeout: exports.SYNC_GIT_TIMEOUT_MS,
564
+ maxBuffer: exports.SYNC_GIT_MAX_BUFFER_BYTES,
316
565
  });
566
+ // rebase + retry push both succeeded — fall through to success
567
+ (0, runtime_1.emitNervesEvent)({
568
+ component: "heart",
569
+ event: "heart.sync_push_end",
570
+ message: "post-turn push complete after rebase retry",
571
+ meta: { agentRoot, changedCount },
572
+ });
573
+ clearPendingSync(agentRoot);
574
+ return { ok: true };
317
575
  }
318
- catch (err) {
319
- rebaseError = err instanceof Error ? err.message : String(err);
320
- }
321
- if (rebaseError === null) {
322
- try {
323
- (0, child_process_1.execFileSync)("git", ["push", config.remote], {
324
- cwd: agentRoot,
325
- stdio: "pipe",
326
- timeout: 30000,
327
- });
328
- // rebase + retry push both succeeded — fall through to success
329
- (0, runtime_1.emitNervesEvent)({
330
- component: "heart",
331
- event: "heart.sync_push_end",
332
- message: "post-turn push complete after rebase retry",
333
- meta: { agentRoot, changedCount },
334
- });
335
- return { ok: true };
336
- }
337
- catch (retryErr) {
338
- // Second push rejected — remote advanced again during rebase
339
- const retryError = retryErr instanceof Error ? retryErr.message : /* v8 ignore next -- defensive non-Error catch @preserve */ String(retryErr);
340
- writePendingSync(agentRoot, retryError, "push_rejected", []);
341
- (0, runtime_1.emitNervesEvent)({
342
- component: "heart",
343
- event: "heart.sync_push_error",
344
- message: "post-turn push failed after retry: push_rejected",
345
- meta: { agentRoot, error: retryError, classification: "push_rejected" },
346
- });
347
- return { ok: false, error: retryError };
348
- }
576
+ catch (retryErr) {
577
+ // Second push rejected — remote advanced again during rebase
578
+ const retryError = retryErr instanceof Error ? retryErr.message : /* v8 ignore next -- defensive non-Error catch @preserve */ String(retryErr);
579
+ recordPendingSync(agentRoot, retryError, "push_rejected", []);
580
+ (0, runtime_1.emitNervesEvent)({
581
+ component: "heart",
582
+ event: "heart.sync_push_error",
583
+ message: "post-turn push failed after retry: push_rejected",
584
+ meta: { agentRoot, error: retryError, classification: "push_rejected" },
585
+ });
586
+ return { ok: false, error: retryError };
349
587
  }
350
- // Rebase failed — detect conflict files via git status. Preserve
351
- // the original rebase error message so callers see the real cause.
352
- const conflictFiles = collectRebaseConflictFiles(agentRoot);
353
- const classification = conflictFiles.length > 0 ? "pull_rebase_conflict" : "unknown";
354
- writePendingSync(agentRoot, rebaseError, classification, conflictFiles);
355
- (0, runtime_1.emitNervesEvent)({
356
- component: "heart",
357
- event: "heart.sync_push_error",
358
- message: `post-turn push failed: ${classification}`,
359
- meta: { agentRoot, error: rebaseError, classification, conflictFiles },
360
- });
361
- return { ok: false, error: rebaseError };
362
- }
363
- (0, runtime_1.emitNervesEvent)({
364
- component: "heart",
365
- event: "heart.sync_push_end",
366
- message: "post-turn push complete",
367
- meta: { agentRoot, changedCount },
368
- });
369
- return { ok: true };
370
- }
371
- catch (err) {
372
- const error = err instanceof Error ? err.message : String(err);
373
- // Record it so the start-of-turn packet surfaces the failure; otherwise a
374
- // failing local commit is visible only in nerves logs.
375
- try {
376
- writePendingSync(agentRoot, error, "unknown", []);
377
- }
378
- catch {
379
- // Best effort: the nerves event below still records the failure.
380
588
  }
589
+ // Rebase failed — detect conflict files via git status. Preserve
590
+ // the original rebase error message so callers see the real cause.
591
+ const conflictFiles = collectRebaseConflictFiles(agentRoot);
592
+ const classification = conflictFiles.length > 0 ? "pull_rebase_conflict" : "unknown";
593
+ recordPendingSync(agentRoot, rebaseError, classification, conflictFiles);
381
594
  (0, runtime_1.emitNervesEvent)({
382
595
  component: "heart",
383
596
  event: "heart.sync_push_error",
384
- message: "post-turn push failed",
385
- meta: { agentRoot, error },
597
+ message: `post-turn push failed: ${classification}`,
598
+ meta: { agentRoot, error: rebaseError, classification, conflictFiles },
386
599
  });
387
- return { ok: false, error };
600
+ return { ok: false, error: rebaseError };
388
601
  }
602
+ (0, runtime_1.emitNervesEvent)({
603
+ component: "heart",
604
+ event: "heart.sync_push_end",
605
+ message: "post-turn push complete",
606
+ meta: { agentRoot, changedCount },
607
+ });
608
+ clearPendingSync(agentRoot);
609
+ return { ok: true };
389
610
  }
390
611
  /**
391
612
  * Layer 2 — async, signal-aware sibling of `preTurnPull`.
@@ -79,6 +79,7 @@ const path = __importStar(require("path"));
79
79
  const runtime_1 = require("../nerves/runtime");
80
80
  const identity_1 = require("../heart/identity");
81
81
  const bundle_state_1 = require("../heart/bundle-state");
82
+ const sync_1 = require("../heart/sync");
82
83
  const bundle_templates_1 = require("./bundle-templates");
83
84
  function gitExec(bundleRoot, args, timeoutMs = 10000) {
84
85
  try {
@@ -778,6 +779,7 @@ function makePushHandler(deps = {}) {
778
779
  const push = gitExec(bundleRoot, ["push", remote, "HEAD"], 30000);
779
780
  /* v8 ignore start -- push success branch requires a reachable remote, not practical to cover in unit tests; failure branch covered by network-failure test @preserve */
780
781
  if (push.code === 0) {
782
+ (0, sync_1.clearPendingSync)(bundleRoot);
781
783
  return finish(true, { remote, firstPush: isFirstPush });
782
784
  }
783
785
  /* v8 ignore stop */
@@ -63,6 +63,7 @@ const start_of_turn_packet_1 = require("../heart/start-of-turn-packet");
63
63
  const bundle_state_1 = require("../heart/bundle-state");
64
64
  const sync_1 = require("../heart/sync");
65
65
  const agent_discovery_1 = require("../heart/daemon/agent-discovery");
66
+ const sync_health_1 = require("../heart/sync-health");
66
67
  const config_1 = require("../heart/config");
67
68
  const session_events_1 = require("../heart/session-events");
68
69
  const session_activity_1 = require("../heart/session-activity");
@@ -754,14 +755,16 @@ async function handleInboundTurnExclusive(input) {
754
755
  if (!pullResult.ok) {
755
756
  syncFailure = pullResult.error;
756
757
  }
757
- // Check for pending-sync from a prior failed push
758
+ // Check for pending-sync from a prior failed push. The record stays
759
+ // until a sync succeeds, so every turn sees a failure that persists.
758
760
  if (!syncFailure) {
759
761
  const pendingSyncPath = path.join((0, identity_1.getAgentRoot)(), "state", "pending-sync.json");
760
762
  try {
761
763
  if (fs.existsSync(pendingSyncPath)) {
762
- const pendingSync = JSON.parse(fs.readFileSync(pendingSyncPath, "utf-8"));
763
- syncFailure = `prior sync push failed: ${pendingSync.error ?? "unknown"}`;
764
- fs.unlinkSync(pendingSyncPath);
764
+ const failure = (0, sync_health_1.parseBundleSyncFailure)(JSON.parse(fs.readFileSync(pendingSyncPath, "utf-8")));
765
+ syncFailure = failure
766
+ ? `bundle ${(0, sync_health_1.describeSyncFailure)(failure)}`
767
+ : "bundle sync failed earlier; state/pending-sync.json is unreadable";
765
768
  }
766
769
  }
767
770
  catch {
@@ -218,17 +218,21 @@ function createHerdrHost(connection, options = {}) {
218
218
  stream?.close();
219
219
  return;
220
220
  }
221
+ // Agent-status subscriptions name each pane, so a new or closed pane needs a new subscription. Herdr sends a new
222
+ // pane as `data.pane` with no top-level `pane_id`; the fresh pane list after resubscribing picks it up.
223
+ if (name === "pane_created") {
224
+ refresh = true;
225
+ stream?.close();
226
+ return;
227
+ }
221
228
  const paneId = str(data.pane_id);
222
229
  if (!paneId)
223
230
  return;
224
231
  if (name === "pane_focused")
225
232
  return handlers.focused(paneId);
226
- if (name === "pane_created" || name === "pane_closed") {
227
- if (name === "pane_closed") {
228
- cancelPending(paneId);
229
- status.delete(paneId);
230
- }
231
- // Agent-status subscriptions name each pane, so a new or closed pane needs a new subscription.
233
+ if (name === "pane_closed") {
234
+ cancelPending(paneId);
235
+ status.delete(paneId);
232
236
  refresh = true;
233
237
  stream?.close();
234
238
  return;
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.900",
3
+ "version": "0.1.0-alpha.903",
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.900",
9
+ "version": "0.1.0-alpha.903",
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.900",
3
+ "version": "0.1.0-alpha.903",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },