@ouro.bot/cli 0.1.0-alpha.990 → 0.1.0-alpha.991

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.
Files changed (37) hide show
  1. package/changelog.json +6 -0
  2. package/deploy/unraid/Dockerfile +15 -2
  3. package/deploy/unraid/README.txt +44 -9
  4. package/deploy/unraid/github_known_hosts +3 -0
  5. package/deploy/unraid/ouro-sync-ssh +98 -0
  6. package/deploy/unraid/ouro-sync.sudoers +3 -0
  7. package/deploy/unraid/sanctuary-butler-upgrade.mjs +26 -2
  8. package/deploy/unraid/sanctuary-replay-gate.mjs +361 -8
  9. package/deploy/unraid/sanctuary-sync-cutover.sh +1039 -0
  10. package/deploy/unraid/sanctuary.ouro/agent.json +2 -1
  11. package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
  12. package/deploy/unraid/sanctuary.ouro/tool-profiles.json +3 -1
  13. package/deploy/unraid/sanctuary.xml +2 -1
  14. package/dist/heart/config.js +2 -1
  15. package/dist/heart/daemon/agent-discovery.js +5 -1
  16. package/dist/heart/daemon/agent-home.js +67 -31
  17. package/dist/heart/daemon/boot-sync-probe.js +36 -10
  18. package/dist/heart/daemon/cli-exec.js +79 -20
  19. package/dist/heart/daemon/cli-help.js +12 -0
  20. package/dist/heart/daemon/cli-parse.js +14 -0
  21. package/dist/heart/daemon/container-spec-auditor-main.js +22 -6
  22. package/dist/heart/daemon/container-spec-auditor.js +18 -2
  23. package/dist/heart/daemon/daemon-entry.js +22 -0
  24. package/dist/heart/daemon/sanctuary-acceptance-harness.js +1 -1
  25. package/dist/heart/daemon/sanctuary-bundle-migration.js +97 -1
  26. package/dist/heart/daemon/sanctuary-package-management.js +10 -0
  27. package/dist/heart/sync-lock.js +431 -0
  28. package/dist/heart/sync.js +1252 -399
  29. package/dist/mind/prompt.js +92 -19
  30. package/dist/repertoire/guardrails.js +285 -3
  31. package/dist/repertoire/tools-bundle.js +107 -185
  32. package/dist/repertoire/tools.js +5 -1
  33. package/dist/senses/pipeline.js +37 -3
  34. package/dist/senses/private-runtime.js +1 -6
  35. package/dist/senses/voice/twilio-phone.js +16 -16
  36. package/npm-shrinkwrap.json +2 -2
  37. package/package.json +1 -1
@@ -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, push and pull. `git add -A` took 17 s on a
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
- function writePendingSync(agentRoot, error, classification, conflictFiles) {
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((0, child_process_1.execFileSync)("git", ["diff", "--cached", "--name-status", "-M", "-z"], {
161
- cwd: agentRoot,
162
- stdio: "pipe",
163
- timeout: exports.SYNC_GIT_TIMEOUT_MS,
164
- maxBuffer: exports.SYNC_GIT_MAX_BUFFER_BYTES,
165
- }).toString());
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
- (0, child_process_1.execFileSync)("git", ["--literal-pathspecs", "reset", "-q", "--", ...unstage], {
188
- cwd: agentRoot,
189
- stdio: "pipe",
190
- timeout: exports.SYNC_GIT_TIMEOUT_MS,
191
- });
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: "post-turn push: left files over the sync size limit uncommitted",
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
- try {
203
- (0, child_process_1.execFileSync)("git", ["diff", "--cached", "--quiet"], {
204
- cwd: agentRoot,
205
- stdio: "pipe",
206
- timeout: exports.SYNC_GIT_TIMEOUT_MS,
207
- });
208
- return false;
209
- }
210
- catch {
211
- return true;
212
- }
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 call
253
- * in this module has a timeout of 30s or less. A lock older than this was left
254
- * behind by a git process that died mid-write; while it exists every `git add`
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 = path.join(agentRoot, ".git", "index.lock");
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: "post-turn push: could not remove stale git index.lock",
285
- meta: { agentRoot, lockPath, error: err instanceof Error ? err.message : /* v8 ignore next -- defensive non-Error catch @preserve */ String(err) },
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: "post-turn push: removed stale git index.lock",
774
+ message: "sync: removed stale git index.lock",
294
775
  meta: { agentRoot, lockPath, ageMs },
295
776
  });
