@ouro.bot/cli 0.1.0-alpha.900 → 0.1.0-alpha.901
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 +6 -0
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
- package/deploy/unraid/sanctuary.xml +1 -1
- package/dist/heart/context-loss-sentinel.js +2 -0
- package/dist/heart/daemon/agent-discovery.js +6 -0
- package/dist/heart/daemon/cli-render.js +15 -0
- package/dist/heart/daemon/daemon-entry.js +2 -0
- package/dist/heart/daemon/health-monitor.js +12 -0
- package/dist/heart/sync-health.js +152 -0
- package/dist/heart/sync.js +314 -93
- package/dist/repertoire/tools-bundle.js +2 -0
- package/dist/senses/pipeline.js +7 -4
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
package/changelog.json
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
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.901",
|
|
6
|
+
"changes": [
|
|
7
|
+
"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."
|
|
8
|
+
]
|
|
9
|
+
},
|
|
4
10
|
{
|
|
5
11
|
"version": "0.1.0-alpha.900",
|
|
6
12
|
"changes": [
|
|
@@ -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.
|
|
4
|
+
<Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.901</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
|
+
}
|
package/dist/heart/sync.js
CHANGED
|
@@ -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
|
|
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
|
|
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:
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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:
|
|
437
|
+
timeout: exports.SYNC_GIT_TIMEOUT_MS,
|
|
438
|
+
maxBuffer: exports.SYNC_GIT_MAX_BUFFER_BYTES,
|
|
280
439
|
});
|
|
281
|
-
|
|
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:
|
|
456
|
+
timeout: exports.SYNC_GIT_TIMEOUT_MS,
|
|
457
|
+
maxBuffer: exports.SYNC_GIT_MAX_BUFFER_BYTES,
|
|
285
458
|
});
|
|
286
|
-
|
|
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", ["
|
|
548
|
+
(0, child_process_1.execFileSync)("git", ["pull", "--rebase", "--autostash", config.remote], {
|
|
303
549
|
cwd: agentRoot,
|
|
304
550
|
stdio: "pipe",
|
|
305
|
-
timeout:
|
|
551
|
+
timeout: exports.SYNC_GIT_TIMEOUT_MS,
|
|
552
|
+
maxBuffer: exports.SYNC_GIT_MAX_BUFFER_BYTES,
|
|
306
553
|
});
|
|
307
554
|
}
|
|
308
|
-
catch {
|
|
309
|
-
|
|
310
|
-
|
|
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", ["
|
|
560
|
+
(0, child_process_1.execFileSync)("git", ["push", config.remote], {
|
|
313
561
|
cwd: agentRoot,
|
|
314
562
|
stdio: "pipe",
|
|
315
|
-
timeout:
|
|
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 (
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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:
|
|
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 */
|
package/dist/senses/pipeline.js
CHANGED
|
@@ -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
|
|
763
|
-
syncFailure =
|
|
764
|
-
|
|
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 {
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ouro.bot/cli",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.901",
|
|
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.
|
|
9
|
+
"version": "0.1.0-alpha.901",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@anthropic-ai/sdk": "^0.78.0",
|
|
12
12
|
"@azure/identity": "^4.13.0",
|