relay-companion 0.1.549 → 0.1.550

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.
@@ -505,7 +505,10 @@ async function launch({ root = __dirname, run = runChild, now = Date.now, env =
505
505
  // to an older bundle here would hand the problem to a runner that only
506
506
  // knows how to download.
507
507
  const networkFailure = /fetch failed|offline|ENOTFOUND|ECONN|ETIMEDOUT|manifest-http-|channel-discovery-http-|download.*(timed out|stalled|ended early|failed after)|configuration-unavailable/i.test(report?.lastError || "");
508
- const retryableReport = ["disabled", "backoff", "emergency-backoff", "restart-failed", "reactivate-failed", "service-repair-failed", "service-repair-unhealthy"].includes(report?.status) || (report?.status === "failed" && networkFailure);
508
+ // A worker that lost the canonical lock to a live installer judged nothing
509
+ // about this bundle either; the runner normally reports that as a deferral.
510
+ const lostTransaction = /recovery-worker-exit-75|transaction-in-progress/i.test(report?.lastError || "");
511
+ const retryableReport = ["disabled", "backoff", "emergency-backoff", "restart-failed", "reactivate-failed", "service-repair-failed", "service-repair-unhealthy"].includes(report?.status) || (report?.status === "failed" && (networkFailure || lostTransaction));
509
512
  if (reported && retryableReport && result.reason !== "deadline") {
510
513
  write(path.join(root, "launcher-status.json"), { schema: 1, at: now(), status: "runner-error", version: candidate.version });
511
514
  log("done status=runner-error");
@@ -9,11 +9,16 @@ const crypto = require("node:crypto");
9
9
  const { spawn, spawnSync } = require("node:child_process");
10
10
  const { stageVerifiedRuntime, releasePlatform } = require("./relay-setup.cjs");
11
11
  const { verifyReleaseEnvelope } = require("./release-signature.cjs");
12
+ const { inFlightTransaction, workerLostLock } = require("./recovery-transaction.cjs");
12
13
  const trust = require("./trust.json");
13
14
  const CHECK_MS = 5 * 60_000;
14
15
  const DEADLINE_MS = 25 * 60_000;
15
16
  const HEARTBEAT_MS = 60_000;
16
17
  const BUSY_GRACE_MS = 15 * 60_000;
18
+ // A runtime committed moments ago is still starting its services. Judging it
19
+ // dead inside this window restarts or replaces a release that was about to
20
+ // answer, and on 2026-09-21 that judgement rolled back a healthy update.
21
+ const STARTUP_GRACE_MS = 3 * 60_000;
17
22
 
18
23
  function read(file) { try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; } }
19
24
  function write(file, value) {
@@ -157,6 +162,7 @@ async function recoverLocked({ homeDir = os.homedir(), env = process.env, now =
157
162
  verifyReady = require("./recovery-readiness.cjs").waitForRecoveryReady,
158
163
  policyFactory = require("./recovery-policy.cjs").recoveryPolicy,
159
164
  validateLocal = require("./recovery-local.cjs").validateLocalRuntime,
165
+ transactions = inFlightTransaction,
160
166
  repairServices = require("./mac-service-recovery.cjs").repairMacServiceRegistrations } = {}) {
161
167
  const root = path.join(homeDir, ".relay");
162
168
  // Local availability is independent of update eligibility, network, memory,
@@ -206,6 +212,18 @@ async function recoverLocked({ homeDir = os.homedir(), env = process.env, now =
206
212
  log(`status=${value.status}${value.desiredVersion ? ` desired=${value.desiredVersion}` : ""}${value.lastError ? ` error=${value.lastError}` : ""}`);
207
213
  return value;
208
214
  };
215
+ // Another live process owns the canonical transaction: the daemon's own
216
+ // updater, a manual `relay update`, or a previous runner still activating.
217
+ // Whatever it installs is judged by readiness later, never by this runner
218
+ // losing a race to it. The staged download, if any, is swept by the caller.
219
+ const deferToTransaction = (extra = {}) => {
220
+ let transaction = null;
221
+ try { transaction = transactions({ homeDir, now: now() }); } catch (error) { log(`transaction check failed: ${error.message}`); }
222
+ if (!transaction) return null;
223
+ log(`canonical transaction in flight: owner pid=${transaction.pid} request=${transaction.requestId || "-"} version=${transaction.version || "-"}`);
224
+ return status({ ok: true, status: "deferred-update-in-flight", runtimeHealthy: false, ...extra,
225
+ transaction: { pid: transaction.pid, requestId: transaction.requestId, version: transaction.version } });
226
+ };
209
227
  if (services.status === "deferred-update-owner") return status({ ...services, runtimeHealthy: false });
210
228
  if (services.changed || !services.ok) {
211
229
  const observed = await ready();
@@ -271,10 +289,18 @@ async function recoverLocked({ homeDir = os.homedir(), env = process.env, now =
271
289
  memoryFreeMB: memoryNow.freeMB };
272
290
  log(`installed ${version} not healthy: heartbeatFresh=${heartbeatFresh} daemonAlive=${daemonAlive} health=${JSON.stringify({ daemon: live?.daemon, pill: live?.pill, oldDaemon: live?.oldDaemon, oldPill: live?.oldPill })} readiness=${readiness ? `${readiness.reason || "ok"}${readiness.detail ? ` (${readiness.detail})` : ""}` : "not-attempted"} memoryPressured=${memoryNow.pressured}`);
273
291
  if (daemonAlive && now() - staleSince < STALE_CONFIRM_MS) return status({ ok: true, status: "stale-observed", ...base });
292
+ const deferred = deferToTransaction(base);
293
+ if (deferred) return deferred;
294
+ const repeatedCrash = require("./daemon-progress.cjs").repeatedStartupCrash(current, { homeDir, now: now() });
295
+ // A commit seconds ago means services are still coming up. Two matching
296
+ // startup crashes are proof of a broken release and end the grace early.
297
+ const committedAt = Number(current.committedAt);
298
+ if (!repeatedCrash && Number.isFinite(committedAt) && committedAt <= now() && now() - committedAt < STARTUP_GRACE_MS) {
299
+ return status({ ok: true, status: "starting", ...base, startupGraceRemainingMs: STARTUP_GRACE_MS - (now() - committedAt) });
300
+ }
274
301
  const busy = busyDecision(heartbeat, { homeDir, now: now() });
275
302
  if (busy) return status({ ok: true, status: busy, ...base });
276
303
  const restartKey = `restart:${current.packageRoot || current.version}`;
277
- const repeatedCrash = require("./daemon-progress.cjs").repeatedStartupCrash(current, { homeDir, now: now() });
278
304
  if (repeatedCrash) {
279
305
  log(`repeated startup crash; skipping restart/reactivation for ${version}`);
280
306
  policy.failure(channel, version, { id: `startup:${current.packageRoot}:${repeatedCrash.fingerprint}`, reason: "repeated-startup-crash" });
@@ -318,7 +344,13 @@ async function recoverLocked({ homeDir = os.homedir(), env = process.env, now =
318
344
  }
319
345
  // Try a distinct, previously committed local release before requiring a
320
346
  // network download. Validation reads the tree without executing its imports.
347
+ // An inactive journal is an incomplete update only once its owner is gone.
348
+ // While the owner lives, the journal is an update in progress.
321
349
  const failedCandidate = current?.active !== true ? current?.candidate : null;
350
+ if (failedCandidate?.version || current?.active !== true) {
351
+ const deferred = deferToTransaction({ desiredVersion, version: current?.version });
352
+ if (deferred) return deferred;
353
+ }
322
354
  if (failedCandidate?.version) policy.failure(channel, failedCandidate.version, { id: `journal:${failedCandidate.packageRoot || failedCandidate.version}`, reason: "incomplete-update" });
323
355
  const good = read(path.join(root, "recovery", "runtime-good.json"));
324
356
  const olderGood = read(path.join(root, "recovery", "runtime-previous-good.json"));
@@ -333,7 +365,17 @@ async function recoverLocked({ homeDir = os.homedir(), env = process.env, now =
333
365
  const observed = await ready({ version: target.version }, activationAt);
334
366
  if (!observed.ok) throw Error("local-runtime-not-healthy");
335
367
  return proven({ ok: true, status: "current", desiredVersion, version: target.version, repair: "local", lastSuccessAt: now(), failures: 0 }, observed);
336
- } catch (error) { progress.fail(error.message); log('local recovery failed: ' + error.message); }
368
+ } catch (error) {
369
+ // A worker that lost the lock did not fail to restore anything: someone
370
+ // else is installing. Give the attempt back and let them finish.
371
+ const lostLock = workerLostLock(error);
372
+ const deferred = deferToTransaction({ desiredVersion, version: current?.version });
373
+ if (deferred || lostLock) {
374
+ progress.refund('local:' + target.packageRoot);
375
+ return deferred || status({ ok: true, status: "deferred-update-in-flight", desiredVersion, version: current?.version, runtimeHealthy: false });
376
+ }
377
+ progress.fail(error.message); log('local recovery failed: ' + error.message);
378
+ }
337
379
  }
338
380
  if (discoveryError) throw discoveryError;
339
381
  let quarantine = policy.decision(channel, desiredVersion);
@@ -346,6 +388,10 @@ async function recoverLocked({ homeDir = os.homedir(), env = process.env, now =
346
388
  if (quarantine.blocked) return status({ ok: true, status: "deferred-release-cooldown", desiredVersion, runtimeHealthy: runtimeVerified, runtimeAvailable: runtimeResponsive, retryAt: quarantine.retryAt, failures: quarantine.failures });
347
389
  const busy = busyDecision(heartbeat, { homeDir, now: now() });
348
390
  if (busy) return status({ ok: true, status: busy, desiredVersion, ...repairState });
391
+ // Never start a five-minute download to race an installer that is already
392
+ // running; the daemon's updater discovers the same release we just did.
393
+ const deferredBeforeDownload = deferToTransaction({ desiredVersion, ...repairState });
394
+ if (deferredBeforeDownload) return deferredBeforeDownload;
349
395
  if (previous?.desiredVersion === desiredVersion && previous.retryAt > now()) return status({ ok: false, status: "backoff", desiredVersion, ...repairState,
350
396
  failures: previous.failures, retryAt: previous.retryAt, lastError: previous.lastError });
351
397
  // Ordinary upgrades wait for pressure to clear. An unavailable installation
@@ -369,6 +415,8 @@ async function recoverLocked({ homeDir = os.homedir(), env = process.env, now =
369
415
  if (read(path.join(root, "recovery", "policy.json"))?.autoUpdate === false) return status({ ok: true, status: "disabled", desiredVersion });
370
416
  const stillBusy = busyDecision(read(path.join(root, "recovery", "daemon.json")), { homeDir, now: now() });
371
417
  if (stillBusy) return status({ ok: true, status: stillBusy, desiredVersion });
418
+ const deferredAfterDownload = deferToTransaction({ desiredVersion, ...repairState });
419
+ if (deferredAfterDownload) return deferredAfterDownload;
372
420
  const entry = path.join(candidate.packageRoot, "src", "recovery-entry.js");
373
421
  if (!fs.existsSync(entry)) throw new Error("candidate-missing-recovery-engine");
374
422
  status({ ok: true, status: "activating", desiredVersion, ...repairState });
@@ -380,6 +428,11 @@ async function recoverLocked({ homeDir = os.homedir(), env = process.env, now =
380
428
  if (!observed.ok) throw new Error("replacement-not-healthy");
381
429
  return proven({ ok: true, status: "current", desiredVersion, version: desiredVersion, repair: "download", restarts: 0, lastSuccessAt: now(), failures: 0 }, observed);
382
430
  } catch (error) {
431
+ // Only a worker that owned the transaction can have judged the release.
432
+ // One that lost the lock, or found another owner mid-flight, judged nothing.
433
+ const deferred = deferToTransaction({ desiredVersion, ...repairState });
434
+ if (deferred) return deferred;
435
+ if (workerLostLock(error)) return status({ ok: true, status: "deferred-update-in-flight", desiredVersion, ...repairState, runtimeHealthy: false });
383
436
  policy.failure(channel, desiredVersion, { id: attemptId, reason: error.message });
384
437
  policy.interrupt();
385
438
  throw error;
@@ -0,0 +1,66 @@
1
+ "use strict";
2
+
3
+ // The daemon's own updater and the scheduled recovery runner both install
4
+ // releases through the same canonical transaction. When both discover a release
5
+ // in the same minute, one of them owns the lock and the other loses it. Losing
6
+ // the lock says nothing about the release: the other owner is installing it
7
+ // right now. On 2026-09-21 the runner read that loss as a broken release,
8
+ // quarantined a version that was booting healthily, and rolled the person back.
9
+ //
10
+ // This module answers one question without importing the application tree:
11
+ // is a canonical runtime transaction currently owned by a live process?
12
+ const fs = require("node:fs");
13
+ const path = require("node:path");
14
+ const { processAlive, nativeProcessIdentity } = require("./recovery-launcher.cjs");
15
+
16
+ // An admitted update request older than this is debris from a worker that died
17
+ // without writing its terminal state; the lock engine reclaims after owner death.
18
+ const REQUEST_IN_FLIGHT_MAX_MS = 45 * 60_000;
19
+ // The exit code a recovery worker uses when the lock belongs to a live owner.
20
+ // EX_TEMPFAIL: try again later; nothing about the release was judged.
21
+ const WORKER_EXIT_TRANSACTION_IN_PROGRESS = 75;
22
+
23
+ function read(file) { try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; } }
24
+
25
+ function liveOwner(owner, { alive = processAlive, identity = nativeProcessIdentity } = {}) {
26
+ const pid = Number(owner?.pid);
27
+ if (!Number.isInteger(pid) || pid <= 0) return false;
28
+ if (!alive(pid)) return false;
29
+ const expected = typeof owner.processIdentity === "string" ? owner.processIdentity : "";
30
+ if (!expected) return true;
31
+ const actual = identity(pid);
32
+ return !actual || actual === expected;
33
+ }
34
+
35
+ // Returns the live transaction, or null when no live process owns one.
36
+ // The lock owner is authoritative; an admitted update request adds the version
37
+ // being installed, which the lock file does not carry.
38
+ function inFlightTransaction({ homeDir, now = Date.now(), alive = processAlive, identity = nativeProcessIdentity } = {}) {
39
+ const root = path.join(homeDir, ".relay", "runtime");
40
+ const owner = read(path.join(root, "transaction.lock", "owner.json"));
41
+ let request = null;
42
+ let names = [];
43
+ try { names = fs.readdirSync(path.join(root, "update-requests")); } catch {}
44
+ for (const name of names) {
45
+ if (!/^[0-9a-f-]{36}\.json$/i.test(name)) continue;
46
+ const value = read(path.join(root, "update-requests", name));
47
+ if (value?.state !== "admitted") continue;
48
+ const admittedAt = Number(value.admittedAt);
49
+ if (!Number.isFinite(admittedAt) || admittedAt > now || now - admittedAt > REQUEST_IN_FLIGHT_MAX_MS) continue;
50
+ const pid = Number(value.workerPid) || Number(value.lockOwner?.pid);
51
+ if (!liveOwner({ pid, processIdentity: value.lockOwner?.processIdentity }, { alive, identity })) continue;
52
+ request = { source: "request", pid, requestId: value.requestId || null, version: value.version || null, admittedAt };
53
+ break;
54
+ }
55
+ if (request) return request;
56
+ if (owner && liveOwner(owner, { alive, identity })) {
57
+ return { source: "lock", pid: Number(owner.pid), requestId: owner.requestId || null, version: null, admittedAt: Number(owner.createdAt) || null };
58
+ }
59
+ return null;
60
+ }
61
+
62
+ function workerLostLock(error) {
63
+ return /recovery-worker-exit-75$/.test(String(error?.message || error || ""));
64
+ }
65
+
66
+ module.exports = { inFlightTransaction, liveOwner, workerLostLock, REQUEST_IN_FLIGHT_MAX_MS, WORKER_EXIT_TRANSACTION_IN_PROGRESS };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-companion",
3
- "version": "0.1.549",
3
+ "version": "0.1.550",
4
4
  "description": "Install Relay for Claude Code, Cowork, and Codex, then sign in from the Relay pill.",
5
5
  "homepage": "https://sendrelays.com/get-started",
6
6
  "repository": {
@@ -1,17 +1,17 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "name": "relay",
4
- "version": "1.1.67",
4
+ "version": "1.1.68",
5
5
  "consentVersion": 2,
6
- "baseUrl": "https://sendrelays.com/skills/relay/v1.1.67",
6
+ "baseUrl": "https://sendrelays.com/skills/relay/v1.1.68",
7
7
  "files": [
8
8
  {
9
9
  "path": "SKILL.md",
10
- "sha256": "133e7fce3c7d0adf4e76c6e93ad50897ce1a92924e354d58a07bc8506857b783",
10
+ "sha256": "0f11648dc5a5d27001b214efc474cdebcfb164eec748574cc9b3192e7a9cce68",
11
11
  "variants": {
12
12
  "dev": {
13
13
  "source": "SKILL.dev.md",
14
- "sha256": "1a1a300cdaee4ceb48bfeb1ee6f3dfb92cc15e304e357b30cc280fcd09fd0653"
14
+ "sha256": "91daeb3d53ccf4eabaacdac701639bd2b0470af05bec94761a20153a7b2bae46"
15
15
  }
16
16
  }
17
17
  },
@@ -381,10 +381,8 @@ asking for thoughts, opinions or answers, which come back as ordinary replies.
381
381
  A technical note with dense agent context is still a message; a small or quick
382
382
  piece of work is still a Task. Respect the account's available capabilities.
383
383
 
384
- A Task is closed only by `relay_task_complete`. For an inbound Task, call
385
- `relay_task_start` before the authorized work. Opening or reading a Task does
386
- not authorize starting it. Once the requested work is genuinely complete, call relay_task_complete with this taskRelayId, a concise forHuman result and the complete useful evidence in forAgent, before telling the human the Task is finished. This sends the completion Relay to the requester and marks the Task Done. Returning that result is part of the human's authorization to carry out the Task: do not wait for another send instruction or request separate approval, unless the human explicitly asked to review or withhold the result. Stay within the authorized task and disclosure scope. Do not use relay_send or relay_share_link as a substitute. If work remains blocked or incomplete, report that to the human without claiming completion. Confirm the tool succeeded; after an uncertain result, retry the same payload and idempotency key, never send a separate completion.
387
-
384
+ A Task is closed only by `relay_task_complete`. Started means beginning authorized work that advances the Task's requested outcome. First check the human's limits: an explicit 'without action' or 'leave status unchanged' means report only, with no start, completion or other status write, even when diagnosis is requested. 'Do not change code' alone is different: authorized investigation still counts as work. Call relay_task_start before authorized investigation, analysis, testing or implementation; read-only work counts. Opening a Task or summarizing its request alone does not count. Do not wait for code changes, experiments or acceptance of the full implementation when the human has authorized investigation. Retain the exact Task ID when a follow-up authorizes work. If the start call fails, report that the status was not updated; never claim Started without confirmation.
385
+ Once the requested work is genuinely complete, call relay_task_complete with this taskRelayId, a concise forHuman result and the complete useful evidence in forAgent, before telling the human the Task is finished. This sends the completion Relay to the requester and marks the Task Done. Returning that result is part of the human's authorization to carry out the Task: do not wait for another send instruction or request separate approval, unless the human explicitly asked to review or withhold the result. Stay within the authorized task and disclosure scope. Do not use relay_send or relay_share_link as a substitute. If work remains blocked or incomplete, report that to the human without claiming completion. Confirm the tool succeeded; after an uncertain result, retry the same payload and idempotency key, never send a separate completion.
388
386
  A reply into the Task's chat never
389
387
  completes it: when the approval or decision itself is the deliverable,
390
388
  `relay_task_complete` carries it as forHuman. Never send a Relay merely to
@@ -381,10 +381,8 @@ asking for thoughts, opinions or answers, which come back as ordinary replies.
381
381
  A technical note with dense agent context is still a message; a small or quick
382
382
  piece of work is still a Task. Respect the account's available capabilities.
383
383
 
384
- A Task is closed only by `relay_task_complete`. For an inbound Task, call
385
- `relay_task_start` before the authorized work. Opening or reading a Task does
386
- not authorize starting it. Once the requested work is genuinely complete, call relay_task_complete with this taskRelayId, a concise forHuman result and the complete useful evidence in forAgent, before telling the human the Task is finished. This sends the completion Relay to the requester and marks the Task Done. Returning that result is part of the human's authorization to carry out the Task: do not wait for another send instruction or request separate approval, unless the human explicitly asked to review or withhold the result. Stay within the authorized task and disclosure scope. Do not use relay_send or relay_share_link as a substitute. If work remains blocked or incomplete, report that to the human without claiming completion. Confirm the tool succeeded; after an uncertain result, retry the same payload and idempotency key, never send a separate completion.
387
-
384
+ A Task is closed only by `relay_task_complete`. Started means beginning authorized work that advances the Task's requested outcome. First check the human's limits: an explicit 'without action' or 'leave status unchanged' means report only, with no start, completion or other status write, even when diagnosis is requested. 'Do not change code' alone is different: authorized investigation still counts as work. Call relay_task_start before authorized investigation, analysis, testing or implementation; read-only work counts. Opening a Task or summarizing its request alone does not count. Do not wait for code changes, experiments or acceptance of the full implementation when the human has authorized investigation. Retain the exact Task ID when a follow-up authorizes work. If the start call fails, report that the status was not updated; never claim Started without confirmation.
385
+ Once the requested work is genuinely complete, call relay_task_complete with this taskRelayId, a concise forHuman result and the complete useful evidence in forAgent, before telling the human the Task is finished. This sends the completion Relay to the requester and marks the Task Done. Returning that result is part of the human's authorization to carry out the Task: do not wait for another send instruction or request separate approval, unless the human explicitly asked to review or withhold the result. Stay within the authorized task and disclosure scope. Do not use relay_send or relay_share_link as a substitute. If work remains blocked or incomplete, report that to the human without claiming completion. Confirm the tool succeeded; after an uncertain result, retry the same payload and idempotency key, never send a separate completion.
388
386
  A reply into the Task's chat never
389
387
  completes it: when the approval or decision itself is the deliverable,
390
388
  `relay_task_complete` carries it as forHuman. Never send a Relay merely to