@scopebond/agent 0.5.6 → 0.6.1

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 (61) hide show
  1. package/dist/agent.d.ts +6 -0
  2. package/dist/agent.d.ts.map +1 -1
  3. package/dist/agent.js +10 -3
  4. package/dist/agent.js.map +1 -1
  5. package/dist/allowance-sender.js +10 -4
  6. package/dist/allowance-sender.js.map +1 -1
  7. package/dist/apps-entry.d.ts +46 -0
  8. package/dist/apps-entry.d.ts.map +1 -0
  9. package/dist/apps-entry.js +118 -0
  10. package/dist/apps-entry.js.map +1 -0
  11. package/dist/autostart.d.ts +11 -5
  12. package/dist/autostart.d.ts.map +1 -1
  13. package/dist/autostart.js +60 -6
  14. package/dist/autostart.js.map +1 -1
  15. package/dist/awake.d.ts +31 -0
  16. package/dist/awake.d.ts.map +1 -0
  17. package/dist/awake.js +57 -0
  18. package/dist/awake.js.map +1 -0
  19. package/dist/cli-main.d.ts.map +1 -1
  20. package/dist/cli-main.js +28 -1
  21. package/dist/cli-main.js.map +1 -1
  22. package/dist/index.d.ts +12 -7
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +8 -5
  25. package/dist/index.js.map +1 -1
  26. package/dist/ipc.d.ts +9 -4
  27. package/dist/ipc.d.ts.map +1 -1
  28. package/dist/ipc.js +15 -9
  29. package/dist/ipc.js.map +1 -1
  30. package/dist/log-file.js.map +1 -1
  31. package/dist/native-update.d.ts +83 -7
  32. package/dist/native-update.d.ts.map +1 -1
  33. package/dist/native-update.js +230 -33
  34. package/dist/native-update.js.map +1 -1
  35. package/dist/prompt.d.ts.map +1 -1
  36. package/dist/prompt.js +1 -0
  37. package/dist/prompt.js.map +1 -1
  38. package/dist/selfcheck.d.ts.map +1 -1
  39. package/dist/selfcheck.js +4 -1
  40. package/dist/selfcheck.js.map +1 -1
  41. package/dist/service.d.ts +20 -3
  42. package/dist/service.d.ts.map +1 -1
  43. package/dist/service.js +141 -44
  44. package/dist/service.js.map +1 -1
  45. package/dist/setup.d.ts +12 -0
  46. package/dist/setup.d.ts.map +1 -1
  47. package/dist/setup.js +42 -13
  48. package/dist/setup.js.map +1 -1
  49. package/dist/tray-model.d.ts +2 -0
  50. package/dist/tray-model.d.ts.map +1 -1
  51. package/dist/tray-model.js +2 -0
  52. package/dist/tray-model.js.map +1 -1
  53. package/dist/update.d.ts +56 -12
  54. package/dist/update.d.ts.map +1 -1
  55. package/dist/update.js +209 -38
  56. package/dist/update.js.map +1 -1
  57. package/dist/upkeep.d.ts +16 -0
  58. package/dist/upkeep.d.ts.map +1 -0
  59. package/dist/upkeep.js +55 -0
  60. package/dist/upkeep.js.map +1 -0
  61. package/package.json +4 -4
package/dist/service.js CHANGED
@@ -4,13 +4,13 @@
4
4
  // versions to run, and once a day it runs the end-to-end self-check.
5
5
  import { spawn } from "node:child_process";
6
6
  import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
7
- import { hostname } from "node:os";
7
+ import { hostname, uptime } from "node:os";
8
8
  import { join } from "node:path";
9
- import { actOnBlocked, blockedQuestion, hookVersion, isSingleExecutable, isManaged, loadConnection, localActivity, readBlocked, readMeta, ruleReport, runStoreUpkeep, writeDeliveryState, userHarnessFile, writeHarnessConfig } from "@scopebond/hook";
9
+ import { actOnBlocked, blockedQuestion, hookVersion, isSingleExecutable, isManaged, loadConnection, localActivity, noteUnresolvableHooks, readBlocked, readMeta, ruleReport, writeDeliveryState, userHarnessFile, writeHarnessConfig } from "@scopebond/hook";
10
10
  import { computerStatus, expectedHarnesses, missingHookEntries, runCycle } from "./agent.js";
11
11
  import { agentCliPath } from "./self.js";