296
777
  }
297
778
  /**
298
- * Pre-turn pull: sync the agent bundle from remote before assembling the start-of-turn packet.
299
- *
300
- * If the bundle has no git remote configured, the pull is skipped and the function
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 preTurnPull(agentRoot, config) {
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.sync_pull_start",
309
- message: "pre-turn pull starting",
310
- meta: { agentRoot, remote: config.remote },
816
+ event: "heart.sync_not_a_repo",
817
+ message: `${step} failed: bundle is not a git repo`,
818
+ meta: { agentRoot },
311
819
  });
312
- // Check that the bundle is actually a git repo before touching git at all.
313
- // Surfaces a clear, actionable error via syncFailure → start-of-turn packet
314
- // so the agent can propose running `git init` (or just do it).
315
- const repoCheck = ensureGitRepo(agentRoot);
316
- if (!repoCheck.ok) {
317
- (0, runtime_1.emitNervesEvent)({
318
- level: "warn",
319
- component: "heart",
320
- event: "heart.sync_not_a_repo",
321
- message: "pre-turn pull failed: bundle is not a git repo",
322
- meta: { agentRoot },
323
- });
324
- return repoCheck;
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
- // Check if any remote is configured. If not, skip the pull (local-only mode).
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
- const remoteOutput = (0, child_process_1.execFileSync)("git", ["remote"], {
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
- const error = err instanceof Error ? err.message : String(err);
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
- (0, child_process_1.execFileSync)("git", ["pull", config.remote], {
355
- cwd: agentRoot,
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
- const error = err instanceof Error ? err.message : String(err);
369
- (0, runtime_1.emitNervesEvent)({
370
- component: "heart",
371
- event: "heart.sync_pull_error",
372
- message: "pre-turn pull failed",
373
- meta: { agentRoot, error },
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
- * Post-turn push: discover dirty files via `git status`, commit, and push.
380
- * Uses git-status-based discovery instead of explicit path tracking, ensuring
381
- * all file writers are captured regardless of whether they call a tracking API.
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 postTurnPush(agentRoot, config) {
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.sync_push_start",
387
- message: "post-turn push starting",
388
- meta: { agentRoot, remote: config.remote },
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
- // Same git-repo check as preTurnPull. This is the more common failure path
391
- // since postTurnPush runs after every turn while preTurnPull only runs on
392
- // user-initiated turns. Prior to this guard, an un-init'd bundle would fail
393
- // the git-status invocation below with a generic "not a git repository"
394
- // error; now we catch it explicitly with an actionable message.
395
- const repoCheck = ensureGitRepo(agentRoot);
396
- if (!repoCheck.ok) {
397
- (0, runtime_1.emitNervesEvent)({
398
- level: "warn",
399
- component: "heart",
400
- event: "heart.sync_not_a_repo",
401
- message: "post-turn push failed: bundle is not a git repo",
402
- meta: { agentRoot },
403
- });
404
- return repoCheck;
405
- }
406
- let statusOutput;
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
- statusOutput = (0, child_process_1.execFileSync)("git", ["status", "--porcelain"], {
409
- cwd: agentRoot,
410
- stdio: "pipe",
411
- timeout: exports.SYNC_GIT_TIMEOUT_MS,
412
- maxBuffer: exports.SYNC_GIT_MAX_BUFFER_BYTES,
413
- }).toString().trim();
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
- const error = err instanceof Error ? err.message : String(err);
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.sync_push_error",
421
- message: "post-turn push: git status failed",
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
- if (statusOutput.length === 0) {
427
- // Nothing to commit, so nothing is skipped for size any more.
428
- writeSkippedSyncFiles(agentRoot, []);
429
- return syncCleanTree(agentRoot, config);
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
- const changedCount = statusOutput.split("\n").length;
432
- clearStaleIndexLock(agentRoot);
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
- (0, child_process_1.execFileSync)("git", ["add", "-A"], {
435
- cwd: agentRoot,
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: "post-turn push: only oversized files changed, nothing committed",
447
- meta: { agentRoot, changedCount, skippedCount: skippedFiles.length },
1187
+ message: "sync: committed locally, no remote configured",
1188
+ meta: { agentRoot },
448
1189
  });
449
- return syncCleanTree(agentRoot, config);
1190
+ return { ok: true };
450
1191
  }
451
- // -q: without it git prints a create/delete line per file, which overran
452
- // the output buffer on a 30,000-file commit after the commit had landed.
453
- (0, child_process_1.execFileSync)("git", ["commit", "-q", "-m", "sync: post-turn update"], {
454
- cwd: agentRoot,
455
- stdio: "pipe",
456
- timeout: exports.SYNC_GIT_TIMEOUT_MS,
457
- maxBuffer: exports.SYNC_GIT_MAX_BUFFER_BYTES,
458
- });
459
- if (!hasRemote(agentRoot)) {
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: "post-turn push: committed locally, no remote configured",
464
- meta: { agentRoot, changedCount },
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
- return pushWithRebaseRetry(agentRoot, config, changedCount);
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 = err instanceof Error ? err.message : String(err);
473
- // Record it so the start-of-turn packet surfaces the failure; otherwise a
474
- // failing local commit is visible only in nerves logs.
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: "post-turn push failed",
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
- * Nothing new to commit. A failure record must not outlive the problem, and
494
- * commits left behind by an earlier failed push still need to reach the
495
- * remote: with no remote, or nothing ahead of the upstream, the record is
496
- * cleared; with unpushed commits, the push is retried.
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 syncCleanTree(agentRoot, config) {
1242
+ async function pushAsync(agentRoot, config, handle, signal) {
499
1243
  try {
500
- if (!hasRemote(agentRoot)) {
501
- clearPendingSync(agentRoot);
502
- }
503
- else {
504
- const ahead = Number.parseInt((0, child_process_1.execFileSync)("git", ["rev-list", "--count", "@{upstream}..HEAD"], {
505
- cwd: agentRoot,
506
- stdio: "pipe",
507
- timeout: 5000,
508
- }).toString().trim(), 10);
509
- if (ahead > 0)
510
- return pushWithRebaseRetry(agentRoot, config, 0);
511
- if (ahead === 0)
512
- clearPendingSync(agentRoot);
513
- }
1244
+ return await pushUnlocked(agentRoot, config, agentNameFromRoot(agentRoot), signal);
514
1245
  }
515
- catch (err) {
516
- // No upstream tracked (or git failed): nothing to compare against, so
517
- // leave any failure record as it is rather than guess.
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.sync_upstream_unknown",
521
- message: "post-turn push: could not compare with upstream",
522
- meta: { agentRoot, error: err instanceof Error ? err.message : /* v8 ignore next -- defensive non-Error catch @preserve */ String(err) },
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.sync_push_end",
528
- message: "post-turn push: no changes to sync",
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
- function pushWithRebaseRetry(agentRoot, config, changedCount) {
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
- (0, child_process_1.execFileSync)("git", ["push", config.remote], {
536
- cwd: agentRoot,
537
- stdio: "pipe",
538
- timeout: exports.SYNC_GIT_TIMEOUT_MS,
539
- maxBuffer: exports.SYNC_GIT_MAX_BUFFER_BYTES,
540
- });
1325
+ remote = hasRemote(agentRoot, agentName, config);
541
1326
  }
