agents-can-communicate 0.4.4 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/bin/entrypoints/acc-hook.mjs +58 -5
- package/docs/ARCHITECTURE.md +9 -4
- package/docs/CLI.md +12 -8
- package/docs/GETTING_STARTED.md +1 -1
- package/docs/PROTOCOL.md +7 -0
- package/docs/RELEASING.md +1 -1
- package/docs/SECURITY_MODEL.md +4 -2
- package/docs/TROUBLESHOOTING.md +9 -5
- package/docs/UPGRADING.md +86 -28
- 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/src/adapter.mjs +4 -2
- package/node_modules/@agents-can-communicate/adapter-codex/src/app-server-client.mjs +4 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/native-delivery.mjs +67 -22
- package/node_modules/@agents-can-communicate/adapter-codex/src/permission-ownership.mjs +19 -2
- 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-delivery.mjs +63 -16
- package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +16 -1
- package/node_modules/@agents-can-communicate/cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +7 -1
- package/node_modules/@agents-can-communicate/cli/src/index.mjs +5 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/activation.mjs +187 -11
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/diagnostics.mjs +2 -1
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/download.mjs +17 -2
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/entry.mjs +14 -6
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/generation.mjs +44 -25
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/install.mjs +46 -29
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/maintenance-state.mjs +1 -1
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/maintenance-worker.mjs +9 -1
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/maintenance.mjs +52 -4
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/management-recovery.mjs +18 -9
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/pins.mjs +125 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/retire.mjs +56 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/staging.mjs +95 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/state.mjs +8 -1
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/worker.mjs +18 -8
- package/node_modules/@agents-can-communicate/cli/src/native-session-diagnostics.mjs +83 -7
- 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/router.mjs +44 -2
- package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
- package/node_modules/@agents-can-communicate/hook-runner/src/native-binding.mjs +10 -1
- package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +55 -6
- package/node_modules/@agents-can-communicate/installer/package.json +1 -1
- 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/node_modules/@agents-can-communicate/storage-filesystem/src/safe-directory.mjs +38 -4
- package/package.json +2 -1
|
@@ -11,6 +11,19 @@ const marker = (source, prefix) => source.startsWith(prefix) ? source.slice(pref
|
|
|
11
11
|
// Permission selection, proxy and network grants form one ownership unit. If
|
|
12
12
|
// any component changes, none can be removed independently: enabled networking
|
|
13
13
|
// without its proxy has a different security meaning to Codex.
|
|
14
|
+
//
|
|
15
|
+
// A block records the table it was written inside, but that table only carries
|
|
16
|
+
// meaning for a block whose first declaration is a bare assignment: such an
|
|
17
|
+
// assignment belongs to whatever table precedes it, so a moved block would
|
|
18
|
+
// silently mean something else. A block that opens with its own header states
|
|
19
|
+
// its full path and is unaffected by what sits above it.
|
|
20
|
+
//
|
|
21
|
+
// The distinction is not theoretical. ACC appends the profile block at end of
|
|
22
|
+
// file and then tells the user to trust ACC's hooks in Codex - and Codex writes
|
|
23
|
+
// those trust entries as new `hooks.state` tables above the block. Comparing
|
|
24
|
+
// the enclosing table unconditionally turned ACC's own documented setup step
|
|
25
|
+
// into `customized`: doctor then reported working permissions as unverified,
|
|
26
|
+
// install refused to repair them, and uninstall would not remove them.
|
|
14
27
|
export function inspectPermissions(source) {
|
|
15
28
|
const blocks = [];
|
|
16
29
|
let open = null, metadata = null;
|
|
@@ -21,7 +34,8 @@ export function inspectPermissions(source) {
|
|
|
21
34
|
if (open || !/^[a-z]+$/.test(start) || blocks.some(block => block.id === start)) {
|
|
22
35
|
unsafe("duplicate or nested native permission marker");
|
|
23
36
|
}
|
|
24
|
-
open = { id: start, start: entry.start, table: entry.table, contentStart: entry.end
|
|
37
|
+
open = { id: start, start: entry.start, table: entry.table, contentStart: entry.end,
|
|
38
|
+
opens: null };
|
|
25
39
|
} else if (comment.startsWith(META)) {
|
|
26
40
|
if (!open || open.id !== "selection" || metadata !== null || entry.start !== open.contentStart) {
|
|
27
41
|
unsafe("misplaced permission metadata");
|
|
@@ -29,6 +43,8 @@ export function inspectPermissions(source) {
|
|
|
29
43
|
try { metadata = JSON.parse(comment.slice(META.length)); }
|
|
30
44
|
catch { unsafe("invalid permission metadata"); }
|
|
31
45
|
open.contentStart = entry.end;
|
|
46
|
+
} else if (open && entry.code && open.opens === null) {
|
|
47
|
+
open.opens = entry.header ? "table" : "assignment";
|
|
32
48
|
} else if (end !== null) {
|
|
33
49
|
if (!open || open.id !== end) unsafe("unmatched native permission marker");
|
|
34
50
|
blocks.push({ ...open, end: entry.end, body: source.slice(open.contentStart, entry.start) });
|
|
@@ -45,7 +61,8 @@ export function inspectPermissions(source) {
|
|
|
45
61
|
if (!record || Object.keys(record).sort().join() !== "before,hash,id,table"
|
|
46
62
|
|| typeof record.before !== "string" || record.hash !== hash(block.body, record)
|
|
47
63
|
|| (block.id === "selection" && block.table.length !== 0)
|
|
48
|
-
||
|
|
64
|
+
|| (block.opens === "assignment"
|
|
65
|
+
&& JSON.stringify(block.table) !== JSON.stringify(record.table))) {
|
|
49
66
|
return { source, state: "customized" };
|
|
50
67
|
}
|
|
51
68
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
export { CAPABILITY_SHAPE, assertCapabilities, defineAdapter } from "./capabilities.mjs";
|
|
4
4
|
export { effectiveCapabilities, validateCertification } from "./certification.mjs";
|
|
5
5
|
export { NATIVE_ACTIVATION_KINDS, NATIVE_BINDING_MODES, NATIVE_PLATFORMS, NATIVE_REASON_CODES,
|
|
6
|
-
compareStableVersions, evaluateNativeEligibility, parseStableVersion,
|
|
6
|
+
compareStableVersions, evaluateNativeEligibility, evaluateVersionContract, parseStableVersion,
|
|
7
7
|
validateNativeActivationPlan, validateNativeDeliveryContract, validateNativeHandshake }
|
|
8
8
|
from "./native-delivery.mjs";
|
|
9
9
|
export { EVENT_KINDS, NORMALIZED_EVENT_KEYS, normalizedEvent } from "./events.mjs";
|
|
@@ -102,19 +102,45 @@ function knownBadHit(contract, version) {
|
|
|
102
102
|
: compareStableVersions(version, entry.from) >= 0 && compareStableVersions(version, entry.to) <= 0));
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
//
|
|
106
|
-
|
|
105
|
+
// Judges one reported client version against an adapter's captured
|
|
106
|
+
// native-delivery contract: is the platform captured, is the version at or
|
|
107
|
+
// above the captured minimum, and is it not on the denylist. Returns
|
|
108
|
+
// { reasonCode, minimumVersion, protocolContract }; reasonCode is null when
|
|
109
|
+
// the version satisfies the contract, otherwise one of
|
|
110
|
+
// "native_delivery_unsupported", "platform_not_captured",
|
|
111
|
+
// "version_unavailable", "prerelease_not_captured", "below_minimum_version",
|
|
112
|
+
// or "known_bad_version".
|
|
113
|
+
//
|
|
114
|
+
// This is the static half of the rule only: it never contacts the client, so
|
|
115
|
+
// passing here proves nothing about whether a live probe or handshake
|
|
116
|
+
// actually confirms the protocol or delivery modes. A caller that needs that
|
|
117
|
+
// layers a probe or handshake check on top (see evaluateNativeEligibility,
|
|
118
|
+
// validateNativeHandshake below, and the delivery router's own offer check,
|
|
119
|
+
// which uses this function alone because a response already carries no probe
|
|
120
|
+
// or handshake shape to check further).
|
|
121
|
+
export function evaluateVersionContract(adapter, { clientVersion, platform }) {
|
|
107
122
|
const contract = adapter?.nativeDelivery;
|
|
108
|
-
|
|
123
|
+
// `== null` rather than `=== undefined`: a null declaration is as much "no
|
|
124
|
+
// contract" as a missing one, and reading `contract.minimumByPlatform` off it
|
|
125
|
+
// threw the very TypeError the guard below exists to prevent. defineAdapter
|
|
126
|
+
// cannot produce that shape; a hand-built registry entry can.
|
|
127
|
+
if (contract == null) {
|
|
109
128
|
return { reasonCode: "native_delivery_unsupported", minimumVersion: null, protocolContract: null };
|
|
110
129
|
}
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
130
|
+
const uncaptured = { reasonCode: "platform_not_captured", minimumVersion: null,
|
|
131
|
+
protocolContract: null };
|
|
132
|
+
// validateNativeDeliveryContract guarantees a minimum map and a matching
|
|
133
|
+
// anchor, but this function is also handed adapter objects that never went
|
|
134
|
+
// through it. A declaration missing either half has captured nothing for
|
|
135
|
+
// this platform, which is a closed answer - not a TypeError raised deep
|
|
136
|
+
// inside a delivery offer, far from the declaration that caused it. Every
|
|
137
|
+
// other entry point below already answers malformed input this way.
|
|
138
|
+
if (typeof platform !== "string" || !isPlainObject(contract.minimumByPlatform)) return uncaptured;
|
|
139
|
+
const minimumVersion = contract.minimumByPlatform[platform] ?? null;
|
|
140
|
+
if (minimumVersion === null) return uncaptured;
|
|
141
|
+
const anchor = (Array.isArray(contract.anchors) ? contract.anchors : [])
|
|
142
|
+
.find(item => item.platform === platform && item.version === minimumVersion);
|
|
143
|
+
if (anchor === undefined) return uncaptured;
|
|
118
144
|
const facts = { minimumVersion, protocolContract: anchor.protocolContract };
|
|
119
145
|
if (!isText(clientVersion)) return { ...facts, reasonCode: "version_unavailable" };
|
|
120
146
|
if (parseStableVersion(clientVersion) === null) {
|
|
@@ -147,7 +173,12 @@ function validateNativeProbe(probe) {
|
|
|
147
173
|
}
|
|
148
174
|
|
|
149
175
|
export function evaluateNativeEligibility(adapter, { clientVersion, platform, probe }) {
|
|
150
|
-
|
|
176
|
+
// The probe names the process that will serve the delivery. Judging the
|
|
177
|
+
// detected binary instead refuses a service that satisfies the contract.
|
|
178
|
+
// Only the version is read here; the shape is validated where it always was,
|
|
179
|
+
// so a malformed probe still returns a closed result rather than throwing.
|
|
180
|
+
const serving = isText(probe?.clientVersion) ? probe.clientVersion : clientVersion;
|
|
181
|
+
const rule = evaluateVersionContract(adapter, { clientVersion: serving, platform });
|
|
151
182
|
const base = { eligible: false, reasonCode: null, minimumVersion: rule.minimumVersion,
|
|
152
183
|
protocolContract: rule.protocolContract, modes: [] };
|
|
153
184
|
const closedResult = reasonCode => deepFreeze({ ...base, reasonCode });
|
|
@@ -155,7 +186,6 @@ export function evaluateNativeEligibility(adapter, { clientVersion, platform, pr
|
|
|
155
186
|
if (probe === null || probe === undefined) return closedResult("feature_probe_failed");
|
|
156
187
|
const facts = validateNativeProbe(probe);
|
|
157
188
|
if (facts.supported !== true) return closedResult(facts.reasonCode ?? "feature_probe_failed");
|
|
158
|
-
if (facts.clientVersion !== clientVersion) return closedResult("probe_version_mismatch");
|
|
159
189
|
if (facts.protocolContract !== rule.protocolContract) return closedResult("protocol_mismatch");
|
|
160
190
|
const modes = orderedModes(facts.modes);
|
|
161
191
|
if (!modes.includes("livePush")) return closedResult("feature_probe_failed");
|
|
@@ -191,19 +221,36 @@ function validateNativeHandshakeShape(handshake) {
|
|
|
191
221
|
|
|
192
222
|
// The per-session half: the same static rule again, then the adapter's live
|
|
193
223
|
// handshake facts. The launch-time executable fingerprint stays probe-only.
|
|
224
|
+
//
|
|
225
|
+
// An admitted verdict reports the version it admitted as `clientVersion`. That
|
|
226
|
+
// is the whole point of returning it: the caller publishes a binding, and the
|
|
227
|
+
// binding must record the version the admission actually rests on - the one
|
|
228
|
+
// that will serve - rather than the caller's own detected-CLI claim. 0.5.0
|
|
229
|
+
// judged the serving version here and left the caller publishing its claim, so
|
|
230
|
+
// the two records disagreed on every machine where a daemon had updated under
|
|
231
|
+
// its CLI, which is exactly the case the release exists to support. A refused
|
|
232
|
+
// verdict admits nothing and carries null.
|
|
194
233
|
export function validateNativeHandshake(adapter, { clientVersion, platform, handshake }) {
|
|
195
|
-
|
|
196
|
-
|
|
234
|
+
// Same rationale as the probe: the handshake names the session that will
|
|
235
|
+
// actually serve, so the static rule is judged against that version. Only
|
|
236
|
+
// the version is read here; the shape is validated where it always was, so
|
|
237
|
+
// a malformed handshake still returns a closed result rather than throwing.
|
|
238
|
+
const serving = isText(handshake?.clientVersion) ? handshake.clientVersion : clientVersion;
|
|
239
|
+
const rule = evaluateVersionContract(adapter, { clientVersion: serving, platform });
|
|
240
|
+
const base = { ok: false, reasonCode: null, clientVersion: null,
|
|
241
|
+
protocolContract: rule.protocolContract, modes: [],
|
|
197
242
|
opaqueEndpointRef: null, leaseUntil: null };
|
|
198
243
|
const closedResult = reasonCode => deepFreeze({ ...base, reasonCode });
|
|
199
244
|
if (rule.reasonCode !== null) return closedResult(rule.reasonCode);
|
|
200
245
|
if (handshake === null || handshake === undefined) return closedResult("handshake_failed");
|
|
201
246
|
const facts = validateNativeHandshakeShape(handshake);
|
|
202
247
|
if (facts.supported !== true) return closedResult(facts.reasonCode ?? "handshake_failed");
|
|
203
|
-
if (facts.clientVersion !== clientVersion) return closedResult("handshake_version_mismatch");
|
|
204
248
|
if (facts.protocolContract !== rule.protocolContract) return closedResult("protocol_mismatch");
|
|
205
249
|
const modes = orderedModes(facts.modes);
|
|
206
250
|
if (!modes.includes("livePush")) return closedResult("handshake_failed");
|
|
207
|
-
|
|
251
|
+
// `serving` is a text version whenever the rule admitted it: every other
|
|
252
|
+
// shape leaves evaluateVersionContract at "version_unavailable" above.
|
|
253
|
+
return deepFreeze({ ok: true, reasonCode: null, clientVersion: serving,
|
|
254
|
+
protocolContract: rule.protocolContract, modes,
|
|
208
255
|
opaqueEndpointRef: facts.opaqueEndpointRef, leaseUntil: facts.leaseUntil });
|
|
209
256
|
}
|
|
@@ -43,7 +43,7 @@ function assertBindingDeadline(deadlineAt) {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
export async function storeSessionBinding({ runtimeDir, harnessSessionId, accSessionId,
|
|
46
|
-
generation, clientVersion, platform, clientPid, deadlineAt }) {
|
|
46
|
+
generation, clientVersion, platform, clientPid, deadlineAt, storeVersion, runtimeRoot }) {
|
|
47
47
|
assertBindingDeadline(deadlineAt);
|
|
48
48
|
const file = fileFor(runtimeDir, harnessSessionId);
|
|
49
49
|
const record = { schemaVersion: SCHEMA_VERSION, harnessSessionId, accSessionId, generation };
|
|
@@ -62,6 +62,21 @@ export async function storeSessionBinding({ runtimeDir, harnessSessionId, accSes
|
|
|
62
62
|
}
|
|
63
63
|
record.clientPid = clientPid;
|
|
64
64
|
}
|
|
65
|
+
// Runtime facts, kept separate from `generation`, which names the ACC session
|
|
66
|
+
// generation and must not be overloaded with a runtime identity.
|
|
67
|
+
if (storeVersion !== undefined && storeVersion !== null) {
|
|
68
|
+
if (!Number.isSafeInteger(storeVersion) || storeVersion <= 0) {
|
|
69
|
+
throw new AccError(EXIT.USAGE, "session binding storeVersion must be a positive integer",
|
|
70
|
+
{ storeVersion });
|
|
71
|
+
}
|
|
72
|
+
record.storeVersion = storeVersion;
|
|
73
|
+
}
|
|
74
|
+
if (runtimeRoot !== undefined && runtimeRoot !== null) {
|
|
75
|
+
if (typeof runtimeRoot !== "string" || runtimeRoot === "") {
|
|
76
|
+
throw new AccError(EXIT.USAGE, "session binding runtimeRoot must be a non-empty string", {});
|
|
77
|
+
}
|
|
78
|
+
record.runtimeRoot = runtimeRoot;
|
|
79
|
+
}
|
|
65
80
|
const temporary = `${file}.${process.pid}.tmp`;
|
|
66
81
|
await writeFile(temporary, `${JSON.stringify(record, null, 2)}\n`, "utf8");
|
|
67
82
|
// Replace rather than append: re-attaching supersedes the old generation, and
|
|
@@ -232,7 +232,13 @@ export async function runDoctor({ options, context, runtime }) {
|
|
|
232
232
|
const service = context.service ?? await context.openService();
|
|
233
233
|
const status = await service.collectStatus({});
|
|
234
234
|
updateNativeRuntime(adapters, status.deliveryBindings);
|
|
235
|
-
|
|
235
|
+
// The runtime state above is read from the binding records alone. The
|
|
236
|
+
// session pass then asks each adapter whether the receiver those records
|
|
237
|
+
// name can still take a push, and downgrades anything that only looked
|
|
238
|
+
// active. It needs the adapter objects to ask, which the diagnostic entries
|
|
239
|
+
// do not carry.
|
|
240
|
+
await updateNativeSessions(adapters, { service, status, root, now: clock.now(),
|
|
241
|
+
registry: ALL_ADAPTERS() });
|
|
236
242
|
for (const adapter of adapters) {
|
|
237
243
|
adapter.remediation.push(...nativeRemediation(adapter));
|
|
238
244
|
}
|
|
@@ -9,3 +9,8 @@ export { discoverWorkspace } from "./workspace-discovery.mjs";
|
|
|
9
9
|
export { createGitProbe, hermeticEnv } from "./git-probe.mjs";
|
|
10
10
|
export { platformDataHome, runtimePaths } from "./runtime-paths.mjs";
|
|
11
11
|
export { platformPaths } from "./platform-paths.mjs";
|
|
12
|
+
// A pin names the generation a session started with, kept beside its binding
|
|
13
|
+
// so a hook can be made to keep loading that generation once a newer one
|
|
14
|
+
// activates.
|
|
15
|
+
export { clearPin, hookEntrypointFor, readPin, reapPins, resolvePinnedGeneration, writePin }
|
|
16
|
+
from "./managed-runtime/pins.mjs";
|
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { readdir } from "node:fs/promises";
|
|
2
|
+
import { readdir, rm } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { pathToFileURL } from "node:url";
|
|
5
5
|
import { assertPortableId } from "@agents-can-communicate/protocol";
|
|
6
6
|
import { readSessionRecord } from "@agents-can-communicate/storage-filesystem";
|
|
7
7
|
import { listRuntimeHolds } from "./leases.mjs";
|
|
8
8
|
import { confirmedDead, defaultPidIsAlive, withManagerLock } from "./mutex.mjs";
|
|
9
|
-
import {
|
|
9
|
+
import { reapPins } from "./pins.mjs";
|
|
10
|
+
import { reapStagingHolds } from "./staging.mjs";
|
|
11
|
+
import { canonicalManagerRoot, managedDirectory, readControl, readManagedJson, syncDirectory, writeControl } from "./state.mjs";
|
|
12
|
+
|
|
13
|
+
/** The staged generation states its own contract in its manifest, so the
|
|
14
|
+
* manager never imports code from a generation it has not activated. */
|
|
15
|
+
export async function declaredStoreVersion(generationRoot) {
|
|
16
|
+
const manifest = await readManagedJson(path.join(generationRoot, "package.json")).catch(() => null);
|
|
17
|
+
return Number.isSafeInteger(manifest?.accStoreVersion) && manifest.accStoreVersion > 0
|
|
18
|
+
? manifest.accStoreVersion : null;
|
|
19
|
+
}
|
|
10
20
|
|
|
11
21
|
// Both 0.3.1 and current MCP write this exact continuity record, without
|
|
12
22
|
// native client facts. A foreign harness id may look like an MCP key: require
|
|
@@ -47,36 +57,60 @@ export async function listNativeHolds(root, { pidIsAlive = defaultPidIsAlive } =
|
|
|
47
57
|
const pid = record?.schemaVersion === 1 && Number.isSafeInteger(record.clientPid)
|
|
48
58
|
&& record.clientPid > 0 ? record.clientPid : null;
|
|
49
59
|
if (pid !== null && await confirmedDead(pid, pidIsAlive)) continue;
|
|
50
|
-
holds.push({ kind: "native", pid, reason: pid === null ? "unknown_client_pid" : "client_running"
|
|
60
|
+
holds.push({ kind: "native", pid, reason: pid === null ? "unknown_client_pid" : "client_running",
|
|
61
|
+
storeVersion: Number.isSafeInteger(record?.storeVersion) ? record.storeVersion : null });
|
|
51
62
|
}
|
|
52
63
|
}
|
|
53
64
|
return holds;
|
|
54
65
|
}
|
|
55
66
|
|
|
67
|
+
/** Unknown on either side cannot be compared, and an uncomparable contract
|
|
68
|
+
* stays a hold. Equal contracts coexist by the store's own rule. */
|
|
69
|
+
export const blocksActivation = (hold, incomingStoreVersion) =>
|
|
70
|
+
!Number.isSafeInteger(incomingStoreVersion) || !Number.isSafeInteger(hold.storeVersion)
|
|
71
|
+
|| hold.storeVersion !== incomingStoreVersion;
|
|
72
|
+
|
|
56
73
|
/** Activation calls this under the admission mutex; diagnostics are a snapshot.
|
|
57
74
|
* The updater may ignore its own leases, never a native client's lifetime.
|
|
58
75
|
* Project only process facts: leases and bindings carry private owner tokens.
|
|
76
|
+
* A hold blocks only when its declared contract differs from the incoming
|
|
77
|
+
* generation's, or when either side declares none.
|
|
59
78
|
*/
|
|
60
|
-
export async function listActivationBlockers(root, { pidIsAlive = defaultPidIsAlive,
|
|
79
|
+
export async function listActivationBlockers(root, { pidIsAlive = defaultPidIsAlive,
|
|
80
|
+
ignorePid = null, incomingStoreVersion = null } = {}) {
|
|
61
81
|
const leases = (await listRuntimeHolds(root, { pidIsAlive })).filter(lease => lease.pid !== ignorePid);
|
|
62
82
|
const native = await listNativeHolds(root, { pidIsAlive });
|
|
63
|
-
const holds = [
|
|
64
|
-
...
|
|
83
|
+
const holds = [
|
|
84
|
+
...leases.map(({ pid, kind, runtime }) => ({ pid, kind, nativeBindings: 0,
|
|
85
|
+
storeVersion: runtime?.storeVersion ?? null })),
|
|
86
|
+
...native.map(hold => ({ ...hold, nativeBindings: hold.reason === "unknown_workspace" ? 0 : 1,
|
|
87
|
+
storeVersion: hold.storeVersion ?? null })),
|
|
88
|
+
];
|
|
65
89
|
const byPid = new Map(), unidentified = [];
|
|
66
90
|
for (const hold of holds) {
|
|
67
91
|
const pid = hold.pid ?? null;
|
|
68
92
|
const nativeBindings = hold.nativeBindings;
|
|
69
93
|
if (pid === null) {
|
|
70
|
-
unidentified.push({ pid, kinds: [hold.kind], reason: hold.reason, nativeBindings
|
|
94
|
+
unidentified.push({ pid, kinds: [hold.kind], reason: hold.reason, nativeBindings,
|
|
95
|
+
storeVersion: hold.storeVersion });
|
|
71
96
|
continue;
|
|
72
97
|
}
|
|
73
|
-
const blocker = byPid.get(pid) ?? { pid, kinds: [], reason: "process_running",
|
|
98
|
+
const blocker = byPid.get(pid) ?? { pid, kinds: [], reason: "process_running",
|
|
99
|
+
nativeBindings: 0, storeVersion: hold.storeVersion };
|
|
74
100
|
if (!blocker.kinds.includes(hold.kind)) blocker.kinds.push(hold.kind);
|
|
75
101
|
blocker.nativeBindings += nativeBindings;
|
|
102
|
+
// One process can publish several holds. Only an unbroken run of holds
|
|
103
|
+
// declaring the same contract may keep it for the whole process; an
|
|
104
|
+
// unknown contract, or one that differs from what has already
|
|
105
|
+
// accumulated, collapses the process to uncomparable for good.
|
|
106
|
+
if (!Number.isSafeInteger(hold.storeVersion) || hold.storeVersion !== blocker.storeVersion) {
|
|
107
|
+
blocker.storeVersion = null;
|
|
108
|
+
}
|
|
76
109
|
byPid.set(pid, blocker);
|
|
77
110
|
}
|
|
78
111
|
return [...byPid.values()].sort((a, b) => a.pid - b.pid)
|
|
79
|
-
.map(blocker => ({ ...blocker, kinds: blocker.kinds.sort() })).concat(unidentified)
|
|
112
|
+
.map(blocker => ({ ...blocker, kinds: blocker.kinds.sort() })).concat(unidentified)
|
|
113
|
+
.filter(blocker => blocksActivation(blocker, incomingStoreVersion));
|
|
80
114
|
}
|
|
81
115
|
|
|
82
116
|
export function activationBlockerNotice(version, blockers) {
|
|
@@ -84,6 +118,8 @@ export function activationBlockerNotice(version, blockers) {
|
|
|
84
118
|
const details = blockers.slice(0, 10).map(blocker => {
|
|
85
119
|
const facts = [blocker.kinds.map(safeText).join(", ")];
|
|
86
120
|
if (blocker.nativeBindings) facts.push(`${blocker.nativeBindings} native binding${blocker.nativeBindings === 1 ? "" : "s"}`);
|
|
121
|
+
facts.push(Number.isSafeInteger(blocker.storeVersion)
|
|
122
|
+
? `store contract ${blocker.storeVersion}` : "contract unknown");
|
|
87
123
|
if (blocker.pid === null) facts.push(blocker.reason.replaceAll("_", " "));
|
|
88
124
|
return `${blocker.pid === null ? "unidentified process" : `PID ${blocker.pid}`} (${facts.join("; ")})`;
|
|
89
125
|
});
|
|
@@ -91,6 +127,133 @@ export function activationBlockerNotice(version, blockers) {
|
|
|
91
127
|
+ details.join("; ") + (blockers.length > 10 ? `; and ${blockers.length - 10} more` : "") + ".";
|
|
92
128
|
}
|
|
93
129
|
|
|
130
|
+
/** A candidate reference that is simply absent (no pending update, no
|
|
131
|
+
* explicit active override) is normal. One that is present but cannot be
|
|
132
|
+
* resolved to a real filesystem path is unknown - possibly corrupt data -
|
|
133
|
+
* and must postpone the whole pass rather than be silently ignored. Every
|
|
134
|
+
* candidate is resolved the same way a `generations/<name>` entry is, so a
|
|
135
|
+
* symlinked data home can never make a live reference look unrelated to the
|
|
136
|
+
* directory it actually names.
|
|
137
|
+
*/
|
|
138
|
+
async function resolveCandidate(value) {
|
|
139
|
+
if (value === null || value === undefined) return { ok: true, root: null };
|
|
140
|
+
if (typeof value !== "string" || value === "") return { ok: false };
|
|
141
|
+
try { return { ok: true, root: await canonicalManagerRoot(value) }; }
|
|
142
|
+
catch { return { ok: false }; }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** A generation is reclaimable only when nothing can still import from it:
|
|
146
|
+
* not the active or pending control pointer, not a live runtime lease, not a
|
|
147
|
+
* live session pin, and not a live staging hold for a generation still being
|
|
148
|
+
* verified before publication. An unidentified holder - an unreadable lease,
|
|
149
|
+
* a pin or staging hold that is missing, corrupt, or declares an
|
|
150
|
+
* unresolvable root, or the absence of control.json itself - postpones the
|
|
151
|
+
* whole pass rather than being assumed dead, matching the rule admission
|
|
152
|
+
* already applies to its own bookkeeping.
|
|
153
|
+
*
|
|
154
|
+
* Takes its own manager lock, so a caller already holding one (activation)
|
|
155
|
+
* must call this only after releasing it.
|
|
156
|
+
*/
|
|
157
|
+
export async function reclaimGenerations({ root, active = null, pidIsAlive = defaultPidIsAlive } = {}) {
|
|
158
|
+
root = await canonicalManagerRoot(root);
|
|
159
|
+
return withManagerLock(root, async () => {
|
|
160
|
+
const generations = path.join(root, "generations");
|
|
161
|
+
if (!await managedDirectory(generations)) return { removed: [] };
|
|
162
|
+
// Candidates are captured before any holder is read, not after. A hold
|
|
163
|
+
// (or a control pointer) that lands later either names a directory this
|
|
164
|
+
// snapshot never saw - so it was never a deletion candidate regardless -
|
|
165
|
+
// or was already durable before this snapshot was taken, which, since
|
|
166
|
+
// every writer here writes its hold before the rename that *creates*
|
|
167
|
+
// the directory, guarantees it is already visible to every holder read
|
|
168
|
+
// below. Reading candidates last is exactly the window that left a
|
|
169
|
+
// staged-but-unpublished generation deletable; reading them first closes
|
|
170
|
+
// it for good, not just for the writer order this codebase happens to use.
|
|
171
|
+
//
|
|
172
|
+
// That guarantee is specifically about a directory the rename path
|
|
173
|
+
// creates. stageOwnGeneration's fast path (existingMatches) instead
|
|
174
|
+
// *adopts* a directory that already existed - from an earlier run,
|
|
175
|
+
// possibly one whose own hold is long gone - and only then writes this
|
|
176
|
+
// attempt's own hold for it. Such a directory can already be in this
|
|
177
|
+
// snapshot with no hold covering it yet, so a reclaim pass whose staging
|
|
178
|
+
// read happens to run before that adopting hold lands can still remove
|
|
179
|
+
// it. This is bounded, not open-ended: it can only affect a generation
|
|
180
|
+
// that is at that moment unreferenced by control, lease, and pin alike -
|
|
181
|
+
// the same shape as any other orphan - and it predates this reordering
|
|
182
|
+
// fix rather than being introduced by it.
|
|
183
|
+
const candidates = await readdir(generations, { withFileTypes: true });
|
|
184
|
+
const control = await readControl(root);
|
|
185
|
+
// No control at all is exactly as unknown as a corrupt one - which
|
|
186
|
+
// readControl already throws on, and every real caller of this function
|
|
187
|
+
// treats that throw as reason to postpone. Match that here explicitly:
|
|
188
|
+
// an absent control.json is not proof there is nothing to protect.
|
|
189
|
+
if (control === null) return { removed: [] };
|
|
190
|
+
const referenced = new Set();
|
|
191
|
+
for (const candidate of [active, control.active.root, control.pending?.root]) {
|
|
192
|
+
const resolved = await resolveCandidate(candidate);
|
|
193
|
+
if (!resolved.ok) return { removed: [] };
|
|
194
|
+
if (resolved.root) referenced.add(resolved.root);
|
|
195
|
+
}
|
|
196
|
+
let holds;
|
|
197
|
+
try { holds = await listRuntimeHolds(root, { pidIsAlive }); }
|
|
198
|
+
catch { return { removed: [] }; } // An unreadable lease is an unknown holder.
|
|
199
|
+
for (const lease of holds) {
|
|
200
|
+
const resolved = await resolveCandidate(lease.runtime?.root);
|
|
201
|
+
if (!resolved.ok) return { removed: [] };
|
|
202
|
+
if (resolved.root) referenced.add(resolved.root);
|
|
203
|
+
}
|
|
204
|
+
// A client that exits without a clean session end leaves its pin behind;
|
|
205
|
+
// reap confirmed-dead ones before treating every remaining pin as live,
|
|
206
|
+
// the same way admission already reaps confirmed-dead leases.
|
|
207
|
+
await reapPins({ root, pidIsAlive });
|
|
208
|
+
const pins = path.join(root, "pins");
|
|
209
|
+
if (await managedDirectory(pins)) {
|
|
210
|
+
for (const name of await readdir(pins)) {
|
|
211
|
+
if (!name.endsWith(".json")) continue;
|
|
212
|
+
const record = await readManagedJson(path.join(pins, name)).catch(() => null);
|
|
213
|
+
if (record === undefined) continue; // Concurrent pin removal, not a holder.
|
|
214
|
+
if (!record || record.schemaVersion !== 1 || typeof record.runtimeRoot !== "string"
|
|
215
|
+
|| record.runtimeRoot === "") return { removed: [] }; // Malformed pin: unknown holder.
|
|
216
|
+
const resolved = await resolveCandidate(record.runtimeRoot);
|
|
217
|
+
if (!resolved.ok) return { removed: [] };
|
|
218
|
+
referenced.add(resolved.root);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
// Staging renames a fully-formed generation into place, then runs it as a
|
|
222
|
+
// spawned process to verify it, before any control pointer, lease, or pin
|
|
223
|
+
// can reference it. A live hold names exactly the generation that step
|
|
224
|
+
// is building; a staging process that exited without publishing leaves
|
|
225
|
+
// its hold behind, reaped the same way an abandoned pin is.
|
|
226
|
+
await reapStagingHolds({ root, pidIsAlive });
|
|
227
|
+
const staging = path.join(root, "staging");
|
|
228
|
+
if (await managedDirectory(staging)) {
|
|
229
|
+
for (const name of await readdir(staging)) {
|
|
230
|
+
if (!name.endsWith(".json")) continue;
|
|
231
|
+
const record = await readManagedJson(path.join(staging, name)).catch(() => null);
|
|
232
|
+
if (record === undefined) continue; // Concurrent hold release, not a holder.
|
|
233
|
+
if (!record || record.schemaVersion !== 1 || typeof record.generationRoot !== "string"
|
|
234
|
+
|| record.generationRoot === "") return { removed: [] }; // Malformed hold: unknown holder.
|
|
235
|
+
const resolved = await resolveCandidate(record.generationRoot);
|
|
236
|
+
if (!resolved.ok) return { removed: [] };
|
|
237
|
+
referenced.add(resolved.root);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
const removed = [];
|
|
241
|
+
for (const entry of candidates) {
|
|
242
|
+
// A dot-prefixed entry is always an in-flight staging temp (mkdtemp's
|
|
243
|
+
// own naming), never a published generation. Its own creator cleans it
|
|
244
|
+
// up; guessing whether it is still being written into is not this
|
|
245
|
+
// function's job, and deleting one mid-write would corrupt that write.
|
|
246
|
+
if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
|
|
247
|
+
const directory = path.join(generations, entry.name);
|
|
248
|
+
if (referenced.has(directory)) continue;
|
|
249
|
+
await rm(directory, { recursive: true, force: true });
|
|
250
|
+
removed.push(entry.name);
|
|
251
|
+
}
|
|
252
|
+
if (removed.length) await syncDirectory(generations);
|
|
253
|
+
return { removed };
|
|
254
|
+
}, { pidIsAlive });
|
|
255
|
+
}
|
|
256
|
+
|
|
94
257
|
async function prepareCandidate(control, root, env) {
|
|
95
258
|
const file = path.join(control.pending.root, "node_modules", "@agents-can-communicate", "cli",
|
|
96
259
|
"src", "managed-runtime", "refresh.mjs");
|
|
@@ -114,11 +277,13 @@ export async function activatePending(root, { prepare = prepareCandidate, env =
|
|
|
114
277
|
const before = await readControl(root);
|
|
115
278
|
if (!before?.pending) return { activated: false, reason: "no_pending_update" };
|
|
116
279
|
const apply = await prepare(before, root, env);
|
|
117
|
-
|
|
280
|
+
let activatedRoot = null;
|
|
281
|
+
const outcome = await withManagerLock(root, async () => {
|
|
118
282
|
const current = await readControl(root);
|
|
119
283
|
if (!current || recipe(current) !== recipe(before)) return { activated: false, reason: "state_changed" };
|
|
120
284
|
if (beforeActivation) await beforeActivation(current);
|
|
121
|
-
const blockers = await listActivationBlockers(root, { pidIsAlive, ignorePid
|
|
285
|
+
const blockers = await listActivationBlockers(root, { pidIsAlive, ignorePid,
|
|
286
|
+
incomingStoreVersion: current.pending.storeVersion });
|
|
122
287
|
if (blockers.length) {
|
|
123
288
|
const notice = activationBlockerNotice(current.pending.version, blockers);
|
|
124
289
|
await writeControl(root, { ...current, notice });
|
|
@@ -143,6 +308,17 @@ export async function activatePending(root, { prepare = prepareCandidate, env =
|
|
|
143
308
|
}
|
|
144
309
|
await writeControl(root, { ...activating, active: current.pending, pending: null,
|
|
145
310
|
phase: "ready", notice: `ACC ${current.pending.version} is active.` });
|
|
311
|
+
activatedRoot = current.pending.root;
|
|
146
312
|
return { activated: true, version: current.pending.version, installation: result };
|
|
147
313
|
});
|
|
314
|
+
// Reclaim only after the fence has released and the new pointer is durable:
|
|
315
|
+
// reclaimGenerations takes its own manager lock, so calling it while still
|
|
316
|
+
// inside this one would deadlock, and a reclaim failure here must never
|
|
317
|
+
// turn an already-completed activation into a reported failure - it can
|
|
318
|
+
// always run again on the next activation.
|
|
319
|
+
if (outcome.activated) {
|
|
320
|
+
try { await reclaimGenerations({ root, active: activatedRoot, pidIsAlive }); }
|
|
321
|
+
catch { /* Best effort; never fails an activation that already succeeded. */ }
|
|
322
|
+
}
|
|
323
|
+
return outcome;
|
|
148
324
|
}
|
|
@@ -17,7 +17,8 @@ export async function runManagementDoctor({ runtime }) {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export async function managedUpdateDiagnostic(root, manager, running) {
|
|
20
|
-
const blockers = manager.pending ? await listActivationBlockers(root, { ignorePid: process.pid
|
|
20
|
+
const blockers = manager.pending ? await listActivationBlockers(root, { ignorePid: process.pid,
|
|
21
|
+
incomingStoreVersion: manager.pending.storeVersion }) : null;
|
|
21
22
|
const update = { checked: false, running, latest: manager.pending?.version ?? null,
|
|
22
23
|
newer: manager.pending !== null, auto: manager.auto, pin: manager.pin,
|
|
23
24
|
pending: manager.pending?.version ?? null, phase: manager.phase,
|
|
@@ -2,9 +2,11 @@ import { execFile } from "node:child_process";
|
|
|
2
2
|
import { access, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
|
+
import { declaredStoreVersion } from "./activation.mjs";
|
|
5
6
|
import { stageOwnGeneration } from "./generation.mjs";
|
|
6
7
|
import { verifyGeneration } from "./install.mjs";
|
|
7
8
|
import { managedDirectory } from "./state.mjs";
|
|
9
|
+
import { releaseStagingHold } from "./staging.mjs";
|
|
8
10
|
import { ENTRY_KINDS } from "./entry.mjs";
|
|
9
11
|
|
|
10
12
|
const NAME = "agents-can-communicate";
|
|
@@ -65,7 +67,20 @@ export async function downloadRelease(root, release, { env = process.env } = {})
|
|
|
65
67
|
for (const name of ENTRY_KINDS) await access(path.join(packageRoot, "bin", "entrypoints", `${name}.mjs`));
|
|
66
68
|
await access(path.join(packageRoot, "bin", "acc-update-worker.mjs"));
|
|
67
69
|
const generation = await stageOwnGeneration({ packageRoot, managerRoot: root });
|
|
68
|
-
|
|
69
|
-
|
|
70
|
+
// A failed verify, or any other failure before this function actually
|
|
71
|
+
// returns, means this candidate is abandoned here, so release its hold
|
|
72
|
+
// now - declaredStoreVersion included, so a future change to it (or any
|
|
73
|
+
// other exception in this region) cannot silently leak the hold instead
|
|
74
|
+
// of releasing it. Only a clean return hands the hold on to the caller
|
|
75
|
+
// (worker.mjs), since publication happens later there, and it is
|
|
76
|
+
// responsible for releasing it once that publish attempt resolves.
|
|
77
|
+
try {
|
|
78
|
+
await verifyGeneration(generation, { env });
|
|
79
|
+
return { version: generation.version, root: generation.root,
|
|
80
|
+
storeVersion: await declaredStoreVersion(generation.root), hold: generation.hold };
|
|
81
|
+
} catch (error) {
|
|
82
|
+
await releaseStagingHold(generation.hold);
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
70
85
|
} finally { await rm(temporary, { recursive: true, force: true }); }
|
|
71
86
|
}
|