12
- import { fetchVerifiedInstaller, installAfterExit, installKind } from "./native-update.js";
13
- import { AGENT_LOG_ENV, launcherIsCurrent, launcherPath, refreshLauncher, startCommands } from "./autostart.js";
12
+ import { fetchVerifiedInstaller, installAfterExit, installerUnchanged, installKind, nativeTrayPath, takeInstallResult } from "./native-update.js";
13
+ import { AGENT_LOG_ENV, launcherIsCurrent, launcherPath, refreshLauncher, retireLauncherRunValue, startCommands } from "./autostart.js";
14
14
  import { callAgent, startControl } from "./ipc.js";
15
15
  import { runSelfCheck } from "./selfcheck.js";
16
16
  import { flushReasons, queueReason } from "./override-reasons.js";
@@ -21,7 +21,9 @@ import { sendAllowancesAndRequests } from "./allowance-sender.js";
21
21
  import { checkResult, trayModel } from "./tray-model.js";
22
22
  import { fetchComputerSummary, openInBrowser, sameOrigin } from "./summary.js";
23
23
  import { startReconnect } from "./reconnect.js";
24
- import { agentVersion, compareVersions, fetchClientVersion, installAgent, maintainHookEntries, maintainedHookCommand } from "./update.js";
24
+ import { agentVersion, compareVersions, fetchClientVersion, installAgent, maintainHookEntries, maintainedHookCommand, recentlyFailedUpdate, recordFailedUpdate, startCheck, } from "./update.js";
25
+ import { runUpkeepApart } from "./upkeep.js";
26
+ import { awakeSinceAtStart, readAwake, writeAwake } from "./awake.js";
25
27
  export const AGENT_VERSION = `agent/${agentVersion()}`;
26
28
  const INTERVAL_MS = 60_000;
27
29
  const MAX_BACKOFF_MS = 15 * 60_000;
@@ -29,6 +31,9 @@ const UPDATE_EVERY_MS = 6 * 60 * 60 * 1000;
29
31
  const SELF_CHECK_EVERY_MS = 24 * 60 * 60 * 1000;
30
32
  const SUMMARY_EVERY_MS = 5 * 60 * 1000;
31
33
  export const AFTER_PID_ENV = "SCOPEBOND_AGENT_AFTER_PID";
34
+ /** Left in the Scopebond folder when this computer's user stops the agent (`scopebond-agent stop`), and removed when it
35
+ * starts: the native tray then leaves it stopped instead of starting it again. */
36
+ export const STOPPED_FILE = "agent-stopped.json";
32
37
  /** The person's tray settings (D143: notifications about problems by default, none about blocks). */
33
38
  export const TRAY_SETTINGS_FILE = "agent-settings.json";
