agents-can-communicate 0.10.0 → 0.10.2
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 +11 -2
- package/docs/UPGRADING.md +25 -3
- 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-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/cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/activation.mjs +142 -11
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/refresh.mjs +4 -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
|
@@ -267,8 +267,17 @@ every integration, and only publishes the new active pointer after all applicati
|
|
|
267
267
|
remains fenced and repairs forward, with the old active pointer retained but normal
|
|
268
268
|
workspace admission unavailable until recovery completes. Native bindings cease holding
|
|
269
269
|
after observed SessionEnd cleanup or confirmed process death; the vendor daemon may remain
|
|
270
|
-
running.
|
|
271
|
-
|
|
270
|
+
running. A binding that names no client process is judged by its session record instead
|
|
271
|
+
(#273): it stops holding when the record is gone, when the record names a process that has
|
|
272
|
+
exited, or, with no process recorded, when ACC's presence rule calls the session offline -
|
|
273
|
+
closed, or 30 minutes without a heartbeat. A live recorded process, or a record that cannot be
|
|
274
|
+
read, keeps it a hold. A client that wakes later runs its next hook through the launcher on
|
|
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. Otherwise presence TTL, `acc finish`, and delivery off do not prove native lifecycle
|
|
279
|
+
end. A pending release that cannot activate is replaced by a newer one when `acc update` runs
|
|
280
|
+
or an automatic check is due (#277). Normal background updates never
|
|
272
281
|
restart a vendor daemon. An explicitly approved maintenance job can do so through the
|
|
273
282
|
adapter lifecycle port: it waits for idle work, validates exact process identity, stops
|
|
274
283
|
the service under the admission fence, refreshes integrations, and restores and verifies
|
package/docs/UPGRADING.md
CHANGED
|
@@ -50,6 +50,26 @@ acc doctor
|
|
|
50
50
|
- **Sending a message reads less of the store.** A send and its retries use a bounded index
|
|
51
51
|
instead of rereading unrelated messages and receipts.
|
|
52
52
|
|
|
53
|
+
0.10.1 lets this update finish where 0.10.0 could stay pending. A session that ended without
|
|
54
|
+
naming its client process could leave a native binding that 0.10.0 counted as a live client
|
|
55
|
+
forever, so `acc update` kept reporting `unknown client pid` holds after every session had
|
|
56
|
+
closed. 0.10.1 judges such a binding by its session record and removes it while it prepares
|
|
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
|
+
keeps the store contract, so it activates without waiting for open sessions.
|
|
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
|
+
|
|
53
73
|
## From 0.8.x
|
|
54
74
|
|
|
55
75
|
Update as usual:
|
|
@@ -433,9 +453,11 @@ Running ACC processes, including idle MCP servers, still publish a hold for as l
|
|
|
433
453
|
live, and native bindings still hold until observed client SessionEnd cleanup or confirmed
|
|
434
454
|
process death; the vendor daemon can remain running after that lifecycle event. What
|
|
435
455
|
changed is which of those holds keeps an update pending: only one whose declared store
|
|
436
|
-
contract differs from the incoming version's, or that declares none at all.
|
|
437
|
-
|
|
438
|
-
|
|
456
|
+
contract differs from the incoming version's, or that declares none at all. From 0.10.1, a
|
|
457
|
+
binding that names no client process stops holding when its session record is gone, names an
|
|
458
|
+
exited process, or, with no process recorded, is offline: closed, or 30 minutes without a
|
|
459
|
+
heartbeat (#273). For a binding that names its client, `acc finish`, presence TTL, and
|
|
460
|
+
delivery off still do not prove native end. When a hold does block, close the relevant client
|
|
439
461
|
sessions and persistent ACC processes to release it, or accept the eligible Codex service
|
|
440
462
|
maintenance offer in `acc update`. Background updates never request fresh restart consent.
|
|
441
463
|
Hooks never wait for a network download. If integration refresh temporarily prevents
|
|
@@ -1,12 +1,16 @@
|
|
|
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 } 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 { readWindowsProcess } from "@agents-can-communicate/adapter-sdk";
|
|
11
|
+
import { classifySessionPresence } from "@agents-can-communicate/core";
|
|
8
12
|
import { assertPortableId } from "@agents-can-communicate/protocol";
|
|
9
|
-
import { readSessionRecord } from "@agents-can-communicate/storage-filesystem";
|
|
13
|
+
import { readSessionRecord, withWriterMutex } from "@agents-can-communicate/storage-filesystem";
|
|
10
14
|
import { listRuntimeHolds } from "./leases.mjs";
|
|
11
15
|
import { confirmedDead, defaultPidIsAlive, withManagerLock } from "./mutex.mjs";
|
|
12
16
|
import { reapPins } from "./pins.mjs";
|
|
@@ -45,10 +49,78 @@ async function isMcpContinuity(record, name, workspaceRoot, workspaceId) {
|
|
|
45
49
|
} catch { return false; } // Ambiguous or corrupt ownership remains a safety hold.
|
|
46
50
|
}
|
|
47
51
|
|
|
52
|
+
const clientPidOf = record => record?.schemaVersion === 1 && Number.isSafeInteger(record.clientPid)
|
|
53
|
+
&& record.clientPid > 0 ? record.clientPid : null;
|
|
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
|
+
// One start-time reading per pid for one pass: a vendor service can hold several bindings.
|
|
87
|
+
const perPass = startedAt => {
|
|
88
|
+
const seen = new Map();
|
|
89
|
+
return pid => {
|
|
90
|
+
if (!seen.has(pid)) seen.set(pid, Promise.resolve().then(() => startedAt(pid)).catch(() => null));
|
|
91
|
+
return seen.get(pid);
|
|
92
|
+
};
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** A binding that names no client process cannot be judged by its own facts;
|
|
96
|
+
* its session record can. It is stale when the record is gone, when the record
|
|
97
|
+
* names a process that has exited, or, with no process recorded, when ACC's own
|
|
98
|
+
* presence rule calls the session offline - closed, or quiet past the floor for
|
|
99
|
+
* an unknown process - the rule retention removes sessions by. A live recorded
|
|
100
|
+
* process, or a record or store that cannot be read, keeps the binding a hold.
|
|
101
|
+
* On 2026-10-06 the maintainer's 0.10.0 waited behind 32 such bindings from
|
|
102
|
+
* 0.5.10 to 0.9.0 that no client could clear (#273). */
|
|
103
|
+
async function staleWithoutPid(workspaceRoot, workspaceId, record, pidIsAlive, file, startedAt) {
|
|
104
|
+
if (record?.schemaVersion !== 1) return false;
|
|
105
|
+
let session;
|
|
106
|
+
try {
|
|
107
|
+
session = await readSessionRecord({ root: workspaceRoot, workspaceId,
|
|
108
|
+
sessionId: record.accSessionId, allowLegacyIdentity: true });
|
|
109
|
+
} catch { return false; }
|
|
110
|
+
if (session === null) return true;
|
|
111
|
+
if (Number.isSafeInteger(session.pid) && session.pid > 0) {
|
|
112
|
+
return await confirmedDead(session.pid, pidIsAlive) || pidReused(session.pid, file, startedAt);
|
|
113
|
+
}
|
|
114
|
+
// No process to ask, so the probe is never called.
|
|
115
|
+
return classifySessionPresence(session, new Date().toISOString(), () => true) === "offline";
|
|
116
|
+
}
|
|
117
|
+
|
|
48
118
|
/** Native bindings outlive finish/TTL; MCP process lifetime is covered by its runtime lease. */
|
|
49
|
-
export async function listNativeHolds(root, { pidIsAlive = defaultPidIsAlive, strict = false
|
|
119
|
+
export async function listNativeHolds(root, { pidIsAlive = defaultPidIsAlive, strict = false,
|
|
120
|
+
startedAt = processStartedAt } = {}) {
|
|
50
121
|
const workspaces = path.join(path.dirname(await canonicalManagerRoot(root)), "workspaces");
|
|
51
122
|
if (!await managedDirectory(workspaces)) return [];
|
|
123
|
+
const started = perPass(startedAt);
|
|
52
124
|
const holds = [];
|
|
53
125
|
for (const entry of await readdir(workspaces, { withFileTypes: true })) {
|
|
54
126
|
if (!entry.isDirectory()) {
|
|
@@ -62,15 +134,17 @@ export async function listNativeHolds(root, { pidIsAlive = defaultPidIsAlive, st
|
|
|
62
134
|
if (!await managedDirectory(bindings)) continue;
|
|
63
135
|
for (const name of await readdir(bindings)) {
|
|
64
136
|
if (!name.endsWith(".json")) continue;
|
|
65
|
-
const
|
|
137
|
+
const file = path.join(bindings, name);
|
|
138
|
+
const record = await readManagedJson(file).catch(error => {
|
|
66
139
|
if (strict) throw error;
|
|
67
140
|
return null;
|
|
68
141
|
});
|
|
69
142
|
if (record === undefined) continue; // Concurrent lifecycle removal, before fencing.
|
|
70
143
|
if (await isMcpContinuity(record, name, path.dirname(bindings), entry.name)) continue;
|
|
71
|
-
const pid =
|
|
72
|
-
|
|
73
|
-
if (pid
|
|
144
|
+
const pid = clientPidOf(record);
|
|
145
|
+
if (pid !== null && (await confirmedDead(pid, pidIsAlive) || await pidReused(pid, file, started))) continue;
|
|
146
|
+
if (pid === null && await staleWithoutPid(path.dirname(bindings), entry.name, record, pidIsAlive,
|
|
147
|
+
file, started)) continue;
|
|
74
148
|
holds.push({ kind: "native", pid, reason: pid === null ? "unknown_client_pid" : "client_running",
|
|
75
149
|
storeVersion: Number.isSafeInteger(record?.storeVersion) ? record.storeVersion : null });
|
|
76
150
|
}
|
|
@@ -78,6 +152,62 @@ export async function listNativeHolds(root, { pidIsAlive = defaultPidIsAlive, st
|
|
|
78
152
|
return holds;
|
|
79
153
|
}
|
|
80
154
|
|
|
155
|
+
// The lock a hook holds while it loads, renews and publishes a binding: the
|
|
156
|
+
// hook runner's session lifecycle mutex, keyed by the harness session id.
|
|
157
|
+
const LIFECYCLE_LOCK_MS = 2_000;
|
|
158
|
+
const lifecycleLocks = (workspaceRoot, harnessSessionId) => ({ locks: path.join(workspaceRoot,
|
|
159
|
+
"lifecycle-locks", createHash("sha256").update(String(harnessSessionId)).digest("hex")) });
|
|
160
|
+
|
|
161
|
+
/** 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
|
|
164
|
+
* activates its successor with its own rule, and loads the pending generation's
|
|
165
|
+
* refresh before it lists blockers, so that refresh calls this. A binding whose
|
|
166
|
+
* pid is merely dead holds nothing for either and stays, as the record a resumed
|
|
167
|
+
* conversation finds. Each one is judged again and removed under its session's
|
|
168
|
+
* lifecycle lock, so a hook that renews it is either seen or comes after (AI
|
|
169
|
+
* review of #274); a lock that stays busy leaves the binding as it is. */
|
|
170
|
+
export async function sweepStaleBindings(root, { pidIsAlive = defaultPidIsAlive,
|
|
171
|
+
startedAt = processStartedAt } = {}) {
|
|
172
|
+
const workspaces = path.join(path.dirname(await canonicalManagerRoot(root)), "workspaces");
|
|
173
|
+
if (!await managedDirectory(workspaces)) return 0;
|
|
174
|
+
const clock = { now: () => new Date().toISOString() };
|
|
175
|
+
const started = perPass(startedAt);
|
|
176
|
+
let removed = 0;
|
|
177
|
+
for (const entry of await readdir(workspaces, { withFileTypes: true })) {
|
|
178
|
+
if (!entry.isDirectory()) continue;
|
|
179
|
+
const workspaceRoot = path.join(workspaces, entry.name);
|
|
180
|
+
const bindings = path.join(workspaceRoot, "bindings");
|
|
181
|
+
if (!await managedDirectory(bindings)) continue;
|
|
182
|
+
let changed = false;
|
|
183
|
+
for (const name of await readdir(bindings)) {
|
|
184
|
+
if (!name.endsWith(".json")) continue;
|
|
185
|
+
const file = path.join(bindings, name);
|
|
186
|
+
const stale = async record => {
|
|
187
|
+
if (record === undefined || record === null || typeof record.harnessSessionId !== "string"
|
|
188
|
+
|| await isMcpContinuity(record, name, workspaceRoot, entry.name)) return false;
|
|
189
|
+
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);
|
|
192
|
+
};
|
|
193
|
+
const judged = await readManagedJson(file).catch(() => undefined);
|
|
194
|
+
if (!await stale(judged)) continue;
|
|
195
|
+
const gone = await withWriterMutex(lifecycleLocks(workspaceRoot, judged.harnessSessionId),
|
|
196
|
+
{ root: workspaceRoot, clock, deadlineAt: Date.now() + LIFECYCLE_LOCK_MS }, async () => {
|
|
197
|
+
const current = await readManagedJson(file).catch(() => undefined);
|
|
198
|
+
if (current?.harnessSessionId !== judged.harnessSessionId || !await stale(current)) return false;
|
|
199
|
+
await rm(file, { force: true });
|
|
200
|
+
return true;
|
|
201
|
+
}).catch(() => false);
|
|
202
|
+
if (!gone) continue;
|
|
203
|
+
removed += 1;
|
|
204
|
+
changed = true;
|
|
205
|
+
}
|
|
206
|
+
if (changed) await syncDirectory(bindings);
|
|
207
|
+
}
|
|
208
|
+
return removed;
|
|
209
|
+
}
|
|
210
|
+
|
|
81
211
|
/** Unknown on either side cannot be compared, and an uncomparable contract
|
|
82
212
|
* stays a hold. Equal contracts coexist by the store's own rule. */
|
|
83
213
|
export const blocksActivation = (hold, incomingStoreVersion) =>
|
|
@@ -91,9 +221,10 @@ export const blocksActivation = (hold, incomingStoreVersion) =>
|
|
|
91
221
|
* generation's, or when either side declares none.
|
|
92
222
|
*/
|
|
93
223
|
export async function listActivationBlockers(root, { pidIsAlive = defaultPidIsAlive,
|
|
94
|
-
ignorePid = null, incomingStoreVersion = null, strictNative = false } = {}) {
|
|
224
|
+
ignorePid = null, incomingStoreVersion = null, strictNative = false, startedAt } = {}) {
|
|
95
225
|
const leases = (await listRuntimeHolds(root, { pidIsAlive })).filter(lease => lease.pid !== ignorePid);
|
|
96
|
-
const native = await listNativeHolds(root, { pidIsAlive, strict: strictNative
|
|
226
|
+
const native = await listNativeHolds(root, { pidIsAlive, strict: strictNative,
|
|
227
|
+
...(startedAt === undefined ? {} : { startedAt }) });
|
|
97
228
|
const holds = [
|
|
98
229
|
...leases.map(({ pid, kind, runtime }) => ({ pid, kind, nativeBindings: 0,
|
|
99
230
|
storeVersion: runtime?.storeVersion ?? null })),
|
|
@@ -290,7 +421,7 @@ const recipe = c => JSON.stringify([c.active, c.pending, c.home, c.targets, c.au
|
|
|
290
421
|
|
|
291
422
|
/** Detection is outside the fence; every integration write and commit stays inside it. */
|
|
292
423
|
export async function activatePending(root, { prepare = prepareCandidate, env = process.env,
|
|
293
|
-
pidIsAlive = defaultPidIsAlive, ignorePid = null, beforeActivation = null } = {}) {
|
|
424
|
+
pidIsAlive = defaultPidIsAlive, ignorePid = null, beforeActivation = null, startedAt } = {}) {
|
|
294
425
|
root = await canonicalManagerRoot(root);
|
|
295
426
|
const before = await readControl(root);
|
|
296
427
|
if (!before?.pending) return { activated: false, reason: "no_pending_update" };
|
|
@@ -300,7 +431,7 @@ export async function activatePending(root, { prepare = prepareCandidate, env =
|
|
|
300
431
|
const current = await readControl(root);
|
|
301
432
|
if (!current || recipe(current) !== recipe(before)) return { activated: false, reason: "state_changed" };
|
|
302
433
|
if (beforeActivation) await beforeActivation(current);
|
|
303
|
-
const blockers = await listActivationBlockers(root, { pidIsAlive, ignorePid,
|
|
434
|
+
const blockers = await listActivationBlockers(root, { pidIsAlive, ignorePid, startedAt,
|
|
304
435
|
incomingStoreVersion: current.pending.storeVersion });
|
|
305
436
|
if (blockers.length) {
|
|
306
437
|
const notice = activationBlockerNotice(current.pending.version, blockers);
|
|
@@ -2,12 +2,16 @@ 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 { sweepStaleBindings } from "./activation.mjs";
|
|
5
6
|
import { stablePaths, writeLaunchers } from "./launchers.mjs";
|
|
6
7
|
import { bridgeLegacyMaintenance } from "./legacy-maintenance.mjs";
|
|
7
8
|
|
|
8
9
|
/** Loaded from the candidate, so its own adapters supply its integration bytes. */
|
|
9
10
|
export async function prepareRefresh({ control, root, env = process.env, callerProtocol = 1 }) {
|
|
10
11
|
await bridgeLegacyMaintenance({ control, root, env, callerProtocol });
|
|
12
|
+
// An older generation lists its blockers right after this returns, by a rule
|
|
13
|
+
// that counts a binding without a client pid as a live client forever (#273).
|
|
14
|
+
await sweepStaleBindings(root).catch(() => {});
|
|
11
15
|
const dataHome = path.dirname(path.dirname(root));
|
|
12
16
|
const wanted = new Set(control.targets);
|
|
13
17
|
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,
|