agents-can-communicate 0.10.2 → 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.
Files changed (28) hide show
  1. package/docs/ARCHITECTURE.md +3 -1
  2. package/docs/UPGRADING.md +5 -0
  3. package/node_modules/@agents-can-communicate/adapter-antigravity/package.json +1 -1
  4. package/node_modules/@agents-can-communicate/adapter-antigravity/plugin/plugin.json +1 -1
  5. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
  6. package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
  7. package/node_modules/@agents-can-communicate/adapter-codex/src/maintenance-host.mjs +6 -0
  8. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
  9. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
  10. package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
  11. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
  12. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  13. package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +1 -1
  14. package/node_modules/@agents-can-communicate/adapter-sdk/src/process-table.mjs +20 -0
  15. package/node_modules/@agents-can-communicate/cli/package.json +1 -1
  16. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/activation.mjs +44 -25
  17. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/diagnostics.mjs +6 -1
  18. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/leases.mjs +4 -2
  19. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/pins.mjs +3 -2
  20. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/refresh.mjs +5 -0
  21. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  22. package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
  23. package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
  24. package/node_modules/@agents-can-communicate/installer/package.json +1 -1
  25. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  26. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  27. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  28. package/package.json +1 -1
@@ -275,7 +275,9 @@ read, keeps it a hold. A client that wakes later runs its next hook through the
275
275
  the active generation. A binding that names its client stops holding when that process exits,
276
276
  or when the process that now has its pid started after the binding was last written, since a