34
39
  export function readTraySettings(dir) {
@@ -113,8 +118,11 @@ export async function takeOver(dir, cli, waitMs = 30_000) {
113
118
  const refresh = process.env[REFRESH_LAUNCHER_ENV] === "1";
114
119
  delete process.env[AFTER_PID_ENV];
115
120
  delete process.env[REFRESH_LAUNCHER_ENV];
121
+ // The agent being replaced stops serving (control channel closed, lock released) and then waits for this one to answer
122
+ // before it exits, so it can go back to its own version if this one never starts. Unless the launcher must be rewritten
123
+ // (nothing may still run it), its stopping is enough.
116
124
  if (Number.isInteger(previous) && previous > 0)
117
- await waitForExit(previous, waitMs);
125
+ await waitForExit(previous, waitMs, refresh ? undefined : () => !lockHeldBy(dir, previous));
118
126
  if (!refresh)
119
127
  return;
120
128
  // The old launcher reads its last lines after its agent exits; give it a moment to finish before its file changes.
@@ -127,7 +135,7 @@ export async function takeOver(dir, cli, waitMs = 30_000) {
127
135
  console.log(`${new Date().toISOString()} could not rewrite the autostart launcher: ${error.message}`);
128
136
  }
129
137
  }
130
- export async function waitForExit(pid, timeoutMs) {
138
+ export async function waitForExit(pid, timeoutMs, released) {
131
139
  const until = Date.now() + timeoutMs;
132
140
  while (Date.now() < until) {
133
141
  try {
@@ -136,9 +144,33 @@ export async function waitForExit(pid, timeoutMs) {
136
144
  catch {
137
145
  return;
138
146
  }
147
+ if (released?.())
148
+ return;
139
149
  await new Promise((r) => setTimeout(r, 250));
140
150
  }
141
151
  }
152
+ /** Whether the one-agent lock names `pid`. */
153
+ export function lockHeldBy(dir, pid) {
154
+ try {
155
+ return readFileSync(join(dir, AGENT_LOCK), "utf8").trim().split(/\s+/)[0] === String(pid);
156
+ }
157
+ catch {
158
+ return false;
159
+ }
160
+ }
161
+ /** How long the agent that handed over waits for its replacement to answer before going back to its own version. */
162
+ export const HANDOVER_CONFIRM_MS = 3 * 60_000;
163
+ /** Wait until an agent other than this process answers on the local channel reporting `version`. */
164
+ export async function waitForAgentVersion(dir, version, timeoutMs) {
165
+ const until = Date.now() + timeoutMs;
166
+ while (Date.now() < until) {
167
+ const status = await callAgent(dir, "GET", "/status", undefined, 2_000);
168
+ if (status?.agent && status.agent.pid !== process.pid && status.agent.version === `agent/${version}`)
169
+ return true;
170
+ await new Promise((r) => setTimeout(r, 1_000));
171
+ }
172
+ return false;
173
+ }
142
174
  export const AGENT_LOCK = "agent.lock";
143
175
  /** How long a lock whose agent never answered on its local channel still counts as an agent starting up. */
144
176
  export const AGENT_LOCK_STARTING_MS = 60_000;
@@ -197,7 +229,13 @@ export async function startService(options) {
197
229
  const lock = acquireAgentLock(options.dir);
198
230
  if (!lock)
199
231
  throw new Error("a Scopebond Agent is already running for this computer");
232
+ rmSync(join(options.dir, STOPPED_FILE), { force: true });
233
+ // What the install helper of a signed update recorded before this start (installAfterExit).
234
+ const installed = takeInstallResult(options.dir);
235
+ if (installed)
236
+ log(installed.installed ? `the Scopebond Agent ${installed.version} was installed` : `the update to ${installed.version} was not installed: ${installed.reason ?? "unknown"}`);
200
237
  const interval = options.intervalMs ?? INTERVAL_MS;
238
+ const nativeTray = process.platform === "win32" ? nativeTrayPath() : null;
201
239
  let last = null;
202
240
  let lastMaintenance = null;
203
241
  let failures = 0;
@@ -209,8 +247,17 @@ export async function startService(options) {
209
247
  let stopped = false;
210
248
  let lastSelfCheckAt = 0;
211
249
  // SB388: time asleep never counts as records waiting. A gap between cycles longer than the schedule allows means the
212
- // computer slept (or the agent was stopped); waiting is counted again from the next cycle.
213
- let awakeSince = Date.now();
250
+ // computer slept (or the agent was stopped); waiting is counted again from the next cycle. The wake time is kept across
251
+ // restarts of the agent alone (awake.ts), so a restart never hides a backlog; after a long gap the waiting records decide.
252
+ const allowedGapMs = Math.max(3 * interval, 5 * 60_000);
253
+ const startedAt = Date.now();
254
+ let oldestPendingAt = null;
255
+ try {
256
+ const age = computerStatus(options.dir, startedAt).delivery.oldest_pending_age_s;
257
+ oldestPendingAt = age === null ? null : startedAt - age * 1000;
258
+ }
259
+ catch { /* no store yet: nothing waiting */ }
260
+ let awakeSince = awakeSinceAtStart({ saved: readAwake(options.dir), now: startedAt, uptimeMs: uptime() * 1000, allowedGapMs, oldestPendingAt });
214
261
  let lastCycleAt = 0;
215
262
  // A long step the person should see as "working" (an update), or null.
216
263
  let working = null;
@@ -251,17 +298,18 @@ export async function startService(options) {
251
298
  if (running)
252
299
  return running;
253
300
  const started = Date.now();
254
- const allowedGap = failures ? MAX_BACKOFF_MS + 5 * 60_000 : Math.max(3 * interval, 5 * 60_000);
301
+ const allowedGap = failures ? MAX_BACKOFF_MS + 5 * 60_000 : allowedGapMs;
255
302
  if (lastCycleAt && started - lastCycleAt > allowedGap)
256
303
  awakeSince = started;
257
304
  lastCycleAt = started;
305
+ writeAwake(options.dir, { awake_since: awakeSince, last_cycle_at: started });
258
306
  cycleStartedAt = started;
259
307
  // A cycle that does not finish within its limit is left behind (its requests time out on their own), counted as
260
308
  // a failure, and the loop goes on. Nothing — the next cycle, Send now, stop — waits on it.
261
309
  let limitTimer;
262
310
  const overrun = new Promise((resolve) => {
263
311
  limitTimer = setTimeout(() => resolve({ at: started, connected: true, delivered: 0, pending: computerStatus(options.dir).delivery.pending,
264
- deliveryError: `a delivery cycle did not finish within ${Math.round(cycleLimit / 1000)} s`, rules: "skipped", missingHookEntries: [], requested: null }), cycleLimit);
312
+ deliveryError: `a delivery cycle did not finish within ${Math.round(cycleLimit / 1000)} s`, rules: "skipped", missingHookEntries: [], requested: null, more: false }), cycleLimit);
265
313
  limitTimer.unref?.();
266
314
  });
267
315
  const work = runCycle({ dir: options.dir, fetchImpl: options.fetchImpl, deliveryTimeoutMs: options.deliveryTimeoutMs });
@@ -287,6 +335,9 @@ export async function startService(options) {
287
335
  log("the workspace asked this computer to send now");
288
336
  setTimeout(() => { void cycle(); }, 0);
289
337
  }
338
+ // A long queue: this cycle's sending time ran out, so the next one starts right away instead of at the next interval.
339
+ else if (result.more && !stopped)
340
+ setTimeout(() => { void cycle(); }, 1_000);
290
341
  if (result.requested === "self_check") {
291
342
  log("the workspace asked this computer to check now");
292
343
  setTimeout(() => { void maintain(true); }, 0);
@@ -306,59 +357,73 @@ export async function startService(options) {
306
357
  if (target)
307
358
  result.policy = target.policy;
308
359
  const current = agentVersion();
309
- // Hook entries first: under "recommended" they move to the newest hook named or carried; under "hold" only broken ones
310
- // are repaired, at the version they already name or the one the agent carries. Done before a self-update, so an update
311
- // whose handover fails never leaves the hook behind.
312
- const hookTarget = target?.policy === "recommended" && target.hook && compareVersions(target.hook, hookVersion()) > 0 ? target.hook : hookVersion();
313
- result.hookEntries = maintainHookEntries(harnesses, hookTarget, target?.policy !== "hold");
360
+ // Hook entries first: always the hook this agent carries, by its path (never a version the workspace names, and never
361
+ // an npx form resolved at run time). Under "hold" only broken ones are repaired. After an update the new agent pins
362
+ // them to the hook it carries at its own first upkeep.
363
+ // Codex and Cursor send nothing between actions, so a hook entry of theirs that cannot start is kept as a gap the
364
+ // workspace hears of, before the repair below (the actions in between ran unchecked either way).
365
+ const outage = noteUnresolvableHooks(options.dir, harnesses.filter((h) => h === "codex" || h === "cursor"));
366
+ if (outage.recorded.length)
367
+ log(`hook entry cannot start for ${outage.recorded.join(", ")}: kept as a gap for the workspace`);
368
+ result.hookEntries = maintainHookEntries(harnesses, target?.policy !== "hold");
314
369
  for (const change of result.hookEntries)
315
370
  log(`${change.reason}: ${change.file}`);
316
371
  const kind = installKind();
317
- if (target?.policy === "recommended" && target.agent && compareVersions(target.agent, current) > 0 && kind === "per-machine") {
372
+ const newer = target?.policy === "recommended" && target.agent && compareVersions(target.agent, current) > 0 ? target.agent : null;
373
+ if (newer && kind === "per-machine") {
318
374
  // Installed for every user: the organisation that deployed it updates it.
319
- log(`the workspace recommends the Scopebond Agent ${target.agent}; this install is updated by your organization`);
375
+ log(`the workspace recommends the Scopebond Agent ${newer}; this install is updated by your organization`);
320
376
  }
321
- else if (target?.policy === "recommended" && target.agent && compareVersions(target.agent, current) > 0 && kind === "per-user") {
322
- log(`updating the Scopebond Agent ${current} -> ${target.agent} (signed installer)`);
323
- const verified = await fetchVerifiedInstaller(target.agent, { dir: options.dir, fetchImpl: options.fetchImpl });
324
- if (verified.ok) {
325
- result.updatedTo = target.agent;
377
+ else if (newer && kind === "per-user") {
378
+ log(`updating the Scopebond Agent ${current} -> ${newer} (signed installer)`);
379
+ const verified = await fetchVerifiedInstaller(newer, { dir: options.dir, fetchImpl: options.fetchImpl });
380
+ if (verified.ok && installerUnchanged(verified)) {
381
+ result.updatedTo = newer;
326
382
  lastMaintenance = result;
327
383
  if (options.onUpdated)
328
- options.onUpdated(target.agent);
384
+ options.onUpdated(newer);
329
385
  else {
330
- // The installer replaces this file, so it runs after this agent has exited, and starts the agent again.
331
- installAfterExit(verified.path, process.pid, existsSync(launcherPath(options.dir)) ? launcherPath(options.dir) : null);
386
+ // The installer replaces this file, so it runs after this agent has exited, checks it again, and starts the tray
387
+ // (which starts the agent) or the agent again.
388
+ installAfterExit(verified, process.pid, existsSync(launcherPath(options.dir)) ? launcherPath(options.dir) : null, nativeTray);
332
389
  setTimeout(() => { setTimeout(() => process.exit(0), 5_000).unref(); void stop().finally(() => process.exit(0)); }, 500);
333
390
  }
334
391
  return result;
335
392
  }
336
- result.error = `update to ${target.agent} could not be verified: ${verified.reason}; nothing was installed`;
393
+ result.error = `update to ${newer} could not be verified: ${verified.ok ? "the installer changed after it was checked" : verified.reason}; nothing was installed`;
337
394
  log(result.error);
338
395
  }
339
- else if (target?.policy === "recommended" && target.agent && compareVersions(target.agent, current) > 0) {
340
- log(`updating the Scopebond Agent ${current} -> ${target.agent}`);
341
- const installed = await installAgent(target.agent);
342
- if (installed.ok) {
343
- result.updatedTo = target.agent;
396
+ else if (newer && recentlyFailedUpdate(options.dir, newer)) {
397
+ log(`the Scopebond Agent ${newer} did not start here earlier; staying on ${current} for now`);
398
+ }
399
+ else if (newer) {
400
+ log(`updating the Scopebond Agent ${current} -> ${newer}`);
401
+ const installed = await installAgent(newer, { fetchImpl: options.fetchImpl });
402
+ const started = installed.ok ? await (options.startCheck ?? startCheck)(newer) : null;
403
+ if (installed.ok && started?.ok) {
404
+ result.updatedTo = newer;
344
405
  lastMaintenance = result;
345
406
  if (options.onUpdated)
346
- options.onUpdated(target.agent);
347
- else {
348
- const code = spawnReplacement(options.dir) ? 0 : RESTART_EXIT_CODE;
349
- // Hand over: stop, then exit. A stop that never finishes (a tray or window child that will not close) must not
350
- // keep the old agent alive with the replacement waiting on it, so the exit has a hard deadline.
351
- setTimeout(() => { setTimeout(() => process.exit(code), 5_000).unref(); void stop().finally(() => process.exit(code)); }, 500);
352
- }
407
+ options.onUpdated(newer);
408
+ else
409
+ handOver(newer, current);
353
410
  return result;
354
411
  }
355
- result.error = `update to ${target.agent} failed: ${installed.output.slice(-300)}`;
412
+ if (installed.ok) {
413
+ // Installed, but the new program does not start: put the running version back.
414
+ recordFailedUpdate(options.dir, newer);
415
+ const back = await installAgent(current, { provenance: false });
416
+ result.error = `the Scopebond Agent ${newer} did not start (${(started?.output ?? "").slice(-200)}); ${back.ok ? `went back to ${current}` : `going back to ${current} failed: ${back.output.slice(-200)}`}`;
417
+ }
418
+ else
419
+ result.error = `update to ${newer} failed: ${installed.output.slice(-300)}`;
356
420
  log(result.error);
357
421
  }
358
422
  // The local store: older rows rewritten, acknowledged receipts past the retention window removed, space returned.
359
423
  // The agent may rewrite the whole file once (the hook never does that during a tool call).
360
424
  try {
361
- result.store = runStoreUpkeep(options.dir, { budgetMs: 30_000, allowFullVacuum: true });
425
+ // In a process of its own: a pass is synchronous SQLite work for up to 30 s, and the agent keeps answering meanwhile.
426
+ result.store = await (options.upkeep ?? runUpkeepApart)(options.dir);
362
427
  const kept = result.store;
363
428
  if (kept && (kept.migrated || kept.receiptsRemoved || kept.stateRemoved || kept.pagesFreed)) {
364
429
  log(`local store: ${kept.migrated} row(s) rewritten, ${kept.receiptsRemoved} receipt(s) past retention removed, ${kept.stateRemoved} finished check record(s) removed, ${kept.pagesFreed} page(s) returned`);
@@ -494,6 +559,10 @@ export async function startService(options) {
494
559
  // `scopebond-agent stop` and `autostart off`: answer first, then stop, so the caller hears back.
495
560
  "POST /stop": () => {
496
561
  log("stopping: asked to by this computer's user");
562
+ try {
563
+ writeFileSync(join(options.dir, STOPPED_FILE), `${JSON.stringify({ stopped_at: new Date().toISOString(), pid: process.pid })}\n`);
564
+ }
565
+ catch { /* the tray may start it again */ }
497
566
  setTimeout(() => { void stop().finally(() => options.onStopped?.()); }, 50);
498
567
  return { stopping: true };
499
568
  },
@@ -514,8 +583,11 @@ export async function startService(options) {
514
583
  setTimeout(() => { void sendReasons(); }, 2_000);
515
584
  return answer;
516
585
  },
517
- });
518
- const tray = (options.tray ?? true) && process.env.SCOPEBOND_AGENT_TRAY !== "off" ? startTray(options.dir) : null;
586
+ }, nativeTray ? { loopback: false } : {}); // beside the native tray nothing uses loopback: its tray and its hook use the pipe
587
+ // Beside the native tray (the signed install), the tray is that program: it started this agent, or starts at sign-in.
588
+ const tray = (options.tray ?? true) && process.env.SCOPEBOND_AGENT_TRAY !== "off" && !nativeTray ? startTray(options.dir) : null;
589
+ if (nativeTray && retireLauncherRunValue())
590
+ log("removed the ScopebondAgent sign-in entry: the Scopebond tray starts the agent");
519
591
  const stop = async () => {
520
592
  stopped = true;
521
593
  try {
@@ -532,7 +604,32 @@ export async function startService(options) {
532
604
  await control.close();
533
605
  releaseAgentLock(lock);
534
606
  };
535
- log(`Scopebond Agent ${agentVersion()} running for ${options.dir} (control on 127.0.0.1:${control.endpoint.port})`);
607
+ /** Hand over to the agent just installed: start it, stop serving, and wait for it to answer with the new version. If it
608
+ * never does, put `previous` back, start that, and exit. A service manager (systemd) restarts the agent itself instead. */
609
+ const handOver = (version, previous) => {
610
+ setTimeout(() => {
611
+ void (async () => {
612
+ // Whatever happens below, this process ends.
613
+ setTimeout(() => process.exit(0), HANDOVER_CONFIRM_MS + 10 * 60_000).unref();
614
+ const spawned = spawnReplacement(options.dir);
615
+ // A stop that never finishes (a tray or window child that will not close) must not keep this agent serving.
616
+ await Promise.race([stop().catch(() => undefined), new Promise((r) => setTimeout(r, 5_000))]);
617
+ if (!spawned)
618
+ process.exit(RESTART_EXIT_CODE);
619
+ if (await waitForAgentVersion(options.dir, version, HANDOVER_CONFIRM_MS))
620
+ process.exit(0);
621
+ log(`the Scopebond Agent ${version} did not answer within ${Math.round(HANDOVER_CONFIRM_MS / 1000)} s; going back to ${previous}`);
622
+ recordFailedUpdate(options.dir, version);
623
+ const back = await installAgent(previous, { provenance: false });
624
+ log(back.ok ? `went back to the Scopebond Agent ${previous}` : `going back to ${previous} failed: ${back.output.slice(-300)}`);
625
+ // The replacement's launcher may restart it on the files just put back; otherwise start the previous version now.
626
+ if (!await waitForAgentVersion(options.dir, previous, 45_000))
627
+ spawnReplacement(options.dir);
628
+ process.exit(0);
629
+ })();
630
+ }, 500);
631
+ };
632
+ log(`Scopebond Agent ${agentVersion()} running for ${options.dir} (control on ${[control.endpoint.socket, control.endpoint.port ? `127.0.0.1:${control.endpoint.port}` : null].filter(Boolean).join(" and ")})`);
536
633
  // Maintenance is armed before the first cycle, so a first cycle that hangs cannot keep it from ever starting.
537
634
  if (options.maintenance !== false) {
538
635
  // Shortly after start (so a fresh sign-in is not slowed), then every six hours.