agents-can-communicate 0.8.3 → 0.8.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/bin/acc-update-worker.mjs +4 -1
- package/bin/entrypoints/acc-mcp.mjs +3 -0
- package/bin/entrypoints/acc.mjs +5 -0
- package/docs/ARCHITECTURE.md +3 -1
- package/docs/CLI.md +7 -1
- package/docs/CONCEPTS.md +2 -2
- package/docs/CONFIGURATION.md +3 -2
- package/docs/GETTING_STARTED.md +12 -9
- package/docs/HOW_IT_WORKS.md +2 -1
- package/docs/TROUBLESHOOTING.md +11 -2
- package/docs/UPGRADING.md +18 -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/src/adapter.mjs +7 -1
- package/node_modules/@agents-can-communicate/adapter-codex/src/app-server-client.mjs +24 -1
- package/node_modules/@agents-can-communicate/adapter-codex/src/embedded-host.mjs +65 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/install.mjs +2 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/maintenance-host.mjs +33 -9
- package/node_modules/@agents-can-communicate/adapter-codex/src/native-delivery.mjs +38 -1
- package/node_modules/@agents-can-communicate/adapter-codex/src/native-discovery.mjs +82 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/service-setup.mjs +59 -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/native-vocabulary.mjs +3 -0
- package/node_modules/@agents-can-communicate/cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +2 -1
- package/node_modules/@agents-can-communicate/cli/src/main.mjs +18 -2
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/activation.mjs +25 -8
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/install.mjs +13 -3
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/schedule.mjs +19 -1
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/worker.mjs +38 -3
- package/node_modules/@agents-can-communicate/cli/src/native-delivery-status.mjs +5 -1
- package/node_modules/@agents-can-communicate/cli/src/native-session-diagnostics.mjs +3 -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/native-reason.mjs +18 -0
- package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +15 -3
- package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
- package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +110 -4
- package/node_modules/@agents-can-communicate/installer/package.json +1 -1
- package/node_modules/@agents-can-communicate/installer/src/delivery-diagnostics.mjs +2 -0
- package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
- package/node_modules/@agents-can-communicate/mcp-server/src/server.mjs +3 -0
- 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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { lstat, realpath } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { compareVersions } from "./app-server-client.mjs";
|
|
3
4
|
import { readySocketPath, socketIsReady } from "./native-endpoint.mjs";
|
|
4
5
|
import { probeNativeDelivery } from "./native-delivery.mjs";
|
|
5
6
|
import { installCodexStandalone } from "./standalone-install.mjs";
|
|
@@ -13,6 +14,9 @@ const keys = ["home", "codexHome", "cliPath", "cliVersion", "managedPath", "mana
|
|
|
13
14
|
const prerequisiteKeys = ["home", "codexHome", "cliPath", "cliVersion", "cliIdentity", "socketPath", "pidPath", "platform"];
|
|
14
15
|
const same = (a, b, fields = keys) => fields.every(key => a?.[key] === b?.[key]);
|
|
15
16
|
const own = info => typeof process.getuid !== "function" || info.uid === process.getuid();
|
|
17
|
+
// The first CLI measured installing the daemon's own package in a home that
|
|
18
|
+
// has none (2026-09-26; again on 0.159.0, 2026-09-29).
|
|
19
|
+
const SELF_INSTALL_MINIMUM = "0.157.1";
|
|
16
20
|
const sessionNeeded = "Codex service ready; open a new Codex session to establish its delivery binding and review client hooks and permissions";
|
|
17
21
|
const diagnostics = {
|
|
18
22
|
native_endpoint_unavailable: "Prepare the missing Codex service with codex app-server daemon start, then open a new Codex session",
|
|
@@ -26,13 +30,25 @@ const diagnostics = {
|
|
|
26
30
|
managed_binary_mismatch: "Codex CLI and managed standalone versions differ; repair the vendor installation, then retry acc install",
|
|
27
31
|
maintenance_cli_unsupported: "Codex cold service preparation requires codex-cli 0.154.0 or newer with daemon commands; update the vendor installation, then retry acc install",
|
|
28
32
|
};
|
|
33
|
+
const startsOnLaunch = "The Codex service is not running; Codex starts it with the next Codex session "
|
|
34
|
+
+ "(daemon_auto_start), so open Codex, or run acc install to start it now";
|
|
29
35
|
function report(state, reasonCode, facts = {}) {
|
|
30
36
|
return { ...facts, state, reasonCode, diagnostic: state === "ready"
|
|
31
37
|
? reasonCode === "native_session_unavailable" ? sessionNeeded
|
|
32
38
|
: "Codex service infrastructure is ready; current session binding is reported separately"
|
|
39
|
+
: facts.startsOnLaunch === true ? startsOnLaunch
|
|
33
40
|
: diagnostics[reasonCode] ?? `Codex service preparation could not verify ${reasonCode}; inspect the vendor service and retry acc install` };
|
|
34
41
|
}
|
|
35
42
|
|
|
43
|
+
// Since 0.157.1 a TUI that finds no service starts one (`daemon_auto_start`,
|
|
44
|
+
// stable and on by default). `codex features list` reports the effective value
|
|
45
|
+
// after config and profiles; any other answer leaves the start to acc install.
|
|
46
|
+
const AUTO_START = /^daemon_auto_start\s+\S+\s+true\s*$/m;
|
|
47
|
+
async function launchStart(run, paths, facts) {
|
|
48
|
+
const listed = await run(facts.cliPath, ["features", "list"], paths.options).catch(() => null);
|
|
49
|
+
return listed?.status === 0 && AUTO_START.test(listed.stdout) ? { startsOnLaunch: true } : {};
|
|
50
|
+
}
|
|
51
|
+
|
|
36
52
|
export function createCodexServiceSetup({ run = runMaintenanceCommand, probe = probeNativeDelivery,
|
|
37
53
|
fs = { lstat, realpath }, contextPaths = maintenanceContext,
|
|
38
54
|
installStandalone = installCodexStandalone } = {}) {
|
|
@@ -90,9 +106,43 @@ export function createCodexServiceSetup({ run = runMaintenanceCommand, probe = p
|
|
|
90
106
|
const installation = await probeMaintenanceCli({ ...paths,
|
|
91
107
|
options: { ...paths.options, cwd: paths.options.env.HOME } }, run);
|
|
92
108
|
const cli = await executableIdentity(installation.cliPath);
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
109
|
+
const facts = { home: paths.options.env.HOME, codexHome: paths.codexHome, cliIdentity: cli.identity,
|
|
110
|
+
socketPath: paths.socketPath, pidPath: paths.pidPath, platform: paths.platform, ...installation };
|
|
111
|
+
// A CLI that installs the daemon's own package at its first start needs
|
|
112
|
+
// no standalone download: starting the service is the whole setup (#205).
|
|
113
|
+
if (compareVersions(installation.cliVersion, SELF_INSTALL_MINIMUM) >= 0) {
|
|
114
|
+
return report("needed", "native_endpoint_unavailable", { ...facts, selfInstall: true,
|
|
115
|
+
...await launchStart(run, paths, facts) });
|
|
116
|
+
}
|
|
117
|
+
return report("needed", "managed_install_missing", { requiresInstall: true, ...facts });
|
|
118
|
+
}
|
|
119
|
+
// The service a self-installing CLI prepares: start it, then verify the
|
|
120
|
+
// package and daemon it installed exactly as an existing one is verified.
|
|
121
|
+
async function startSelfInstalling(context, plan) {
|
|
122
|
+
let started = false;
|
|
123
|
+
try {
|
|
124
|
+
if (plan.state !== "needed" || !prerequisiteKeys.every(key => typeof plan[key] === "string")) {
|
|
125
|
+
failMaintenance("invalid_service_setup_plan");
|
|
126
|
+
}
|
|
127
|
+
const current = await inspect(context, { strict: true });
|
|
128
|
+
if (current.state !== "needed" || current.selfInstall !== true || !same(plan, current, prerequisiteKeys)) {
|
|
129
|
+
failMaintenance("service_identity_changed");
|
|
130
|
+
}
|
|
131
|
+
started = true;
|
|
132
|
+
const result = await run(plan.cliPath, ["app-server", "daemon", "start"], {
|
|
133
|
+
cwd: plan.codexHome, env: { ...(context?.env ?? process.env), HOME: plan.home, CODEX_HOME: plan.codexHome },
|
|
134
|
+
timeout: 15_000,
|
|
135
|
+
});
|
|
136
|
+
if (result.status !== 0) failMaintenance("daemon_start_failed");
|
|
137
|
+
const paths = await contextPaths(context);
|
|
138
|
+
const facts = await installed(paths);
|
|
139
|
+
if (!same(plan, facts, ["codexHome", "cliPath", "cliVersion", "cliIdentity", "socketPath"])) {
|
|
140
|
+
failMaintenance("service_identity_changed");
|
|
141
|
+
}
|
|
142
|
+
return { ...await verify(paths, facts), started };
|
|
143
|
+
} catch (error) {
|
|
144
|
+
return { ...report(started ? "failed" : "blocked", error.reasonCode ?? "daemon_start_failed"), started };
|
|
145
|
+
}
|
|
96
146
|
}
|
|
97
147
|
async function verify(paths, facts) {
|
|
98
148
|
if (!await readySocketPath(paths.socketPath)) failMaintenance("daemon_socket_unproven");
|
|
@@ -129,9 +179,12 @@ export function createCodexServiceSetup({ run = runMaintenanceCommand, probe = p
|
|
|
129
179
|
if (!await info(paths.managedPath)) return await missingPrerequisite(paths);
|
|
130
180
|
const facts = await installed(paths);
|
|
131
181
|
if (!await info(paths.socketPath) && !await info(paths.pidPath)) {
|
|
132
|
-
return report("needed", "native_endpoint_unavailable",
|
|
182
|
+
return report("needed", "native_endpoint_unavailable",
|
|
183
|
+
{ ...facts, ...await launchStart(run, paths, facts) });
|
|
184
|
+
}
|
|
185
|
+
if (await serviceStopped(paths)) {
|
|
186
|
+
return report("needed", "service_stopped", { ...facts, ...await launchStart(run, paths, facts) });
|
|
133
187
|
}
|
|
134
|
-
if (await serviceStopped(paths)) return report("needed", "service_stopped", facts);
|
|
135
188
|
return await verify(paths, facts);
|
|
136
189
|
} catch (error) {
|
|
137
190
|
const reason = error.reasonCode ?? "service_inspection_failed";
|
|
@@ -165,6 +218,7 @@ export function createCodexServiceSetup({ run = runMaintenanceCommand, probe = p
|
|
|
165
218
|
}
|
|
166
219
|
async function prepareNativeServiceSetup({ context, plan, installPrerequisites, download } = {}) {
|
|
167
220
|
if (plan?.requiresInstall === true) return preparePrerequisite(context, plan, installPrerequisites, download);
|
|
221
|
+
if (plan?.selfInstall === true) return startSelfInstalling(context, plan);
|
|
168
222
|
let started = false;
|
|
169
223
|
try {
|
|
170
224
|
if (plan?.state === "ready") {
|
|
@@ -34,6 +34,9 @@ export const NATIVE_REASON_CODES = Object.freeze([
|
|
|
34
34
|
// The current session's canonical workspace identity could not be verified.
|
|
35
35
|
// This is a per-session refusal, not a universal vendor limitation.
|
|
36
36
|
"workspace_identity_unavailable",
|
|
37
|
+
// The client runs this session on its own embedded service, which no sender
|
|
38
|
+
// can reach, instead of the shared local one; it stays so until it closes.
|
|
39
|
+
"client_session_embedded",
|
|
37
40
|
]);
|
|
38
41
|
|
|
39
42
|
export const STABLE_VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\+[0-9A-Za-z.-]+)?$/;
|
|
@@ -12,7 +12,7 @@ export { describeNative } from "./native-delivery-status.mjs";
|
|
|
12
12
|
import { nativeSessionLines, updateNativeSessions } from "./native-session-diagnostics.mjs";
|
|
13
13
|
import { ALL_ADAPTERS, clientContext, probeTimeout } from "./install-command.mjs";
|
|
14
14
|
import { decisionOf } from "./install-delivery-consent.mjs";
|
|
15
|
-
import { describePresence, presenceBreakdown } from "./main.mjs";
|
|
15
|
+
import { describePresence, presenceBreakdown, registerNativeSessions } from "./main.mjs";
|
|
16
16
|
import { platformPaths } from "./platform-paths.mjs";
|
|
17
17
|
import { noticeUpdate } from "./update-check.mjs";
|
|
18
18
|
import { managedUpdateDiagnostic } from "./managed-runtime/diagnostics.mjs";
|
|
@@ -233,6 +233,7 @@ export async function runDoctor({ options, context, runtime }) {
|
|
|
233
233
|
// context built for this command opens the store on request rather than up
|
|
234
234
|
// front.
|
|
235
235
|
const service = context.service ?? await context.openService();
|
|
236
|
+
await registerNativeSessions(runtime, context);
|
|
236
237
|
const status = await service.collectStatus({});
|
|
237
238
|
updateNativeRuntime(adapters, status.deliveryBindings);
|
|
238
239
|
// The runtime state above is read from the binding records alone. The
|
|
@@ -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 { describeNativeReason } from "@agents-can-communicate/installer";
|
|
9
10
|
import { openFilesystemStore } from "@agents-can-communicate/storage-filesystem";
|
|
10
11
|
|
|
11
12
|
import { parseArgs, positiveNumber } from "./args.mjs";
|
|
@@ -102,6 +103,18 @@ async function openContext(options, runtime) {
|
|
|
102
103
|
return withService(await locateContext(options, runtime), runtime);
|
|
103
104
|
}
|
|
104
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Before a peer reads status, chats its clients hold but ACC has not seen yet
|
|
108
|
+
* are registered, so the peer can address them before their first turn
|
|
109
|
+
* (#167). Bounded and fail-open inside the port; status never waits on it
|
|
110
|
+
* beyond its budget or fails because of it.
|
|
111
|
+
*/
|
|
112
|
+
export async function registerNativeSessions(runtime, context) {
|
|
113
|
+
if (typeof runtime?.registerNativeSessions !== "function") return [];
|
|
114
|
+
return Promise.resolve().then(() => runtime.registerNativeSessions({
|
|
115
|
+
workspaceId: context.descriptor.id, dataHome: context.dataHome })).catch(() => []);
|
|
116
|
+
}
|
|
117
|
+
|
|
105
118
|
/**
|
|
106
119
|
* A context for the one command that has to survive an unopenable store.
|
|
107
120
|
*
|
|
@@ -150,7 +163,9 @@ export function recordedText(message, delivery) {
|
|
|
150
163
|
? item.errorCode === "transport_permission_denied"
|
|
151
164
|
? "live offer blocked by sender permissions; message remains queued; run acc doctor in the sending session"
|
|
152
165
|
: item.errorCode === "no_live_transport"
|
|
153
|
-
? `${item.recipientParticipantId} has no live transport
|
|
166
|
+
? `${item.recipientParticipantId} has no live transport`
|
|
167
|
+
+ `${item.nativeReasonCode ? ` (${describeNativeReason(item.nativeReasonCode)})` : ""}`
|
|
168
|
+
+ "; the message waits in its inbox"
|
|
154
169
|
: item.errorCode === "recipient_offline" && item.endReason === "clear"
|
|
155
170
|
? `${item.recipientParticipantId}'s conversation was cleared (/clear); the message waits `
|
|
156
171
|
+ "until that conversation resumes"
|
|
@@ -406,7 +421,8 @@ const HANDLERS = Object.freeze({
|
|
|
406
421
|
text: recordedText(handoff.message, routed.delivery) };
|
|
407
422
|
},
|
|
408
423
|
|
|
409
|
-
status: async ({ options, context }) => {
|
|
424
|
+
status: async ({ options, context, runtime }) => {
|
|
425
|
+
await registerNativeSessions(runtime, context);
|
|
410
426
|
const status = await context.service.collectStatus({
|
|
411
427
|
participantId: options.participant, all: options.all === true });
|
|
412
428
|
return { data: status, text: describeStatus(status) };
|
|
@@ -154,6 +154,10 @@ async function resolveCandidate(value) {
|
|
|
154
154
|
* Takes its own manager lock, so a caller already holding one (activation)
|
|
155
155
|
* must call this only after releasing it.
|
|
156
156
|
*/
|
|
157
|
+
// An unknown holder postpones the whole pass; callers that record having
|
|
158
|
+
// reclaimed need to tell that from a pass that found nothing to remove.
|
|
159
|
+
const POSTPONED = Object.freeze({ removed: [], postponed: true });
|
|
160
|
+
|
|
157
161
|
export async function reclaimGenerations({ root, active = null, pidIsAlive = defaultPidIsAlive } = {}) {
|
|
158
162
|
root = await canonicalManagerRoot(root);
|
|
159
163
|
return withManagerLock(root, async () => {
|
|
@@ -186,19 +190,19 @@ export async function reclaimGenerations({ root, active = null, pidIsAlive = def
|
|
|
186
190
|
// readControl already throws on, and every real caller of this function
|
|
187
191
|
// treats that throw as reason to postpone. Match that here explicitly:
|
|
188
192
|
// an absent control.json is not proof there is nothing to protect.
|
|
189
|
-
if (control === null) return
|
|
193
|
+
if (control === null) return POSTPONED;
|
|
190
194
|
const referenced = new Set();
|
|
191
195
|
for (const candidate of [active, control.active.root, control.pending?.root]) {
|
|
192
196
|
const resolved = await resolveCandidate(candidate);
|
|
193
|
-
if (!resolved.ok) return
|
|
197
|
+
if (!resolved.ok) return POSTPONED;
|
|
194
198
|
if (resolved.root) referenced.add(resolved.root);
|
|
195
199
|
}
|
|
196
200
|
let holds;
|
|
197
201
|
try { holds = await listRuntimeHolds(root, { pidIsAlive }); }
|
|
198
|
-
catch { return
|
|
202
|
+
catch { return POSTPONED; } // An unreadable lease is an unknown holder.
|
|
199
203
|
for (const lease of holds) {
|
|
200
204
|
const resolved = await resolveCandidate(lease.runtime?.root);
|
|
201
|
-
if (!resolved.ok) return
|
|
205
|
+
if (!resolved.ok) return POSTPONED;
|
|
202
206
|
if (resolved.root) referenced.add(resolved.root);
|
|
203
207
|
}
|
|
204
208
|
// A client that exits without a clean session end leaves its pin behind;
|
|
@@ -212,9 +216,9 @@ export async function reclaimGenerations({ root, active = null, pidIsAlive = def
|
|
|
212
216
|
const record = await readManagedJson(path.join(pins, name)).catch(() => null);
|
|
213
217
|
if (record === undefined) continue; // Concurrent pin removal, not a holder.
|
|
214
218
|
if (!record || record.schemaVersion !== 1 || typeof record.runtimeRoot !== "string"
|
|
215
|
-
|| record.runtimeRoot === "") return
|
|
219
|
+
|| record.runtimeRoot === "") return POSTPONED; // Malformed pin: unknown holder.
|
|
216
220
|
const resolved = await resolveCandidate(record.runtimeRoot);
|
|
217
|
-
if (!resolved.ok) return
|
|
221
|
+
if (!resolved.ok) return POSTPONED;
|
|
218
222
|
referenced.add(resolved.root);
|
|
219
223
|
}
|
|
220
224
|
}
|
|
@@ -231,9 +235,9 @@ export async function reclaimGenerations({ root, active = null, pidIsAlive = def
|
|
|
231
235
|
const record = await readManagedJson(path.join(staging, name)).catch(() => null);
|
|
232
236
|
if (record === undefined) continue; // Concurrent hold release, not a holder.
|
|
233
237
|
if (!record || record.schemaVersion !== 1 || typeof record.generationRoot !== "string"
|
|
234
|
-
|| record.generationRoot === "") return
|
|
238
|
+
|| record.generationRoot === "") return POSTPONED; // Malformed hold: unknown holder.
|
|
235
239
|
const resolved = await resolveCandidate(record.generationRoot);
|
|
236
|
-
if (!resolved.ok) return
|
|
240
|
+
if (!resolved.ok) return POSTPONED;
|
|
237
241
|
referenced.add(resolved.root);
|
|
238
242
|
}
|
|
239
243
|
}
|
|
@@ -319,6 +323,19 @@ export async function activatePending(root, { prepare = prepareCandidate, env =
|
|
|
319
323
|
if (outcome.activated) {
|
|
320
324
|
try { await reclaimGenerations({ root, active: activatedRoot, pidIsAlive }); }
|
|
321
325
|
catch { /* Best effort; never fails an activation that already succeeded. */ }
|
|
326
|
+
// The reclaim above is this, older, code's rule (#208). The activated
|
|
327
|
+
// generation's own rule runs next, from its own files as its refresh did,
|
|
328
|
+
// and records that it ran. A target without it (an older release) is left
|
|
329
|
+
// to its own worker; nothing here can fail the activation.
|
|
330
|
+
try { await reclaimAsActivated(root, activatedRoot); }
|
|
331
|
+
catch { /* Best effort, as above. */ }
|
|
322
332
|
}
|
|
323
333
|
return outcome;
|
|
324
334
|
}
|
|
335
|
+
|
|
336
|
+
async function reclaimAsActivated(root, activatedRoot) {
|
|
337
|
+
const file = path.join(activatedRoot, "node_modules", "@agents-can-communicate", "cli", "src",
|
|
338
|
+
"managed-runtime", "worker.mjs");
|
|
339
|
+
const { reclaimAsActive } = await import(pathToFileURL(file).href);
|
|
340
|
+
if (typeof reclaimAsActive === "function") await reclaimAsActive(root, activatedRoot);
|
|
341
|
+
}
|
|
@@ -9,6 +9,7 @@ import { stageOwnGeneration } from "./generation.mjs";
|
|
|
9
9
|
import { writeLaunchers } from "./launchers.mjs";
|
|
10
10
|
import { canonicalManagerRoot, readControl, writeControl } from "./state.mjs";
|
|
11
11
|
import { scheduleWorker } from "./schedule.mjs";
|
|
12
|
+
import { reclaimAsActive } from "./worker.mjs";
|
|
12
13
|
import { releaseStagingHold } from "./staging.mjs";
|
|
13
14
|
import { withManagerLock } from "./mutex.mjs";
|
|
14
15
|
import { retireManagedHolds } from "./retire.mjs";
|
|
@@ -36,7 +37,8 @@ export async function installManaged({ packageRoot, managerRoot, dataHome, home,
|
|
|
36
37
|
// writes below) or abandoned (any throw between here and there).
|
|
37
38
|
try {
|
|
38
39
|
await verifyGeneration(candidate, { env });
|
|
39
|
-
|
|
40
|
+
let ready = null;
|
|
41
|
+
const installed = await withManagerLock(root, async () => {
|
|
40
42
|
const previous = await readControl(root);
|
|
41
43
|
if (previous !== null && previous.home !== home) {
|
|
42
44
|
throw new Error("this ACC data home manages another client home; use a separate ACC_DATA_HOME");
|
|
@@ -67,13 +69,21 @@ export async function installManaged({ packageRoot, managerRoot, dataHome, home,
|
|
|
67
69
|
const integrationFailed = result.failed.some(failure => failure.stage !== "service-setup"
|
|
68
70
|
|| !result.operations?.some(operation => operation.adapterId === failure.adapterId && operation.applied === true));
|
|
69
71
|
if (!integrationFailed) {
|
|
70
|
-
|
|
71
|
-
await scheduleWorker(root, ready, { env });
|
|
72
|
+
ready = await writeControl(root, { ...control, phase: "ready", pending: null });
|
|
72
73
|
} else {
|
|
73
74
|
await writeControl(root, { ...control, notice: "Integration refresh is incomplete; run acc update to recover." });
|
|
74
75
|
}
|
|
75
76
|
return result;
|
|
76
77
|
});
|
|
78
|
+
if (ready !== null) {
|
|
79
|
+
// This install runs the code of the generation it activated, so its
|
|
80
|
+
// reclaim is that generation's rule (#208). It takes the manager lock
|
|
81
|
+
// itself, hence after the fence; a worker is then started only for work
|
|
82
|
+
// that is actually due.
|
|
83
|
+
await reclaimAsActive(root, candidate.root);
|
|
84
|
+
await scheduleWorker(root, ready, { env });
|
|
85
|
+
}
|
|
86
|
+
return installed;
|
|
77
87
|
} finally {
|
|
78
88
|
await releaseStagingHold(candidate.hold);
|
|
79
89
|
}
|
|
@@ -4,9 +4,27 @@ import { confirmedDead } from "./mutex.mjs";
|
|
|
4
4
|
import { readManagedJson } from "./state.mjs";
|
|
5
5
|
import { checkDue, networkDisabled } from "./policy.mjs";
|
|
6
6
|
|
|
7
|
+
// The active generation has not yet reclaimed with its own rule (#208): the
|
|
8
|
+
// worker records `reclaim.json` naming the root it reclaimed as. Local work, so
|
|
9
|
+
// neither the update opt-out nor the no-network override holds it back. An
|
|
10
|
+
// unreadable marker is not a reason to start a worker on every entry.
|
|
11
|
+
// A pass an unknown holder postponed is retried, at most hourly, so a holder
|
|
12
|
+
// that never becomes readable cannot start a worker on every entry.
|
|
13
|
+
const RECLAIM_RETRY_MS = 60 * 60_000;
|
|
14
|
+
async function reclaimDue(root, control) {
|
|
15
|
+
let marker;
|
|
16
|
+
try { marker = await readManagedJson(path.join(root, "reclaim.json")); } catch { return false; }
|
|
17
|
+
if (marker?.activeRoot !== control.active.root) return true;
|
|
18
|
+
if (marker.complete !== false) return false;
|
|
19
|
+
const attempted = Date.parse(marker.attemptedAt);
|
|
20
|
+
return !Number.isFinite(attempted) || Date.now() - attempted >= RECLAIM_RETRY_MS;
|
|
21
|
+
}
|
|
22
|
+
|
|
7
23
|
/** No network waits here: this only starts an independent background process. */
|
|
8
24
|
export async function scheduleWorker(root, control, { env = process.env } = {}) {
|
|
9
|
-
if (!control?.
|
|
25
|
+
if (!control?.active) return false;
|
|
26
|
+
const update = control.auto && !networkDisabled(env) && (control.pending || checkDue(control));
|
|
27
|
+
if (!update && !await reclaimDue(root, control)) return false;
|
|
10
28
|
try {
|
|
11
29
|
for (const directory of [path.join(root, "worker"), path.join(root, "worker", "poller")]) {
|
|
12
30
|
const owner = await readManagedJson(path.join(directory, "manager.lock", "owner.json"));
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { realpath } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { activatePending, reclaimGenerations } from "./activation.mjs";
|
|
2
5
|
import { downloadRelease, fetchRelease, newerVersion } from "./download.mjs";
|
|
3
6
|
import { withManagerLock } from "./mutex.mjs";
|
|
4
|
-
import { readControl, writeControl } from "./state.mjs";
|
|
7
|
+
import { readControl, writeControl, writeManagedJson } from "./state.mjs";
|
|
5
8
|
import { releaseStagingHold } from "./staging.mjs";
|
|
6
9
|
import { MAINTENANCE_ACTIVE, maintenanceNotice, maintenanceReport, readMaintenance } from "./maintenance-state.mjs";
|
|
7
10
|
import { requestMaintenance } from "./maintenance.mjs";
|
|
@@ -62,8 +65,40 @@ export async function performUpdate(root, { force = false, check = false, env =
|
|
|
62
65
|
return { ...checked, pending: generation.version, ...await activate(root, { env, ignorePid }) };
|
|
63
66
|
}
|
|
64
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Reclaim with this generation's own rule, when it is the active one (#208).
|
|
70
|
+
*
|
|
71
|
+
* An update is run by the older installed code, so the reclaim right after an
|
|
72
|
+
* activation applies the older rule, and a fix to that rule used to take
|
|
73
|
+
* effect only one release later. The active generation's worker runs this on
|
|
74
|
+
* its first pass, which the scheduler starts for a generation that has not
|
|
75
|
+
* recorded it yet, and on every later pass. A reclaim that finds an unknown
|
|
76
|
+
* holder still postpones itself; the next pass tries again.
|
|
77
|
+
*/
|
|
78
|
+
export const RECLAIM_MARKER = "reclaim.json";
|
|
79
|
+
export async function reclaimAsActive(root, generationRoot) {
|
|
80
|
+
if (typeof generationRoot !== "string") return;
|
|
81
|
+
try {
|
|
82
|
+
const control = await readControl(root);
|
|
83
|
+
if (control === null || await realpath(generationRoot) !== await realpath(control.active.root)) return;
|
|
84
|
+
const result = await reclaimGenerations({ root });
|
|
85
|
+
// A postponed pass is recorded as unfinished, so the scheduler tries again
|
|
86
|
+
// (hourly) even with automatic updates off.
|
|
87
|
+
await writeManagedJson(path.join(root, RECLAIM_MARKER), { schemaVersion: 1,
|
|
88
|
+
activeRoot: control.active.root, complete: result?.postponed !== true,
|
|
89
|
+
attemptedAt: new Date().toISOString() });
|
|
90
|
+
} catch { /* Best effort; the next pass reclaims again. */ }
|
|
91
|
+
}
|
|
92
|
+
|
|
65
93
|
/** One poller may wait; the update lock stays available between its passes. */
|
|
66
|
-
export async function runWorker(root, { force = false, env = process.env, wait = false, ignorePid = null
|
|
94
|
+
export async function runWorker(root, { force = false, env = process.env, wait = false, ignorePid = null,
|
|
95
|
+
generationRoot = null } = {}) {
|
|
96
|
+
const result = await pass(root, { force, env, wait, ignorePid });
|
|
97
|
+
await reclaimAsActive(root, generationRoot);
|
|
98
|
+
return result;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function pass(root, { force, env, wait, ignorePid }) {
|
|
67
102
|
const poll = async () => {
|
|
68
103
|
for (;;) {
|
|
69
104
|
const result = await withManagerLock(`${root}/worker`,
|
|
@@ -43,7 +43,11 @@ export function nativeRemediation(entry) {
|
|
|
43
43
|
}
|
|
44
44
|
if (native.reasonCode === "native_endpoint_unavailable") {
|
|
45
45
|
if (["blocked", "unsupported"].includes(service?.state)) steps.push(service.diagnostic);
|
|
46
|
-
else if (service?.state === "needed" &&
|
|
46
|
+
else if (service?.state === "needed" && service.startsOnLaunch === true) {
|
|
47
|
+
// The client starts it itself when its next session opens.
|
|
48
|
+
steps.push(`${entry.displayName}: its local delivery service starts with the next `
|
|
49
|
+
+ `${entry.displayName} session; open one, then run acc doctor here`);
|
|
50
|
+
} else if (service?.state === "needed" && native.configured) {
|
|
47
51
|
steps.push(service.requiresInstall && entry.deliveryDecision?.installPrerequisites === false
|
|
48
52
|
? `acc install --adapter ${entry.adapterId} --delivery ${native.policy === "all" ? "all" : "actionable"}`
|
|
49
53
|
+ " # allow the official Codex download and complete service setup"
|
|
@@ -104,6 +104,9 @@ const failures = {
|
|
|
104
104
|
session_generation_stale: "the session was replaced during the handshake",
|
|
105
105
|
workspace_identity_unavailable: "the bound client session is no longer in this workspace; "
|
|
106
106
|
+ "start a new client session here",
|
|
107
|
+
// It stays embedded until it closes; a later service never adopts it.
|
|
108
|
+
client_session_embedded: "the client runs this session on its own embedded service, which "
|
|
109
|
+
+ "peers cannot reach; start a new client session while its local delivery service runs",
|
|
107
110
|
};
|
|
108
111
|
|
|
109
112
|
function describeAttempt(attempt) {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { listSessionBindings, loadNativeAttempt } from "@agents-can-communicate/adapter-sdk";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Why a session has no live transport, as its own last native attempt said.
|
|
5
|
+
*
|
|
6
|
+
* The same record `acc doctor` reads, found through the hook-owner file that
|
|
7
|
+
* names this exact session generation. Null when none says: an attempt that
|
|
8
|
+
* succeeded, a record for another generation, or no runtime to read.
|
|
9
|
+
*/
|
|
10
|
+
export async function lastNativeReason({ runtimeDir, sessionId, generation }) {
|
|
11
|
+
if (typeof runtimeDir !== "string") return null;
|
|
12
|
+
const owners = (await listSessionBindings({ runtimeDir }))
|
|
13
|
+
.filter(owner => owner.accSessionId === sessionId && owner.generation === generation);
|
|
14
|
+
if (owners.length !== 1) return null;
|
|
15
|
+
const attempt = await loadNativeAttempt({ runtimeDir,
|
|
16
|
+
harnessSessionId: owners[0].harnessSessionId, accSessionId: sessionId, generation });
|
|
17
|
+
return attempt !== null && attempt.state !== "active" ? attempt.reasonCode : null;
|
|
18
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { evaluateVersionContract } from "@agents-can-communicate/adapter-sdk";
|
|
1
|
+
import { NATIVE_REASON_CODES, evaluateVersionContract } from "@agents-can-communicate/adapter-sdk";
|
|
2
2
|
|
|
3
|
+
import { lastNativeReason } from "./native-reason.mjs";
|
|
3
4
|
import { refreshExpiredBinding } from "./refresh-binding.mjs";
|
|
4
5
|
|
|
5
6
|
const SAFE_ERRORS = new Set(["ambiguous_recipient_sessions", "delivery_disabled",
|
|
@@ -45,9 +46,20 @@ function safeTransport(value, opaqueEndpointRef) {
|
|
|
45
46
|
return opaqueEndpointRef === "live-adapter" ? "native-live" : "live-adapter";
|
|
46
47
|
}
|
|
47
48
|
|
|
48
|
-
export function createDeliveryRouter({ service, adapters, clock, readLivePolicy
|
|
49
|
+
export function createDeliveryRouter({ service, adapters, clock, readLivePolicy,
|
|
50
|
+
readNativeReason = input => lastNativeReason({ runtimeDir: service.store?.root, ...input }) }) {
|
|
49
51
|
const registry = adaptersById(adapters);
|
|
50
52
|
|
|
53
|
+
// A session that bound no live transport may have said why: the reason its
|
|
54
|
+
// own last native attempt recorded, from the closed vocabulary only.
|
|
55
|
+
async function withoutLiveTransport(participantId, target) {
|
|
56
|
+
const reason = await Promise.resolve()
|
|
57
|
+
.then(() => readNativeReason({ sessionId: target.sessionId, generation: target.generation }))
|
|
58
|
+
.catch(() => null);
|
|
59
|
+
return { ...durable(participantId, "no_live_transport"),
|
|
60
|
+
...(NATIVE_REASON_CODES.includes(reason) ? { nativeReasonCode: reason } : {}) };
|
|
61
|
+
}
|
|
62
|
+
|
|
51
63
|
// Read at offer time, not from the binding: consent withdrawn after a session
|
|
52
64
|
// bound must stop the next offer, not the next rebind.
|
|
53
65
|
async function policyFor(adapter, binding) {
|
|
@@ -94,7 +106,7 @@ export function createDeliveryRouter({ service, adapters, clock, readLivePolicy
|
|
|
94
106
|
&& binding.generation === target.generation);
|
|
95
107
|
// Online, with nothing to push through: a CLI participant, or a client
|
|
96
108
|
// that bound no live transport. It reads its inbox, so it is not gone.
|
|
97
|
-
if (bindings.length === 0) return
|
|
109
|
+
if (bindings.length === 0) return withoutLiveTransport(participantId, target);
|
|
98
110
|
const evaluated = await Promise.all(bindings.map(async binding => {
|
|
99
111
|
const adapter = registry.get(binding.adapterId);
|
|
100
112
|
return { binding, adapter, policy: await policyFor(adapter, binding) };
|