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
|
-
|
|
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) {
|
|
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
package/skill/manifest.json
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
3
|
"name": "relay",
|
|
4
|
-
"version": "1.1.
|
|
4
|
+
"version": "1.1.68",
|
|
5
5
|
"consentVersion": 2,
|
|
6
|
-
"baseUrl": "https://sendrelays.com/skills/relay/v1.1.
|
|
6
|
+
"baseUrl": "https://sendrelays.com/skills/relay/v1.1.68",
|
|
7
7
|
"files": [
|
|
8
8
|
{
|
|
9
9
|
"path": "SKILL.md",
|
|
10
|
-
"sha256": "
|
|
10
|
+
"sha256": "0f11648dc5a5d27001b214efc474cdebcfb164eec748574cc9b3192e7a9cce68",
|
|
11
11
|
"variants": {
|
|
12
12
|
"dev": {
|
|
13
13
|
"source": "SKILL.dev.md",
|
|
14
|
-
"sha256": "
|
|
14
|
+
"sha256": "91daeb3d53ccf4eabaacdac701639bd2b0470af05bec94761a20153a7b2bae46"
|
|
15
15
|
}
|
|
16
16
|
}
|
|
17
17
|
},
|
package/skill/relay/SKILL.md
CHANGED
|
@@ -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`.
|
|
385
|
-
|
|
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`.
|
|
385
|
-
|
|
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
|