277
277
  client runs when it writes its binding (#276); a start time that cannot be read decides
278
- nothing. Otherwise presence TTL, `acc finish`, and delivery off do not prove native lifecycle
278
+ nothing. A zombie, a process that has exited but that its parent has not collected, counts as
279
+ exited for a binding, an ACC lease and a pin (#280); a process state that cannot be read
280
+ decides nothing. Otherwise presence TTL, `acc finish`, and delivery off do not prove native lifecycle
279
281
  end. A pending release that cannot activate is replaced by a newer one when `acc update` runs
280
282
  or an automatic check is due (#277). Normal background updates never
281
283
  restart a vendor daemon. An explicitly approved maintenance job can do so through the
package/docs/UPGRADING.md CHANGED
@@ -69,6 +69,8 @@ acc update
69
69
  `--pin none` keeps following stable releases and drops a pending release that is not pinned.
70
70
  From 0.10.2, the update does this by itself: a pending release that cannot activate is replaced
71
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.
72
74
 
73
75
  ## From 0.8.x
74
76
 
@@ -368,7 +370,10 @@ PID/start time, executable, socket ownership and versions before stopping the se
368
370
  It refreshes integrations, starts the service again and verifies the result. If refreshing
369
371
  integrations fails, it still restores the service and reports the incomplete update.
370
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.
371
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.
372
377
  A current launcher can use the newer management reader while the old runtime is active.
373
378
  When admission is blocked, doctor returns an update-only report without inspecting the
374
379
  workspace or repairing it. Older launchers gain this reader after activation, or through
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/adapter-antigravity",
3
- "version": "0.10.2",
3
+ "version": "0.10.3",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "acc",
3
- "version": "0.10.2",
3
+ "version": "0.10.3",
4
4
  "description": "Coordinate this Antigravity CLI session with other AI agent sessions working in the same workspace."
5
5
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/adapter-claude-code",
3
- "version": "0.10.2",
3
+ "version": "0.10.3",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/adapter-codex",
3
- "version": "0.10.2",
3
+ "version": "0.10.3",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -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
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-can-communicate",
3
- "version": "0.10.2",
3
+ "version": "0.10.3",
4
4
  "description": "Coordinate this Gemini CLI session with other AI agent sessions working in the same workspace.",
5
5
  "contextFileName": "skills/acc/SKILL.md"
6
6
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/adapter-gemini-cli",
3
- "version": "0.10.2",
3
+ "version": "0.10.3",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/adapter-grok",
3
- "version": "0.10.2",
3
+ "version": "0.10.3",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/adapter-kimi",
3
- "version": "0.10.2",
3
+ "version": "0.10.3",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/adapter-sdk",
3
- "version": "0.10.2",
3
+ "version": "0.10.3",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/cli",
3
- "version": "0.10.2",
3
+ "version": "0.10.3",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -7,7 +7,7 @@ import { readdir, rm, stat } from "node:fs/promises";
7
7
  import path from "node:path";
8
8
  import { pathToFileURL } from "node:url";
9
9
  import { promisify } from "node:util";
10
- import { readWindowsProcess } from "@agents-can-communicate/adapter-sdk";
10
+ import { processIsZombie, readWindowsProcess } from "@agents-can-communicate/adapter-sdk";
11
11
  import { classifySessionPresence } from "@agents-can-communicate/core";
12
12
  import { assertPortableId } from "@agents-can-communicate/protocol";
13
13
  import { readSessionRecord, withWriterMutex } from "@agents-can-communicate/storage-filesystem";
@@ -83,11 +83,18 @@ async function pidReused(pid, file, startedAt) {
83
83
  return started !== null && written !== null && started > written + 1_000;
84
84
  }
85
85
 
86
- // One start-time reading per pid for one pass: a vendor service can hold several bindings.
87
- const perPass = startedAt => {
86
+ // A zombie has exited; only its parent has not collected it, and kill(pid, 0)
87
+ // still succeeds for it. On 2026-10-06 the Codex daemon `daemon stop` ended
88
+ // stayed one under Codex's own pid-update-loop and kept 0.10.2 pending (#280).
89
+ // A state that cannot be read decides nothing.
90
+ const exitedProcess = async (pid, pidIsAlive, zombie) =>
91
+ await confirmedDead(pid, pidIsAlive) || await zombie(pid) === true;
92
+
93
+ // One reading per pid for one pass: a vendor service can hold several bindings.
94
+ const perPass = read => {
88
95
  const seen = new Map();
89
96
  return pid => {
90
- if (!seen.has(pid)) seen.set(pid, Promise.resolve().then(() => startedAt(pid)).catch(() => null));
97
+ if (!seen.has(pid)) seen.set(pid, Promise.resolve().then(() => read(pid)).catch(() => null));
91
98
  return seen.get(pid);
92
99
  };
93
100
  };
@@ -100,7 +107,7 @@ const perPass = startedAt => {
100
107
  * process, or a record or store that cannot be read, keeps the binding a hold.
101
108
  * On 2026-10-06 the maintainer's 0.10.0 waited behind 32 such bindings from
102
109
  * 0.5.10 to 0.9.0 that no client could clear (#273). */
103
- async function staleWithoutPid(workspaceRoot, workspaceId, record, pidIsAlive, file, startedAt) {
110
+ async function staleWithoutPid(workspaceRoot, workspaceId, record, pidIsAlive, file, startedAt, zombie) {
104
111
  if (record?.schemaVersion !== 1) return false;
105
112
  let session;
106
113
  try {
@@ -109,7 +116,7 @@ async function staleWithoutPid(workspaceRoot, workspaceId, record, pidIsAlive, f
109
116
  } catch { return false; }
110
117
  if (session === null) return true;
111
118
  if (Number.isSafeInteger(session.pid) && session.pid > 0) {
112
- return await confirmedDead(session.pid, pidIsAlive) || pidReused(session.pid, file, startedAt);
119
+ return await exitedProcess(session.pid, pidIsAlive, zombie) || pidReused(session.pid, file, startedAt);
113
120
  }
114
121
  // No process to ask, so the probe is never called.
115
122
  return classifySessionPresence(session, new Date().toISOString(), () => true) === "offline";
@@ -117,10 +124,10 @@ async function staleWithoutPid(workspaceRoot, workspaceId, record, pidIsAlive, f
117
124
 
118
125
  /** Native bindings outlive finish/TTL; MCP process lifetime is covered by its runtime lease. */
119
126
  export async function listNativeHolds(root, { pidIsAlive = defaultPidIsAlive, strict = false,
120
- startedAt = processStartedAt } = {}) {
127
+ startedAt = processStartedAt, zombie = processIsZombie } = {}) {
121
128
  const workspaces = path.join(path.dirname(await canonicalManagerRoot(root)), "workspaces");
122
129
  if (!await managedDirectory(workspaces)) return [];
123
- const started = perPass(startedAt);
130
+ const started = perPass(startedAt), zombieNow = perPass(zombie);
124
131
  const holds = [];
125
132
  for (const entry of await readdir(workspaces, { withFileTypes: true })) {
126
133
  if (!entry.isDirectory()) {
@@ -142,9 +149,10 @@ export async function listNativeHolds(root, { pidIsAlive = defaultPidIsAlive, st
142
149
  if (record === undefined) continue; // Concurrent lifecycle removal, before fencing.
143
150
  if (await isMcpContinuity(record, name, path.dirname(bindings), entry.name)) continue;
144
151
  const pid = clientPidOf(record);
145
- if (pid !== null && (await confirmedDead(pid, pidIsAlive) || await pidReused(pid, file, started))) continue;
152
+ if (pid !== null && (await exitedProcess(pid, pidIsAlive, zombieNow)
153
+ || await pidReused(pid, file, started))) continue;
146
154
  if (pid === null && await staleWithoutPid(path.dirname(bindings), entry.name, record, pidIsAlive,
147
- file, started)) continue;
155
+ file, started, zombieNow)) continue;
148
156
  holds.push({ kind: "native", pid, reason: pid === null ? "unknown_client_pid" : "client_running",
149
157
  storeVersion: Number.isSafeInteger(record?.storeVersion) ? record.storeVersion : null });
150
158
  }
@@ -159,8 +167,9 @@ const lifecycleLocks = (workspaceRoot, harnessSessionId) => ({ locks: path.join(
159
167
  "lifecycle-locks", createHash("sha256").update(String(harnessSessionId)).digest("hex")) });
160
168
 
161
169
  /** Remove the bindings that listNativeHolds no longer counts and an older
162
- * generation still does: those without a client pid that are stale (#273), and
163
- * those whose pid was given to another program (#276). An older generation
170
+ * generation still does: those without a client pid that are stale (#273),
171
+ * those whose pid was given to another program (#276), and those whose client
172
+ * is a zombie (#280). An older generation
164
173
  * activates its successor with its own rule, and loads the pending generation's
165
174
  * refresh before it lists blockers, so that refresh calls this. A binding whose
166
175
  * pid is merely dead holds nothing for either and stays, as the record a resumed
@@ -168,11 +177,11 @@ const lifecycleLocks = (workspaceRoot, harnessSessionId) => ({ locks: path.join(
168
177
  * lifecycle lock, so a hook that renews it is either seen or comes after (AI
169
178
  * review of #274); a lock that stays busy leaves the binding as it is. */
170
179
  export async function sweepStaleBindings(root, { pidIsAlive = defaultPidIsAlive,
171
- startedAt = processStartedAt } = {}) {
180
+ startedAt = processStartedAt, zombie = processIsZombie } = {}) {
172
181
  const workspaces = path.join(path.dirname(await canonicalManagerRoot(root)), "workspaces");
173
182
  if (!await managedDirectory(workspaces)) return 0;
174
183
  const clock = { now: () => new Date().toISOString() };
175
- const started = perPass(startedAt);
184
+ const started = perPass(startedAt), zombieNow = perPass(zombie);
176
185
  let removed = 0;
177
186
  for (const entry of await readdir(workspaces, { withFileTypes: true })) {
178
187
  if (!entry.isDirectory()) continue;
@@ -187,8 +196,11 @@ export async function sweepStaleBindings(root, { pidIsAlive = defaultPidIsAlive,
187
196
  if (record === undefined || record === null || typeof record.harnessSessionId !== "string"
188
197
  || await isMcpContinuity(record, name, workspaceRoot, entry.name)) return false;
189
198
  const pid = clientPidOf(record);
190
- if (pid !== null) return !await confirmedDead(pid, pidIsAlive) && pidReused(pid, file, started);
191
- return staleWithoutPid(workspaceRoot, entry.name, record, pidIsAlive, file, started);
199
+ if (pid !== null) {
200
+ return !await confirmedDead(pid, pidIsAlive)
201
+ && (await zombieNow(pid) === true || pidReused(pid, file, started));
202
+ }
203
+ return staleWithoutPid(workspaceRoot, entry.name, record, pidIsAlive, file, started, zombieNow);
192
204
  };
193
205
  const judged = await readManagedJson(file).catch(() => undefined);
194
206
  if (!await stale(judged)) continue;
@@ -221,9 +233,12 @@ export const blocksActivation = (hold, incomingStoreVersion) =>
221
233
  * generation's, or when either side declares none.
222
234
  */
223
235
  export async function listActivationBlockers(root, { pidIsAlive = defaultPidIsAlive,
224
- ignorePid = null, incomingStoreVersion = null, strictNative = false, startedAt } = {}) {
225
- const leases = (await listRuntimeHolds(root, { pidIsAlive })).filter(lease => lease.pid !== ignorePid);
226
- const native = await listNativeHolds(root, { pidIsAlive, strict: strictNative,
236
+ ignorePid = null, incomingStoreVersion = null, strictNative = false, startedAt,
237
+ zombie = processIsZombie } = {}) {
238
+ const zombieNow = perPass(zombie);
239
+ const leases = (await listRuntimeHolds(root, { pidIsAlive, zombie: zombieNow }))
240
+ .filter(lease => lease.pid !== ignorePid);
241
+ const native = await listNativeHolds(root, { pidIsAlive, strict: strictNative, zombie: zombieNow,
227
242
  ...(startedAt === undefined ? {} : { startedAt }) });
228
243
  const holds = [
229
244
  ...leases.map(({ pid, kind, runtime }) => ({ pid, kind, nativeBindings: 0,
@@ -303,8 +318,11 @@ async function resolveCandidate(value) {
303
318
  // reclaimed need to tell that from a pass that found nothing to remove.
304
319
  const POSTPONED = Object.freeze({ removed: [], postponed: true });
305
320
 
306
- export async function reclaimGenerations({ root, active = null, pidIsAlive = defaultPidIsAlive } = {}) {
321
+ export async function reclaimGenerations({ root, active = null, pidIsAlive = defaultPidIsAlive,
322
+ zombie = processIsZombie } = {}) {
307
323
  root = await canonicalManagerRoot(root);
324
+ // A lease or a pin of a client that exited uncollected keeps no generation (#280).
325
+ const zombieNow = perPass(zombie);
308
326
  return withManagerLock(root, async () => {
309
327
  const generations = path.join(root, "generations");
310
328
  if (!await managedDirectory(generations)) return { removed: [] };
@@ -343,7 +361,7 @@ export async function reclaimGenerations({ root, active = null, pidIsAlive = def
343
361
  if (resolved.root) referenced.add(resolved.root);
344
362
  }
345
363
  let holds;
346
- try { holds = await listRuntimeHolds(root, { pidIsAlive }); }
364
+ try { holds = await listRuntimeHolds(root, { pidIsAlive, zombie: zombieNow }); }
347
365
  catch { return POSTPONED; } // An unreadable lease is an unknown holder.
348
366
  for (const lease of holds) {
349
367
  const resolved = await resolveCandidate(lease.runtime?.root);
@@ -353,7 +371,7 @@ export async function reclaimGenerations({ root, active = null, pidIsAlive = def
353
371
  // A client that exits without a clean session end leaves its pin behind;
354
372
  // reap confirmed-dead ones before treating every remaining pin as live,
355
373
  // the same way admission already reaps confirmed-dead leases.
356
- await reapPins({ root, pidIsAlive });
374
+ await reapPins({ root, pidIsAlive, zombie: zombieNow });
357
375
  const pins = path.join(root, "pins");
358
376
  if (await managedDirectory(pins)) {
359
377
  for (const name of await readdir(pins)) {
@@ -421,7 +439,7 @@ const recipe = c => JSON.stringify([c.active, c.pending, c.home, c.targets, c.au
421
439
 
422
440
  /** Detection is outside the fence; every integration write and commit stays inside it. */
423
441
  export async function activatePending(root, { prepare = prepareCandidate, env = process.env,
424
- pidIsAlive = defaultPidIsAlive, ignorePid = null, beforeActivation = null, startedAt } = {}) {
442
+ pidIsAlive = defaultPidIsAlive, ignorePid = null, beforeActivation = null, startedAt, zombie } = {}) {
425
443
  root = await canonicalManagerRoot(root);
426
444
  const before = await readControl(root);
427
445
  if (!before?.pending) return { activated: false, reason: "no_pending_update" };
@@ -432,7 +450,7 @@ export async function activatePending(root, { prepare = prepareCandidate, env =
432
450
  if (!current || recipe(current) !== recipe(before)) return { activated: false, reason: "state_changed" };
433
451
  if (beforeActivation) await beforeActivation(current);
434
452
  const blockers = await listActivationBlockers(root, { pidIsAlive, ignorePid, startedAt,
435
- incomingStoreVersion: current.pending.storeVersion });
453
+ ...(zombie === undefined ? {} : { zombie }), incomingStoreVersion: current.pending.storeVersion });
436
454
  if (blockers.length) {
437
455
  const notice = activationBlockerNotice(current.pending.version, blockers);
438
456
  await writeControl(root, { ...current, notice });
@@ -466,7 +484,8 @@ export async function activatePending(root, { prepare = prepareCandidate, env =
466
484
  // turn an already-completed activation into a reported failure - it can
467
485
  // always run again on the next activation.
468
486
  if (outcome.activated) {
469
- try { await reclaimGenerations({ root, active: activatedRoot, pidIsAlive }); }
487
+ try { await reclaimGenerations({ root, active: activatedRoot, pidIsAlive,
488
+ ...(zombie === undefined ? {} : { zombie }) }); }
470
489
  catch { /* Best effort; never fails an activation that already succeeded. */ }
471
490
  // The reclaim above is this, older, code's rule (#208). The activated
472
491
  // generation's own rule runs next, from its own files as its refresh did,
@@ -27,7 +27,12 @@ export async function managedUpdateDiagnostic(root, manager, running) {
27
27
  : activationBlockerNotice(manager.pending.version, blockers),
28
28
  ...(blockers === null ? {} : { holds: blockers.length, blockers }) };
29
29
  const maintenance = await readMaintenance(root);
30
- if (maintenance && (maintenance.target.root === (manager.pending ?? manager.active).root
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));
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/core",
3
- "version": "0.10.2",
3
+ "version": "0.10.3",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/delivery-router",
3
- "version": "0.10.2",
3
+ "version": "0.10.3",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/hook-runner",
3
- "version": "0.10.2",
3
+ "version": "0.10.3",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/installer",
3
- "version": "0.10.2",
3
+ "version": "0.10.3",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/mcp-server",
3
- "version": "0.10.2",
3
+ "version": "0.10.3",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/protocol",
3
- "version": "0.10.2",
3
+ "version": "0.10.3",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/storage-filesystem",
3
- "version": "0.10.2",
3
+ "version": "0.10.3",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-can-communicate",
3
- "version": "0.10.2",
3
+ "version": "0.10.3",
4
4
  "accManagedUpdateProtocol": 2,
5
5
  "accStoreVersion": 7,
6
6
  "type": "module",