agents-can-communicate 0.4.3 → 0.5.0
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 +8 -4
- package/bin/acc-maintenance-worker.mjs +6 -0
- package/bin/entrypoints/acc-hook.mjs +58 -5
- package/docs/ARCHITECTURE.md +21 -9
- package/docs/CLI.md +21 -10
- package/docs/CONCEPTS.md +2 -1
- package/docs/CONFIGURATION.md +2 -1
- package/docs/GETTING_STARTED.md +4 -2
- package/docs/HOW_IT_WORKS.md +2 -1
- package/docs/PROTOCOL.md +7 -0
- package/docs/RELEASING.md +1 -1
- package/docs/SECURITY_MODEL.md +16 -7
- package/docs/TROUBLESHOOTING.md +13 -6
- package/docs/UPGRADING.md +121 -22
- package/docs/WHY_ACC.md +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/src/adapter.mjs +10 -4
- package/node_modules/@agents-can-communicate/adapter-codex/src/app-server-client.mjs +4 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/maintenance-host.mjs +120 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/maintenance-threads.mjs +49 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/maintenance.mjs +108 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/native-delivery.mjs +67 -22
- 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/args.mjs +1 -1
- package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +10 -5
- package/node_modules/@agents-can-communicate/cli/src/help.mjs +2 -0
- package/node_modules/@agents-can-communicate/cli/src/index.mjs +5 -0
- package/node_modules/@agents-can-communicate/cli/src/main.mjs +5 -3
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/activation.mjs +225 -11
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/command.mjs +15 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/diagnostics.mjs +36 -0
- 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 +54 -14
- 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/legacy-maintenance.mjs +47 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/maintenance-state.mjs +57 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/maintenance-worker.mjs +118 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/maintenance.mjs +140 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/management-recovery.mjs +80 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/pins.mjs +125 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/refresh.mjs +6 -7
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/retire.mjs +56 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/schedule.mjs +4 -2
- 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 +34 -12
- 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/installer/src/apply.mjs +2 -2
- package/node_modules/@agents-can-communicate/installer/src/plan.mjs +10 -3
- 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 +3 -1
|
@@ -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
|
|
@@ -58,7 +58,7 @@ export const COMMANDS = Object.freeze({
|
|
|
58
58
|
uninstall: { required: [], optional: ["home"], repeated: ["adapter"], flags: ["dry-run"] },
|
|
59
59
|
// Asking npm whether there is a newer ACC. The one command that touches the
|
|
60
60
|
// network, and never on the hook path.
|
|
61
|
-
update: { required: [], optional: ["auto", "pin"], flags: ["check", "apply"] },
|
|
61
|
+
update: { required: [], optional: ["auto", "pin"], flags: ["check", "apply", "yes"] },
|
|
62
62
|
// The two things a person types first after installing from a registry. The
|
|
63
63
|
// CLI answered neither: `acc --version` and `acc --help` were both "unknown
|
|
64
64
|
// command", and `acc` on its own asked for a command without naming one.
|
|
@@ -15,6 +15,7 @@ import { ALL_ADAPTERS, clientContext, probeTimeout } from "./install-command.mjs
|
|
|
15
15
|
import { describePresence } from "./main.mjs";
|
|
16
16
|
import { platformPaths } from "./platform-paths.mjs";
|
|
17
17
|
import { noticeUpdate } from "./update-check.mjs";
|
|
18
|
+
import { managedUpdateDiagnostic } from "./managed-runtime/diagnostics.mjs";
|
|
18
19
|
import { readControl } from "./managed-runtime/state.mjs";
|
|
19
20
|
import { diagnoseFilesystemStore, repairFilesystemStore }
|
|
20
21
|
from "@agents-can-communicate/storage-filesystem";
|
|
@@ -207,9 +208,7 @@ export async function runDoctor({ options, context, runtime }) {
|
|
|
207
208
|
const update = manager === null
|
|
208
209
|
? await noticeUpdate({ dataHome, running, env: runtime?.env ?? {},
|
|
209
210
|
now: Date.parse(clock.now()), get: runtime?.fetch, io: { readFile, writeFile, mkdir } })
|
|
210
|
-
:
|
|
211
|
-
newer: manager.pending !== null, auto: manager.auto, pin: manager.pin,
|
|
212
|
-
pending: manager.pending?.version ?? null, phase: manager.phase, notice: manager.notice };
|
|
211
|
+
: await managedUpdateDiagnostic(runtime.managerRoot, manager, running);
|
|
213
212
|
|
|
214
213
|
// Before the store is read for anything else. `collectStatus` reads every
|
|
215
214
|
// record, so on the store this command exists to describe it threw first and
|
|
@@ -233,7 +232,13 @@ export async function runDoctor({ options, context, runtime }) {
|
|
|
233
232
|
const service = context.service ?? await context.openService();
|
|
234
233
|
const status = await service.collectStatus({});
|
|
235
234
|
updateNativeRuntime(adapters, status.deliveryBindings);
|
|
236
|
-
|
|
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() });
|
|
237
242
|
for (const adapter of adapters) {
|
|
238
243
|
adapter.remediation.push(...nativeRemediation(adapter));
|
|
239
244
|
}
|
|
@@ -276,7 +281,7 @@ export async function runDoctor({ options, context, runtime }) {
|
|
|
276
281
|
...adapters.filter(adapter => adapter.present && adapter.outgoingDelivery)
|
|
277
282
|
.map(adapter => ` ${adapter.displayName} ${adapter.outgoingDelivery.diagnostic}`),
|
|
278
283
|
...(manager === null ? [] : [` automatic updates ${manager.auto ? "on" : "off"}; ACC ${manager.active.version}`
|
|
279
|
-
+ (manager.pin ? `; pinned to ${manager.pin}` : ""), ...(
|
|
284
|
+
+ (manager.pin ? `; pinned to ${manager.pin}` : ""), ...(update.notice ? [` ${update.notice}`] : [])]),
|
|
280
285
|
...data.remediation.map(line => ` ${line}`),
|
|
281
286
|
// `0 of 4` is a true line that reads as a broken machine, and on an
|
|
282
287
|
// MCP-only one it would read that way on every run forever. The server needs
|
|
@@ -106,6 +106,8 @@ const NOTES = Object.freeze({
|
|
|
106
106
|
"Use acc update --check to only report whether a newer release exists.",
|
|
107
107
|
"An initial acc install enables automatic updates; reinstalling preserves your choice. Activation waits for active clients and ACC processes.",
|
|
108
108
|
"--auto on|off changes automatic updates; --pin VERSION holds an exact stable version; --pin none follows stable releases.",
|
|
109
|
+
"An eligible Codex service restart asks once; a detached worker waits for idle work and verifies completion. Open clients disconnect; acc doctor reports progress.",
|
|
110
|
+
"--yes explicitly consents to that restart without a prompt; --check never schedules maintenance.",
|
|
109
111
|
"--apply remains accepted as a compatibility alias for acc update."],
|
|
110
112
|
});
|
|
111
113
|
|
|
@@ -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";
|
|
@@ -18,6 +18,7 @@ import { runUpdateCommand } from "./update-command.mjs";
|
|
|
18
18
|
import { runConfigCommand } from "./config-command.mjs";
|
|
19
19
|
import { runInstallCommand } from "./install-command.mjs";
|
|
20
20
|
import { runDoctor } from "./doctor-command.mjs";
|
|
21
|
+
import { runManagementDoctor } from "./managed-runtime/diagnostics.mjs";
|
|
21
22
|
import { createGitProbe } from "./git-probe.mjs";
|
|
22
23
|
import { canonicalClaim } from "./claim-spelling.mjs";
|
|
23
24
|
import { platformDataHome, runtimePaths } from "./runtime-paths.mjs";
|
|
@@ -412,7 +413,8 @@ export async function main(argv, runtime) {
|
|
|
412
413
|
let parsed;
|
|
413
414
|
try {
|
|
414
415
|
parsed = parseArgs(argv);
|
|
415
|
-
|
|
416
|
+
const managementDoctor = runtime.managementOnly && parsed.command === "doctor" && !parsed.options.repair;
|
|
417
|
+
if (runtime.managementOnly && !managementDoctor && !["help", "version", "update"].includes(parsed.command)) {
|
|
416
418
|
throw new AccError(EXIT.DATA, "runtime unavailable; retry after the update or run acc update to recover");
|
|
417
419
|
}
|
|
418
420
|
// `config` is the one command that must work on a workspace ACC cannot
|
|
@@ -423,7 +425,7 @@ export async function main(argv, runtime) {
|
|
|
423
425
|
// discovery cannot open the workspace - that is what a user is trying to
|
|
424
426
|
// find out - install touches client configuration rather than ACC state,
|
|
425
427
|
// and `help` has to answer in a directory that is no workspace at all.
|
|
426
|
-
const context = NO_WORKSPACE.includes(parsed.command)
|
|
428
|
+
const context = managementDoctor || NO_WORKSPACE.includes(parsed.command)
|
|
427
429
|
? null
|
|
428
430
|
: parsed.command === "doctor"
|
|
429
431
|
? await openDiagnosticContext(parsed.options, runtime)
|
|
@@ -434,7 +436,7 @@ export async function main(argv, runtime) {
|
|
|
434
436
|
const options = context === null ? parsed.options
|
|
435
437
|
: await resolveOwner({ command: parsed.command, options: parsed.options,
|
|
436
438
|
context, env: runtime.env });
|
|
437
|
-
const { data, text, error: outcome } = await HANDLERS[parsed.command](
|
|
439
|
+
const { data, text, error: outcome } = await (managementDoctor ? runManagementDoctor : HANDLERS[parsed.command])(
|
|
438
440
|
{ options, context, runtime });
|
|
439
441
|
// A handler may have done real work and still failed: `acc install` writes
|
|
440
442
|
// for the clients it could and reports the one it could not. The data is
|
|
@@ -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,17 +57,208 @@ 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
|
+
|
|
73
|
+
/** Activation calls this under the admission mutex; diagnostics are a snapshot.
|
|
74
|
+
* The updater may ignore its own leases, never a native client's lifetime.
|
|
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.
|
|
78
|
+
*/
|
|
79
|
+
export async function listActivationBlockers(root, { pidIsAlive = defaultPidIsAlive,
|
|
80
|
+
ignorePid = null, incomingStoreVersion = null } = {}) {
|
|
81
|
+
const leases = (await listRuntimeHolds(root, { pidIsAlive })).filter(lease => lease.pid !== ignorePid);
|
|
82
|
+
const native = await listNativeHolds(root, { pidIsAlive });
|
|
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
|
+
];
|
|
89
|
+
const byPid = new Map(), unidentified = [];
|
|
90
|
+
for (const hold of holds) {
|
|
91
|
+
const pid = hold.pid ?? null;
|
|
92
|
+
const nativeBindings = hold.nativeBindings;
|
|
93
|
+
if (pid === null) {
|
|
94
|
+
unidentified.push({ pid, kinds: [hold.kind], reason: hold.reason, nativeBindings,
|
|
95
|
+
storeVersion: hold.storeVersion });
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const blocker = byPid.get(pid) ?? { pid, kinds: [], reason: "process_running",
|
|
99
|
+
nativeBindings: 0, storeVersion: hold.storeVersion };
|
|
100
|
+
if (!blocker.kinds.includes(hold.kind)) blocker.kinds.push(hold.kind);
|
|
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
|
+
}
|
|
109
|
+
byPid.set(pid, blocker);
|
|
110
|
+
}
|
|
111
|
+
return [...byPid.values()].sort((a, b) => a.pid - b.pid)
|
|
112
|
+
.map(blocker => ({ ...blocker, kinds: blocker.kinds.sort() })).concat(unidentified)
|
|
113
|
+
.filter(blocker => blocksActivation(blocker, incomingStoreVersion));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function activationBlockerNotice(version, blockers) {
|
|
117
|
+
if (!blockers.length) return `ACC ${version} is ready; no active or unidentified processes are blocking activation.`;
|
|
118
|
+
const details = blockers.slice(0, 10).map(blocker => {
|
|
119
|
+
const facts = [blocker.kinds.map(safeText).join(", ")];
|
|
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");
|
|
123
|
+
if (blocker.pid === null) facts.push(blocker.reason.replaceAll("_", " "));
|
|
124
|
+
return `${blocker.pid === null ? "unidentified process" : `PID ${blocker.pid}`} (${facts.join("; ")})`;
|
|
125
|
+
});
|
|
126
|
+
return `ACC ${version} is ready; waiting for ${blockers.length} active or unidentified process(es): `
|
|
127
|
+
+ details.join("; ") + (blockers.length > 10 ? `; and ${blockers.length - 10} more` : "") + ".";
|
|
128
|
+
}
|
|
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
|
+
|
|
56
257
|
async function prepareCandidate(control, root, env) {
|
|
57
258
|
const file = path.join(control.pending.root, "node_modules", "@agents-can-communicate", "cli",
|
|
58
259
|
"src", "managed-runtime", "refresh.mjs");
|
|
59
260
|
const { prepareRefresh } = await import(pathToFileURL(file).href);
|
|
60
|
-
return prepareRefresh({ control, root, env });
|
|
261
|
+
return prepareRefresh({ control, root, env, callerProtocol: 2 });
|
|
61
262
|
}
|
|
62
263
|
// JSON parser diagnostics may quote private config bytes. Keep the cause and
|
|
63
264
|
// known artifact paths, never the offending input or terminal control bytes.
|
|
@@ -71,20 +272,22 @@ const recipe = c => JSON.stringify([c.active, c.pending, c.home, c.targets, c.au
|
|
|
71
272
|
|
|
72
273
|
/** Detection is outside the fence; every integration write and commit stays inside it. */
|
|
73
274
|
export async function activatePending(root, { prepare = prepareCandidate, env = process.env,
|
|
74
|
-
pidIsAlive = defaultPidIsAlive, ignorePid = null } = {}) {
|
|
275
|
+
pidIsAlive = defaultPidIsAlive, ignorePid = null, beforeActivation = null } = {}) {
|
|
75
276
|
root = await canonicalManagerRoot(root);
|
|
76
277
|
const before = await readControl(root);
|
|
77
278
|
if (!before?.pending) return { activated: false, reason: "no_pending_update" };
|
|
78
279
|
const apply = await prepare(before, root, env);
|
|
79
|
-
|
|
280
|
+
let activatedRoot = null;
|
|
281
|
+
const outcome = await withManagerLock(root, async () => {
|
|
80
282
|
const current = await readControl(root);
|
|
81
283
|
if (!current || recipe(current) !== recipe(before)) return { activated: false, reason: "state_changed" };
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
284
|
+
if (beforeActivation) await beforeActivation(current);
|
|
285
|
+
const blockers = await listActivationBlockers(root, { pidIsAlive, ignorePid,
|
|
286
|
+
incomingStoreVersion: current.pending.storeVersion });
|
|
287
|
+
if (blockers.length) {
|
|
288
|
+
const notice = activationBlockerNotice(current.pending.version, blockers);
|
|
86
289
|
await writeControl(root, { ...current, notice });
|
|
87
|
-
return { activated: false, reason: "processes_active", holds:
|
|
290
|
+
return { activated: false, reason: "processes_active", holds: blockers.length, blockers, notice };
|
|
88
291
|
}
|
|
89
292
|
const activating = { ...current, phase: "activating", notice: "Finishing integration refresh." };
|
|
90
293
|
await writeControl(root, activating);
|
|
@@ -105,6 +308,17 @@ export async function activatePending(root, { prepare = prepareCandidate, env =
|
|
|
105
308
|
}
|
|
106
309
|
await writeControl(root, { ...activating, active: current.pending, pending: null,
|
|
107
310
|
phase: "ready", notice: `ACC ${current.pending.version} is active.` });
|
|
311
|
+
activatedRoot = current.pending.root;
|
|
108
312
|
return { activated: true, version: current.pending.version, installation: result };
|
|
109
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;
|
|
110
324
|
}
|
|
@@ -4,8 +4,13 @@ import { withManagerLock } from "./mutex.mjs";
|
|
|
4
4
|
import { scheduleWorker } from "./schedule.mjs";
|
|
5
5
|
import { readControl, writeControl } from "./state.mjs";
|
|
6
6
|
import { performUpdate, runWorker } from "./worker.mjs";
|
|
7
|
+
import { requestMaintenance } from "./maintenance.mjs";
|
|
8
|
+
import { retireLegacyUpdateWorker, stageNewerManagementRuntime } from "./management-recovery.mjs";
|
|
7
9
|
|
|
8
10
|
export function validateUpdateOptions(options) {
|
|
11
|
+
if (options.yes && (options.check || options.auto !== undefined || options.pin !== undefined)) {
|
|
12
|
+
throw new AccError(EXIT.USAGE, "--yes only consents to client maintenance during acc update");
|
|
13
|
+
}
|
|
9
14
|
if (options.auto !== undefined && !["on", "off"].includes(options.auto)) {
|
|
10
15
|
throw new AccError(EXIT.USAGE, "--auto expects on or off");
|
|
11
16
|
}
|
|
@@ -40,9 +45,15 @@ export async function runManagedUpdate({ options, runtime }) {
|
|
|
40
45
|
return { data: { auto: control.auto, pin: control.pin, running: control.active.version },
|
|
41
46
|
text: `Automatic updates ${control.auto ? "on" : "off"}. ${control.pin ? `Pinned to ${control.pin}.` : "Following stable releases."}` };
|
|
42
47
|
}
|
|
48
|
+
if (!options.check) await stageNewerManagementRuntime(root, runtime);
|
|
43
49
|
const result = options.check
|
|
44
50
|
? await performUpdate(root, { check: true, env })
|
|
45
51
|
: await runWorker(root, { force: true, env, ignorePid: process.pid });
|
|
52
|
+
if (!options.check && (result.reason === "processes_active" || result.reason === "maintenance_pending"
|
|
53
|
+
|| result.activated || result.checked && !result.newer)) {
|
|
54
|
+
const maintenance = await requestMaintenance({ root, control: await readControl(root), options, runtime });
|
|
55
|
+
if (maintenance) return maintenance;
|
|
56
|
+
}
|
|
46
57
|
const text = result.activated ? `Updated ACC to ${result.version}; integrations refreshed.`
|
|
47
58
|
: result.notice && result.reason === "processes_active" ? result.notice
|
|
48
59
|
: result.reason === "refresh_failed" ? [result.notice,
|
|
@@ -57,6 +68,10 @@ export async function runManagedUpdate({ options, runtime }) {
|
|
|
57
68
|
...(result.reason === "refresh_failed" ? { error: new AccError(EXIT.DATA, text) } : {}) };
|
|
58
69
|
} catch (error) {
|
|
59
70
|
if (/manager lock held/.test(error.message)) {
|
|
71
|
+
if (!options.check && options.auto === undefined && options.pin === undefined && !runtime.legacyWorkerRetried
|
|
72
|
+
&& await retireLegacyUpdateWorker(root).catch(() => false)) {
|
|
73
|
+
return runManagedUpdate({ options, runtime: { ...runtime, legacyWorkerRetried: true } });
|
|
74
|
+
}
|
|
60
75
|
return { data: { inProgress: true }, text: "An update is already in progress; ACC will activate it when running clients exit." };
|
|
61
76
|
}
|
|
62
77
|
throw error instanceof AccError ? error : new AccError(EXIT.DATA, error.message);
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { activationBlockerNotice, listActivationBlockers } from "./activation.mjs";
|
|
2
|
+
import { MAINTENANCE_ACTIVE, maintenanceNotice, maintenanceReport, readMaintenance } from "./maintenance-state.mjs";
|
|
3
|
+
import { readControl } from "./state.mjs";
|
|
4
|
+
|
|
5
|
+
/** Available when workspace admission is fenced, and from a newer management
|
|
6
|
+
* implementation before activation. Never opens a workspace or probes clients.
|
|
7
|
+
*/
|
|
8
|
+
export async function runManagementDoctor({ runtime }) {
|
|
9
|
+
const manager = await readControl(runtime.managerRoot);
|
|
10
|
+
if (!manager) throw new Error("managed update state is unavailable; run acc update to recover");
|
|
11
|
+
const update = await managedUpdateDiagnostic(runtime.managerRoot, manager, manager.active.version);
|
|
12
|
+
return { data: { scope: "update", workspaceInspection: "unavailable_during_update", update,
|
|
13
|
+
remediation: ["acc update"] },
|
|
14
|
+
text: ["Update diagnostics only; workspace and integrations were not inspected.",
|
|
15
|
+
update.notice ?? `ACC ${manager.active.version}; update phase ${manager.phase}.`,
|
|
16
|
+
"Run acc update to finish, then acc doctor for the full workspace report."].join("\n") };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function managedUpdateDiagnostic(root, manager, running) {
|
|
20
|
+
const blockers = manager.pending ? await listActivationBlockers(root, { ignorePid: process.pid,
|
|
21
|
+
incomingStoreVersion: manager.pending.storeVersion }) : null;
|
|
22
|
+
const update = { checked: false, running, latest: manager.pending?.version ?? null,
|
|
23
|
+
newer: manager.pending !== null, auto: manager.auto, pin: manager.pin,
|
|
24
|
+
pending: manager.pending?.version ?? null, phase: manager.phase,
|
|
25
|
+
notice: blockers === null ? manager.notice : manager.phase === "activating" && !blockers.length
|
|
26
|
+
? "Integration refresh is incomplete; run acc update to recover."
|
|
27
|
+
: activationBlockerNotice(manager.pending.version, blockers),
|
|
28
|
+
...(blockers === null ? {} : { holds: blockers.length, blockers }) };
|
|
29
|
+
const maintenance = await readMaintenance(root);
|
|
30
|
+
if (maintenance && (maintenance.target.root === (manager.pending ?? manager.active).root
|
|
31
|
+
|| MAINTENANCE_ACTIVE.includes(maintenance.status))) {
|
|
32
|
+
update.maintenance = maintenanceReport(maintenance);
|
|
33
|
+
update.notice = maintenanceNotice(maintenance);
|
|
34
|
+
}
|
|
35
|
+
return update;
|
|
36
|
+
}
|