agents-can-communicate 0.10.1 → 0.10.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/ARCHITECTURE.md +8 -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/maintenance-host.mjs +6 -0
- 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/process-table.mjs +20 -0
- package/node_modules/@agents-can-communicate/cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/activation.mjs +105 -29
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/diagnostics.mjs +6 -1
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/leases.mjs +4 -2
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/pins.mjs +3 -2
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/refresh.mjs +5 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/worker.mjs +14 -2
- 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/hook-runner/package.json +1 -1
- 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/package.json +1 -1
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -272,8 +272,14 @@ running. A binding that names no client process is judged by its session record
|
|
|
272
272
|
exited, or, with no process recorded, when ACC's presence rule calls the session offline -
|
|
273
273
|
closed, or 30 minutes without a heartbeat. A live recorded process, or a record that cannot be
|
|
274
274
|
read, keeps it a hold. A client that wakes later runs its next hook through the launcher on
|
|
275
|
-
the active generation.
|
|
276
|
-
|
|
275
|
+
the active generation. A binding that names its client stops holding when that process exits,
|
|
276
|
+
or when the process that now has its pid started after the binding was last written, since a
|
|
277
|
+
client runs when it writes its binding (#276); a start time that cannot be read decides
|
|
278
|
+
nothing. A zombie, a process that has exited but that its parent has not collected, counts as
|
|
279
|
+
exited for a binding, an ACC lease and a pin (#280); a process state that cannot be read
|
|
280
|
+
decides nothing. Otherwise presence TTL, `acc finish`, and delivery off do not prove native lifecycle
|
|
281
|
+
end. A pending release that cannot activate is replaced by a newer one when `acc update` runs
|
|
282
|
+
or an automatic check is due (#277). Normal background updates never
|
|
277
283
|
restart a vendor daemon. An explicitly approved maintenance job can do so through the
|
|
278
284
|
adapter lifecycle port: it waits for idle work, validates exact process identity, stops
|
|
279
285
|
the service under the admission fence, refreshes integrations, and restores and verifies
|
package/docs/UPGRADING.md
CHANGED
|
@@ -57,6 +57,21 @@ closed. 0.10.1 judges such a binding by its session record and removes it while
|
|
|
57
57
|
the update, so the update from 0.9.x finishes once no 0.9.x session runs. From 0.10.0, 0.10.1
|
|
58
58
|
keeps the store contract, so it activates without waiting for open sessions.
|
|
59
59
|
|
|
60
|
+
If `acc update` keeps retrying a pending 0.10.0 or 0.10.1 and names processes that are not your
|
|
61
|
+
clients, or `unknown client pid`, clear the pending release and update again:
|
|
62
|
+
|
|
63
|
+
<!-- test:illustration needs an installed ACC with a release pending; a fresh sandbox has neither -->
|
|
64
|
+
```bash
|
|
65
|
+
acc update --pin none
|
|
66
|
+
acc update
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`--pin none` keeps following stable releases and drops a pending release that is not pinned.
|
|
70
|
+
From 0.10.2, the update does this by itself: a pending release that cannot activate is replaced
|
|
71
|
+
by a newer one, and a binding whose pid another program now has no longer holds the update.
|
|
72
|
+
From 0.10.3, a process that has exited but that its parent has not collected (a zombie, `Z` in
|
|
73
|
+
`ps`) no longer holds it either.
|
|
74
|
+
|
|
60
75
|
## From 0.8.x
|
|
61
76
|
|
|
62
77
|
Update as usual:
|
|
@@ -355,7 +370,10 @@ PID/start time, executable, socket ownership and versions before stopping the se
|
|
|
355
370
|
It refreshes integrations, starts the service again and verifies the result. If refreshing
|
|
356
371
|
integrations fails, it still restores the service and reports the incomplete update.
|
|
357
372
|
A later update worker can resume an already approved recovery without another question.
|
|
373
|
+
A daemon that its parent leaves as a zombie after the stop counts as stopped, and the worker
|
|
374
|
+
starts the service again.
|
|
358
375
|
`acc doctor --json` exposes `update.maintenance`; ordinary `acc doctor` describes its state.
|
|
376
|
+
A failed or cancelled job is no longer reported once its release is active by another path.
|
|
359
377
|
A current launcher can use the newer management reader while the old runtime is active.
|
|
360
378
|
When admission is blocked, doctor returns an update-only report without inspecting the
|
|
361
379
|
workspace or repairing it. Older launchers gain this reader after activation, or through
|
|
@@ -181,6 +181,12 @@ export async function observeMaintenanceProcess(pid, paths, run, { system = WIND
|
|
|
181
181
|
if (response.status !== 0) return { state: "unknown" };
|
|
182
182
|
const match = /^(.{24})\s+(.+)$/.exec(response.stdout.trim());
|
|
183
183
|
if (!match || !startTimeValid(match[1])) return { state: "unknown" };
|
|
184
|
+
// A zombie has exited; only its parent has not collected it. `ps` names it
|
|
185
|
+
// `<defunct>` on macOS and `[name] <defunct>` on Linux. On 2026-10-06 the
|
|
186
|
+
// daemon `daemon stop` ended stayed one under Codex's own pid-update-loop,
|
|
187
|
+
// and reading it as alive left the stop unverified and the start refused
|
|
188
|
+
// (#280).
|
|
189
|
+
if (/<defunct>$/.test(match[2])) return { state: "dead" };
|
|
184
190
|
return { state: "alive", processStartTime: match[1], command: match[2] };
|
|
185
191
|
}
|
|
186
192
|
|
|
@@ -29,7 +29,7 @@ export { clearSessionBinding, listSessionBindings, loadSessionBinding,
|
|
|
29
29
|
|
|
30
30
|
export { clearNativeAttempt, loadNativeAttempt, storeNativeAttempt } from "./native-attempt.mjs";
|
|
31
31
|
export { channelSocketDirectory } from "./channel-directory.mjs";
|
|
32
|
-
export { readProcessArgs, readProcessTable, readWindowsProcess, splitWindowsCommandLine }
|
|
32
|
+
export { processIsZombie, readProcessArgs, readProcessTable, readWindowsProcess, splitWindowsCommandLine }
|
|
33
33
|
from "./process-table.mjs";
|
|
34
34
|
export { npmShimTarget, resolveExecutable, runExecutable } from "./executables.mjs";
|
|
35
35
|
export { pathOf } from "./executables.mjs";
|
|
@@ -165,6 +165,26 @@ export async function readProcessTable({ platform = process.platform, from = pro
|
|
|
165
165
|
return posixTable({ run, timeoutMs });
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
+
/**
|
|
169
|
+
* Whether a process has exited and only waits for its parent to collect it -
|
|
170
|
+
* the POSIX zombie, `ps` state `Z`. `kill(pid, 0)` succeeds for one and `ps`
|
|
171
|
+
* still lists its start time, so a liveness check that stops there calls it
|
|
172
|
+
* alive: on 2026-10-06 a stopped Codex daemon left as a zombie under Codex's
|
|
173
|
+
* own pid-update-loop kept an ACC update blocked and its restart refused
|
|
174
|
+
* (#280). True or false when `ps` answers; null when it does not. Windows has
|
|
175
|
+
* no such state.
|
|
176
|
+
*/
|
|
177
|
+
export async function processIsZombie(pid, { platform = process.platform, run = execFileAsync,
|
|
178
|
+
timeoutMs = 2_000 } = {}) {
|
|
179
|
+
if (platform === "win32") return false;
|
|
180
|
+
try {
|
|
181
|
+
const { stdout } = await run("/bin/ps", ["-p", String(pid), "-o", "stat="],
|
|
182
|
+
{ timeout: timeoutMs, env: { ...process.env, LC_ALL: "C" } });
|
|
183
|
+
const state = String(stdout).trim();
|
|
184
|
+
return state === "" ? null : state.startsWith("Z");
|
|
185
|
+
} catch { return null; }
|
|
186
|
+
}
|
|
187
|
+
|
|
168
188
|
/**
|
|
169
189
|
* The words one process was started with, or null when they cannot be read.
|
|
170
190
|
*
|
|
@@ -1,10 +1,13 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
1
2
|
import { createHash } from "node:crypto";
|
|
2
3
|
// Kept above 300 lines because activation and generation retirement must use
|
|
3
4
|
// the same validated runtime/native hold definitions under the admission mutex.
|
|
4
5
|
// Splitting their safety rules risks disagreeing about which users are live.
|
|
5
|
-
import { readdir, rm } from "node:fs/promises";
|
|
6
|
+
import { readdir, rm, stat } from "node:fs/promises";
|
|
6
7
|
import path from "node:path";
|
|
7
8
|
import { pathToFileURL } from "node:url";
|
|
9
|
+
import { promisify } from "node:util";
|
|
10
|
+
import { processIsZombie, readWindowsProcess } from "@agents-can-communicate/adapter-sdk";
|
|
8
11
|
import { classifySessionPresence } from "@agents-can-communicate/core";
|
|
9
12
|
import { assertPortableId } from "@agents-can-communicate/protocol";
|
|
10
13
|
import { readSessionRecord, withWriterMutex } from "@agents-can-communicate/storage-filesystem";
|
|
@@ -49,6 +52,53 @@ async function isMcpContinuity(record, name, workspaceRoot, workspaceId) {
|
|
|
49
52
|
const clientPidOf = record => record?.schemaVersion === 1 && Number.isSafeInteger(record.clientPid)
|
|
50
53
|
&& record.clientPid > 0 ? record.clientPid : null;
|
|
51
54
|
|
|
55
|
+
const execFileAsync = promisify(execFile);
|
|
56
|
+
|
|
57
|
+
/** When a process started, in milliseconds since the epoch; null when it cannot be read. */
|
|
58
|
+
export async function processStartedAt(pid, { platform = process.platform, run = execFileAsync } = {}) {
|
|
59
|
+
try {
|
|
60
|
+
if (platform === "win32") {
|
|
61
|
+
const found = await readWindowsProcess(pid, { timeoutMs: 5_000 });
|
|
62
|
+
if (found?.pid !== pid || typeof found.start !== "string" || !/^\d+$/.test(found.start)) return null;
|
|
63
|
+
// A FILETIME: 100-nanosecond intervals since 1601-01-01 UTC.
|
|
64
|
+
return Number(BigInt(found.start) / 10_000n) - 11_644_473_600_000;
|
|
65
|
+
}
|
|
66
|
+
const { stdout } = await run("/bin/ps", ["-p", String(pid), "-o", "lstart="],
|
|
67
|
+
{ timeout: 2_000, env: { ...process.env, LC_ALL: "C" } });
|
|
68
|
+
const started = Date.parse(stdout.trim());
|
|
69
|
+
return Number.isFinite(started) ? started : null;
|
|
70
|
+
} catch { return null; }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A client runs when it writes its binding, so a process that started after
|
|
74
|
+
* the binding was last written is not that client: the operating system gave
|
|
75
|
+
* its pid to another program. On 2026-10-06 chrome-devtools-mcp and two IntelliJ
|
|
76
|
+
* helpers held three such pids and kept 0.10.1 pending (#276). `ps` counts whole
|
|
77
|
+
* seconds and a filesystem may too; a second of slack keeps a client's own
|
|
78
|
+
* process from reading as another. A start time that cannot be read decides
|
|
79
|
+
* nothing. */
|
|
80
|
+
async function pidReused(pid, file, startedAt) {
|
|
81
|
+
const [started, written] = await Promise.all([startedAt(pid),
|
|
82
|
+
stat(file).then(info => info.mtimeMs, () => null)]);
|
|
83
|
+
return started !== null && written !== null && started > written + 1_000;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// A zombie has exited; only its parent has not collected it, and kill(pid, 0)
|
|
87
|
+
// still succeeds for it. On 2026-10-06 the Codex daemon `daemon stop` ended
|
|
88
|
+
// stayed one under Codex's own pid-update-loop and kept 0.10.2 pending (#280).
|
|
89
|
+
// A state that cannot be read decides nothing.
|
|
90
|
+
const exitedProcess = async (pid, pidIsAlive, zombie) =>
|
|
91
|
+
await confirmedDead(pid, pidIsAlive) || await zombie(pid) === true;
|
|
92
|
+
|
|
93
|
+
// One reading per pid for one pass: a vendor service can hold several bindings.
|
|
94
|
+
const perPass = read => {
|
|
95
|
+
const seen = new Map();
|
|
96
|
+
return pid => {
|
|
97
|
+
if (!seen.has(pid)) seen.set(pid, Promise.resolve().then(() => read(pid)).catch(() => null));
|
|
98
|
+
return seen.get(pid);
|
|
99
|
+
};
|
|
100
|
+
};
|
|
101
|
+
|
|
52
102
|
/** A binding that names no client process cannot be judged by its own facts;
|
|
53
103
|
* its session record can. It is stale when the record is gone, when the record
|
|
54
104
|
* names a process that has exited, or, with no process recorded, when ACC's own
|
|
@@ -57,7 +107,7 @@ const clientPidOf = record => record?.schemaVersion === 1 && Number.isSafeIntege
|
|
|
57
107
|
* process, or a record or store that cannot be read, keeps the binding a hold.
|
|
58
108
|
* On 2026-10-06 the maintainer's 0.10.0 waited behind 32 such bindings from
|
|
59
109
|
* 0.5.10 to 0.9.0 that no client could clear (#273). */
|
|
60
|
-
async function staleWithoutPid(workspaceRoot, workspaceId, record, pidIsAlive) {
|
|
110
|
+
async function staleWithoutPid(workspaceRoot, workspaceId, record, pidIsAlive, file, startedAt, zombie) {
|
|
61
111
|
if (record?.schemaVersion !== 1) return false;
|
|
62
112
|
let session;
|
|
63
113
|
try {
|
|
@@ -65,15 +115,19 @@ async function staleWithoutPid(workspaceRoot, workspaceId, record, pidIsAlive) {
|
|
|
65
115
|
sessionId: record.accSessionId, allowLegacyIdentity: true });
|
|
66
116
|
} catch { return false; }
|
|
67
117
|
if (session === null) return true;
|
|
68
|
-
if (Number.isSafeInteger(session.pid) && session.pid > 0)
|
|
118
|
+
if (Number.isSafeInteger(session.pid) && session.pid > 0) {
|
|
119
|
+
return await exitedProcess(session.pid, pidIsAlive, zombie) || pidReused(session.pid, file, startedAt);
|
|
120
|
+
}
|
|
69
121
|
// No process to ask, so the probe is never called.
|
|
70
122
|
return classifySessionPresence(session, new Date().toISOString(), () => true) === "offline";
|
|
71
123
|
}
|
|
72
124
|
|
|
73
125
|
/** Native bindings outlive finish/TTL; MCP process lifetime is covered by its runtime lease. */
|
|
74
|
-
export async function listNativeHolds(root, { pidIsAlive = defaultPidIsAlive, strict = false
|
|
126
|
+
export async function listNativeHolds(root, { pidIsAlive = defaultPidIsAlive, strict = false,
|
|
127
|
+
startedAt = processStartedAt, zombie = processIsZombie } = {}) {
|
|
75
128
|
const workspaces = path.join(path.dirname(await canonicalManagerRoot(root)), "workspaces");
|
|
76
129
|
if (!await managedDirectory(workspaces)) return [];
|
|
130
|
+
const started = perPass(startedAt), zombieNow = perPass(zombie);
|
|
77
131
|
const holds = [];
|
|
78
132
|
for (const entry of await readdir(workspaces, { withFileTypes: true })) {
|
|
79
133
|
if (!entry.isDirectory()) {
|
|
@@ -87,15 +141,18 @@ export async function listNativeHolds(root, { pidIsAlive = defaultPidIsAlive, st
|
|
|
87
141
|
if (!await managedDirectory(bindings)) continue;
|
|
88
142
|
for (const name of await readdir(bindings)) {
|
|
89
143
|
if (!name.endsWith(".json")) continue;
|
|
90
|
-
const
|
|
144
|
+
const file = path.join(bindings, name);
|
|
145
|
+
const record = await readManagedJson(file).catch(error => {
|
|
91
146
|
if (strict) throw error;
|
|
92
147
|
return null;
|
|
93
148
|
});
|
|
94
149
|
if (record === undefined) continue; // Concurrent lifecycle removal, before fencing.
|
|
95
150
|
if (await isMcpContinuity(record, name, path.dirname(bindings), entry.name)) continue;
|
|
96
151
|
const pid = clientPidOf(record);
|
|
97
|
-
if (pid !== null && await
|
|
98
|
-
|
|
152
|
+
if (pid !== null && (await exitedProcess(pid, pidIsAlive, zombieNow)
|
|
153
|
+
|| await pidReused(pid, file, started))) continue;
|
|
154
|
+
if (pid === null && await staleWithoutPid(path.dirname(bindings), entry.name, record, pidIsAlive,
|
|
155
|
+
file, started, zombieNow)) continue;
|
|
99
156
|
holds.push({ kind: "native", pid, reason: pid === null ? "unknown_client_pid" : "client_running",
|
|
100
157
|
storeVersion: Number.isSafeInteger(record?.storeVersion) ? record.storeVersion : null });
|
|
101
158
|
}
|
|
@@ -109,17 +166,22 @@ const LIFECYCLE_LOCK_MS = 2_000;
|
|
|
109
166
|
const lifecycleLocks = (workspaceRoot, harnessSessionId) => ({ locks: path.join(workspaceRoot,
|
|
110
167
|
"lifecycle-locks", createHash("sha256").update(String(harnessSessionId)).digest("hex")) });
|
|
111
168
|
|
|
112
|
-
/** Remove the bindings
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
|
|
169
|
+
/** Remove the bindings that listNativeHolds no longer counts and an older
|
|
170
|
+
* generation still does: those without a client pid that are stale (#273),
|
|
171
|
+
* those whose pid was given to another program (#276), and those whose client
|
|
172
|
+
* is a zombie (#280). An older generation
|
|
173
|
+
* activates its successor with its own rule, and loads the pending generation's
|
|
174
|
+
* refresh before it lists blockers, so that refresh calls this. A binding whose
|
|
175
|
+
* pid is merely dead holds nothing for either and stays, as the record a resumed
|
|
176
|
+
* conversation finds. Each one is judged again and removed under its session's
|
|
177
|
+
* lifecycle lock, so a hook that renews it is either seen or comes after (AI
|
|
178
|
+
* review of #274); a lock that stays busy leaves the binding as it is. */
|
|
179
|
+
export async function sweepStaleBindings(root, { pidIsAlive = defaultPidIsAlive,
|
|
180
|
+
startedAt = processStartedAt, zombie = processIsZombie } = {}) {
|
|
120
181
|
const workspaces = path.join(path.dirname(await canonicalManagerRoot(root)), "workspaces");
|
|
121
182
|
if (!await managedDirectory(workspaces)) return 0;
|
|
122
183
|
const clock = { now: () => new Date().toISOString() };
|
|
184
|
+
const started = perPass(startedAt), zombieNow = perPass(zombie);
|
|
123
185
|
let removed = 0;
|
|
124
186
|
for (const entry of await readdir(workspaces, { withFileTypes: true })) {
|
|
125
187
|
if (!entry.isDirectory()) continue;
|
|
@@ -130,10 +192,16 @@ export async function sweepStaleBindings(root, { pidIsAlive = defaultPidIsAlive
|
|
|
130
192
|
for (const name of await readdir(bindings)) {
|
|
131
193
|
if (!name.endsWith(".json")) continue;
|
|
132
194
|
const file = path.join(bindings, name);
|
|
133
|
-
const stale = async record =>
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
195
|
+
const stale = async record => {
|
|
196
|
+
if (record === undefined || record === null || typeof record.harnessSessionId !== "string"
|
|
197
|
+
|| await isMcpContinuity(record, name, workspaceRoot, entry.name)) return false;
|
|
198
|
+
const pid = clientPidOf(record);
|
|
199
|
+
if (pid !== null) {
|
|
200
|
+
return !await confirmedDead(pid, pidIsAlive)
|
|
201
|
+
&& (await zombieNow(pid) === true || pidReused(pid, file, started));
|
|
202
|
+
}
|
|
203
|
+
return staleWithoutPid(workspaceRoot, entry.name, record, pidIsAlive, file, started, zombieNow);
|
|
204
|
+
};
|
|
137
205
|
const judged = await readManagedJson(file).catch(() => undefined);
|
|
138
206
|
if (!await stale(judged)) continue;
|
|
139
207
|
const gone = await withWriterMutex(lifecycleLocks(workspaceRoot, judged.harnessSessionId),
|
|
@@ -165,9 +233,13 @@ export const blocksActivation = (hold, incomingStoreVersion) =>
|
|
|
165
233
|
* generation's, or when either side declares none.
|
|
166
234
|
*/
|
|
167
235
|
export async function listActivationBlockers(root, { pidIsAlive = defaultPidIsAlive,
|
|
168
|
-
ignorePid = null, incomingStoreVersion = null, strictNative = false
|
|
169
|
-
|
|
170
|
-
const
|
|
236
|
+
ignorePid = null, incomingStoreVersion = null, strictNative = false, startedAt,
|
|
237
|
+
zombie = processIsZombie } = {}) {
|
|
238
|
+
const zombieNow = perPass(zombie);
|
|
239
|
+
const leases = (await listRuntimeHolds(root, { pidIsAlive, zombie: zombieNow }))
|
|
240
|
+
.filter(lease => lease.pid !== ignorePid);
|
|
241
|
+
const native = await listNativeHolds(root, { pidIsAlive, strict: strictNative, zombie: zombieNow,
|
|
242
|
+
...(startedAt === undefined ? {} : { startedAt }) });
|
|
171
243
|
const holds = [
|
|
172
244
|
...leases.map(({ pid, kind, runtime }) => ({ pid, kind, nativeBindings: 0,
|
|
173
245
|
storeVersion: runtime?.storeVersion ?? null })),
|
|
@@ -246,8 +318,11 @@ async function resolveCandidate(value) {
|
|
|
246
318
|
// reclaimed need to tell that from a pass that found nothing to remove.
|
|
247
319
|
const POSTPONED = Object.freeze({ removed: [], postponed: true });
|
|
248
320
|
|
|
249
|
-
export async function reclaimGenerations({ root, active = null, pidIsAlive = defaultPidIsAlive
|
|
321
|
+
export async function reclaimGenerations({ root, active = null, pidIsAlive = defaultPidIsAlive,
|
|
322
|
+
zombie = processIsZombie } = {}) {
|
|
250
323
|
root = await canonicalManagerRoot(root);
|
|
324
|
+
// A lease or a pin of a client that exited uncollected keeps no generation (#280).
|
|
325
|
+
const zombieNow = perPass(zombie);
|
|
251
326
|
return withManagerLock(root, async () => {
|
|
252
327
|
const generations = path.join(root, "generations");
|
|
253
328
|
if (!await managedDirectory(generations)) return { removed: [] };
|
|
@@ -286,7 +361,7 @@ export async function reclaimGenerations({ root, active = null, pidIsAlive = def
|
|
|
286
361
|
if (resolved.root) referenced.add(resolved.root);
|
|
287
362
|
}
|
|
288
363
|
let holds;
|
|
289
|
-
try { holds = await listRuntimeHolds(root, { pidIsAlive }); }
|
|
364
|
+
try { holds = await listRuntimeHolds(root, { pidIsAlive, zombie: zombieNow }); }
|
|
290
365
|
catch { return POSTPONED; } // An unreadable lease is an unknown holder.
|
|
291
366
|
for (const lease of holds) {
|
|
292
367
|
const resolved = await resolveCandidate(lease.runtime?.root);
|
|
@@ -296,7 +371,7 @@ export async function reclaimGenerations({ root, active = null, pidIsAlive = def
|
|
|
296
371
|
// A client that exits without a clean session end leaves its pin behind;
|
|
297
372
|
// reap confirmed-dead ones before treating every remaining pin as live,
|
|
298
373
|
// the same way admission already reaps confirmed-dead leases.
|
|
299
|
-
await reapPins({ root, pidIsAlive });
|
|
374
|
+
await reapPins({ root, pidIsAlive, zombie: zombieNow });
|
|
300
375
|
const pins = path.join(root, "pins");
|
|
301
376
|
if (await managedDirectory(pins)) {
|
|
302
377
|
for (const name of await readdir(pins)) {
|
|
@@ -364,7 +439,7 @@ const recipe = c => JSON.stringify([c.active, c.pending, c.home, c.targets, c.au
|
|
|
364
439
|
|
|
365
440
|
/** Detection is outside the fence; every integration write and commit stays inside it. */
|
|
366
441
|
export async function activatePending(root, { prepare = prepareCandidate, env = process.env,
|
|
367
|
-
pidIsAlive = defaultPidIsAlive, ignorePid = null, beforeActivation = null } = {}) {
|
|
442
|
+
pidIsAlive = defaultPidIsAlive, ignorePid = null, beforeActivation = null, startedAt, zombie } = {}) {
|
|
368
443
|
root = await canonicalManagerRoot(root);
|
|
369
444
|
const before = await readControl(root);
|
|
370
445
|
if (!before?.pending) return { activated: false, reason: "no_pending_update" };
|
|
@@ -374,8 +449,8 @@ export async function activatePending(root, { prepare = prepareCandidate, env =
|
|
|
374
449
|
const current = await readControl(root);
|
|
375
450
|
if (!current || recipe(current) !== recipe(before)) return { activated: false, reason: "state_changed" };
|
|
376
451
|
if (beforeActivation) await beforeActivation(current);
|
|
377
|
-
const blockers = await listActivationBlockers(root, { pidIsAlive, ignorePid,
|
|
378
|
-
incomingStoreVersion: current.pending.storeVersion });
|
|
452
|
+
const blockers = await listActivationBlockers(root, { pidIsAlive, ignorePid, startedAt,
|
|
453
|
+
...(zombie === undefined ? {} : { zombie }), incomingStoreVersion: current.pending.storeVersion });
|
|
379
454
|
if (blockers.length) {
|
|
380
455
|
const notice = activationBlockerNotice(current.pending.version, blockers);
|
|
381
456
|
await writeControl(root, { ...current, notice });
|
|
@@ -409,7 +484,8 @@ export async function activatePending(root, { prepare = prepareCandidate, env =
|
|
|
409
484
|
// turn an already-completed activation into a reported failure - it can
|
|
410
485
|
// always run again on the next activation.
|
|
411
486
|
if (outcome.activated) {
|
|
412
|
-
try { await reclaimGenerations({ root, active: activatedRoot, pidIsAlive
|
|
487
|
+
try { await reclaimGenerations({ root, active: activatedRoot, pidIsAlive,
|
|
488
|
+
...(zombie === undefined ? {} : { zombie }) }); }
|
|
413
489
|
catch { /* Best effort; never fails an activation that already succeeded. */ }
|
|
414
490
|
// The reclaim above is this, older, code's rule (#208). The activated
|
|
415
491
|
// generation's own rule runs next, from its own files as its refresh did,
|
|
@@ -27,7 +27,12 @@ export async function managedUpdateDiagnostic(root, manager, running) {
|
|
|
27
27
|
: activationBlockerNotice(manager.pending.version, blockers),
|
|
28
28
|
...(blockers === null ? {} : { holds: blockers.length, blockers }) };
|
|
29
29
|
const maintenance = await readMaintenance(root);
|
|
30
|
-
|
|
30
|
+
// A job that failed or was cancelled for the release that is now active is
|
|
31
|
+
// history: another path activated it, and "run acc update to retry" asked
|
|
32
|
+
// for an update already done (#281). A completed job still reports its restart.
|
|
33
|
+
const settled = maintenance && manager.pending === null && maintenance.target.root === manager.active.root
|
|
34
|
+
&& ["failed", "cancelled"].includes(maintenance.status);
|
|
35
|
+
if (maintenance && !settled && (maintenance.target.root === (manager.pending ?? manager.active).root
|
|
31
36
|
|| MAINTENANCE_ACTIVE.includes(maintenance.status))) {
|
|
32
37
|
update.maintenance = maintenanceReport(maintenance);
|
|
33
38
|
update.notice = maintenanceNotice(maintenance);
|
|
@@ -31,8 +31,10 @@ export async function acquireRuntime(root, { pid = process.pid, kind = "cli" } =
|
|
|
31
31
|
/** Management decisions call this under withManagerLock to exclude admissions.
|
|
32
32
|
* Records are immutable and uniquely named; confirmed-dead cleanup also works
|
|
33
33
|
* outside a transaction. No release-on-return or timestamp expiry exists.
|
|
34
|
+
* A decision passes `zombie` so a holder that exited but was never collected
|
|
35
|
+
* holds nothing (#280); admission leaves it out and spawns no process.
|
|
34
36
|
*/
|
|
35
|
-
export async function listRuntimeHolds(root, { pidIsAlive = defaultPidIsAlive } = {}) {
|
|
37
|
+
export async function listRuntimeHolds(root, { pidIsAlive = defaultPidIsAlive, zombie = null } = {}) {
|
|
36
38
|
root = await canonicalManagerRoot(root);
|
|
37
39
|
if (!await managedDirectory(root)) return [];
|
|
38
40
|
const directory = path.join(root, "leases");
|
|
@@ -50,7 +52,7 @@ export async function listRuntimeHolds(root, { pidIsAlive = defaultPidIsAlive }
|
|
|
50
52
|
|| typeof lease.kind !== "string" || !lease.kind
|
|
51
53
|
|| !Number.isFinite(Date.parse(lease.createdAt))) throw new Error("invalid runtime lease");
|
|
52
54
|
const runtime = await validateRuntime(root, lease.runtime);
|
|
53
|
-
if (await confirmedDead(lease.pid, pidIsAlive)) {
|
|
55
|
+
if (await confirmedDead(lease.pid, pidIsAlive) || zombie !== null && await zombie(lease.pid) === true) {
|
|
54
56
|
await rm(file, { force: true });
|
|
55
57
|
removed = true;
|
|
56
58
|
} else holds.push({ ...lease, runtime });
|
|
@@ -115,7 +115,7 @@ export async function resolvePinnedGeneration({ root, harnessSessionId, active }
|
|
|
115
115
|
* already reaps dead leases; pins follow the same confirmed-death rule. A pin
|
|
116
116
|
* that names no client, which 0.8.0 and earlier wrote when the pid was not yet
|
|
117
117
|
* known, is reaped outright: nothing could ever prove its session over. */
|
|
118
|
-
export async function reapPins({ root, pidIsAlive = defaultPidIsAlive } = {}) {
|
|
118
|
+
export async function reapPins({ root, pidIsAlive = defaultPidIsAlive, zombie = null } = {}) {
|
|
119
119
|
root = await canonicalManagerRoot(root);
|
|
120
120
|
const directory = path.join(root, "pins");
|
|
121
121
|
if (!await managedDirectory(directory)) return;
|
|
@@ -125,7 +125,8 @@ export async function reapPins({ root, pidIsAlive = defaultPidIsAlive } = {}) {
|
|
|
125
125
|
const file = path.join(directory, name);
|
|
126
126
|
const record = await readManagedJson(file).catch(() => null);
|
|
127
127
|
if (record?.schemaVersion !== SCHEMA_VERSION) continue; // Unknown shape: an unknown holder.
|
|
128
|
-
if (!isPid(record.clientPid) || await confirmedDead(record.clientPid, pidIsAlive)
|
|
128
|
+
if (!isPid(record.clientPid) || await confirmedDead(record.clientPid, pidIsAlive)
|
|
129
|
+
|| zombie !== null && await zombie(record.clientPid) === true) {
|
|
129
130
|
await rm(file, { force: true });
|
|
130
131
|
removed = true;
|
|
131
132
|
}
|
|
@@ -2,7 +2,9 @@ import path from "node:path";
|
|
|
2
2
|
import { applyPlan, detectInstallation, livePolicyOf, loadOwnership, planInstallation }
|
|
3
3
|
from "@agents-can-communicate/installer";
|
|
4
4
|
import { ALL_ADAPTERS, clientContext, probeTimeout } from "../install-command.mjs";
|
|
5
|
+
import { processIsZombie } from "@agents-can-communicate/adapter-sdk";
|
|
5
6
|
import { sweepStaleBindings } from "./activation.mjs";
|
|
7
|
+
import { listRuntimeHolds } from "./leases.mjs";
|
|
6
8
|
import { stablePaths, writeLaunchers } from "./launchers.mjs";
|
|
7
9
|
import { bridgeLegacyMaintenance } from "./legacy-maintenance.mjs";
|
|
8
10
|
|
|
@@ -12,6 +14,9 @@ export async function prepareRefresh({ control, root, env = process.env, callerP
|
|
|
12
14
|
// An older generation lists its blockers right after this returns, by a rule
|
|
13
15
|
// that counts a binding without a client pid as a live client forever (#273).
|
|
14
16
|
await sweepStaleBindings(root).catch(() => {});
|
|
17
|
+
// It counts a zombie's lease as a live ACC process too (#280); a zombie's lease
|
|
18
|
+
// is removed like a dead process's.
|
|
19
|
+
await listRuntimeHolds(root, { zombie: processIsZombie }).catch(() => {});
|
|
15
20
|
const dataHome = path.dirname(path.dirname(root));
|
|
16
21
|
const wanted = new Set(control.targets);
|
|
17
22
|
const adapters = ALL_ADAPTERS().filter(adapter => wanted.has(adapter.id));
|
|
@@ -32,8 +32,18 @@ export async function performUpdate(root, { force = false, check = false, env =
|
|
|
32
32
|
}
|
|
33
33
|
if (!force && !check && !initial.auto) return { ...data, checked: false, reason: "auto_off" };
|
|
34
34
|
// Recovery is local; the hard no-network override must not strand a partial refresh.
|
|
35
|
+
let baseline = initial.active.version;
|
|
36
|
+
let attempt = null;
|
|
35
37
|
if (initial.pending && !check && (force || !networkDisabled(env))) {
|
|
36
|
-
|
|
38
|
+
attempt = await activate(root, { env, ignorePid });
|
|
39
|
+
// A pending release that cannot activate kept every later one out: on
|
|
40
|
+
// 2026-10-06 0.10.0 stayed pending behind stale bindings, and the 0.10.1
|
|
41
|
+
// that fixed them reached the machine only after its pending release was
|
|
42
|
+
// cleared by hand (#277). Once a check is due, or when asked, a newer
|
|
43
|
+
// release is staged in its place.
|
|
44
|
+
if (attempt.activated || attempt.reason !== "processes_active" || networkDisabled(env)
|
|
45
|
+
|| !force && !checkDue(initial)) return { ...data, ...attempt, checked: false };
|
|
46
|
+
baseline = initial.pending.version;
|
|
37
47
|
}
|
|
38
48
|
if (networkDisabled(env)) return { ...data, checked: false, reason: "network_disabled" };
|
|
39
49
|
if (!force && !check && !checkDue(initial)) return { ...data, checked: false, reason: "not_due" };
|
|
@@ -44,7 +54,9 @@ export async function performUpdate(root, { force = false, check = false, env =
|
|
|
44
54
|
const release = await discover({ pin: initial.pin, env });
|
|
45
55
|
const newer = newerVersion(release.version, initial.active.version);
|
|
46
56
|
const checked = { ...data, checked: true, latest: release.version, newer };
|
|
47
|
-
|
|
57
|
+
// Nothing newer than a blocked pending release: report the activation that
|
|
58
|
+
// waits, as before - the caller asks to restart a client service from it.
|
|
59
|
+
if (check || !newerVersion(release.version, baseline)) return attempt === null ? checked : { ...checked, ...attempt };
|
|
48
60
|
const generation = await download(root, release, { env });
|
|
49
61
|
// generation.hold has protected this candidate since it was staged;
|
|
50
62
|
// release it once this publish attempt resolves either way - published,
|