agents-can-communicate 0.10.2 → 0.10.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/ARCHITECTURE.md +3 -1
- package/docs/TROUBLESHOOTING.md +10 -0
- package/docs/UPGRADING.md +21 -0
- package/node_modules/@agents-can-communicate/adapter-antigravity/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-antigravity/plugin/plugin.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +19 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/maintenance-host.mjs +6 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/native-delivery.mjs +9 -6
- package/node_modules/@agents-can-communicate/adapter-codex/src/native-endpoint.mjs +16 -5
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +1 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/native-vocabulary.mjs +2 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/process-table.mjs +20 -0
- package/node_modules/@agents-can-communicate/cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/cli/src/main.mjs +5 -1
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/activation.mjs +44 -25
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/diagnostics.mjs +6 -1
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/leases.mjs +4 -2
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/pins.mjs +3 -2
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/refresh.mjs +5 -0
- package/node_modules/@agents-can-communicate/cli/src/native-session-diagnostics.mjs +2 -0
- package/node_modules/@agents-can-communicate/core/package.json +1 -1
- package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
- package/node_modules/@agents-can-communicate/delivery-router/src/refresh-binding.mjs +9 -6
- package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +6 -1
- package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
- package/node_modules/@agents-can-communicate/installer/package.json +1 -1
- package/node_modules/@agents-can-communicate/installer/src/delivery-diagnostics.mjs +1 -0
- package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
- package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
- package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
- package/package.json +1 -1
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -275,7 +275,9 @@ read, keeps it a hold. A client that wakes later runs its next hook through the
|
|
|
275
275
|
the active generation. A binding that names its client stops holding when that process exits,
|
|
276
276
|
or when the process that now has its pid started after the binding was last written, since a
|
|
277
277
|
client runs when it writes its binding (#276); a start time that cannot be read decides
|
|
278
|
-
nothing.
|
|
278
|
+
nothing. A zombie, a process that has exited but that its parent has not collected, counts as
|
|
279
|
+
exited for a binding, an ACC lease and a pin (#280); a process state that cannot be read
|
|
280
|
+
decides nothing. Otherwise presence TTL, `acc finish`, and delivery off do not prove native lifecycle
|
|
279
281
|
end. A pending release that cannot activate is replaced by a newer one when `acc update` runs
|
|
280
282
|
or an automatic check is due (#277). Normal background updates never
|
|
281
283
|
restart a vendor daemon. An explicitly approved maintenance job can do so through the
|
package/docs/TROUBLESHOOTING.md
CHANGED
|
@@ -158,6 +158,16 @@ delivery, start Codex without it; put a setting you need in `config.toml` instea
|
|
|
158
158
|
If a reply stays queued with `transport_permission_denied`, the sender's permissions
|
|
159
159
|
blocked local transport. This is distinct from an unavailable recipient. The message was
|
|
160
160
|
recorded successfully; the error does not mean it was read or acknowledged.
|
|
161
|
+
For Codex sends, use the exact installed ACC wrapper through the client's approved
|
|
162
|
+
command path (`exec_command` with `sandbox_permissions: "require_escalated"`). The
|
|
163
|
+
installed ACC prefix rule can authorize that command. Keep it separate from other
|
|
164
|
+
shell commands. If delivery was already attempted, retry the same arguments and
|
|
165
|
+
the original `--client-message-id` printed by the CLI. This retries the recorded
|
|
166
|
+
message instead of recording another one. An initial execution refusal records
|
|
167
|
+
nothing; a refused retry does not erase an earlier recorded message.
|
|
168
|
+
On Codex 0.162.0 on macOS, even an installed socket allowlist did not permit direct
|
|
169
|
+
access to the daemon socket from the sandbox. A configured profile alone therefore
|
|
170
|
+
does not prove that a sandboxed sender can reach another Codex session.
|
|
161
171
|
On Codex 0.153.4 or newer, rerun `acc install --adapter codex` with existing
|
|
162
172
|
live consent. Add `--delivery actionable` when new consent is required, then start a new session.
|
|
163
173
|
With ACC 0.8.0 or 0.8.1, every live offer from a Codex session to Claude Code failed this way:
|
package/docs/UPGRADING.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Upgrading
|
|
2
2
|
|
|
3
|
+
## From 0.10.3
|
|
4
|
+
|
|
5
|
+
Update as usual with `acc update`. Version 0.10.4 preserves sender permission
|
|
6
|
+
failures as `transport_permission_denied`, including when access to socket
|
|
7
|
+
metadata or an expired delivery binding is denied. The CLI prints the original
|
|
8
|
+
retry key so the same recorded message can be offered again without a duplicate.
|
|
9
|
+
|
|
10
|
+
The refreshed Codex skill uses the client's approved command path for outgoing
|
|
11
|
+
ACC messages. ACC's existing exact-wrapper rule can authorize those commands.
|
|
12
|
+
An initial execution refusal records no message; a refused retry preserves an
|
|
13
|
+
earlier durable message. See [sender permissions](TROUBLESHOOTING.md#i-enabled-live-delivery-but-got-fallback)
|
|
14
|
+
for the recovery procedure.
|
|
15
|
+
|
|
16
|
+
This release keeps store contract 7 and adds no data migration. The update
|
|
17
|
+
refreshes installed integrations while preserving their recorded delivery policy.
|
|
18
|
+
|
|
3
19
|
## Store contract 6 to 7
|
|
4
20
|
|
|
5
21
|
ACC moves each workspace's store from contract 6 to 7 by itself, right after an update
|
|
@@ -69,6 +85,8 @@ acc update
|
|
|
69
85
|
`--pin none` keeps following stable releases and drops a pending release that is not pinned.
|
|
70
86
|
From 0.10.2, the update does this by itself: a pending release that cannot activate is replaced
|
|
71
87
|
by a newer one, and a binding whose pid another program now has no longer holds the update.
|
|
88
|
+
From 0.10.3, a process that has exited but that its parent has not collected (a zombie, `Z` in
|
|
89
|
+
`ps`) no longer holds it either.
|
|
72
90
|
|
|
73
91
|
## From 0.8.x
|
|
74
92
|
|
|
@@ -368,7 +386,10 @@ PID/start time, executable, socket ownership and versions before stopping the se
|
|
|
368
386
|
It refreshes integrations, starts the service again and verifies the result. If refreshing
|
|
369
387
|
integrations fails, it still restores the service and reports the incomplete update.
|
|
370
388
|
A later update worker can resume an already approved recovery without another question.
|
|
389
|
+
A daemon that its parent leaves as a zombie after the stop counts as stopped, and the worker
|
|
390
|
+
starts the service again.
|
|
371
391
|
`acc doctor --json` exposes `update.maintenance`; ordinary `acc doctor` describes its state.
|
|
392
|
+
A failed or cancelled job is no longer reported once its release is active by another path.
|
|
372
393
|
A current launcher can use the newer management reader while the old runtime is active.
|
|
373
394
|
When admission is blocked, doctor returns an update-only report without inspecting the
|
|
374
395
|
workspace or repairing it. Older launchers gain this reader after activation, or through
|
|
@@ -16,6 +16,25 @@ Use this skill when hook context reports peers or actionable attention, or
|
|
|
16
16
|
the user asks for coordination between sessions. An `ACC CLI (append):` header
|
|
17
17
|
by itself supplies identity for later use; continue the user's ordinary work.
|
|
18
18
|
|
|
19
|
+
### Send through Codex's approved command path
|
|
20
|
+
|
|
21
|
+
Run `message`, `request`, `reply`, and `finish` with `exec_command` and
|
|
22
|
+
`sandbox_permissions: "require_escalated"`. Put the installed `{{ACC}}` wrapper
|
|
23
|
+
first in the command. ACC's installed prefix rule can then authorize that exact
|
|
24
|
+
local command. Do not combine it with other shell commands. These sends stay
|
|
25
|
+
on this machine and reach the user's own agent sessions.
|
|
26
|
+
|
|
27
|
+
Codex can deny its daemon socket inside the sandbox even when the ACC profile
|
|
28
|
+
allows the socket. The approved command path preserves that sandbox boundary.
|
|
29
|
+
An initial execution refusal records no message. If a retry is refused, the
|
|
30
|
+
earlier recorded message remains durable. Report which case occurred.
|
|
31
|
+
|
|
32
|
+
Choose a distinct `--client-message-id` for each new send. If delivery reports
|
|
33
|
+
`transport_permission_denied`, retry the same command through approved execution
|
|
34
|
+
with the same key and content. The CLI prints the original key when it generated
|
|
35
|
+
one. A retry uses the recorded message and offers it again only while queued.
|
|
36
|
+
Use normal sandboxed execution for local reads and claims.
|
|
37
|
+
|
|
19
38
|
## Use your own CLI credentials
|
|
20
39
|
|
|
21
40
|
When this turn's ACC hook supplies `ACC CLI (append):`, append those exact
|
|
@@ -181,6 +181,12 @@ export async function observeMaintenanceProcess(pid, paths, run, { system = WIND
|
|
|
181
181
|
if (response.status !== 0) return { state: "unknown" };
|
|
182
182
|
const match = /^(.{24})\s+(.+)$/.exec(response.stdout.trim());
|
|
183
183
|
if (!match || !startTimeValid(match[1])) return { state: "unknown" };
|
|
184
|
+
// A zombie has exited; only its parent has not collected it. `ps` names it
|
|
185
|
+
// `<defunct>` on macOS and `[name] <defunct>` on Linux. On 2026-10-06 the
|
|
186
|
+
// daemon `daemon stop` ended stayed one under Codex's own pid-update-loop,
|
|
187
|
+
// and reading it as alive left the stop unverified and the start refused
|
|
188
|
+
// (#280).
|
|
189
|
+
if (/<defunct>$/.test(match[2])) return { state: "dead" };
|
|
184
190
|
return { state: "alive", processStartTime: match[1], command: match[2] };
|
|
185
191
|
}
|
|
186
192
|
|
|
@@ -168,22 +168,25 @@ async function bindAttempt({ event, clientPid, clientVersion, runtimeDir,
|
|
|
168
168
|
// what `codex --version` printed, and a daemon that had updated under its CLI
|
|
169
169
|
// was refused here on that difference alone.
|
|
170
170
|
async function receiverFor(binding, runtimeDir, platform = process.platform) {
|
|
171
|
-
const endpoint = await readNativeEndpoint({ runtimeDir, endpointId: binding?.opaqueEndpointRef,
|
|
172
|
-
|
|
171
|
+
const endpoint = await readNativeEndpoint({ runtimeDir, endpointId: binding?.opaqueEndpointRef,
|
|
172
|
+
platform, throwPermissionErrors: true });
|
|
173
|
+
return endpoint !== null && await socketIsReady(endpoint.socketPath,
|
|
174
|
+
{ platform, throwPermissionErrors: true }) ? endpoint : null;
|
|
173
175
|
}
|
|
174
176
|
|
|
175
177
|
export async function refreshNativeSession({ binding, runtimeDir, timeoutMs = 750,
|
|
176
178
|
now = Date.now, open = openCodexAppServer, platform = process.platform } = {}) {
|
|
177
179
|
const rejected = reason => closed(binding?.clientVersion, reason);
|
|
178
|
-
const endpoint = await receiverFor(binding, runtimeDir, platform);
|
|
179
|
-
if (endpoint === null) return rejected("handshake_failed");
|
|
180
180
|
try {
|
|
181
|
+
const endpoint = await receiverFor(binding, runtimeDir, platform);
|
|
182
|
+
if (endpoint === null) return rejected("handshake_failed");
|
|
181
183
|
return await usingPeer(endpoint.socketPath, timeoutMs, open, async peer => {
|
|
182
184
|
const { reasonCode, servingVersion } = await verifyReceiver(peer, endpoint);
|
|
183
185
|
return reasonCode === null
|
|
184
186
|
? handshake({ ...endpoint, clientVersion: servingVersion }, now) : rejected(reasonCode);
|
|
185
187
|
});
|
|
186
188
|
} catch (error) {
|
|
189
|
+
if (["EPERM", "EACCES"].includes(error?.code)) return rejected("transport_permission_denied");
|
|
187
190
|
return rejected(error?.code === "ETIMEDOUT" ? "handshake_timeout" : "handshake_failed");
|
|
188
191
|
}
|
|
189
192
|
}
|
|
@@ -192,9 +195,9 @@ export async function offerMessage({ binding, message, runtimeDir, timeoutMs = 5
|
|
|
192
195
|
open = openCodexAppServer, platform = process.platform } = {}) {
|
|
193
196
|
const rejected = safeErrorCode => ({ accepted: false, transport: "codex-app-server",
|
|
194
197
|
clientVersion: binding?.clientVersion ?? null, safeErrorCode });
|
|
195
|
-
const endpoint = await receiverFor(binding, runtimeDir, platform);
|
|
196
|
-
if (endpoint === null) return rejected("recipient_unavailable");
|
|
197
198
|
try {
|
|
199
|
+
const endpoint = await receiverFor(binding, runtimeDir, platform);
|
|
200
|
+
if (endpoint === null) return rejected("recipient_unavailable");
|
|
198
201
|
return await usingPeer(endpoint.socketPath, timeoutMs, open, async peer => {
|
|
199
202
|
const { reasonCode, servingVersion } = await verifyReceiver(peer, endpoint);
|
|
200
203
|
if (reasonCode !== null) return rejected(reasonCode === "handshake_timeout" ? "transport_error"
|
|
@@ -50,18 +50,25 @@ async function directory(runtimeDir, create = false, platform = process.platform
|
|
|
50
50
|
* 0.159.3), so there the socket counts while its directory lists it; Codex keeps
|
|
51
51
|
* that directory to its user.
|
|
52
52
|
*/
|
|
53
|
-
export async function socketIsReady(socketPath, { platform = process.platform
|
|
53
|
+
export async function socketIsReady(socketPath, { platform = process.platform,
|
|
54
|
+
throwPermissionErrors = false } = {}) {
|
|
54
55
|
if (isWindowsPlatform(platform)) {
|
|
55
56
|
if (!absolute(socketPath)) return false;
|
|
56
57
|
const name = path.basename(socketPath).toLowerCase();
|
|
57
|
-
const names = await readdir(path.dirname(socketPath)).catch(
|
|
58
|
+
const names = await readdir(path.dirname(socketPath)).catch(error => {
|
|
59
|
+
if (throwPermissionErrors && ["EPERM", "EACCES"].includes(error.code)) throw error;
|
|
60
|
+
return [];
|
|
61
|
+
});
|
|
58
62
|
return names.some(entry => entry.toLowerCase() === name);
|
|
59
63
|
}
|
|
60
64
|
if (!absolute(socketPath)) return false;
|
|
61
65
|
try {
|
|
62
66
|
const info = await lstat(socketPath);
|
|
63
67
|
return info.isSocket() && !info.isSymbolicLink() && own(info);
|
|
64
|
-
} catch {
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (throwPermissionErrors && ["EPERM", "EACCES"].includes(error.code)) throw error;
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
65
72
|
}
|
|
66
73
|
|
|
67
74
|
/** The socket a control socket path leads to, or null. Codex 0.157.1 keeps a
|
|
@@ -104,7 +111,8 @@ export async function writeNativeEndpoint({ runtimeDir, record, platform = proce
|
|
|
104
111
|
|
|
105
112
|
// Expired records are metadata for revalidation, never proof of reachability.
|
|
106
113
|
// Refuse links and oversized files before reading; no foreign error text escapes.
|
|
107
|
-
export async function readNativeEndpoint({ runtimeDir, endpointId, platform = process.platform
|
|
114
|
+
export async function readNativeEndpoint({ runtimeDir, endpointId, platform = process.platform,
|
|
115
|
+
throwPermissionErrors = false }) {
|
|
108
116
|
if (typeof endpointId !== "string" || !ENDPOINT.test(endpointId)) return null;
|
|
109
117
|
let handle;
|
|
110
118
|
try {
|
|
@@ -116,7 +124,10 @@ export async function readNativeEndpoint({ runtimeDir, endpointId, platform = pr
|
|
|
116
124
|
}
|
|
117
125
|
const record = JSON.parse(await handle.readFile("utf8"));
|
|
118
126
|
return valid(record) && record.endpointId === endpointId ? record : null;
|
|
119
|
-
} catch {
|
|
127
|
+
} catch (error) {
|
|
128
|
+
if (throwPermissionErrors && ["EPERM", "EACCES"].includes(error.code)) throw error;
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
120
131
|
finally { await handle?.close().catch(() => null); }
|
|
121
132
|
}
|
|
122
133
|
|
|
@@ -29,7 +29,7 @@ export { clearSessionBinding, listSessionBindings, loadSessionBinding,
|
|
|
29
29
|
|
|
30
30
|
export { clearNativeAttempt, loadNativeAttempt, storeNativeAttempt } from "./native-attempt.mjs";
|
|
31
31
|
export { channelSocketDirectory } from "./channel-directory.mjs";
|
|
32
|
-
export { readProcessArgs, readProcessTable, readWindowsProcess, splitWindowsCommandLine }
|
|
32
|
+
export { processIsZombie, readProcessArgs, readProcessTable, readWindowsProcess, splitWindowsCommandLine }
|
|
33
33
|
from "./process-table.mjs";
|
|
34
34
|
export { npmShimTarget, resolveExecutable, runExecutable } from "./executables.mjs";
|
|
35
35
|
export { pathOf } from "./executables.mjs";
|
|
@@ -29,6 +29,8 @@ export const NATIVE_REASON_CODES = Object.freeze([
|
|
|
29
29
|
"prerelease_not_captured", "below_minimum_version", "known_bad_version",
|
|
30
30
|
"feature_probe_failed", "probe_timeout", "probe_version_mismatch", "protocol_mismatch",
|
|
31
31
|
"native_endpoint_unavailable", "native_session_unavailable",
|
|
32
|
+
// A caller can be denied access to a healthy receiver's local transport.
|
|
33
|
+
"transport_permission_denied",
|
|
32
34
|
"handshake_failed", "handshake_timeout", "handshake_version_mismatch",
|
|
33
35
|
"session_generation_stale", "client_process_unknown", "unsupported_shell",
|
|
34
36
|
// The current session's canonical workspace identity could not be verified.
|
|
@@ -165,6 +165,26 @@ export async function readProcessTable({ platform = process.platform, from = pro
|
|
|
165
165
|
return posixTable({ run, timeoutMs });
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
+
/**
|
|
169
|
+
* Whether a process has exited and only waits for its parent to collect it -
|
|
170
|
+
* the POSIX zombie, `ps` state `Z`. `kill(pid, 0)` succeeds for one and `ps`
|
|
171
|
+
* still lists its start time, so a liveness check that stops there calls it
|
|
172
|
+
* alive: on 2026-10-06 a stopped Codex daemon left as a zombie under Codex's
|
|
173
|
+
* own pid-update-loop kept an ACC update blocked and its restart refused
|
|
174
|
+
* (#280). True or false when `ps` answers; null when it does not. Windows has
|
|
175
|
+
* no such state.
|
|
176
|
+
*/
|
|
177
|
+
export async function processIsZombie(pid, { platform = process.platform, run = execFileAsync,
|
|
178
|
+
timeoutMs = 2_000 } = {}) {
|
|
179
|
+
if (platform === "win32") return false;
|
|
180
|
+
try {
|
|
181
|
+
const { stdout } = await run("/bin/ps", ["-p", String(pid), "-o", "stat="],
|
|
182
|
+
{ timeout: timeoutMs, env: { ...process.env, LC_ALL: "C" } });
|
|
183
|
+
const state = String(stdout).trim();
|
|
184
|
+
return state === "" ? null : state.startsWith("Z");
|
|
185
|
+
} catch { return null; }
|
|
186
|
+
}
|
|
187
|
+
|
|
168
188
|
/**
|
|
169
189
|
* The words one process was started with, or null when they cannot be read.
|
|
170
190
|
*
|
|
@@ -6,6 +6,7 @@ import { AccError, CONFIG_FILENAME, EXIT, GENERIC_MESSAGE_KINDS, VALID_OBLIGATIO
|
|
|
6
6
|
failure, ok }
|
|
7
7
|
from "@agents-can-communicate/protocol";
|
|
8
8
|
import { createCoordinationService } from "@agents-can-communicate/core";
|
|
9
|
+
import { shellQuote } from "@agents-can-communicate/adapter-sdk";
|
|
9
10
|
import { describeNativeReason } from "@agents-can-communicate/installer";
|
|
10
11
|
import { openFilesystemStore } from "@agents-can-communicate/storage-filesystem";
|
|
11
12
|
|
|
@@ -162,7 +163,10 @@ export function recordedText(message, delivery) {
|
|
|
162
163
|
? `woke ${item.recipientParticipantId} via ${item.transport}; the message arrives with its next turn`
|
|
163
164
|
: item.outcome === "queued"
|
|
164
165
|
? item.errorCode === "transport_permission_denied"
|
|
165
|
-
? "live offer blocked by sender permissions; message remains queued;
|
|
166
|
+
? "live offer blocked by sender permissions; message remains queued; retry the same command "
|
|
167
|
+
+ "through approved execution"
|
|
168
|
+
+ (typeof message.clientMessageId === "string"
|
|
169
|
+
? ` with --client-message-id ${shellQuote(message.clientMessageId)}` : " with its original retry key")
|
|
166
170
|
: item.errorCode === "no_live_transport"
|
|
167
171
|
? `${item.recipientParticipantId} has no live transport`
|
|
168
172
|
+ `${item.nativeReasonCode ? ` (${describeNativeReason(item.nativeReasonCode,
|
|
@@ -7,7 +7,7 @@ import { readdir, rm, stat } from "node:fs/promises";
|
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
import { pathToFileURL } from "node:url";
|
|
9
9
|
import { promisify } from "node:util";
|
|
10
|
-
import { readWindowsProcess } from "@agents-can-communicate/adapter-sdk";
|
|
10
|
+
import { processIsZombie, readWindowsProcess } from "@agents-can-communicate/adapter-sdk";
|
|
11
11
|
import { classifySessionPresence } from "@agents-can-communicate/core";
|
|
12
12
|
import { assertPortableId } from "@agents-can-communicate/protocol";
|
|
13
13
|
import { readSessionRecord, withWriterMutex } from "@agents-can-communicate/storage-filesystem";
|
|
@@ -83,11 +83,18 @@ async function pidReused(pid, file, startedAt) {
|
|
|
83
83
|
return started !== null && written !== null && started > written + 1_000;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
//
|
|
87
|
-
|
|
86
|
+
// A zombie has exited; only its parent has not collected it, and kill(pid, 0)
|
|
87
|
+
// still succeeds for it. On 2026-10-06 the Codex daemon `daemon stop` ended
|
|
88
|
+
// stayed one under Codex's own pid-update-loop and kept 0.10.2 pending (#280).
|
|
89
|
+
// A state that cannot be read decides nothing.
|
|
90
|
+
const exitedProcess = async (pid, pidIsAlive, zombie) =>
|
|
91
|
+
await confirmedDead(pid, pidIsAlive) || await zombie(pid) === true;
|
|
92
|
+
|
|
93
|
+
// One reading per pid for one pass: a vendor service can hold several bindings.
|
|
94
|
+
const perPass = read => {
|
|
88
95
|
const seen = new Map();
|
|
89
96
|
return pid => {
|
|
90
|
-
if (!seen.has(pid)) seen.set(pid, Promise.resolve().then(() =>
|
|
97
|
+
if (!seen.has(pid)) seen.set(pid, Promise.resolve().then(() => read(pid)).catch(() => null));
|
|
91
98
|
return seen.get(pid);
|
|
92
99
|
};
|
|
93
100
|
};
|
|
@@ -100,7 +107,7 @@ const perPass = startedAt => {
|
|
|
100
107
|
* process, or a record or store that cannot be read, keeps the binding a hold.
|
|
101
108
|
* On 2026-10-06 the maintainer's 0.10.0 waited behind 32 such bindings from
|
|
102
109
|
* 0.5.10 to 0.9.0 that no client could clear (#273). */
|
|
103
|
-
async function staleWithoutPid(workspaceRoot, workspaceId, record, pidIsAlive, file, startedAt) {
|
|
110
|
+
async function staleWithoutPid(workspaceRoot, workspaceId, record, pidIsAlive, file, startedAt, zombie) {
|
|
104
111
|
if (record?.schemaVersion !== 1) return false;
|
|
105
112
|
let session;
|
|
106
113
|
try {
|
|
@@ -109,7 +116,7 @@ async function staleWithoutPid(workspaceRoot, workspaceId, record, pidIsAlive, f
|
|
|
109
116
|
} catch { return false; }
|
|
110
117
|
if (session === null) return true;
|
|
111
118
|
if (Number.isSafeInteger(session.pid) && session.pid > 0) {
|
|
112
|
-
return await
|
|
119
|
+
return await exitedProcess(session.pid, pidIsAlive, zombie) || pidReused(session.pid, file, startedAt);
|
|
113
120
|
}
|
|
114
121
|
// No process to ask, so the probe is never called.
|
|
115
122
|
return classifySessionPresence(session, new Date().toISOString(), () => true) === "offline";
|
|
@@ -117,10 +124,10 @@ async function staleWithoutPid(workspaceRoot, workspaceId, record, pidIsAlive, f
|
|
|
117
124
|
|
|
118
125
|
/** Native bindings outlive finish/TTL; MCP process lifetime is covered by its runtime lease. */
|
|
119
126
|
export async function listNativeHolds(root, { pidIsAlive = defaultPidIsAlive, strict = false,
|
|
120
|
-
startedAt = processStartedAt } = {}) {
|
|
127
|
+
startedAt = processStartedAt, zombie = processIsZombie } = {}) {
|
|
121
128
|
const workspaces = path.join(path.dirname(await canonicalManagerRoot(root)), "workspaces");
|
|
122
129
|
if (!await managedDirectory(workspaces)) return [];
|
|
123
|
-
const started = perPass(startedAt);
|
|
130
|
+
const started = perPass(startedAt), zombieNow = perPass(zombie);
|
|
124
131
|
const holds = [];
|
|
125
132
|
for (const entry of await readdir(workspaces, { withFileTypes: true })) {
|
|
126
133
|
if (!entry.isDirectory()) {
|
|
@@ -142,9 +149,10 @@ export async function listNativeHolds(root, { pidIsAlive = defaultPidIsAlive, st
|
|
|
142
149
|
if (record === undefined) continue; // Concurrent lifecycle removal, before fencing.
|
|
143
150
|
if (await isMcpContinuity(record, name, path.dirname(bindings), entry.name)) continue;
|
|
144
151
|
const pid = clientPidOf(record);
|
|
145
|
-
if (pid !== null && (await
|
|
152
|
+
if (pid !== null && (await exitedProcess(pid, pidIsAlive, zombieNow)
|
|
153
|
+
|| await pidReused(pid, file, started))) continue;
|
|
146
154
|
if (pid === null && await staleWithoutPid(path.dirname(bindings), entry.name, record, pidIsAlive,
|
|
147
|
-
file, started)) continue;
|
|
155
|
+
file, started, zombieNow)) continue;
|
|
148
156
|
holds.push({ kind: "native", pid, reason: pid === null ? "unknown_client_pid" : "client_running",
|
|
149
157
|
storeVersion: Number.isSafeInteger(record?.storeVersion) ? record.storeVersion : null });
|
|
150
158
|
}
|
|
@@ -159,8 +167,9 @@ const lifecycleLocks = (workspaceRoot, harnessSessionId) => ({ locks: path.join(
|
|
|
159
167
|
"lifecycle-locks", createHash("sha256").update(String(harnessSessionId)).digest("hex")) });
|
|
160
168
|
|
|
161
169
|
/** Remove the bindings that listNativeHolds no longer counts and an older
|
|
162
|
-
* generation still does: those without a client pid that are stale (#273),
|
|
163
|
-
* those whose pid was given to another program (#276)
|
|
170
|
+
* generation still does: those without a client pid that are stale (#273),
|
|
171
|
+
* those whose pid was given to another program (#276), and those whose client
|
|
172
|
+
* is a zombie (#280). An older generation
|
|
164
173
|
* activates its successor with its own rule, and loads the pending generation's
|
|
165
174
|
* refresh before it lists blockers, so that refresh calls this. A binding whose
|
|
166
175
|
* pid is merely dead holds nothing for either and stays, as the record a resumed
|
|
@@ -168,11 +177,11 @@ const lifecycleLocks = (workspaceRoot, harnessSessionId) => ({ locks: path.join(
|
|
|
168
177
|
* lifecycle lock, so a hook that renews it is either seen or comes after (AI
|
|
169
178
|
* review of #274); a lock that stays busy leaves the binding as it is. */
|
|
170
179
|
export async function sweepStaleBindings(root, { pidIsAlive = defaultPidIsAlive,
|
|
171
|
-
startedAt = processStartedAt } = {}) {
|
|
180
|
+
startedAt = processStartedAt, zombie = processIsZombie } = {}) {
|
|
172
181
|
const workspaces = path.join(path.dirname(await canonicalManagerRoot(root)), "workspaces");
|
|
173
182
|
if (!await managedDirectory(workspaces)) return 0;
|
|
174
183
|
const clock = { now: () => new Date().toISOString() };
|
|
175
|
-
const started = perPass(startedAt);
|
|
184
|
+
const started = perPass(startedAt), zombieNow = perPass(zombie);
|
|
176
185
|
let removed = 0;
|
|
177
186
|
for (const entry of await readdir(workspaces, { withFileTypes: true })) {
|
|
178
187
|
if (!entry.isDirectory()) continue;
|
|
@@ -187,8 +196,11 @@ export async function sweepStaleBindings(root, { pidIsAlive = defaultPidIsAlive,
|
|
|
187
196
|
if (record === undefined || record === null || typeof record.harnessSessionId !== "string"
|
|
188
197
|
|| await isMcpContinuity(record, name, workspaceRoot, entry.name)) return false;
|
|
189
198
|
const pid = clientPidOf(record);
|
|
190
|
-
if (pid !== null)
|
|
191
|
-
|
|
199
|
+
if (pid !== null) {
|
|
200
|
+
return !await confirmedDead(pid, pidIsAlive)
|
|
201
|
+
&& (await zombieNow(pid) === true || pidReused(pid, file, started));
|
|
202
|
+
}
|
|
203
|
+
return staleWithoutPid(workspaceRoot, entry.name, record, pidIsAlive, file, started, zombieNow);
|
|
192
204
|
};
|
|
193
205
|
const judged = await readManagedJson(file).catch(() => undefined);
|
|
194
206
|
if (!await stale(judged)) continue;
|
|
@@ -221,9 +233,12 @@ export const blocksActivation = (hold, incomingStoreVersion) =>
|
|
|
221
233
|
* generation's, or when either side declares none.
|
|
222
234
|
*/
|
|
223
235
|
export async function listActivationBlockers(root, { pidIsAlive = defaultPidIsAlive,
|
|
224
|
-
ignorePid = null, incomingStoreVersion = null, strictNative = false, startedAt
|
|
225
|
-
|
|
226
|
-
const
|
|
236
|
+
ignorePid = null, incomingStoreVersion = null, strictNative = false, startedAt,
|
|
237
|
+
zombie = processIsZombie } = {}) {
|
|
238
|
+
const zombieNow = perPass(zombie);
|
|
239
|
+
const leases = (await listRuntimeHolds(root, { pidIsAlive, zombie: zombieNow }))
|
|
240
|
+
.filter(lease => lease.pid !== ignorePid);
|
|
241
|
+
const native = await listNativeHolds(root, { pidIsAlive, strict: strictNative, zombie: zombieNow,
|
|
227
242
|
...(startedAt === undefined ? {} : { startedAt }) });
|
|
228
243
|
const holds = [
|
|
229
244
|
...leases.map(({ pid, kind, runtime }) => ({ pid, kind, nativeBindings: 0,
|
|
@@ -303,8 +318,11 @@ async function resolveCandidate(value) {
|
|
|
303
318
|
// reclaimed need to tell that from a pass that found nothing to remove.
|
|
304
319
|
const POSTPONED = Object.freeze({ removed: [], postponed: true });
|
|
305
320
|
|
|
306
|
-
export async function reclaimGenerations({ root, active = null, pidIsAlive = defaultPidIsAlive
|
|
321
|
+
export async function reclaimGenerations({ root, active = null, pidIsAlive = defaultPidIsAlive,
|
|
322
|
+
zombie = processIsZombie } = {}) {
|
|
307
323
|
root = await canonicalManagerRoot(root);
|
|
324
|
+
// A lease or a pin of a client that exited uncollected keeps no generation (#280).
|
|
325
|
+
const zombieNow = perPass(zombie);
|
|
308
326
|
return withManagerLock(root, async () => {
|
|
309
327
|
const generations = path.join(root, "generations");
|
|
310
328
|
if (!await managedDirectory(generations)) return { removed: [] };
|
|
@@ -343,7 +361,7 @@ export async function reclaimGenerations({ root, active = null, pidIsAlive = def
|
|
|
343
361
|
if (resolved.root) referenced.add(resolved.root);
|
|
344
362
|
}
|
|
345
363
|
let holds;
|
|
346
|
-
try { holds = await listRuntimeHolds(root, { pidIsAlive }); }
|
|
364
|
+
try { holds = await listRuntimeHolds(root, { pidIsAlive, zombie: zombieNow }); }
|
|
347
365
|
catch { return POSTPONED; } // An unreadable lease is an unknown holder.
|
|
348
366
|
for (const lease of holds) {
|
|
349
367
|
const resolved = await resolveCandidate(lease.runtime?.root);
|
|
@@ -353,7 +371,7 @@ export async function reclaimGenerations({ root, active = null, pidIsAlive = def
|
|
|
353
371
|
// A client that exits without a clean session end leaves its pin behind;
|
|
354
372
|
// reap confirmed-dead ones before treating every remaining pin as live,
|
|
355
373
|
// the same way admission already reaps confirmed-dead leases.
|
|
356
|
-
await reapPins({ root, pidIsAlive });
|
|
374
|
+
await reapPins({ root, pidIsAlive, zombie: zombieNow });
|
|
357
375
|
const pins = path.join(root, "pins");
|
|
358
376
|
if (await managedDirectory(pins)) {
|
|
359
377
|
for (const name of await readdir(pins)) {
|
|
@@ -421,7 +439,7 @@ const recipe = c => JSON.stringify([c.active, c.pending, c.home, c.targets, c.au
|
|
|
421
439
|
|
|
422
440
|
/** Detection is outside the fence; every integration write and commit stays inside it. */
|
|
423
441
|
export async function activatePending(root, { prepare = prepareCandidate, env = process.env,
|
|
424
|
-
pidIsAlive = defaultPidIsAlive, ignorePid = null, beforeActivation = null, startedAt } = {}) {
|
|
442
|
+
pidIsAlive = defaultPidIsAlive, ignorePid = null, beforeActivation = null, startedAt, zombie } = {}) {
|
|
425
443
|
root = await canonicalManagerRoot(root);
|
|
426
444
|
const before = await readControl(root);
|
|
427
445
|
if (!before?.pending) return { activated: false, reason: "no_pending_update" };
|
|
@@ -432,7 +450,7 @@ export async function activatePending(root, { prepare = prepareCandidate, env =
|
|
|
432
450
|
if (!current || recipe(current) !== recipe(before)) return { activated: false, reason: "state_changed" };
|
|
433
451
|
if (beforeActivation) await beforeActivation(current);
|
|
434
452
|
const blockers = await listActivationBlockers(root, { pidIsAlive, ignorePid, startedAt,
|
|
435
|
-
incomingStoreVersion: current.pending.storeVersion });
|
|
453
|
+
...(zombie === undefined ? {} : { zombie }), incomingStoreVersion: current.pending.storeVersion });
|
|
436
454
|
if (blockers.length) {
|
|
437
455
|
const notice = activationBlockerNotice(current.pending.version, blockers);
|
|
438
456
|
await writeControl(root, { ...current, notice });
|
|
@@ -466,7 +484,8 @@ export async function activatePending(root, { prepare = prepareCandidate, env =
|
|
|
466
484
|
// turn an already-completed activation into a reported failure - it can
|
|
467
485
|
// always run again on the next activation.
|
|
468
486
|
if (outcome.activated) {
|
|
469
|
-
try { await reclaimGenerations({ root, active: activatedRoot, pidIsAlive
|
|
487
|
+
try { await reclaimGenerations({ root, active: activatedRoot, pidIsAlive,
|
|
488
|
+
...(zombie === undefined ? {} : { zombie }) }); }
|
|
470
489
|
catch { /* Best effort; never fails an activation that already succeeded. */ }
|
|
471
490
|
// The reclaim above is this, older, code's rule (#208). The activated
|
|
472
491
|
// generation's own rule runs next, from its own files as its refresh did,
|
|
@@ -27,7 +27,12 @@ export async function managedUpdateDiagnostic(root, manager, running) {
|
|
|
27
27
|
: activationBlockerNotice(manager.pending.version, blockers),
|
|
28
28
|
...(blockers === null ? {} : { holds: blockers.length, blockers }) };
|
|
29
29
|
const maintenance = await readMaintenance(root);
|
|
30
|
-
|
|
30
|
+
// A job that failed or was cancelled for the release that is now active is
|
|
31
|
+
// history: another path activated it, and "run acc update to retry" asked
|
|
32
|
+
// for an update already done (#281). A completed job still reports its restart.
|
|
33
|
+
const settled = maintenance && manager.pending === null && maintenance.target.root === manager.active.root
|
|
34
|
+
&& ["failed", "cancelled"].includes(maintenance.status);
|
|
35
|
+
if (maintenance && !settled && (maintenance.target.root === (manager.pending ?? manager.active).root
|
|
31
36
|
|| MAINTENANCE_ACTIVE.includes(maintenance.status))) {
|
|
32
37
|
update.maintenance = maintenanceReport(maintenance);
|
|
33
38
|
update.notice = maintenanceNotice(maintenance);
|
|
@@ -31,8 +31,10 @@ export async function acquireRuntime(root, { pid = process.pid, kind = "cli" } =
|
|
|
31
31
|
/** Management decisions call this under withManagerLock to exclude admissions.
|
|
32
32
|
* Records are immutable and uniquely named; confirmed-dead cleanup also works
|
|
33
33
|
* outside a transaction. No release-on-return or timestamp expiry exists.
|
|
34
|
+
* A decision passes `zombie` so a holder that exited but was never collected
|
|
35
|
+
* holds nothing (#280); admission leaves it out and spawns no process.
|
|
34
36
|
*/
|
|
35
|
-
export async function listRuntimeHolds(root, { pidIsAlive = defaultPidIsAlive } = {}) {
|
|
37
|
+
export async function listRuntimeHolds(root, { pidIsAlive = defaultPidIsAlive, zombie = null } = {}) {
|
|
36
38
|
root = await canonicalManagerRoot(root);
|
|
37
39
|
if (!await managedDirectory(root)) return [];
|
|
38
40
|
const directory = path.join(root, "leases");
|
|
@@ -50,7 +52,7 @@ export async function listRuntimeHolds(root, { pidIsAlive = defaultPidIsAlive }
|
|
|
50
52
|
|| typeof lease.kind !== "string" || !lease.kind
|
|
51
53
|
|| !Number.isFinite(Date.parse(lease.createdAt))) throw new Error("invalid runtime lease");
|
|
52
54
|
const runtime = await validateRuntime(root, lease.runtime);
|
|
53
|
-
if (await confirmedDead(lease.pid, pidIsAlive)) {
|
|
55
|
+
if (await confirmedDead(lease.pid, pidIsAlive) || zombie !== null && await zombie(lease.pid) === true) {
|
|
54
56
|
await rm(file, { force: true });
|
|
55
57
|
removed = true;
|
|
56
58
|
} else holds.push({ ...lease, runtime });
|
|
@@ -115,7 +115,7 @@ export async function resolvePinnedGeneration({ root, harnessSessionId, active }
|
|
|
115
115
|
* already reaps dead leases; pins follow the same confirmed-death rule. A pin
|
|
116
116
|
* that names no client, which 0.8.0 and earlier wrote when the pid was not yet
|
|
117
117
|
* known, is reaped outright: nothing could ever prove its session over. */
|
|
118
|
-
export async function reapPins({ root, pidIsAlive = defaultPidIsAlive } = {}) {
|
|
118
|
+
export async function reapPins({ root, pidIsAlive = defaultPidIsAlive, zombie = null } = {}) {
|
|
119
119
|
root = await canonicalManagerRoot(root);
|
|
120
120
|
const directory = path.join(root, "pins");
|
|
121
121
|
if (!await managedDirectory(directory)) return;
|
|
@@ -125,7 +125,8 @@ export async function reapPins({ root, pidIsAlive = defaultPidIsAlive } = {}) {
|
|
|
125
125
|
const file = path.join(directory, name);
|
|
126
126
|
const record = await readManagedJson(file).catch(() => null);
|
|
127
127
|
if (record?.schemaVersion !== SCHEMA_VERSION) continue; // Unknown shape: an unknown holder.
|
|
128
|
-
if (!isPid(record.clientPid) || await confirmedDead(record.clientPid, pidIsAlive)
|
|
128
|
+
if (!isPid(record.clientPid) || await confirmedDead(record.clientPid, pidIsAlive)
|
|
129
|
+
|| zombie !== null && await zombie(record.clientPid) === true) {
|
|
129
130
|
await rm(file, { force: true });
|
|
130
131
|
removed = true;
|
|
131
132
|
}
|
|
@@ -2,7 +2,9 @@ import path from "node:path";
|
|
|
2
2
|
import { applyPlan, detectInstallation, livePolicyOf, loadOwnership, planInstallation }
|
|
3
3
|
from "@agents-can-communicate/installer";
|
|
4
4
|
import { ALL_ADAPTERS, clientContext, probeTimeout } from "../install-command.mjs";
|
|
5
|
+
import { processIsZombie } from "@agents-can-communicate/adapter-sdk";
|
|
5
6
|
import { sweepStaleBindings } from "./activation.mjs";
|
|
7
|
+
import { listRuntimeHolds } from "./leases.mjs";
|
|
6
8
|
import { stablePaths, writeLaunchers } from "./launchers.mjs";
|
|
7
9
|
import { bridgeLegacyMaintenance } from "./legacy-maintenance.mjs";
|
|
8
10
|
|
|
@@ -12,6 +14,9 @@ export async function prepareRefresh({ control, root, env = process.env, callerP
|
|
|
12
14
|
// An older generation lists its blockers right after this returns, by a rule
|
|
13
15
|
// that counts a binding without a client pid as a live client forever (#273).
|
|
14
16
|
await sweepStaleBindings(root).catch(() => {});
|
|
17
|
+
// It counts a zombie's lease as a live ACC process too (#280); a zombie's lease
|
|
18
|
+
// is removed like a dead process's.
|
|
19
|
+
await listRuntimeHolds(root, { zombie: processIsZombie }).catch(() => {});
|
|
15
20
|
const dataHome = path.dirname(path.dirname(root));
|
|
16
21
|
const wanted = new Set(control.targets);
|
|
17
22
|
const adapters = ALL_ADAPTERS().filter(adapter => wanted.has(adapter.id));
|
|
@@ -97,6 +97,8 @@ export async function updateNativeSessions(adapters, { service, status, root, no
|
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
const failures = {
|
|
100
|
+
transport_permission_denied: "this command cannot access the local delivery channel; "
|
|
101
|
+
+ "check the sender's command permissions",
|
|
100
102
|
client_process_unknown: "the client process could not be identified; start a new client session",
|
|
101
103
|
handshake_failed: "the session handshake failed; check the client's integration/channel setup",
|
|
102
104
|
handshake_timeout: "the session handshake timed out; check the local channel and retry on the next turn",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { validateNativeHandshake } from "@agents-can-communicate/adapter-sdk";
|
|
2
2
|
|
|
3
3
|
const MAX_REFRESH_LEASE_MS = 120_000;
|
|
4
|
+
const refused = (errorCode = "recipient_unavailable") => ({ refreshed: false, errorCode });
|
|
4
5
|
|
|
5
6
|
function sameBinding(current, expected, leaseUntil) {
|
|
6
7
|
return current !== null && current !== undefined
|
|
@@ -15,24 +16,26 @@ function sameBinding(current, expected, leaseUntil) {
|
|
|
15
16
|
|
|
16
17
|
export async function refreshExpiredBinding({ service, adapter, binding, runtimeDir,
|
|
17
18
|
clock, timeoutMs = 750 }) {
|
|
18
|
-
if (typeof adapter?.refreshNativeSession !== "function") return
|
|
19
|
+
if (typeof adapter?.refreshNativeSession !== "function") return refused();
|
|
19
20
|
const now = Date.parse(clock.now());
|
|
20
|
-
if (!Number.isFinite(now)) return
|
|
21
|
+
if (!Number.isFinite(now)) return refused();
|
|
21
22
|
try {
|
|
22
23
|
const handshake = await adapter.refreshNativeSession({ binding, runtimeDir, timeoutMs });
|
|
23
24
|
const validated = validateNativeHandshake(adapter, {
|
|
24
25
|
clientVersion: binding.clientVersion, handshake,
|
|
25
26
|
});
|
|
26
|
-
if (!validated.ok
|
|
27
|
+
if (!validated.ok) return refused(validated.reasonCode === "transport_permission_denied"
|
|
28
|
+
? "transport_permission_denied" : "recipient_unavailable");
|
|
29
|
+
if (validated.opaqueEndpointRef !== binding.opaqueEndpointRef) return refused();
|
|
27
30
|
const requestedLease = Date.parse(validated.leaseUntil);
|
|
28
|
-
if (!Number.isFinite(requestedLease) || requestedLease <= now) return
|
|
31
|
+
if (!Number.isFinite(requestedLease) || requestedLease <= now) return refused();
|
|
29
32
|
const leaseUntil = new Date(Math.min(requestedLease,
|
|
30
33
|
now + MAX_REFRESH_LEASE_MS)).toISOString();
|
|
31
34
|
await service.refreshDeliveryBinding({ sessionId: binding.sessionId,
|
|
32
35
|
generation: binding.generation, leaseUntil });
|
|
33
36
|
const current = await service.store?.ephemeral?.get("deliveryBinding", binding.sessionId);
|
|
34
|
-
return sameBinding(current, binding, leaseUntil);
|
|
37
|
+
return sameBinding(current, binding, leaseUntil) ? { refreshed: true, errorCode: null } : refused();
|
|
35
38
|
} catch {
|
|
36
|
-
return
|
|
39
|
+
return refused();
|
|
37
40
|
}
|
|
38
41
|
}
|
|
@@ -132,7 +132,12 @@ export function createDeliveryRouter({ service, adapters, clock, readLivePolicy,
|
|
|
132
132
|
if (Date.parse(binding.leaseUntil) <= Date.parse(now)) {
|
|
133
133
|
const refreshed = await refreshExpiredBinding({ service, adapter, binding,
|
|
134
134
|
runtimeDir: service.store?.root, clock });
|
|
135
|
-
if (!refreshed)
|
|
135
|
+
if (!refreshed.refreshed) {
|
|
136
|
+
if (refreshed.errorCode === "transport_permission_denied") {
|
|
137
|
+
await recordFailure(binding, message, participantId, "live-adapter", refreshed.errorCode);
|
|
138
|
+
}
|
|
139
|
+
return durable(participantId, refreshed.errorCode);
|
|
140
|
+
}
|
|
136
141
|
const current = (await service.listDeliveryBindings({
|
|
137
142
|
participantId, now: clock.now() })).filter(item => item.sessionId === binding.sessionId
|
|
138
143
|
&& item.generation === binding.generation
|
|
@@ -10,6 +10,7 @@ const REASONS = Object.freeze({
|
|
|
10
10
|
known_bad_version: "this client version has a known native delivery failure",
|
|
11
11
|
native_endpoint_unavailable: "the client's local delivery service is unavailable",
|
|
12
12
|
native_session_unavailable: "the local delivery service has no loaded client session",
|
|
13
|
+
transport_permission_denied: "the calling process lacks permission to access the local delivery channel",
|
|
13
14
|
feature_probe_failed: "the native protocol probe did not succeed",
|
|
14
15
|
probe_timeout: "the native protocol probe timed out",
|
|
15
16
|
probe_version_mismatch: "the local service and CLI versions differ",
|