@ouro.bot/cli 0.1.0-alpha.990 → 0.1.0-alpha.992
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 +12 -0
- package/deploy/unraid/Dockerfile +15 -2
- package/deploy/unraid/README.txt +44 -9
- package/deploy/unraid/github_known_hosts +3 -0
- package/deploy/unraid/ouro-sync-ssh +98 -0
- package/deploy/unraid/ouro-sync.sudoers +3 -0
- package/deploy/unraid/sanctuary-butler-upgrade.mjs +26 -2
- package/deploy/unraid/sanctuary-replay-gate.mjs +361 -8
- package/deploy/unraid/sanctuary-sync-cutover.sh +1039 -0
- package/deploy/unraid/sanctuary.ouro/agent.json +2 -1
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
- package/deploy/unraid/sanctuary.ouro/tool-profiles.json +3 -1
- package/deploy/unraid/sanctuary.xml +2 -1
- package/dist/heart/config.js +2 -1
- package/dist/heart/daemon/agent-discovery.js +5 -1
- package/dist/heart/daemon/agent-home.js +67 -31
- package/dist/heart/daemon/boot-sync-probe.js +36 -10
- package/dist/heart/daemon/cli-exec.js +79 -20
- package/dist/heart/daemon/cli-help.js +12 -0
- package/dist/heart/daemon/cli-parse.js +14 -0
- package/dist/heart/daemon/container-spec-auditor-main.js +22 -6
- package/dist/heart/daemon/container-spec-auditor.js +18 -2
- package/dist/heart/daemon/daemon-entry.js +22 -0
- package/dist/heart/daemon/sanctuary-acceptance-harness.js +1 -1
- package/dist/heart/daemon/sanctuary-authority-root-lifecycle.js +21 -5
- package/dist/heart/daemon/sanctuary-bundle-migration.js +97 -1
- package/dist/heart/daemon/sanctuary-package-management.js +10 -0
- package/dist/heart/sync-lock.js +431 -0
- package/dist/heart/sync.js +1252 -399
- package/dist/mind/prompt.js +92 -19
- package/dist/repertoire/guardrails.js +285 -3
- package/dist/repertoire/tools-bundle.js +107 -185
- package/dist/repertoire/tools.js +5 -1
- package/dist/senses/pipeline.js +37 -3
- package/dist/senses/private-runtime.js +1 -6
- package/dist/senses/voice/twilio-phone.js +16 -16
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
package/dist/heart/sync.js
CHANGED
|
@@ -33,18 +33,42 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
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;
|
|
36
|
+
exports.NOT_A_REPO_ERROR = exports.STALE_INDEX_LOCK_MS = exports.SYNC_KILL_PROBE_MS = exports.SYNC_PAUSE_MAX_MS = exports.__testHooks = exports.PRE_TURN_LOCK_WAIT_MS = exports.OWNER_WINS_MESSAGE = exports.SYNC_SSH_COMMAND = exports.SYNC_SECRET_DIR = exports.SYNC_KILL_GRACE_MS = exports.SYNC_PUSH_TIMEOUT_MS = exports.SYNC_FETCH_TIMEOUT_MS = exports.SYNC_GIT_TIMEOUT_MS = exports.SYNC_GIT_MAX_BUFFER_BYTES = exports.SYNC_FILE_SIZE_LIMIT_BYTES = exports.FIRST_PUSH_HELD_NOTICE = void 0;
|
|
37
|
+
exports.syncSecretDir = syncSecretDir;
|
|
37
38
|
exports.pendingSyncPath = pendingSyncPath;
|
|
38
39
|
exports.skippedSyncFilesPath = skippedSyncFilesPath;
|
|
40
|
+
exports.syncPausedPath = syncPausedPath;
|
|
41
|
+
exports.firstPushHeldPath = firstPushHeldPath;
|
|
42
|
+
exports.agentNameFromRoot = agentNameFromRoot;
|
|
43
|
+
exports.syncNetworkEnv = syncNetworkEnv;
|
|
44
|
+
exports.syncGroupExists = syncGroupExists;
|
|
45
|
+
exports.abortInFlightSync = abortInFlightSync;
|
|
39
46
|
exports.clearPendingSync = clearPendingSync;
|
|
47
|
+
exports.setSyncOwnerNotifier = setSyncOwnerNotifier;
|
|
48
|
+
exports.tellOwner = tellOwner;
|
|
49
|
+
exports.commitLocalChanges = commitLocalChanges;
|
|
50
|
+
exports.integrateFetched = integrateFetched;
|
|
51
|
+
exports.fetchRemote = fetchRemote;
|
|
52
|
+
exports.listRemoteRefs = listRemoteRefs;
|
|
53
|
+
exports.pushAsync = pushAsync;
|
|
54
|
+
exports.syncPausedUntil = syncPausedUntil;
|
|
55
|
+
exports.syncTrackingRef = syncTrackingRef;
|
|
56
|
+
exports.holdSyncLock = holdSyncLock;
|
|
57
|
+
exports.pushHeadToBranch = pushHeadToBranch;
|
|
58
|
+
exports.probeSync = probeSync;
|
|
40
59
|
exports.preTurnPull = preTurnPull;
|
|
41
|
-
exports.postTurnPush = postTurnPush;
|
|
42
60
|
exports.preTurnPullAsync = preTurnPullAsync;
|
|
61
|
+
exports.postTurnPush = postTurnPush;
|
|
62
|
+
exports.syncNow = syncNow;
|
|
43
63
|
const child_process_1 = require("child_process");
|
|
64
|
+
const crypto_1 = require("crypto");
|
|
44
65
|
const fs = __importStar(require("fs"));
|
|
45
66
|
const path = __importStar(require("path"));
|
|
46
67
|
const runtime_1 = require("../nerves/runtime");
|
|
47
68
|
const sync_health_1 = require("./sync-health");
|
|
69
|
+
const sync_lock_1 = require("./sync-lock");
|
|
70
|
+
/** What sync tells the owner, once, when it holds off a first push to an empty remote. */
|
|
71
|
+
exports.FIRST_PUSH_HELD_NOTICE = "My bundle's GitHub repo is empty. Before I push to it for the first time, please look over what would go up and tell me yes.";
|
|
48
72
|
/**
|
|
49
73
|
* Sync never commits a file larger than this. GitHub rejects any push that
|
|
50
74
|
* contains a file over 100 MiB and warns above 50 MiB; one such file in an
|
|
@@ -59,16 +83,351 @@ exports.SYNC_FILE_SIZE_LIMIT_BYTES = 50 * 1024 * 1024;
|
|
|
59
83
|
*/
|
|
60
84
|
exports.SYNC_GIT_MAX_BUFFER_BYTES = 256 * 1024 * 1024;
|
|
61
85
|
/**
|
|
62
|
-
* Timeout for sync's add, commit,
|
|
63
|
-
* bundle with 30,000 changed files, past the old 10 s limit.
|
|
86
|
+
* Timeout for sync's local git steps (status, add, commit, merge). `git add -A`
|
|
87
|
+
* took 17 s on a bundle with 30,000 changed files, past the old 10 s limit.
|
|
64
88
|
*/
|
|
65
89
|
exports.SYNC_GIT_TIMEOUT_MS = 120_000;
|
|
90
|
+
/** Network steps run as detached children, killed by process group at these limits. */
|
|
91
|
+
exports.SYNC_FETCH_TIMEOUT_MS = 10_000;
|
|
92
|
+
exports.SYNC_PUSH_TIMEOUT_MS = 60_000;
|
|
93
|
+
/**
|
|
94
|
+
* Between the SIGTERM and the SIGKILL of a timed-out network step. In the
|
|
95
|
+
* container ssh runs as ouro-sync, which the agent's uid cannot signal; sudo
|
|
96
|
+
* can, and relays the SIGTERM to it.
|
|
97
|
+
*/
|
|
98
|
+
exports.SYNC_KILL_GRACE_MS = 2_000;
|
|
99
|
+
/**
|
|
100
|
+
* The container's read-only mount for the sync deploy key. The key is owned by
|
|
101
|
+
* ouro-sync (10002) mode 0400, so the agent's uid cannot read it.
|
|
102
|
+
*/
|
|
103
|
+
exports.SYNC_SECRET_DIR = "/run/ouro-sync";
|
|
104
|
+
/**
|
|
105
|
+
* git's ssh in the container: the root-owned wrapper, run as ouro-sync, is the
|
|
106
|
+
* only thing that reads the key. It accepts only git's call for one GitHub repo.
|
|
107
|
+
*/
|
|
108
|
+
exports.SYNC_SSH_COMMAND = "sudo -n -u ouro-sync /usr/local/libexec/ouro-sync-ssh";
|
|
109
|
+
/** The deploy key directory: SYNC_SECRET_DIR, unless a test points it elsewhere. */
|
|
110
|
+
function syncSecretDir() {
|
|
111
|
+
return exports.__testHooks.syncSecretDir ?? exports.SYNC_SECRET_DIR;
|
|
112
|
+
}
|
|
113
|
+
/** Message of the merge commit in which the owner's psyche edits win. */
|
|
114
|
+
exports.OWNER_WINS_MESSAGE = "sync: owner's psyche edit wins";
|
|
115
|
+
/**
|
|
116
|
+
* How long `preTurnPull` waits for the bundle lock before skipping. A turn
|
|
117
|
+
* skips rather than queues: the next turn integrates anyway.
|
|
118
|
+
*/
|
|
119
|
+
exports.PRE_TURN_LOCK_WAIT_MS = 5_000;
|
|
120
|
+
/**
|
|
121
|
+
* Test seams.
|
|
122
|
+
* @internal Not part of the public API; production code must not set these.
|
|
123
|
+
*/
|
|
124
|
+
exports.__testHooks = {};
|
|
125
|
+
/** A pause marker longer than this is garbage: a crashed pauser must not stop sync for good. */
|
|
126
|
+
exports.SYNC_PAUSE_MAX_MS = 24 * 60 * 60 * 1000;
|
|
66
127
|
function pendingSyncPath(agentRoot) {
|
|
67
128
|
return path.join(agentRoot, "state", "pending-sync.json");
|
|
68
129
|
}
|
|
69
130
|
function skippedSyncFilesPath(agentRoot) {
|
|
70
131
|
return path.join(agentRoot, "state", "sync-skipped-files.json");
|
|
71
132
|
}
|
|
133
|
+
function syncPausedPath(agentRoot) {
|
|
134
|
+
return path.join(agentRoot, "state", "sync-paused");
|
|
135
|
+
}
|
|
136
|
+
/** Present once the owner has been told a first push is held, so they are told only once. */
|
|
137
|
+
function firstPushHeldPath(agentRoot) {
|
|
138
|
+
return path.join(agentRoot, "state", "sync-first-push-held.json");
|
|
139
|
+
}
|
|
140
|
+
function syncConflictStatePath(agentRoot) {
|
|
141
|
+
return path.join(agentRoot, "state", "sync-conflict.json");
|
|
142
|
+
}
|
|
143
|
+
function agentNameFromRoot(agentRoot) {
|
|
144
|
+
return path.basename(agentRoot).replace(/\.ouro$/, "");
|
|
145
|
+
}
|
|
146
|
+
function branchOf(config) {
|
|
147
|
+
return config.branch ?? "main";
|
|
148
|
+
}
|
|
149
|
+
function errorText(err) {
|
|
150
|
+
return err instanceof Error ? err.message : String(err);
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Flags on every call. The identity: a container or an empty HOME has no
|
|
154
|
+
* gitconfig. The rest: the owner's global gitconfig on the Mac must not
|
|
155
|
+
* stash, replay recorded resolutions, run hooks or demand signatures.
|
|
156
|
+
*/
|
|
157
|
+
function identityArgs(agentName) {
|
|
158
|
+
return [
|
|
159
|
+
"-c", `user.name=${agentName}`,
|
|
160
|
+
"-c", `user.email=${agentName}@ouro.local`,
|
|
161
|
+
"-c", "commit.gpgsign=false",
|
|
162
|
+
"-c", "merge.autoStash=false",
|
|
163
|
+
"-c", "rerere.enabled=false",
|
|
164
|
+
"-c", "core.hooksPath=/dev/null",
|
|
165
|
+
"-c", "merge.verifySignatures=false",
|
|
166
|
+
"-c", "core.fsmonitor=false",
|
|
167
|
+
...(exports.__testHooks.extraGitConfig ?? []),
|
|
168
|
+
];
|
|
169
|
+
}
|
|
170
|
+
/** git's messages are matched in English, so the locale is pinned. */
|
|
171
|
+
function gitEnv(extra = {}) {
|
|
172
|
+
return { ...process.env, GIT_TERMINAL_PROMPT: "0", LC_ALL: "C", LANG: "C", ...extra };
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* The environment of a fetch or push. When the container has the key mount,
|
|
176
|
+
* git's ssh is the sync wrapper under sudo. `GIT_SSH_VARIANT=ssh` stops git
|
|
177
|
+
* probing an unknown "sudo" with `-G`. Without the mount (the Mac, or before
|
|
178
|
+
* the cutover) ssh is left to the host's own setup.
|
|
179
|
+
*/
|
|
180
|
+
function syncNetworkEnv() {
|
|
181
|
+
if (!fs.existsSync(syncSecretDir()))
|
|
182
|
+
return gitEnv();
|
|
183
|
+
return gitEnv({ GIT_SSH_COMMAND: exports.SYNC_SSH_COMMAND, GIT_SSH_VARIANT: "ssh" });
|
|
184
|
+
}
|
|
185
|
+
function runGit(agentRoot, agentName, args, env, input) {
|
|
186
|
+
const run = (0, child_process_1.spawnSync)("git", [...identityArgs(agentName), ...args], {
|
|
187
|
+
cwd: agentRoot,
|
|
188
|
+
env: gitEnv(env),
|
|
189
|
+
input,
|
|
190
|
+
stdio: [input === undefined ? "ignore" : "pipe", "pipe", "pipe"],
|
|
191
|
+
timeout: exports.SYNC_GIT_TIMEOUT_MS,
|
|
192
|
+
maxBuffer: exports.SYNC_GIT_MAX_BUFFER_BYTES,
|
|
193
|
+
});
|
|
194
|
+
/* v8 ignore next -- spawn failures (ENOENT, timeout, ENOBUFS) need a broken host @preserve */
|
|
195
|
+
if (run.error)
|
|
196
|
+
return { status: null, stdout: "", stderr: run.error.message };
|
|
197
|
+
return { status: run.status, stdout: run.stdout.toString(), stderr: run.stderr.toString() };
|
|
198
|
+
}
|
|
199
|
+
function gitFailure(args, run) {
|
|
200
|
+
return new Error(`git ${args[0]} failed: ${(run.stderr || run.stdout).trim()}`);
|
|
201
|
+
}
|
|
202
|
+
/** Run git and return stdout; throw with git's message on a non-zero exit. */
|
|
203
|
+
function git(agentRoot, agentName, args, env, input) {
|
|
204
|
+
const run = runGit(agentRoot, agentName, args, env, input);
|
|
205
|
+
if (run.status !== 0)
|
|
206
|
+
throw gitFailure(args, run);
|
|
207
|
+
return run.stdout;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Run a git command over a list of paths, passed NUL-separated on stdin and
|
|
211
|
+
* matched literally. A list in argv dies with E2BIG past about 1 MB.
|
|
212
|
+
*/
|
|
213
|
+
function gitOnPaths(agentRoot, agentName, args, paths) {
|
|
214
|
+
return git(agentRoot, agentName, ["--literal-pathspecs", ...args, "--pathspec-from-file=-", "--pathspec-file-nul"], undefined, paths.join("\0"));
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* A network git call. It runs detached, in its own process group, so a timeout
|
|
218
|
+
* or an abort ends git and everything it started (ssh, a credential helper):
|
|
219
|
+
* a lone `kill` of git leaves ssh holding the pipes open. The group gets
|
|
220
|
+
* SIGTERM, which sudo relays to the ssh it runs, then SIGKILL after a grace.
|
|
221
|
+
*/
|
|
222
|
+
function runGitAsync(agentRoot, agentName, args, timeoutMs, signal) {
|
|
223
|
+
return new Promise((resolve) => {
|
|
224
|
+
const child = (0, child_process_1.spawn)("git", [...identityArgs(agentName), ...args], {
|
|
225
|
+
cwd: agentRoot,
|
|
226
|
+
env: syncNetworkEnv(),
|
|
227
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
228
|
+
detached: true,
|
|
229
|
+
});
|
|
230
|
+
let stdout = "";
|
|
231
|
+
let stderr = "";
|
|
232
|
+
let timedOut = false;
|
|
233
|
+
let aborted = false;
|
|
234
|
+
let settled = false;
|
|
235
|
+
const pid = child.pid;
|
|
236
|
+
inFlightChildren.add(pid);
|
|
237
|
+
child.stdout.on("data", (chunk) => { stdout += chunk.toString(); });
|
|
238
|
+
child.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
|
|
239
|
+
const killGroup = () => killSyncGroup(pid);
|
|
240
|
+
const onAbort = () => {
|
|
241
|
+
aborted = true;
|
|
242
|
+
killGroup();
|
|
243
|
+
};
|
|
244
|
+
const timer = setTimeout(() => {
|
|
245
|
+
timedOut = true;
|
|
246
|
+
killGroup();
|
|
247
|
+
}, timeoutMs);
|
|
248
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
249
|
+
const finish = (status, extra = "") => {
|
|
250
|
+
/* v8 ignore next -- only a spawn error followed by close settles twice @preserve */
|
|
251
|
+
if (settled)
|
|
252
|
+
return;
|
|
253
|
+
settled = true;
|
|
254
|
+
inFlightChildren.delete(pid);
|
|
255
|
+
clearTimeout(timer);
|
|
256
|
+
signal?.removeEventListener("abort", onAbort);
|
|
257
|
+
resolve({ status, stdout, stderr: extra + stderr, timedOut, aborted });
|
|
258
|
+
};
|
|
259
|
+
/* v8 ignore next -- spawning git itself failing needs a host without git @preserve */
|
|
260
|
+
child.on("error", (err) => finish(null, `${err.message}\n`));
|
|
261
|
+
child.on("close", (code) => finish(code));
|
|
262
|
+
if (signal?.aborted)
|
|
263
|
+
onAbort();
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
function signalSyncGroup(pid, name) {
|
|
267
|
+
try {
|
|
268
|
+
process.kill(-pid, name);
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
// The group is already gone.
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
/** How often a killed group is probed during the grace, so its timers stop once it is gone. */
|
|
275
|
+
exports.SYNC_KILL_PROBE_MS = 50;
|
|
276
|
+
/**
|
|
277
|
+
* Whether anything is left in process group `pid`. EPERM means a member this
|
|
278
|
+
* uid cannot signal (the ouro-sync ssh) is still there.
|
|
279
|
+
*/
|
|
280
|
+
function syncGroupExists(pid, kill = process.kill.bind(process)) {
|
|
281
|
+
try {
|
|
282
|
+
kill(-pid, 0);
|
|
283
|
+
return true;
|
|
284
|
+
}
|
|
285
|
+
catch (error) {
|
|
286
|
+
return error.code === "EPERM";
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* SIGTERM to a network git child's process group, then SIGKILL after the
|
|
291
|
+
* grace. sudo relays only the SIGTERM to the ouro-sync ssh, which the agent's
|
|
292
|
+
* uid cannot signal. The timers are not tied to git closing: whatever in the
|
|
293
|
+
* group outlived the SIGTERM still gets the SIGKILL. They stop as soon as a
|
|
294
|
+
* probe finds the group gone, and the SIGKILL goes only to a group that still
|
|
295
|
+
* exists, so a reused group id is never signalled.
|
|
296
|
+
*/
|
|
297
|
+
function killSyncGroup(pid) {
|
|
298
|
+
signalSyncGroup(pid, "SIGTERM");
|
|
299
|
+
const probe = setInterval(() => {
|
|
300
|
+
if (syncGroupExists(pid))
|
|
301
|
+
return;
|
|
302
|
+
clearInterval(probe);
|
|
303
|
+
clearTimeout(kill);
|
|
304
|
+
}, exports.__testHooks.killProbeMs ?? exports.SYNC_KILL_PROBE_MS);
|
|
305
|
+
probe.unref();
|
|
306
|
+
const kill = setTimeout(() => {
|
|
307
|
+
clearInterval(probe);
|
|
308
|
+
if (syncGroupExists(pid))
|
|
309
|
+
signalSyncGroup(pid, "SIGKILL");
|
|
310
|
+
}, exports.__testHooks.killGraceMs ?? exports.SYNC_KILL_GRACE_MS);
|
|
311
|
+
kill.unref();
|
|
312
|
+
}
|
|
313
|
+
// ---------- termination ----------
|
|
314
|
+
/** Process groups of the network git children now running. */
|
|
315
|
+
const inFlightChildren = new Set();
|
|
316
|
+
/** Sync locks this module holds, wrapped by `trackLock`. */
|
|
317
|
+
const heldLocks = new Set();
|
|
318
|
+
const TERMINATION_SIGNALS = ["SIGTERM", "SIGINT"];
|
|
319
|
+
const terminationListeners = {
|
|
320
|
+
SIGTERM: () => onTerminationSignal("SIGTERM"),
|
|
321
|
+
SIGINT: () => onTerminationSignal("SIGINT"),
|
|
322
|
+
};
|
|
323
|
+
let guardInstalled = false;
|
|
324
|
+
/**
|
|
325
|
+
* Kill every network git child's process group and release every sync lock
|
|
326
|
+
* this process holds. The daemon's SIGTERM handler calls it; so do the guard
|
|
327
|
+
* listeners below, for processes (the agent worker) with no handler of their own.
|
|
328
|
+
*/
|
|
329
|
+
function abortInFlightSync() {
|
|
330
|
+
const children = inFlightChildren.size;
|
|
331
|
+
const locks = heldLocks.size;
|
|
332
|
+
if (children === 0 && locks === 0)
|
|
333
|
+
return;
|
|
334
|
+
for (const pid of inFlightChildren)
|
|
335
|
+
killSyncGroup(pid);
|
|
336
|
+
inFlightChildren.clear();
|
|
337
|
+
for (const handle of [...heldLocks])
|
|
338
|
+
handle.release();
|
|
339
|
+
(0, runtime_1.emitNervesEvent)({
|
|
340
|
+
level: "warn",
|
|
341
|
+
component: "heart",
|
|
342
|
+
event: "heart.sync_aborted",
|
|
343
|
+
message: "sync: killed in-flight git and released the bundle lock",
|
|
344
|
+
meta: { children, locks },
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* The guard is prepended, so it runs before every other listener for the
|
|
349
|
+
* signal, `once` wrappers included, and sees them all still registered. It
|
|
350
|
+
* kills sync's git children and releases the locks synchronously, then steps
|
|
351
|
+
* aside: another component's own shutdown runs to completion. Only when no
|
|
352
|
+
* other listener exists does it re-raise, restoring the default exit.
|
|
353
|
+
*/
|
|
354
|
+
function onTerminationSignal(signal) {
|
|
355
|
+
const others = process.rawListeners(signal).filter((listener) => listener !== terminationListeners[signal]);
|
|
356
|
+
abortInFlightSync();
|
|
357
|
+
removeTerminationGuard();
|
|
358
|
+
/* v8 ignore next -- re-raising ends the process; the SIGTERM child-process test covers it @preserve */
|
|
359
|
+
if (others.length === 0)
|
|
360
|
+
process.kill(process.pid, signal);
|
|
361
|
+
}
|
|
362
|
+
function installTerminationGuard() {
|
|
363
|
+
if (guardInstalled)
|
|
364
|
+
return;
|
|
365
|
+
guardInstalled = true;
|
|
366
|
+
for (const signal of TERMINATION_SIGNALS)
|
|
367
|
+
process.prependListener(signal, terminationListeners[signal]);
|
|
368
|
+
process.on("exit", abortInFlightSync);
|
|
369
|
+
}
|
|
370
|
+
function removeTerminationGuard() {
|
|
371
|
+
if (!guardInstalled)
|
|
372
|
+
return;
|
|
373
|
+
guardInstalled = false;
|
|
374
|
+
for (const signal of TERMINATION_SIGNALS)
|
|
375
|
+
process.removeListener(signal, terminationListeners[signal]);
|
|
376
|
+
process.removeListener("exit", abortInFlightSync);
|
|
377
|
+
}
|
|
378
|
+
/** While any lock is held, a signal or an exit kills sync's children and releases the lock. */
|
|
379
|
+
function trackLock(handle) {
|
|
380
|
+
let released = false;
|
|
381
|
+
const tracked = {
|
|
382
|
+
release() {
|
|
383
|
+
if (released)
|
|
384
|
+
return;
|
|
385
|
+
released = true;
|
|
386
|
+
heldLocks.delete(tracked);
|
|
387
|
+
try {
|
|
388
|
+
handle.release();
|
|
389
|
+
}
|
|
390
|
+
finally {
|
|
391
|
+
if (heldLocks.size === 0)
|
|
392
|
+
removeTerminationGuard();
|
|
393
|
+
}
|
|
394
|
+
},
|
|
395
|
+
};
|
|
396
|
+
heldLocks.add(tracked);
|
|
397
|
+
installTerminationGuard();
|
|
398
|
+
return tracked;
|
|
399
|
+
}
|
|
400
|
+
function describeAsyncFailure(step, run, timeoutMs) {
|
|
401
|
+
if (run.timedOut)
|
|
402
|
+
return `git ${step} timed out after ${timeoutMs} ms`;
|
|
403
|
+
if (run.aborted)
|
|
404
|
+
return `git ${step} aborted`;
|
|
405
|
+
return `git ${step} failed: ${run.stderr.trim()}`;
|
|
406
|
+
}
|
|
407
|
+
function hasRemote(agentRoot, agentName, config) {
|
|
408
|
+
return git(agentRoot, agentName, ["remote"]).split("\n").map((line) => line.trim()).includes(config.remote);
|
|
409
|
+
}
|
|
410
|
+
function remoteTrackingRef(config) {
|
|
411
|
+
return `refs/remotes/${config.remote}/${branchOf(config)}`;
|
|
412
|
+
}
|
|
413
|
+
function refExists(agentRoot, agentName, ref) {
|
|
414
|
+
return runGit(agentRoot, agentName, ["rev-parse", "--verify", "--quiet", ref]).status === 0;
|
|
415
|
+
}
|
|
416
|
+
/** Commits on HEAD that the last fetched remote branch lacks. An unborn HEAD has none. */
|
|
417
|
+
function commitsAhead(agentRoot, agentName, config) {
|
|
418
|
+
if (!refExists(agentRoot, agentName, "HEAD"))
|
|
419
|
+
return 0;
|
|
420
|
+
const ref = remoteTrackingRef(config);
|
|
421
|
+
const range = refExists(agentRoot, agentName, ref) ? `${ref}..HEAD` : "HEAD";
|
|
422
|
+
return Number.parseInt(git(agentRoot, agentName, ["rev-list", "--count", range]).trim(), 10);
|
|
423
|
+
}
|
|
424
|
+
function unmergedPaths(agentRoot, agentName) {
|
|
425
|
+
return git(agentRoot, agentName, ["diff", "--name-only", "--diff-filter=U", "-z"]).split("\0").filter((p) => p.length > 0);
|
|
426
|
+
}
|
|
427
|
+
function gitDirEntry(agentRoot, name) {
|
|
428
|
+
return path.join(agentRoot, ".git", name);
|
|
429
|
+
}
|
|
430
|
+
// ---------- persistent records ----------
|
|
72
431
|
function readPreviousFailure(agentRoot) {
|
|
73
432
|
try {
|
|
74
433
|
return (0, sync_health_1.parseBundleSyncFailure)(JSON.parse(fs.readFileSync(pendingSyncPath(agentRoot), "utf-8")));
|
|
@@ -77,7 +436,17 @@ function readPreviousFailure(agentRoot) {
|
|
|
77
436
|
return null;
|
|
78
437
|
}
|
|
79
438
|
}
|
|
80
|
-
|
|
439
|
+
/** The repair ref the pending record names, if any. */
|
|
440
|
+
function pendingRepairRef(agentRoot) {
|
|
441
|
+
try {
|
|
442
|
+
const record = JSON.parse(fs.readFileSync(pendingSyncPath(agentRoot), "utf-8"));
|
|
443
|
+
return typeof record?.repairRef === "string" ? record.repairRef : null;
|
|
444
|
+
}
|
|
445
|
+
catch {
|
|
446
|
+
return null;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
function writePendingSync(agentRoot, error, classification, conflictFiles, repairRef) {
|
|
81
450
|
const previous = readPreviousFailure(agentRoot);
|
|
82
451
|
const failedAt = new Date().toISOString();
|
|
83
452
|
const previousFirst = previous && previous.firstFailedAt !== "unknown" ? previous.firstFailedAt : null;
|
|
@@ -89,12 +458,16 @@ function writePendingSync(agentRoot, error, classification, conflictFiles) {
|
|
|
89
458
|
conflictFiles,
|
|
90
459
|
firstFailedAt: previousFirst ?? failedAt,
|
|
91
460
|
consecutiveFailures: (previous?.consecutiveFailures ?? 0) + 1,
|
|
461
|
+
...(repairRef === undefined ? {} : { repairRef }),
|
|
92
462
|
};
|
|
93
463
|
fs.writeFileSync(pendingSyncPath(agentRoot), JSON.stringify(record, null, 2), "utf-8");
|
|
94
464
|
}
|
|
95
|
-
function recordPendingSync(agentRoot, error, classification, conflictFiles) {
|
|
465
|
+
function recordPendingSync(agentRoot, error, classification, conflictFiles, repairRef) {
|
|
466
|
+
// An incomplete restore outranks later failures: it names the ref holding the agent's work.
|
|
467
|
+
if (classification !== "repair_restore_incomplete" && readPreviousFailure(agentRoot)?.classification === "repair_restore_incomplete")
|
|
468
|
+
return;
|
|
96
469
|
try {
|
|
97
|
-
writePendingSync(agentRoot, error, classification, conflictFiles);
|
|
470
|
+
writePendingSync(agentRoot, error, classification, conflictFiles, repairRef);
|
|
98
471
|
}
|
|
99
472
|
catch {
|
|
100
473
|
// Best effort: the caller's nerves event still records the failure.
|
|
@@ -114,6 +487,16 @@ function clearPendingSync(agentRoot) {
|
|
|
114
487
|
// Best effort: a stale record is reported again and cleared next time.
|
|
115
488
|
}
|
|
116
489
|
}
|
|
490
|
+
/**
|
|
491
|
+
* Clear the pending record after a sync step succeeded, unless it reports an
|
|
492
|
+
* incomplete restore: that one stays until a person clears it
|
|
493
|
+
* (`clearPendingSync`), since the agent's work is only in the named ref.
|
|
494
|
+
*/
|
|
495
|
+
function settlePendingSync(agentRoot) {
|
|
496
|
+
if (readPreviousFailure(agentRoot)?.classification === "repair_restore_incomplete")
|
|
497
|
+
return;
|
|
498
|
+
clearPendingSync(agentRoot);
|
|
499
|
+
}
|
|
117
500
|
function writeSkippedSyncFiles(agentRoot, files) {
|
|
118
501
|
try {
|
|
119
502
|
if (files.length === 0) {
|
|
@@ -132,6 +515,155 @@ function writeSkippedSyncFiles(agentRoot, files) {
|
|
|
132
515
|
// Best effort: the nerves event emitted by the caller names the files.
|
|
133
516
|
}
|
|
134
517
|
}
|
|
518
|
+
function readConflictState(agentRoot) {
|
|
519
|
+
try {
|
|
520
|
+
const parsed = JSON.parse(fs.readFileSync(syncConflictStatePath(agentRoot), "utf-8"));
|
|
521
|
+
return typeof parsed.hash === "string" ? parsed : null;
|
|
522
|
+
}
|
|
523
|
+
catch {
|
|
524
|
+
return null;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
function writeConflictState(agentRoot, state) {
|
|
528
|
+
try {
|
|
529
|
+
fs.mkdirSync(path.join(agentRoot, "state"), { recursive: true });
|
|
530
|
+
fs.writeFileSync(syncConflictStatePath(agentRoot), JSON.stringify(state, null, 2), "utf-8");
|
|
531
|
+
}
|
|
532
|
+
catch {
|
|
533
|
+
// Best effort: without the record the owner may be told twice.
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
function clearConflictState(agentRoot) {
|
|
537
|
+
try {
|
|
538
|
+
fs.rmSync(syncConflictStatePath(agentRoot), { force: true });
|
|
539
|
+
}
|
|
540
|
+
catch {
|
|
541
|
+
// Best effort: a stale record only suppresses a repeat notice.
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
// ---------- owner notices ----------
|
|
545
|
+
let ownerNotifier = null;
|
|
546
|
+
/**
|
|
547
|
+
* Replace how sync tells the owner. Null restores the default: Telegram when
|
|
548
|
+
* the agent has it; otherwise the notice goes to the agent's private-runtime
|
|
549
|
+
* pending queue, and the agent tells the owner through whatever sense it has.
|
|
550
|
+
*/
|
|
551
|
+
function setSyncOwnerNotifier(notifier) {
|
|
552
|
+
ownerNotifier = notifier;
|
|
553
|
+
}
|
|
554
|
+
async function defaultOwnerNotifier(agentRoot, agentName, notice) {
|
|
555
|
+
try {
|
|
556
|
+
const { sendTelegramOwnerNotice } = await Promise.resolve().then(() => __importStar(require("../senses/telegram")));
|
|
557
|
+
await sendTelegramOwnerNotice(agentName, { ...notice, signal: AbortSignal.timeout(30_000) });
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
catch (err) {
|
|
561
|
+
// Not every agent has Telegram: queue it once, for the agent to relay.
|
|
562
|
+
(0, runtime_1.emitNervesEvent)({
|
|
563
|
+
component: "heart",
|
|
564
|
+
event: "heart.sync_owner_notice_telegram_skipped",
|
|
565
|
+
message: "sync: Telegram did not send the owner notice; queued it instead",
|
|
566
|
+
meta: { agentRoot, noticeId: notice.noticeId, error: errorText(err) },
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
const { getPrivateRuntimePendingDir, queuePendingMessageOnce } = await Promise.resolve().then(() => __importStar(require("../mind/pending")));
|
|
570
|
+
queuePendingMessageOnce(getPrivateRuntimePendingDir(agentName, agentRoot), {
|
|
571
|
+
from: agentName,
|
|
572
|
+
friendId: "self",
|
|
573
|
+
channel: "inner",
|
|
574
|
+
key: "dialog",
|
|
575
|
+
content: `${notice.text}\n\ni should tell my owner.`,
|
|
576
|
+
timestamp: Date.now(),
|
|
577
|
+
packetId: notice.noticeId,
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
/** Fire and forget: a turn never waits on a notice. */
|
|
581
|
+
function tellOwner(agentRoot, agentName, notice) {
|
|
582
|
+
(0, runtime_1.emitNervesEvent)({
|
|
583
|
+
component: "heart",
|
|
584
|
+
event: "heart.sync_owner_notice",
|
|
585
|
+
message: "sync: telling the owner",
|
|
586
|
+
meta: { agentRoot, noticeId: notice.noticeId },
|
|
587
|
+
});
|
|
588
|
+
const send = ownerNotifier ?? ((name, body) => defaultOwnerNotifier(agentRoot, name, body));
|
|
589
|
+
void Promise.resolve()
|
|
590
|
+
.then(() => send(agentName, notice))
|
|
591
|
+
.catch((err) => {
|
|
592
|
+
(0, runtime_1.emitNervesEvent)({
|
|
593
|
+
level: "warn",
|
|
594
|
+
component: "heart",
|
|
595
|
+
event: "heart.sync_owner_notice_error",
|
|
596
|
+
message: "sync: could not tell the owner",
|
|
597
|
+
meta: { agentRoot, noticeId: notice.noticeId, error: errorText(err) },
|
|
598
|
+
});
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
/** Read the pause marker without changing anything. */
|
|
602
|
+
function readPauseMarker(agentRoot) {
|
|
603
|
+
let raw;
|
|
604
|
+
try {
|
|
605
|
+
raw = fs.readFileSync(syncPausedPath(agentRoot), "utf-8");
|
|
606
|
+
}
|
|
607
|
+
catch {
|
|
608
|
+
return { kind: "none" };
|
|
609
|
+
}
|
|
610
|
+
let marker;
|
|
611
|
+
try {
|
|
612
|
+
marker = JSON.parse(raw);
|
|
613
|
+
}
|
|
614
|
+
catch {
|
|
615
|
+
marker = null;
|
|
616
|
+
}
|
|
617
|
+
const expiresAt = marker?.expiresAt;
|
|
618
|
+
const expiresMs = typeof expiresAt === "string" ? Date.parse(expiresAt) : Number.NaN;
|
|
619
|
+
const startedMs = typeof marker?.startedAt === "string" ? Date.parse(marker.startedAt) : Number.NaN;
|
|
620
|
+
const now = Date.now();
|
|
621
|
+
const longestEnd = (Number.isFinite(startedMs) ? startedMs : now) + exports.SYNC_PAUSE_MAX_MS;
|
|
622
|
+
if (Number.isFinite(expiresMs) && expiresMs > now && expiresMs <= longestEnd)
|
|
623
|
+
return { kind: "active", expiresAt: expiresAt };
|
|
624
|
+
return { kind: "garbage", expiresAt };
|
|
625
|
+
}
|
|
626
|
+
function activePause(agentRoot) {
|
|
627
|
+
const markerPath = syncPausedPath(agentRoot);
|
|
628
|
+
const state = readPauseMarker(agentRoot);
|
|
629
|
+
if (state.kind === "none")
|
|
630
|
+
return null;
|
|
631
|
+
if (state.kind === "active")
|
|
632
|
+
return state.expiresAt;
|
|
633
|
+
const expiresAt = state.expiresAt;
|
|
634
|
+
try {
|
|
635
|
+
fs.rmSync(markerPath, { force: true });
|
|
636
|
+
}
|
|
637
|
+
catch (err) {
|
|
638
|
+
(0, runtime_1.emitNervesEvent)({
|
|
639
|
+
level: "warn",
|
|
640
|
+
component: "heart",
|
|
641
|
+
event: "heart.sync_pause_clear_failed",
|
|
642
|
+
message: "sync: could not remove an expired or unreadable pause marker",
|
|
643
|
+
meta: { agentRoot, error: errorText(err) },
|
|
644
|
+
});
|
|
645
|
+
return null;
|
|
646
|
+
}
|
|
647
|
+
(0, runtime_1.emitNervesEvent)({
|
|
648
|
+
component: "heart",
|
|
649
|
+
event: "heart.sync_pause_expired",
|
|
650
|
+
message: "sync: removed an expired or unreadable pause marker",
|
|
651
|
+
meta: { agentRoot, expiresAt: typeof expiresAt === "string" ? expiresAt : null },
|
|
652
|
+
});
|
|
653
|
+
return null;
|
|
654
|
+
}
|
|
655
|
+
function pausedResult(agentRoot, step) {
|
|
656
|
+
const until = activePause(agentRoot);
|
|
657
|
+
if (until === null)
|
|
658
|
+
return null;
|
|
659
|
+
(0, runtime_1.emitNervesEvent)({
|
|
660
|
+
component: "heart",
|
|
661
|
+
event: "heart.sync_paused",
|
|
662
|
+
message: `${step} skipped: sync is paused`,
|
|
663
|
+
meta: { agentRoot, expiresAt: until },
|
|
664
|
+
});
|
|
665
|
+
return { ok: true, paused: true, pausedUntil: until };
|
|
666
|
+
}
|
|
135
667
|
/** Parse `git diff --cached --name-status -z` output. */
|
|
136
668
|
function parseStagedChanges(output) {
|
|
137
669
|
const tokens = output.split("\0").filter((entry) => entry.length > 0);
|
|
@@ -156,13 +688,8 @@ function parseStagedChanges(output) {
|
|
|
156
688
|
* the deletion of the old path is unstaged too; otherwise the remote would
|
|
157
689
|
* lose the old path while never receiving the new one.
|
|
158
690
|
*/
|
|
159
|
-
function unstageOversizedFiles(agentRoot) {
|
|
160
|
-
const changes = parseStagedChanges((
|
|
161
|
-
cwd: agentRoot,
|
|
162
|
-
stdio: "pipe",
|
|
163
|
-
timeout: exports.SYNC_GIT_TIMEOUT_MS,
|
|
164
|
-
maxBuffer: exports.SYNC_GIT_MAX_BUFFER_BYTES,
|
|
165
|
-
}).toString());
|
|
691
|
+
function unstageOversizedFiles(agentRoot, agentName) {
|
|
692
|
+
const changes = parseStagedChanges(git(agentRoot, agentName, ["diff", "--cached", "--name-status", "-M", "-z"]));
|
|
166
693
|
const oversized = [];
|
|
167
694
|
const unstage = [];
|
|
168
695
|
for (const change of changes) {
|
|
@@ -173,6 +700,7 @@ function unstageOversizedFiles(agentRoot) {
|
|
|
173
700
|
bytes = fs.lstatSync(path.join(agentRoot, change.path)).size;
|
|
174
701
|
}
|
|
175
702
|
catch {
|
|
703
|
+
/* v8 ignore next -- the file was removed between `git add` and this stat @preserve */
|
|
176
704
|
continue;
|
|
177
705
|
}
|
|
178
706
|
if (bytes <= exports.SYNC_FILE_SIZE_LIMIT_BYTES)
|
|
@@ -184,75 +712,28 @@ function unstageOversizedFiles(agentRoot) {
|
|
|
184
712
|
}
|
|
185
713
|
if (oversized.length === 0)
|
|
186
714
|
return oversized;
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
715
|
+
// An unborn HEAD has nothing to reset to; `rm --cached` drops the new entry instead.
|
|
716
|
+
if (refExists(agentRoot, agentName, "HEAD"))
|
|
717
|
+
gitOnPaths(agentRoot, agentName, ["reset", "-q"], unstage);
|
|
718
|
+
else
|
|
719
|
+
gitOnPaths(agentRoot, agentName, ["rm", "-q", "--cached"], unstage);
|
|
192
720
|
(0, runtime_1.emitNervesEvent)({
|
|
193
721
|
level: "warn",
|
|
194
722
|
component: "heart",
|
|
195
723
|
event: "heart.sync_oversized_files_skipped",
|
|
196
|
-
message: "
|
|
724
|
+
message: "sync: left files over the sync size limit uncommitted",
|
|
197
725
|
meta: { agentRoot, limitBytes: exports.SYNC_FILE_SIZE_LIMIT_BYTES, files: oversized },
|
|
198
726
|
});
|
|
199
727
|
return oversized;
|
|
200
728
|
}
|
|
201
|
-
function hasStagedChanges(agentRoot) {
|
|
202
|
-
|
|
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
|
-
}
|
|
213
|
-
}
|
|
214
|
-
function collectRebaseConflictFiles(agentRoot) {
|
|
215
|
-
try {
|
|
216
|
-
const output = (0, child_process_1.execFileSync)("git", ["status", "--porcelain=v1"], {
|
|
217
|
-
cwd: agentRoot,
|
|
218
|
-
stdio: "pipe",
|
|
219
|
-
timeout: 5000,
|
|
220
|
-
maxBuffer: exports.SYNC_GIT_MAX_BUFFER_BYTES,
|
|
221
|
-
}).toString();
|
|
222
|
-
const files = [];
|
|
223
|
-
for (const line of output.split("\n")) {
|
|
224
|
-
// Unmerged paths in porcelain v1 are prefixed with UU/AA/DD/AU/UA/DU/UD
|
|
225
|
-
if (/^(UU|AA|DD|AU|UA|DU|UD) /.test(line)) {
|
|
226
|
-
files.push(line.slice(3).trim());
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
return files;
|
|
230
|
-
}
|
|
231
|
-
catch {
|
|
232
|
-
/* v8 ignore next -- git status --porcelain failure inside a repo would require a corrupt repo @preserve */
|
|
233
|
-
return [];
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
/**
|
|
237
|
-
* Check whether the bundle is initialized as a git repo.
|
|
238
|
-
* Used by both pre-turn pull and post-turn push to surface a clear,
|
|
239
|
-
* actionable error when sync is enabled but the user never ran `git init`
|
|
240
|
-
* inside their bundle. This error is propagated all the way into the
|
|
241
|
-
* agent's start-of-turn packet as a Sync warning, so the agent can
|
|
242
|
-
* either ask the user or run `git init` itself.
|
|
243
|
-
*/
|
|
244
|
-
function ensureGitRepo(agentRoot) {
|
|
245
|
-
if (fs.existsSync(path.join(agentRoot, ".git"))) {
|
|
246
|
-
return { ok: true };
|
|
247
|
-
}
|
|
248
|
-
const error = `bundle is not a git repo; run \`git init\` inside ${agentRoot} to enable sync (or disable sync in agent.json)`;
|
|
249
|
-
return { ok: false, error };
|
|
729
|
+
function hasStagedChanges(agentRoot, agentName) {
|
|
730
|
+
return runGit(agentRoot, agentName, ["diff", "--cached", "--quiet"]).status !== 0;
|
|
250
731
|
}
|
|
251
732
|
/**
|
|
252
|
-
* Git holds `.git/index.lock` for milliseconds to seconds, and every git
|
|
253
|
-
* in this module has a timeout
|
|
254
|
-
* behind by a git process that died mid-write; while it exists every
|
|
255
|
-
* fails, which silently stopped one bundle's sync for three months.
|
|
733
|
+
* Git holds `.git/index.lock` for milliseconds to seconds, and every local git
|
|
734
|
+
* call in this module has a timeout well under this. A lock older than this was
|
|
735
|
+
* left behind by a git process that died mid-write; while it exists every
|
|
736
|
+
* `git add` fails, which silently stopped one bundle's sync for three months.
|
|
256
737
|
*/
|
|
257
738
|
exports.STALE_INDEX_LOCK_MS = 10 * 60 * 1000;
|
|
258
739
|
// Only an empty lock is removed: git writes the new index into the lock right
|
|
@@ -260,7 +741,7 @@ exports.STALE_INDEX_LOCK_MS = 10 * 60 * 1000;
|
|
|
260
741
|
// full, unchanging lock that can be older than the threshold. When `.git` is a
|
|
261
742
|
// gitdir file (worktree), statSync fails and the lock is left alone.
|
|
262
743
|
function clearStaleIndexLock(agentRoot) {
|
|
263
|
-
const lockPath =
|
|
744
|
+
const lockPath = gitDirEntry(agentRoot, "index.lock");
|
|
264
745
|
let ageMs;
|
|
265
746
|
try {
|
|
266
747
|
const stat = fs.statSync(lockPath);
|
|
@@ -281,8 +762,8 @@ function clearStaleIndexLock(agentRoot) {
|
|
|
281
762
|
level: "warn",
|
|
282
763
|
component: "heart",
|
|
283
764
|
event: "heart.sync_stale_lock_clear_failed",
|
|
284
|
-
message: "
|
|
285
|
-
meta: { agentRoot, lockPath, error:
|
|
765
|
+
message: "sync: could not remove stale git index.lock",
|
|
766
|
+
meta: { agentRoot, lockPath, error: errorText(err) },
|
|
286
767
|
});
|
|
287
768
|
return;
|
|
288
769
|
}
|
|
@@ -290,358 +771,635 @@ function clearStaleIndexLock(agentRoot) {
|
|
|
290
771
|
level: "warn",
|
|
291
772
|
component: "heart",
|
|
292
773
|
event: "heart.sync_stale_lock_cleared",
|
|
293
|
-
message: "
|
|
774
|
+
message: "sync: removed stale git index.lock",
|
|
294
775
|
meta: { agentRoot, lockPath, ageMs },
|
|
295
776
|
});
|
|
296
777
|
}
|
|
297
778
|
/**
|
|
298
|
-
*
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
* returns ok — matching the no-remote behavior of postTurnPush. This supports the
|
|
302
|
-
* "local-only sync" mode where the bundle accumulates a commit log without ever
|
|
303
|
-
* pushing or pulling from a remote.
|
|
779
|
+
* Commit every change in the bundle except files over the size limit, as the
|
|
780
|
+
* agent. Synchronous, so no turn's file writes interleave with it. Throws with
|
|
781
|
+
* git's message when git fails; never commits unmerged paths (conflict markers).
|
|
304
782
|
*/
|
|
305
|
-
function
|
|
783
|
+
function commitLocalChanges(agentRoot, agentName, message) {
|
|
784
|
+
const status = git(agentRoot, agentName, ["status", "--porcelain"]).trim();
|
|
785
|
+
if (status.length === 0) {
|
|
786
|
+
// Nothing to commit, so nothing is skipped for size any more.
|
|
787
|
+
writeSkippedSyncFiles(agentRoot, []);
|
|
788
|
+
return { committed: false, skipped: [] };
|
|
789
|
+
}
|
|
790
|
+
const unmerged = unmergedPaths(agentRoot, agentName);
|
|
791
|
+
if (unmerged.length > 0)
|
|
792
|
+
throw new Error(`unmerged paths in the index: ${unmerged.join(", ")}`);
|
|
793
|
+
clearStaleIndexLock(agentRoot);
|
|
794
|
+
git(agentRoot, agentName, ["add", "-A"]);
|
|
795
|
+
const skipped = unstageOversizedFiles(agentRoot, agentName);
|
|
796
|
+
writeSkippedSyncFiles(agentRoot, skipped);
|
|
797
|
+
if (!hasStagedChanges(agentRoot, agentName))
|
|
798
|
+
return { committed: false, skipped };
|
|
799
|
+
// -q: without it git prints a create/delete line per file, which overran
|
|
800
|
+
// the output buffer on a 30,000-file commit after the commit had landed.
|
|
801
|
+
git(agentRoot, agentName, ["commit", "-q", "-m", message]);
|
|
802
|
+
return { committed: true, skipped };
|
|
803
|
+
}
|
|
804
|
+
// ---------- integrate ----------
|
|
805
|
+
/**
|
|
806
|
+
* Check whether the bundle is initialized as a git repo. The error reaches the
|
|
807
|
+
* agent's start-of-turn packet as a sync warning.
|
|
808
|
+
*/
|
|
809
|
+
exports.NOT_A_REPO_ERROR = "my bundle is not a git repository";
|
|
810
|
+
function ensureGitRepo(agentRoot, step) {
|
|
811
|
+
if (fs.existsSync(path.join(agentRoot, ".git")))
|
|
812
|
+
return { ok: true };
|
|
306
813
|
(0, runtime_1.emitNervesEvent)({
|
|
814
|
+
level: "warn",
|
|
307
815
|
component: "heart",
|
|
308
|
-
event: "heart.
|
|
309
|
-
message:
|
|
310
|
-
meta: { agentRoot
|
|
816
|
+
event: "heart.sync_not_a_repo",
|
|
817
|
+
message: `${step} failed: bundle is not a git repo`,
|
|
818
|
+
meta: { agentRoot },
|
|
311
819
|
});
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
820
|
+
return { ok: false, error: exports.NOT_A_REPO_ERROR };
|
|
821
|
+
}
|
|
822
|
+
const REPAIR_REF_PREFIX = "refs/ouro-sync/repair/";
|
|
823
|
+
/** Repair refs kept regardless of age. */
|
|
824
|
+
const REPAIR_REFS_KEPT = 10;
|
|
825
|
+
/** Repair refs younger than this are kept regardless of count. */
|
|
826
|
+
const REPAIR_REF_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
827
|
+
/** Repair refs, newest first (names are ISO timestamps, so they sort by time). */
|
|
828
|
+
function listRepairRefs(agentRoot, agentName) {
|
|
829
|
+
return git(agentRoot, agentName, ["for-each-ref", "--sort=-refname", "--format=%(refname) %(tree) %(committerdate:unix)", REPAIR_REF_PREFIX])
|
|
830
|
+
.split("\n")
|
|
831
|
+
.filter((line) => line.length > 0)
|
|
832
|
+
.map((line) => {
|
|
833
|
+
const [name, tree, seconds] = line.split(" ");
|
|
834
|
+
return { name, tree, committedMs: Number(seconds) * 1000 };
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
/** Drop repair refs past the newest ten that are 30 days old, except one the pending record names. */
|
|
838
|
+
function pruneRepairRefs(agentRoot, agentName, refs) {
|
|
839
|
+
const protectedRef = pendingRepairRef(agentRoot);
|
|
840
|
+
const now = Date.now();
|
|
841
|
+
const doomed = refs.filter((ref, i) => i >= REPAIR_REFS_KEPT && now - ref.committedMs >= REPAIR_REF_MAX_AGE_MS && ref.name !== protectedRef);
|
|
842
|
+
if (doomed.length === 0)
|
|
843
|
+
return;
|
|
844
|
+
git(agentRoot, agentName, ["update-ref", "--stdin"], undefined, doomed.map((ref) => `delete ${ref.name}\n`).join(""));
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* Commit the whole working tree, as it is now, to `refs/ouro-sync/repair/<ts>`
|
|
848
|
+
* without touching the real index. Its parent is the current HEAD, which for a
|
|
849
|
+
* stranded rebase is the detached HEAD, so commits made there stay reachable.
|
|
850
|
+
* When the newest repair ref already holds this tree, that ref is reused.
|
|
851
|
+
*/
|
|
852
|
+
function snapshotWorkingTree(agentRoot, agentName) {
|
|
853
|
+
const indexFile = gitDirEntry(agentRoot, `ouro-sync-repair-${process.pid}.index`);
|
|
854
|
+
const env = { GIT_INDEX_FILE: indexFile };
|
|
855
|
+
let tree;
|
|
856
|
+
try {
|
|
857
|
+
// Seed from HEAD so tracked files that match an ignore rule are kept.
|
|
858
|
+
git(agentRoot, agentName, ["read-tree", "HEAD"], env);
|
|
859
|
+
git(agentRoot, agentName, ["add", "-A"], env);
|
|
860
|
+
tree = git(agentRoot, agentName, ["write-tree"], env).trim();
|
|
325
861
|
}
|
|
326
|
-
|
|
862
|
+
finally {
|
|
863
|
+
fs.rmSync(indexFile, { force: true });
|
|
864
|
+
}
|
|
865
|
+
const existing = listRepairRefs(agentRoot, agentName);
|
|
866
|
+
if (existing[0]?.tree === tree)
|
|
867
|
+
return existing[0].name;
|
|
868
|
+
const commit = git(agentRoot, agentName, ["commit-tree", tree, "-p", "HEAD", "-m", "sync: repair snapshot"]).trim();
|
|
869
|
+
// A refname may not contain ":".
|
|
870
|
+
const ref = `${REPAIR_REF_PREFIX}${new Date().toISOString().replace(/:/g, "-")}`;
|
|
871
|
+
git(agentRoot, agentName, ["update-ref", ref, commit]);
|
|
872
|
+
pruneRepairRefs(agentRoot, agentName, [{ name: ref, tree, committedMs: Date.now() }, ...existing]);
|
|
873
|
+
return ref;
|
|
874
|
+
}
|
|
875
|
+
/** `merge --abort`, else `reset --merge`, else `reset --hard HEAD` (the snapshot restores the rest). */
|
|
876
|
+
function abortMergeThoroughly(agentRoot, agentName) {
|
|
877
|
+
if (runGit(agentRoot, agentName, exports.__testHooks.mergeAbortArgs ?? ["merge", "--abort"]).status === 0)
|
|
878
|
+
return "merge --abort";
|
|
879
|
+
if (runGit(agentRoot, agentName, ["reset", "-q", "--merge"]).status === 0)
|
|
880
|
+
return "reset --merge";
|
|
881
|
+
git(agentRoot, agentName, ["reset", "-q", "--hard", "HEAD"]);
|
|
882
|
+
return "reset --hard";
|
|
883
|
+
}
|
|
884
|
+
/** Paths whose content in the snapshot differs from HEAD. */
|
|
885
|
+
function pathsChangedInSnapshot(agentRoot, agentName, ref) {
|
|
886
|
+
return git(agentRoot, agentName, ["diff", "--name-only", "--no-renames", "-z", "HEAD", ref])
|
|
887
|
+
.split("\0")
|
|
888
|
+
.filter((p) => p.length > 0);
|
|
889
|
+
}
|
|
890
|
+
const refTimestamp = (ref) => ref.slice(REPAIR_REF_PREFIX.length);
|
|
891
|
+
/**
|
|
892
|
+
* Abort a rebase or merge left in progress, keeping the work done since. The
|
|
893
|
+
* working tree is snapshotted first; after the abort, every path the operation
|
|
894
|
+
* did not conflict on is restored from it. Conflicted paths keep HEAD's
|
|
895
|
+
* version; edits to them stay in the snapshot ref, which the owner is told about.
|
|
896
|
+
*
|
|
897
|
+
* Returns null when there was nothing to repair or the repair completed, else
|
|
898
|
+
* the failure to report. A failed snapshot touches nothing, so the stranded
|
|
899
|
+
* state stays exactly as found for a later attempt.
|
|
900
|
+
*/
|
|
901
|
+
function repairInterrupted(agentRoot, agentName) {
|
|
902
|
+
const rebase = fs.existsSync(gitDirEntry(agentRoot, "rebase-merge")) || fs.existsSync(gitDirEntry(agentRoot, "rebase-apply"));
|
|
903
|
+
const merge = fs.existsSync(gitDirEntry(agentRoot, "MERGE_HEAD"));
|
|
904
|
+
if (!rebase && !merge)
|
|
905
|
+
return null;
|
|
906
|
+
const operation = rebase ? "rebase" : "merge";
|
|
907
|
+
const conflicted = unmergedPaths(agentRoot, agentName);
|
|
908
|
+
let ref;
|
|
327
909
|
try {
|
|
328
|
-
|
|
329
|
-
cwd: agentRoot,
|
|
330
|
-
stdio: "pipe",
|
|
331
|
-
timeout: 5000,
|
|
332
|
-
}).toString().trim();
|
|
333
|
-
if (remoteOutput.length === 0) {
|
|
334
|
-
(0, runtime_1.emitNervesEvent)({
|
|
335
|
-
component: "heart",
|
|
336
|
-
event: "heart.sync_pull_end",
|
|
337
|
-
message: "pre-turn pull skipped: no remote configured",
|
|
338
|
-
meta: { agentRoot },
|
|
339
|
-
});
|
|
340
|
-
return { ok: true };
|
|
341
|
-
}
|
|
910
|
+
ref = snapshotWorkingTree(agentRoot, agentName);
|
|
342
911
|
}
|
|
343
912
|
catch (err) {
|
|
344
|
-
|
|
345
|
-
(0, runtime_1.emitNervesEvent)({
|
|
346
|
-
component: "heart",
|
|
347
|
-
event: "heart.sync_pull_error",
|
|
348
|
-
message: "pre-turn pull failed: git remote check failed",
|
|
349
|
-
meta: { agentRoot, error },
|
|
350
|
-
});
|
|
351
|
-
return { ok: false, error };
|
|
913
|
+
return reportSnapshotFailure(agentRoot, agentName, operation, errorText(err));
|
|
352
914
|
}
|
|
915
|
+
let abortedWith = "rebase --abort";
|
|
916
|
+
if (rebase)
|
|
917
|
+
git(agentRoot, agentName, ["rebase", "--abort"]);
|
|
918
|
+
else
|
|
919
|
+
abortedWith = abortMergeThoroughly(agentRoot, agentName);
|
|
920
|
+
const changed = pathsChangedInSnapshot(agentRoot, agentName, ref);
|
|
921
|
+
const skip = new Set(conflicted);
|
|
922
|
+
const restore = changed.filter((p) => !skip.has(p));
|
|
923
|
+
const kept = changed.filter((p) => skip.has(p));
|
|
353
924
|
try {
|
|
354
|
-
(
|
|
355
|
-
|
|
356
|
-
stdio: "pipe",
|
|
357
|
-
timeout: 30000,
|
|
358
|
-
});
|
|
359
|
-
(0, runtime_1.emitNervesEvent)({
|
|
360
|
-
component: "heart",
|
|
361
|
-
event: "heart.sync_pull_end",
|
|
362
|
-
message: "pre-turn pull complete",
|
|
363
|
-
meta: { agentRoot },
|
|
364
|
-
});
|
|
365
|
-
return { ok: true };
|
|
925
|
+
if (restore.length > 0)
|
|
926
|
+
gitOnPaths(agentRoot, agentName, ["restore", `--source=${ref}`, "--worktree"], restore);
|
|
366
927
|
}
|
|
367
928
|
catch (err) {
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
929
|
+
return reportRestoreIncomplete(agentRoot, agentName, operation, ref, errorText(err));
|
|
930
|
+
}
|
|
931
|
+
(0, runtime_1.emitNervesEvent)({
|
|
932
|
+
level: "warn",
|
|
933
|
+
component: "heart",
|
|
934
|
+
event: "heart.sync_repaired",
|
|
935
|
+
message: "sync: aborted a rebase or merge left in progress",
|
|
936
|
+
meta: { agentRoot, rebase, merge, ref, conflicted, kept, abortedWith },
|
|
937
|
+
});
|
|
938
|
+
if (kept.length === 0)
|
|
939
|
+
return null;
|
|
940
|
+
recordPendingSync(agentRoot, `an interrupted ${operation} was aborted; edits to ${kept.join(", ")} are kept in ${ref}`, "unknown", kept, ref);
|
|
941
|
+
tellOwner(agentRoot, agentName, {
|
|
942
|
+
noticeId: `sync-repair:${agentName}:${refTimestamp(ref)}`,
|
|
943
|
+
text: `Sync found an interrupted ${operation} in ${agentName}'s bundle and aborted it. Edits to ${kept.join(", ")} made since are kept in ${ref}.`,
|
|
944
|
+
});
|
|
945
|
+
return null;
|
|
946
|
+
}
|
|
947
|
+
function reportSnapshotFailure(agentRoot, agentName, operation, gitError) {
|
|
948
|
+
const error = `an interrupted ${operation} was left in place: the working tree could not be saved first (${gitError})`;
|
|
949
|
+
const previous = readPreviousFailure(agentRoot);
|
|
950
|
+
const repeat = previous?.classification === "repair_snapshot_failed" && previous.error === error;
|
|
951
|
+
recordPendingSync(agentRoot, error, "repair_snapshot_failed", []);
|
|
952
|
+
(0, runtime_1.emitNervesEvent)({
|
|
953
|
+
level: "warn",
|
|
954
|
+
component: "heart",
|
|
955
|
+
event: "heart.sync_repair_snapshot_failed",
|
|
956
|
+
message: "sync: could not snapshot before repair; nothing was changed",
|
|
957
|
+
meta: { agentRoot, operation, error: gitError },
|
|
958
|
+
});
|
|
959
|
+
if (!repeat) {
|
|
960
|
+
tellOwner(agentRoot, agentName, {
|
|
961
|
+
noticeId: `sync-repair-snapshot:${agentName}:${(0, crypto_1.createHash)("sha256").update(error).digest("hex").slice(0, 16)}`,
|
|
962
|
+
text: `Sync found an interrupted ${operation} in ${agentName}'s bundle but could not save the working tree first, so it left everything as it is and stopped syncing. git said: ${gitError}`,
|
|
374
963
|
});
|
|
375
|
-
return { ok: false, error };
|
|
376
964
|
}
|
|
965
|
+
return { ok: false, error };
|
|
966
|
+
}
|
|
967
|
+
function reportRestoreIncomplete(agentRoot, agentName, operation, ref, gitError) {
|
|
968
|
+
const error = `an interrupted ${operation} was aborted but not every file was put back; the full working tree is in ${ref} (${gitError})`;
|
|
969
|
+
recordPendingSync(agentRoot, error, "repair_restore_incomplete", [], ref);
|
|
970
|
+
(0, runtime_1.emitNervesEvent)({
|
|
971
|
+
level: "warn",
|
|
972
|
+
component: "heart",
|
|
973
|
+
event: "heart.sync_repair_restore_incomplete",
|
|
974
|
+
message: "sync: repair could not restore every file from its snapshot",
|
|
975
|
+
meta: { agentRoot, operation, ref, error: gitError },
|
|
976
|
+
});
|
|
977
|
+
tellOwner(agentRoot, agentName, {
|
|
978
|
+
noticeId: `sync-repair-restore:${agentName}:${refTimestamp(ref)}`,
|
|
979
|
+
text: `Sync aborted an interrupted ${operation} in ${agentName}'s bundle but could not put every file back. The working tree as it was is in ${ref}; git said: ${gitError}`,
|
|
980
|
+
});
|
|
981
|
+
return { ok: false, error };
|
|
982
|
+
}
|
|
983
|
+
function abortMergeIfAny(agentRoot, agentName) {
|
|
984
|
+
if (fs.existsSync(gitDirEntry(agentRoot, "MERGE_HEAD")))
|
|
985
|
+
runGit(agentRoot, agentName, ["merge", "--abort"]);
|
|
377
986
|
}
|
|
378
987
|
/**
|
|
379
|
-
*
|
|
380
|
-
*
|
|
381
|
-
*
|
|
988
|
+
* Redo a merge whose conflicts are all in `psyche/` so the owner's conflicting
|
|
989
|
+
* hunks win and the agent's other hunks stay. Delete/modify conflicts take the
|
|
990
|
+
* owner's side too: removed where the owner deleted, the owner's file where the
|
|
991
|
+
* owner modified.
|
|
382
992
|
*/
|
|
383
|
-
function
|
|
993
|
+
function mergeOwnerWins(agentRoot, agentName, ref, paths) {
|
|
994
|
+
git(agentRoot, agentName, ["merge", "--abort"]);
|
|
995
|
+
const merge = runGit(agentRoot, agentName, ["merge", "--no-edit", "--ff", "-X", "theirs", "-m", exports.OWNER_WINS_MESSAGE, ref]);
|
|
996
|
+
if (merge.status !== 0) {
|
|
997
|
+
const remaining = unmergedPaths(agentRoot, agentName);
|
|
998
|
+
if (remaining.length === 0)
|
|
999
|
+
throw gitFailure(["merge"], merge);
|
|
1000
|
+
for (const file of remaining) {
|
|
1001
|
+
const stages = git(agentRoot, agentName, ["--literal-pathspecs", "ls-files", "-u", "--", file]);
|
|
1002
|
+
const theirsHasIt = stages.split("\n").some((line) => /^\d+ [0-9a-f]+ 3\t/.test(line));
|
|
1003
|
+
if (theirsHasIt) {
|
|
1004
|
+
git(agentRoot, agentName, ["--literal-pathspecs", "checkout", "--theirs", "--", file]);
|
|
1005
|
+
git(agentRoot, agentName, ["--literal-pathspecs", "add", "--", file]);
|
|
1006
|
+
}
|
|
1007
|
+
else {
|
|
1008
|
+
git(agentRoot, agentName, ["--literal-pathspecs", "rm", "-q", "--", file]);
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
git(agentRoot, agentName, ["commit", "-q", "-m", exports.OWNER_WINS_MESSAGE]);
|
|
1012
|
+
}
|
|
384
1013
|
(0, runtime_1.emitNervesEvent)({
|
|
385
1014
|
component: "heart",
|
|
386
|
-
event: "heart.
|
|
387
|
-
message: "
|
|
388
|
-
meta: { agentRoot,
|
|
1015
|
+
event: "heart.sync_owner_wins",
|
|
1016
|
+
message: "sync: merged with the owner's psyche edits winning conflicts",
|
|
1017
|
+
meta: { agentRoot, paths },
|
|
389
1018
|
});
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
1019
|
+
}
|
|
1020
|
+
function conflictHash(paths) {
|
|
1021
|
+
return (0, crypto_1.createHash)("sha256").update(paths.join("\0")).digest("hex").slice(0, 16);
|
|
1022
|
+
}
|
|
1023
|
+
function handleNonPsycheConflict(agentRoot, config, agentName, paths) {
|
|
1024
|
+
git(agentRoot, agentName, ["merge", "--abort"]);
|
|
1025
|
+
const sorted = [...paths].sort();
|
|
1026
|
+
const hash = conflictHash(sorted);
|
|
1027
|
+
const error = `merge with ${config.remote}/${branchOf(config)} conflicts in ${sorted.join(", ")}; the merge was aborted and local commits are kept`;
|
|
1028
|
+
recordPendingSync(agentRoot, error, "pull_rebase_conflict", sorted);
|
|
1029
|
+
(0, runtime_1.emitNervesEvent)({
|
|
1030
|
+
level: "warn",
|
|
1031
|
+
component: "heart",
|
|
1032
|
+
event: "heart.sync_conflict",
|
|
1033
|
+
message: "sync: merge conflict outside psyche/, merge aborted",
|
|
1034
|
+
meta: { agentRoot, conflictFiles: sorted, hash },
|
|
1035
|
+
});
|
|
1036
|
+
const previous = readConflictState(agentRoot);
|
|
1037
|
+
if (previous?.hash === hash)
|
|
1038
|
+
return { result: { ok: false, error }, conflictBranch: previous };
|
|
1039
|
+
writeConflictState(agentRoot, { hash, paths: sorted });
|
|
1040
|
+
tellOwner(agentRoot, agentName, {
|
|
1041
|
+
noticeId: `sync-conflict:${agentName}:${hash}`,
|
|
1042
|
+
text: `Bundle sync for ${agentName} stopped: ${sorted.join(", ")} changed on both sides. My changes stay committed locally until the conflict is merged.`,
|
|
1043
|
+
});
|
|
1044
|
+
return { result: { ok: false, error } };
|
|
1045
|
+
}
|
|
1046
|
+
/** Record a successful integrate: a resolved conflict is over, and a synced bundle has nothing pending. */
|
|
1047
|
+
function settleIntegrated(agentRoot, config, agentName) {
|
|
1048
|
+
clearConflictState(agentRoot);
|
|
1049
|
+
const pending = readPreviousFailure(agentRoot);
|
|
1050
|
+
if (!pending)
|
|
1051
|
+
return;
|
|
1052
|
+
if (pending.classification === "pull_rebase_conflict" || commitsAhead(agentRoot, agentName, config) === 0)
|
|
1053
|
+
settlePendingSync(agentRoot);
|
|
1054
|
+
}
|
|
1055
|
+
function integrateCore(agentRoot, config, agentName, commitMessage = "sync: pre-turn update") {
|
|
1056
|
+
// Outside the try: its catch aborts a merge, which must never run before a snapshot.
|
|
1057
|
+
const repairFailure = repairInterrupted(agentRoot, agentName);
|
|
1058
|
+
if (repairFailure)
|
|
1059
|
+
return { result: repairFailure };
|
|
407
1060
|
try {
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
}
|
|
1061
|
+
commitLocalChanges(agentRoot, agentName, commitMessage);
|
|
1062
|
+
const ref = remoteTrackingRef(config);
|
|
1063
|
+
if (!refExists(agentRoot, agentName, ref)) {
|
|
1064
|
+
settleIntegrated(agentRoot, config, agentName);
|
|
1065
|
+
return { result: { ok: true } };
|
|
1066
|
+
}
|
|
1067
|
+
const shortRef = `${config.remote}/${branchOf(config)}`;
|
|
1068
|
+
const merge = runGit(agentRoot, agentName, ["merge", "--no-edit", "--ff", shortRef]);
|
|
1069
|
+
if (merge.status !== 0) {
|
|
1070
|
+
const paths = unmergedPaths(agentRoot, agentName);
|
|
1071
|
+
if (paths.length === 0) {
|
|
1072
|
+
// Refused before touching anything ("would be overwritten"): no conflict state.
|
|
1073
|
+
abortMergeIfAny(agentRoot, agentName);
|
|
1074
|
+
const error = gitFailure(["merge"], merge).message;
|
|
1075
|
+
recordPendingSync(agentRoot, error, "unknown", []);
|
|
1076
|
+
(0, runtime_1.emitNervesEvent)({
|
|
1077
|
+
level: "warn",
|
|
1078
|
+
component: "heart",
|
|
1079
|
+
event: "heart.sync_merge_failed",
|
|
1080
|
+
message: "sync: merge refused",
|
|
1081
|
+
meta: { agentRoot, error },
|
|
1082
|
+
});
|
|
1083
|
+
return { result: { ok: false, error } };
|
|
1084
|
+
}
|
|
1085
|
+
if (paths.some((p) => !p.startsWith("psyche/")))
|
|
1086
|
+
return handleNonPsycheConflict(agentRoot, config, agentName, paths);
|
|
1087
|
+
mergeOwnerWins(agentRoot, agentName, shortRef, paths);
|
|
1088
|
+
}
|
|
1089
|
+
settleIntegrated(agentRoot, config, agentName);
|
|
1090
|
+
return { result: { ok: true } };
|
|
414
1091
|
}
|
|
415
1092
|
catch (err) {
|
|
416
|
-
|
|
1093
|
+
abortMergeIfAny(agentRoot, agentName);
|
|
1094
|
+
const error = errorText(err);
|
|
417
1095
|
recordPendingSync(agentRoot, error, "unknown", []);
|
|
418
1096
|
(0, runtime_1.emitNervesEvent)({
|
|
1097
|
+
level: "warn",
|
|
419
1098
|
component: "heart",
|
|
420
|
-
event: "heart.
|
|
421
|
-
message: "
|
|
1099
|
+
event: "heart.sync_merge_failed",
|
|
1100
|
+
message: "sync: integrate failed",
|
|
422
1101
|
meta: { agentRoot, error },
|
|
423
1102
|
});
|
|
424
|
-
return { ok: false, error };
|
|
1103
|
+
return { result: { ok: false, error } };
|
|
425
1104
|
}
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
1105
|
+
}
|
|
1106
|
+
/**
|
|
1107
|
+
* Merge the fetched remote branch into the bundle. Synchronous. The caller
|
|
1108
|
+
* holds the bundle lock. Steps: repair an interrupted rebase or merge, commit
|
|
1109
|
+
* local changes, then merge `<remote>/<branch>` (fast-forward when possible).
|
|
1110
|
+
* Conflicts only in `psyche/` go to the owner; any other conflict aborts the
|
|
1111
|
+
* merge and is recorded in `state/pending-sync.json`.
|
|
1112
|
+
*/
|
|
1113
|
+
function integrateFetched(agentRoot, config, agentName) {
|
|
1114
|
+
return integrateCore(agentRoot, config, agentName).result;
|
|
1115
|
+
}
|
|
1116
|
+
// ---------- network steps ----------
|
|
1117
|
+
async function fetchUnlocked(agentRoot, config, agentName, signal) {
|
|
1118
|
+
const branch = branchOf(config);
|
|
1119
|
+
const timeoutMs = exports.__testHooks.fetchTimeoutMs ?? exports.SYNC_FETCH_TIMEOUT_MS;
|
|
1120
|
+
const run = await runGitAsync(agentRoot, agentName, ["fetch", "--no-tags", config.remote, `+refs/heads/${branch}:${remoteTrackingRef(config)}`], timeoutMs, signal);
|
|
1121
|
+
// An empty remote has no branch yet; integrate then only commits.
|
|
1122
|
+
if (run.status === 0 || /couldn't find remote ref/i.test(run.stderr))
|
|
1123
|
+
return { ok: true };
|
|
1124
|
+
const error = describeAsyncFailure("fetch", run, timeoutMs);
|
|
1125
|
+
(0, runtime_1.emitNervesEvent)({
|
|
1126
|
+
level: "warn",
|
|
1127
|
+
component: "heart",
|
|
1128
|
+
event: "heart.sync_fetch_error",
|
|
1129
|
+
message: "sync: fetch failed",
|
|
1130
|
+
meta: { agentRoot, remote: config.remote, branch, error },
|
|
1131
|
+
});
|
|
1132
|
+
return { ok: false, error };
|
|
1133
|
+
}
|
|
1134
|
+
/** `git fetch <remote> <branch>` as a detached child, killed by process group at 10 s or on abort. The caller holds the lock. */
|
|
1135
|
+
function fetchRemote(agentRoot, config, signal) {
|
|
1136
|
+
return fetchUnlocked(agentRoot, config, agentNameFromRoot(agentRoot), signal);
|
|
1137
|
+
}
|
|
1138
|
+
/**
|
|
1139
|
+
* `git ls-remote <remote> [patterns]` the way sync's own fetch and push reach
|
|
1140
|
+
* the remote: the same flags and environment (in the container, the ouro-sync
|
|
1141
|
+
* ssh), detached and killed by process group at the fetch timeout. `refs` are
|
|
1142
|
+
* the ref names listed; an empty remote lists none.
|
|
1143
|
+
*/
|
|
1144
|
+
async function listRemoteRefs(agentRoot, remote, patterns = [], signal) {
|
|
1145
|
+
const timeoutMs = exports.__testHooks.fetchTimeoutMs ?? exports.SYNC_FETCH_TIMEOUT_MS;
|
|
1146
|
+
const run = await runGitAsync(agentRoot, agentNameFromRoot(agentRoot), ["ls-remote", remote, ...patterns], timeoutMs, signal);
|
|
1147
|
+
if (run.status !== 0)
|
|
1148
|
+
return { ok: false, error: describeAsyncFailure("ls-remote", run, timeoutMs) };
|
|
1149
|
+
const refs = run.stdout.split("\n").map((line) => line.split("\t")[1]).filter((ref) => ref !== undefined && ref.length > 0);
|
|
1150
|
+
return { ok: true, refs };
|
|
1151
|
+
}
|
|
1152
|
+
/**
|
|
1153
|
+
* Spec R3.10: sync never makes the first push to an empty remote (one with no
|
|
1154
|
+
* refs) without the owner's yes. A remote branch fetched before means the
|
|
1155
|
+
* remote has history, and no network is needed. Otherwise the remote is asked;
|
|
1156
|
+
* when it is empty and no yes came with this push, the push is held and the
|
|
1157
|
+
* owner is told once. A remote that cannot be asked is not pushed to.
|
|
1158
|
+
*/
|
|
1159
|
+
async function holdFirstPush(agentRoot, config, agentName, options, signal) {
|
|
1160
|
+
if (options.firstPushApproved === true || refExists(agentRoot, agentName, remoteTrackingRef(config)))
|
|
1161
|
+
return null;
|
|
1162
|
+
const listed = await listRemoteRefs(agentRoot, config.remote, [], signal);
|
|
1163
|
+
if (!listed.ok)
|
|
1164
|
+
return { ok: false, error: `could not check whether ${config.remote} is empty before pushing to it: ${listed.error}` };
|
|
1165
|
+
if (listed.refs.length > 0)
|
|
1166
|
+
return null;
|
|
1167
|
+
(0, runtime_1.emitNervesEvent)({
|
|
1168
|
+
component: "heart",
|
|
1169
|
+
event: "heart.sync_first_push_held",
|
|
1170
|
+
message: "sync: remote is empty; holding the first push for the owner's yes",
|
|
1171
|
+
meta: { agentRoot, remote: config.remote },
|
|
1172
|
+
});
|
|
1173
|
+
if (!fs.existsSync(firstPushHeldPath(agentRoot))) {
|
|
1174
|
+
fs.mkdirSync(path.dirname(firstPushHeldPath(agentRoot)), { recursive: true });
|
|
1175
|
+
fs.writeFileSync(firstPushHeldPath(agentRoot), `${JSON.stringify({ remote: config.remote, at: new Date().toISOString() })}\n`);
|
|
1176
|
+
tellOwner(agentRoot, agentName, { noticeId: `sync-first-push-held:${agentName}`, text: exports.FIRST_PUSH_HELD_NOTICE });
|
|
430
1177
|
}
|
|
431
|
-
|
|
432
|
-
|
|
1178
|
+
return { ok: false, firstPushHeld: true, error: `${config.remote} is empty, so the first push waits for the owner's yes (bundle_first_push_review)` };
|
|
1179
|
+
}
|
|
1180
|
+
async function pushUnlocked(agentRoot, config, agentName, signal, options = {}) {
|
|
433
1181
|
try {
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
stdio: "pipe",
|
|
437
|
-
timeout: exports.SYNC_GIT_TIMEOUT_MS,
|
|
438
|
-
maxBuffer: exports.SYNC_GIT_MAX_BUFFER_BYTES,
|
|
439
|
-
});
|
|
440
|
-
const skippedFiles = unstageOversizedFiles(agentRoot);
|
|
441
|
-
writeSkippedSyncFiles(agentRoot, skippedFiles);
|
|
442
|
-
if (skippedFiles.length > 0 && !hasStagedChanges(agentRoot)) {
|
|
1182
|
+
if (!hasRemote(agentRoot, agentName, config)) {
|
|
1183
|
+
settlePendingSync(agentRoot);
|
|
443
1184
|
(0, runtime_1.emitNervesEvent)({
|
|
444
1185
|
component: "heart",
|
|
445
1186
|
event: "heart.sync_push_end",
|
|
446
|
-
message: "
|
|
447
|
-
meta: { agentRoot
|
|
1187
|
+
message: "sync: committed locally, no remote configured",
|
|
1188
|
+
meta: { agentRoot },
|
|
448
1189
|
});
|
|
449
|
-
return
|
|
1190
|
+
return { ok: true };
|
|
450
1191
|
}
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
1192
|
+
const held = await holdFirstPush(agentRoot, config, agentName, options, signal);
|
|
1193
|
+
if (held)
|
|
1194
|
+
return held;
|
|
1195
|
+
const branch = branchOf(config);
|
|
1196
|
+
const timeoutMs = exports.__testHooks.pushTimeoutMs ?? exports.SYNC_PUSH_TIMEOUT_MS;
|
|
1197
|
+
const run = await runGitAsync(agentRoot, agentName, ["push", config.remote, `HEAD:refs/heads/${branch}`], timeoutMs, signal);
|
|
1198
|
+
if (run.status === 0) {
|
|
1199
|
+
settlePendingSync(agentRoot);
|
|
1200
|
+
fs.rmSync(firstPushHeldPath(agentRoot), { force: true });
|
|
460
1201
|
(0, runtime_1.emitNervesEvent)({
|
|
461
1202
|
component: "heart",
|
|
462
1203
|
event: "heart.sync_push_end",
|
|
463
|
-
message: "
|
|
464
|
-
meta: { agentRoot,
|
|
1204
|
+
message: "sync: push complete",
|
|
1205
|
+
meta: { agentRoot, remote: config.remote, branch },
|
|
465
1206
|
});
|
|
466
|
-
clearPendingSync(agentRoot);
|
|
467
1207
|
return { ok: true };
|
|
468
1208
|
}
|
|
469
|
-
|
|
1209
|
+
const rejected = !run.timedOut && !run.aborted && /\[rejected\]|non-fast-forward|fetch first/.test(run.stderr);
|
|
1210
|
+
const error = describeAsyncFailure("push", run, timeoutMs);
|
|
1211
|
+
const classification = rejected ? "push_rejected" : "unknown";
|
|
1212
|
+
recordPendingSync(agentRoot, error, classification, []);
|
|
1213
|
+
(0, runtime_1.emitNervesEvent)({
|
|
1214
|
+
level: "warn",
|
|
1215
|
+
component: "heart",
|
|
1216
|
+
event: "heart.sync_push_error",
|
|
1217
|
+
message: `sync: push failed: ${classification}`,
|
|
1218
|
+
meta: { agentRoot, error, classification },
|
|
1219
|
+
});
|
|
1220
|
+
return { ok: false, error };
|
|
470
1221
|
}
|
|
471
1222
|
catch (err) {
|
|
472
|
-
const error =
|
|
473
|
-
// Record it so the start-of-turn packet surfaces the failure; otherwise a
|
|
474
|
-
// failing local commit is visible only in nerves logs.
|
|
1223
|
+
const error = errorText(err);
|
|
475
1224
|
recordPendingSync(agentRoot, error, "unknown", []);
|
|
476
1225
|
(0, runtime_1.emitNervesEvent)({
|
|
1226
|
+
level: "warn",
|
|
477
1227
|
component: "heart",
|
|
478
1228
|
event: "heart.sync_push_error",
|
|
479
|
-
message: "
|
|
1229
|
+
message: "sync: push failed",
|
|
480
1230
|
meta: { agentRoot, error },
|
|
481
1231
|
});
|
|
482
1232
|
return { ok: false, error };
|
|
483
1233
|
}
|
|
484
1234
|
}
|
|
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
1235
|
/**
|
|
493
|
-
*
|
|
494
|
-
*
|
|
495
|
-
*
|
|
496
|
-
*
|
|
1236
|
+
* `git push <remote> HEAD:<branch>` as a detached child, killed by process
|
|
1237
|
+
* group at 60 s. It takes the lock the caller acquired before committing and
|
|
1238
|
+
* releases it when the push ends, so nothing runs between commit and push. A
|
|
1239
|
+
* rejection is recorded as `push_rejected`; the next integrate merges and the
|
|
1240
|
+
* push after it succeeds. Never throws.
|
|
497
1241
|
*/
|
|
498
|
-
function
|
|
1242
|
+
async function pushAsync(agentRoot, config, handle, signal) {
|
|
499
1243
|
try {
|
|
500
|
-
|
|
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
|
-
}
|
|
1244
|
+
return await pushUnlocked(agentRoot, config, agentNameFromRoot(agentRoot), signal);
|
|
514
1245
|
}
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
1246
|
+
finally {
|
|
1247
|
+
handle.release();
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
// ---------- building blocks for other bundle writers (ouro move, the home check) ----------
|
|
1251
|
+
/** The active pause's `expiresAt`, or null. Reads the marker and changes nothing. */
|
|
1252
|
+
function syncPausedUntil(agentRoot) {
|
|
1253
|
+
const state = readPauseMarker(agentRoot);
|
|
1254
|
+
return state.kind === "active" ? state.expiresAt : null;
|
|
1255
|
+
}
|
|
1256
|
+
/** `refs/remotes/<remote>/<branch>`, the ref `fetchRemote` updates. */
|
|
1257
|
+
function syncTrackingRef(config) {
|
|
1258
|
+
return remoteTrackingRef(config);
|
|
1259
|
+
}
|
|
1260
|
+
/**
|
|
1261
|
+
* Take the bundle sync lock, waiting up to `waitMs`. While it is held, a
|
|
1262
|
+
* SIGTERM or SIGINT kills sync's git children and releases it, as for sync itself.
|
|
1263
|
+
*/
|
|
1264
|
+
async function holdSyncLock(agentRoot, waitMs) {
|
|
1265
|
+
const acquired = waitMs > 0 ? await (0, sync_lock_1.acquireSyncLock)(agentRoot, waitMs) : (0, sync_lock_1.tryAcquireSyncLockSync)(agentRoot);
|
|
1266
|
+
return "busy" in acquired ? acquired : trackLock(acquired);
|
|
1267
|
+
}
|
|
1268
|
+
/**
|
|
1269
|
+
* `git push <remote> HEAD:refs/heads/<branch>` as a detached child, killed by
|
|
1270
|
+
* process group at 60 s. It records nothing in `state/pending-sync.json`: the
|
|
1271
|
+
* caller, which holds the lock, decides what a refusal means.
|
|
1272
|
+
*/
|
|
1273
|
+
async function pushHeadToBranch(agentRoot, config, signal) {
|
|
1274
|
+
const timeoutMs = exports.__testHooks.pushTimeoutMs ?? exports.SYNC_PUSH_TIMEOUT_MS;
|
|
1275
|
+
const run = await runGitAsync(agentRoot, agentNameFromRoot(agentRoot), ["push", config.remote, `HEAD:refs/heads/${branchOf(config)}`], timeoutMs, signal);
|
|
1276
|
+
if (run.status === 0)
|
|
1277
|
+
return { ok: true };
|
|
1278
|
+
const rejected = !run.timedOut && !run.aborted && /\[rejected\]|non-fast-forward|fetch first/.test(run.stderr);
|
|
1279
|
+
return { ok: false, rejected, error: describeAsyncFailure("push", run, timeoutMs) };
|
|
1280
|
+
}
|
|
1281
|
+
/** The same conflict recurred: put the agent's side where the owner can merge it from the Mac. */
|
|
1282
|
+
async function pushConflictBranch(agentRoot, config, agentName, state, signal) {
|
|
1283
|
+
const branch = `sync-conflict/${agentName}`;
|
|
1284
|
+
const head = git(agentRoot, agentName, ["rev-parse", "HEAD"]).trim();
|
|
1285
|
+
// Pushed already, and nothing new since: skip the network.
|
|
1286
|
+
if (state.branchPushedHead === head)
|
|
1287
|
+
return;
|
|
1288
|
+
const timeoutMs = exports.__testHooks.pushTimeoutMs ?? exports.SYNC_PUSH_TIMEOUT_MS;
|
|
1289
|
+
const run = await runGitAsync(agentRoot, agentName, ["push", config.remote, `HEAD:refs/heads/${branch}`], timeoutMs, signal);
|
|
1290
|
+
if (run.status !== 0) {
|
|
518
1291
|
(0, runtime_1.emitNervesEvent)({
|
|
1292
|
+
level: "warn",
|
|
519
1293
|
component: "heart",
|
|
520
|
-
event: "heart.
|
|
521
|
-
message: "
|
|
522
|
-
meta: { agentRoot, error:
|
|
1294
|
+
event: "heart.sync_conflict_branch_push_error",
|
|
1295
|
+
message: "sync: could not push the conflict branch",
|
|
1296
|
+
meta: { agentRoot, branch, error: describeAsyncFailure("push", run, timeoutMs) },
|
|
523
1297
|
});
|
|
1298
|
+
return;
|
|
524
1299
|
}
|
|
525
1300
|
(0, runtime_1.emitNervesEvent)({
|
|
526
1301
|
component: "heart",
|
|
527
|
-
event: "heart.
|
|
528
|
-
message: "
|
|
529
|
-
meta: { agentRoot },
|
|
1302
|
+
event: "heart.sync_conflict_branch_pushed",
|
|
1303
|
+
message: "sync: pushed the agent's side of a conflict",
|
|
1304
|
+
meta: { agentRoot, branch },
|
|
1305
|
+
});
|
|
1306
|
+
writeConflictState(agentRoot, { ...state, branchNoticeSent: true, branchPushedHead: head });
|
|
1307
|
+
if (state.branchNoticeSent)
|
|
1308
|
+
return;
|
|
1309
|
+
tellOwner(agentRoot, agentName, {
|
|
1310
|
+
noticeId: `sync-conflict-branch:${agentName}:${state.hash}`,
|
|
1311
|
+
text: `My side of the sync conflict in ${state.paths.join(", ")} is on branch ${branch} in ${config.remote}. Merging it into ${branchOf(config)} from the Mac resumes sync.`,
|
|
530
1312
|
});
|
|
531
|
-
return { ok: true };
|
|
532
1313
|
}
|
|
533
|
-
|
|
1314
|
+
/** An unborn HEAD (nothing ever committed) has nothing to push. */
|
|
1315
|
+
function pushIfBorn(agentRoot, config, agentName, options) {
|
|
1316
|
+
if (refExists(agentRoot, agentName, "HEAD"))
|
|
1317
|
+
return pushUnlocked(agentRoot, config, agentName, undefined, options);
|
|
1318
|
+
settlePendingSync(agentRoot);
|
|
1319
|
+
return Promise.resolve({ ok: true });
|
|
1320
|
+
}
|
|
1321
|
+
/** Fetch then integrate. The caller holds the lock. */
|
|
1322
|
+
async function pullUnlocked(agentRoot, config, agentName, signal, commitMessage) {
|
|
1323
|
+
let remote;
|
|
534
1324
|
try {
|
|
535
|
-
|
|
536
|
-
cwd: agentRoot,
|
|
537
|
-
stdio: "pipe",
|
|
538
|
-
timeout: exports.SYNC_GIT_TIMEOUT_MS,
|
|
539
|
-
maxBuffer: exports.SYNC_GIT_MAX_BUFFER_BYTES,
|
|
540
|
-
});
|
|
1325
|
+
remote = hasRemote(agentRoot, agentName, config);
|
|
541
1326
|
}
|
|
542
|
-
catch {
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
1327
|
+
catch (err) {
|
|
1328
|
+
return { ok: false, error: errorText(err) };
|
|
1329
|
+
}
|
|
1330
|
+
if (remote) {
|
|
1331
|
+
const fetched = await fetchUnlocked(agentRoot, config, agentName, signal);
|
|
1332
|
+
if (!fetched.ok)
|
|
1333
|
+
return fetched;
|
|
1334
|
+
}
|
|
1335
|
+
const outcome = integrateCore(agentRoot, config, agentName, commitMessage);
|
|
1336
|
+
if (outcome.conflictBranch)
|
|
1337
|
+
await pushConflictBranch(agentRoot, config, agentName, outcome.conflictBranch, signal);
|
|
1338
|
+
return outcome.result;
|
|
1339
|
+
}
|
|
1340
|
+
/** Commits on each side of HEAD and the last fetched remote branch. */
|
|
1341
|
+
function aheadBehind(agentRoot, agentName, config) {
|
|
1342
|
+
const ahead = commitsAhead(agentRoot, agentName, config);
|
|
1343
|
+
const ref = remoteTrackingRef(config);
|
|
1344
|
+
if (!refExists(agentRoot, agentName, ref))
|
|
1345
|
+
return { ahead, behind: 0 };
|
|
1346
|
+
const range = refExists(agentRoot, agentName, "HEAD") ? `HEAD..${ref}` : ref;
|
|
1347
|
+
return { ahead, behind: Number.parseInt(git(agentRoot, agentName, ["rev-list", "--count", range]).trim(), 10) };
|
|
1348
|
+
}
|
|
1349
|
+
/**
|
|
1350
|
+
* The boot probe: fetch the remote branch and count commits ahead and behind.
|
|
1351
|
+
* It takes the lock without waiting, never merges, commits or pushes, and
|
|
1352
|
+
* writes nothing under `state/` (an expired pause marker is left for the next
|
|
1353
|
+
* sync to remove).
|
|
1354
|
+
*/
|
|
1355
|
+
async function probeSync(agentRoot, config, signal) {
|
|
1356
|
+
const agentName = agentNameFromRoot(agentRoot);
|
|
1357
|
+
const pause = readPauseMarker(agentRoot);
|
|
1358
|
+
if (pause.kind === "active")
|
|
1359
|
+
return { ok: true, paused: true, pausedUntil: pause.expiresAt };
|
|
1360
|
+
const repoCheck = ensureGitRepo(agentRoot, "boot probe");
|
|
1361
|
+
if (!repoCheck.ok)
|
|
1362
|
+
return repoCheck;
|
|
1363
|
+
try {
|
|
1364
|
+
const acquired = (0, sync_lock_1.tryAcquireSyncLockSync)(agentRoot);
|
|
1365
|
+
if ("busy" in acquired)
|
|
1366
|
+
return { ok: true, busy: true, holderPid: acquired.holderPid };
|
|
1367
|
+
const lock = trackLock(acquired);
|
|
547
1368
|
try {
|
|
548
|
-
|
|
549
|
-
cwd: agentRoot,
|
|
550
|
-
stdio: "pipe",
|
|
551
|
-
timeout: exports.SYNC_GIT_TIMEOUT_MS,
|
|
552
|
-
maxBuffer: exports.SYNC_GIT_MAX_BUFFER_BYTES,
|
|
553
|
-
});
|
|
554
|
-
}
|
|
555
|
-
catch (err) {
|
|
556
|
-
rebaseError = err instanceof Error ? err.message : String(err);
|
|
557
|
-
}
|
|
558
|
-
if (rebaseError === null) {
|
|
559
|
-
try {
|
|
560
|
-
(0, child_process_1.execFileSync)("git", ["push", config.remote], {
|
|
561
|
-
cwd: agentRoot,
|
|
562
|
-
stdio: "pipe",
|
|
563
|
-
timeout: exports.SYNC_GIT_TIMEOUT_MS,
|
|
564
|
-
maxBuffer: exports.SYNC_GIT_MAX_BUFFER_BYTES,
|
|
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);
|
|
1369
|
+
if (!hasRemote(agentRoot, agentName, config))
|
|
574
1370
|
return { ok: true };
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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 };
|
|
587
|
-
}
|
|
1371
|
+
const fetched = await fetchUnlocked(agentRoot, config, agentName, signal);
|
|
1372
|
+
if (!fetched.ok)
|
|
1373
|
+
return fetched;
|
|
1374
|
+
return { ok: true, ...aheadBehind(agentRoot, agentName, config) };
|
|
1375
|
+
}
|
|
1376
|
+
finally {
|
|
1377
|
+
lock.release();
|
|
588
1378
|
}
|
|
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);
|
|
594
|
-
(0, runtime_1.emitNervesEvent)({
|
|
595
|
-
component: "heart",
|
|
596
|
-
event: "heart.sync_push_error",
|
|
597
|
-
message: `post-turn push failed: ${classification}`,
|
|
598
|
-
meta: { agentRoot, error: rebaseError, classification, conflictFiles },
|
|
599
|
-
});
|
|
600
|
-
return { ok: false, error: rebaseError };
|
|
601
1379
|
}
|
|
602
|
-
(
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
message: "post-turn push complete",
|
|
606
|
-
meta: { agentRoot, changedCount },
|
|
607
|
-
});
|
|
608
|
-
clearPendingSync(agentRoot);
|
|
609
|
-
return { ok: true };
|
|
1380
|
+
catch (err) {
|
|
1381
|
+
return { ok: false, error: errorText(err) };
|
|
1382
|
+
}
|
|
610
1383
|
}
|
|
611
1384
|
/**
|
|
612
|
-
*
|
|
613
|
-
*
|
|
614
|
-
*
|
|
615
|
-
* the pre-flight pull with end-to-end `AbortSignal` propagation. The
|
|
616
|
-
* underlying `child_process.execFile` accepts the signal and kills the git
|
|
617
|
-
* child process when it aborts, so a hung remote (DNS hole, slow server)
|
|
618
|
-
* can be cut by the boot timeout wrapper rather than hanging the whole
|
|
619
|
-
* boot.
|
|
620
|
-
*
|
|
621
|
-
* The legacy sync `preTurnPull` is preserved unchanged for the per-turn
|
|
622
|
-
* pipeline at `src/senses/pipeline.ts:522`. The two functions share the
|
|
623
|
-
* same `.git` and remote-availability gates — the only difference is the
|
|
624
|
-
* pull itself: `execFileSync` (no signal) vs `execFile` + `{ signal }`.
|
|
625
|
-
*
|
|
626
|
-
* Honour-the-signal contract:
|
|
627
|
-
* - If `options.signal` is already aborted at call time, the pull is
|
|
628
|
-
* skipped and the result is `{ ok: false, error: "aborted" }`.
|
|
629
|
-
* - If `options.signal` aborts mid-fetch, the child receives `SIGTERM`
|
|
630
|
-
* via Node's built-in AbortSignal handling, and the result is
|
|
631
|
-
* `{ ok: false, error: <abort message> }`.
|
|
632
|
-
* - With no signal supplied, behaviour matches the sync version (subject
|
|
633
|
-
* to the small differences listed above — same git-repo / no-remote
|
|
634
|
-
* gates and same nerves events).
|
|
1385
|
+
* Pre-turn pull: under the bundle lock (waiting up to 5 s, then skipping),
|
|
1386
|
+
* fetch the remote branch and merge it, so the turn reads the owner's latest
|
|
1387
|
+
* edits. With no remote, local changes are committed and the result is ok.
|
|
635
1388
|
*/
|
|
636
|
-
function
|
|
1389
|
+
async function preTurnPull(agentRoot, config, options = {}) {
|
|
1390
|
+
const agentName = options.agentName ?? agentNameFromRoot(agentRoot);
|
|
637
1391
|
(0, runtime_1.emitNervesEvent)({
|
|
638
1392
|
component: "heart",
|
|
639
1393
|
event: "heart.sync_pull_start",
|
|
640
|
-
message: "pre-turn pull starting
|
|
641
|
-
meta: { agentRoot, remote: config.remote },
|
|
1394
|
+
message: "pre-turn pull starting",
|
|
1395
|
+
meta: { agentRoot, remote: config.remote, branch: branchOf(config) },
|
|
642
1396
|
});
|
|
643
|
-
|
|
644
|
-
|
|
1397
|
+
const paused = pausedResult(agentRoot, "pre-turn pull");
|
|
1398
|
+
if (paused)
|
|
1399
|
+
return paused;
|
|
1400
|
+
const repoCheck = ensureGitRepo(agentRoot, "pre-turn pull");
|
|
1401
|
+
if (!repoCheck.ok)
|
|
1402
|
+
return repoCheck;
|
|
645
1403
|
if (options.signal?.aborted) {
|
|
646
1404
|
(0, runtime_1.emitNervesEvent)({
|
|
647
1405
|
level: "warn",
|
|
@@ -650,77 +1408,172 @@ function preTurnPullAsync(agentRoot, config, options = {}) {
|
|
|
650
1408
|
message: "pre-turn pull skipped: signal already aborted",
|
|
651
1409
|
meta: { agentRoot },
|
|
652
1410
|
});
|
|
653
|
-
return
|
|
1411
|
+
return { ok: false, error: "aborted before pull started" };
|
|
654
1412
|
}
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
1413
|
+
let acquired;
|
|
1414
|
+
try {
|
|
1415
|
+
acquired = await (0, sync_lock_1.acquireSyncLock)(agentRoot, exports.__testHooks.preTurnLockWaitMs ?? exports.PRE_TURN_LOCK_WAIT_MS, options.signal);
|
|
1416
|
+
}
|
|
1417
|
+
catch (err) {
|
|
1418
|
+
return { ok: false, error: `aborted while waiting for the sync lock: ${errorText(err)}` };
|
|
1419
|
+
}
|
|
1420
|
+
if ("busy" in acquired) {
|
|
658
1421
|
(0, runtime_1.emitNervesEvent)({
|
|
659
|
-
level: "warn",
|
|
660
1422
|
component: "heart",
|
|
661
|
-
event: "heart.
|
|
662
|
-
message: "pre-turn pull
|
|
663
|
-
meta: { agentRoot },
|
|
1423
|
+
event: "heart.sync_pull_end",
|
|
1424
|
+
message: "pre-turn pull skipped: sync lock busy",
|
|
1425
|
+
meta: { agentRoot, holderPid: acquired.holderPid },
|
|
664
1426
|
});
|
|
665
|
-
return
|
|
1427
|
+
return { ok: true, busy: true, holderPid: acquired.holderPid };
|
|
666
1428
|
}
|
|
667
|
-
|
|
668
|
-
// need cancellation. The hangable op is the actual pull.
|
|
1429
|
+
const lock = trackLock(acquired);
|
|
669
1430
|
try {
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
if (
|
|
1431
|
+
// A pause written while this waited for the lock still wins.
|
|
1432
|
+
const pausedNow = pausedResult(agentRoot, "pre-turn pull");
|
|
1433
|
+
if (pausedNow)
|
|
1434
|
+
return pausedNow;
|
|
1435
|
+
const result = await pullUnlocked(agentRoot, config, agentName, options.signal);
|
|
1436
|
+
if (result.ok) {
|
|
1437
|
+
(0, runtime_1.emitNervesEvent)({ component: "heart", event: "heart.sync_pull_end", message: "pre-turn pull complete", meta: { agentRoot } });
|
|
1438
|
+
}
|
|
1439
|
+
else {
|
|
1440
|
+
(0, runtime_1.emitNervesEvent)({ level: "warn", component: "heart", event: "heart.sync_pull_error", message: "pre-turn pull failed", meta: { agentRoot, error: result.error } });
|
|
1441
|
+
}
|
|
1442
|
+
return result;
|
|
1443
|
+
}
|
|
1444
|
+
finally {
|
|
1445
|
+
lock.release();
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
/** Same as `preTurnPull`, kept for older callers. */
|
|
1449
|
+
function preTurnPullAsync(agentRoot, config, options = {}) {
|
|
1450
|
+
return preTurnPull(agentRoot, config, options);
|
|
1451
|
+
}
|
|
1452
|
+
/**
|
|
1453
|
+
* Post-turn push. Synchronous up to the push: take the lock without waiting
|
|
1454
|
+
* (busy means skip; the next turn commits), commit, then hand the held lock to
|
|
1455
|
+
* `pushAsync`, which is not awaited. `push` in the result is that promise.
|
|
1456
|
+
*/
|
|
1457
|
+
function postTurnPush(agentRoot, config, agentName = agentNameFromRoot(agentRoot)) {
|
|
1458
|
+
(0, runtime_1.emitNervesEvent)({
|
|
1459
|
+
component: "heart",
|
|
1460
|
+
event: "heart.sync_push_start",
|
|
1461
|
+
message: "post-turn push starting",
|
|
1462
|
+
meta: { agentRoot, remote: config.remote, branch: branchOf(config) },
|
|
1463
|
+
});
|
|
1464
|
+
const repoCheck = ensureGitRepo(agentRoot, "post-turn push");
|
|
1465
|
+
if (!repoCheck.ok)
|
|
1466
|
+
return repoCheck;
|
|
1467
|
+
const acquired = (0, sync_lock_1.tryAcquireSyncLockSync)(agentRoot);
|
|
1468
|
+
if ("busy" in acquired) {
|
|
1469
|
+
(0, runtime_1.emitNervesEvent)({
|
|
1470
|
+
component: "heart",
|
|
1471
|
+
event: "heart.sync_push_end",
|
|
1472
|
+
message: "post-turn push skipped: sync lock busy",
|
|
1473
|
+
meta: { agentRoot, holderPid: acquired.holderPid },
|
|
1474
|
+
});
|
|
1475
|
+
return { ok: true, busy: true, holderPid: acquired.holderPid };
|
|
1476
|
+
}
|
|
1477
|
+
const lock = trackLock(acquired);
|
|
1478
|
+
let release = true;
|
|
1479
|
+
try {
|
|
1480
|
+
// Checked under the lock, so a pauser that saw the lock free sees no git after it.
|
|
1481
|
+
const paused = pausedResult(agentRoot, "post-turn push");
|
|
1482
|
+
if (paused)
|
|
1483
|
+
return paused;
|
|
1484
|
+
const repairFailure = repairInterrupted(agentRoot, agentName);
|
|
1485
|
+
if (repairFailure)
|
|
1486
|
+
return repairFailure;
|
|
1487
|
+
commitLocalChanges(agentRoot, agentName, "sync: post-turn update");
|
|
1488
|
+
// The turn waits only up to here: the network push, when there is one, runs after it.
|
|
1489
|
+
(0, runtime_1.emitNervesEvent)({ component: "heart", event: "heart.sync_commit_end", message: "post-turn commit done", meta: { agentRoot } });
|
|
1490
|
+
const remote = hasRemote(agentRoot, agentName, config);
|
|
1491
|
+
if (remote && readPreviousFailure(agentRoot)?.classification === "pull_rebase_conflict") {
|
|
1492
|
+
// The push would be rejected until the conflict is merged; the next integrate retries it.
|
|
676
1493
|
(0, runtime_1.emitNervesEvent)({
|
|
677
1494
|
component: "heart",
|
|
678
|
-
event: "heart.
|
|
679
|
-
message: "
|
|
1495
|
+
event: "heart.sync_push_end",
|
|
1496
|
+
message: "post-turn push: committed, push waits for an unresolved conflict",
|
|
680
1497
|
meta: { agentRoot },
|
|
681
1498
|
});
|
|
682
|
-
return
|
|
1499
|
+
return { ok: true };
|
|
1500
|
+
}
|
|
1501
|
+
if (remote && commitsAhead(agentRoot, agentName, config) === 0) {
|
|
1502
|
+
settlePendingSync(agentRoot);
|
|
1503
|
+
(0, runtime_1.emitNervesEvent)({
|
|
1504
|
+
component: "heart",
|
|
1505
|
+
event: "heart.sync_push_end",
|
|
1506
|
+
message: "post-turn push: nothing to push",
|
|
1507
|
+
meta: { agentRoot },
|
|
1508
|
+
});
|
|
1509
|
+
return { ok: true };
|
|
683
1510
|
}
|
|
1511
|
+
release = false;
|
|
1512
|
+
return { ok: true, push: pushAsync(agentRoot, config, lock) };
|
|
684
1513
|
}
|
|
685
1514
|
catch (err) {
|
|
686
|
-
const error =
|
|
1515
|
+
const error = errorText(err);
|
|
1516
|
+
recordPendingSync(agentRoot, error, "unknown", []);
|
|
687
1517
|
(0, runtime_1.emitNervesEvent)({
|
|
1518
|
+
level: "warn",
|
|
688
1519
|
component: "heart",
|
|
689
|
-
event: "heart.
|
|
690
|
-
message: "
|
|
1520
|
+
event: "heart.sync_push_error",
|
|
1521
|
+
message: "post-turn push failed",
|
|
691
1522
|
meta: { agentRoot, error },
|
|
692
1523
|
});
|
|
693
|
-
return
|
|
1524
|
+
return { ok: false, error };
|
|
694
1525
|
}
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
cwd: agentRoot,
|
|
699
|
-
timeout: 30000,
|
|
700
|
-
};
|
|
701
|
-
if (options.signal) {
|
|
702
|
-
execOptions.signal = options.signal;
|
|
1526
|
+
finally {
|
|
1527
|
+
if (release)
|
|
1528
|
+
lock.release();
|
|
703
1529
|
}
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
}
|
|
717
|
-
(0, runtime_1.emitNervesEvent)({
|
|
718
|
-
component: "heart",
|
|
719
|
-
event: "heart.sync_pull_end",
|
|
720
|
-
message: "pre-turn pull complete (async)",
|
|
721
|
-
meta: { agentRoot },
|
|
722
|
-
});
|
|
723
|
-
resolve({ ok: true });
|
|
724
|
-
});
|
|
1530
|
+
}
|
|
1531
|
+
/**
|
|
1532
|
+
* Manual sync (`ouro sync`, the bundle tools): under one lock, waiting up to
|
|
1533
|
+
* `waitMs`, fetch, integrate and push. Built on the unlocked internals, so it
|
|
1534
|
+
* never waits on its own lock.
|
|
1535
|
+
*/
|
|
1536
|
+
async function syncNow(agentRoot, config, agentName, waitMs, options = {}) {
|
|
1537
|
+
(0, runtime_1.emitNervesEvent)({
|
|
1538
|
+
component: "heart",
|
|
1539
|
+
event: "heart.sync_now_start",
|
|
1540
|
+
message: "sync now starting",
|
|
1541
|
+
meta: { agentRoot, remote: config.remote, branch: branchOf(config) },
|
|
725
1542
|
});
|
|
1543
|
+
const paused = pausedResult(agentRoot, "sync now");
|
|
1544
|
+
if (paused)
|
|
1545
|
+
return paused;
|
|
1546
|
+
const repoCheck = ensureGitRepo(agentRoot, "sync now");
|
|
1547
|
+
if (!repoCheck.ok)
|
|
1548
|
+
return repoCheck;
|
|
1549
|
+
const acquired = await (0, sync_lock_1.acquireSyncLock)(agentRoot, waitMs);
|
|
1550
|
+
if ("busy" in acquired) {
|
|
1551
|
+
const error = `bundle sync lock is held${acquired.holderPid === null ? "" : ` by pid ${acquired.holderPid}`}`;
|
|
1552
|
+
(0, runtime_1.emitNervesEvent)({
|
|
1553
|
+
level: "warn",
|
|
1554
|
+
component: "heart",
|
|
1555
|
+
event: "heart.sync_now_error",
|
|
1556
|
+
message: "sync now: lock busy",
|
|
1557
|
+
meta: { agentRoot, error },
|
|
1558
|
+
});
|
|
1559
|
+
return { ok: false, busy: true, holderPid: acquired.holderPid, error };
|
|
1560
|
+
}
|
|
1561
|
+
const lock = trackLock(acquired);
|
|
1562
|
+
try {
|
|
1563
|
+
const pausedNow = pausedResult(agentRoot, "sync now");
|
|
1564
|
+
if (pausedNow)
|
|
1565
|
+
return pausedNow;
|
|
1566
|
+
const pulled = await pullUnlocked(agentRoot, config, agentName, undefined, "sync: update");
|
|
1567
|
+
const result = pulled.ok ? await pushIfBorn(agentRoot, config, agentName, options) : pulled;
|
|
1568
|
+
if (result.ok) {
|
|
1569
|
+
(0, runtime_1.emitNervesEvent)({ component: "heart", event: "heart.sync_now_end", message: "sync now complete", meta: { agentRoot } });
|
|
1570
|
+
}
|
|
1571
|
+
else {
|
|
1572
|
+
(0, runtime_1.emitNervesEvent)({ level: "warn", component: "heart", event: "heart.sync_now_error", message: "sync now failed", meta: { agentRoot, error: result.error } });
|
|
1573
|
+
}
|
|
1574
|
+
return result;
|
|
1575
|
+
}
|
|
1576
|
+
finally {
|
|
1577
|
+
lock.release();
|
|
1578
|
+
}
|
|
726
1579
|
}
|