542
- catch {
543
- // Push rejected -- try pull-rebase-push. --autostash: an oversized file
544
- // the size guard left uncommitted is an unstaged change, and a plain
545
- // rebase refuses to start while one exists.
546
- let rebaseError = null;
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
- (0, child_process_1.execFileSync)("git", ["pull", "--rebase", "--autostash", config.remote], {
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
- catch (retryErr) {
577
- // Second push rejected — remote advanced again during rebase
578
- const retryError = retryErr instanceof Error ? retryErr.message : /* v8 ignore next -- defensive non-Error catch @preserve */ String(retryErr);
579
- recordPendingSync(agentRoot, retryError, "push_rejected", []);
580
- (0, runtime_1.emitNervesEvent)({
581
- component: "heart",
582
- event: "heart.sync_push_error",
583
- message: "post-turn push failed after retry: push_rejected",
584
- meta: { agentRoot, error: retryError, classification: "push_rejected" },
585
- });
586
- return { ok: false, error: retryError };
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
- (0, runtime_1.emitNervesEvent)({
603
- component: "heart",
604
- event: "heart.sync_push_end",
605
- message: "post-turn push complete",
606
- meta: { agentRoot, changedCount },
607
- });
608
- clearPendingSync(agentRoot);
609
- return { ok: true };
1380
+ catch (err) {
1381
+ return { ok: false, error: errorText(err) };
1382
+ }
610
1383
  }
611
1384
  /**
612
- * Layer 2 — async, signal-aware sibling of `preTurnPull`.
613
- *
614
- * Used by `runBootSyncProbe` (the `ouro up` boot orchestrator) to perform
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 preTurnPullAsync(agentRoot, config, options = {}) {
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 (async)",
641
- meta: { agentRoot, remote: config.remote },
1394
+ message: "pre-turn pull starting",
1395
+ meta: { agentRoot, remote: config.remote, branch: branchOf(config) },
642
1396
  });
643
- // Bail early when the caller has already aborted — saves a git invocation
644
- // and signals failure consistently.
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 Promise.resolve({ ok: false, error: "aborted before pull started" });
1411
+ return { ok: false, error: "aborted before pull started" };
654
1412
  }
655
- // Same .git presence check as the sync version.
656
- const repoCheck = ensureGitRepo(agentRoot);
657
- if (!repoCheck.ok) {
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.sync_not_a_repo",
662
- message: "pre-turn pull failed: bundle is not a git repo (async)",
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 Promise.resolve(repoCheck);
1427
+ return { ok: true, busy: true, holderPid: acquired.holderPid };
666
1428
  }
667
- // Remote-presence check stays sync — it's a fast local op and doesn't
668
- // need cancellation. The hangable op is the actual pull.
1429
+ const lock = trackLock(acquired);
669
1430
  try {
670
- const remoteOutput = (0, child_process_1.execFileSync)("git", ["remote"], {
671
- cwd: agentRoot,
672
- stdio: "pipe",
673
- timeout: 5000,
674
- }).toString().trim();
675
- if (remoteOutput.length === 0) {
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.sync_pull_end",
679
- message: "pre-turn pull skipped: no remote configured (async)",
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 Promise.resolve({ ok: true });
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 = err instanceof Error ? err.message : String(err);
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.sync_pull_error",
690
- message: "pre-turn pull failed: git remote check failed (async)",
1520
+ event: "heart.sync_push_error",
1521
+ message: "post-turn push failed",
691
1522
  meta: { agentRoot, error },
692
1523
  });
693
- return Promise.resolve({ ok: false, error });
1524
+ return { ok: false, error };
694
1525
  }
695
- // The hangable op. `execFile` accepts `{ signal }` and kills the child
696
- // when the signal aborts — that's the whole point of the async path.
697
- const execOptions = {
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
- return new Promise((resolve) => {
705
- (0, child_process_1.execFile)("git", ["pull", config.remote], execOptions, (err) => {
706
- if (err) {
707
- const error = err instanceof Error ? err.message : String(err);
708
- (0, runtime_1.emitNervesEvent)({
709
- component: "heart",
710
- event: "heart.sync_pull_error",
711
- message: "pre-turn pull failed (async)",
712
- meta: { agentRoot, error },
713
- });
714
- resolve({ ok: false, error });
715
- return;
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
  }