@platless/axom-cli 0.3.13 → 0.5.9
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/dist/axom-patch/cli/README.md +30 -8
- package/dist/axom-patch/cli/account-limits/README.md +27 -0
- package/dist/axom-patch/cli/account-limits/antigravity-proxy.mjs +47 -0
- package/dist/axom-patch/cli/account-limits/antigravity-read.mjs +90 -0
- package/dist/axom-patch/cli/account-limits/antigravity.mjs +46 -0
- package/dist/axom-patch/cli/account-limits/generate.py +120 -0
- package/dist/axom-patch/cli/account-limits/kimi-proxy.mjs +64 -0
- package/dist/axom-patch/cli/account-limits/normalize.mjs +151 -0
- package/dist/axom-patch/cli/claude-account-limits.json +37 -0
- package/dist/axom-patch/cli/codex-account-limits.json +37 -0
- package/dist/axom-patch/cli/commandcode-account-limits.json +30 -0
- package/dist/axom-patch/cli/nightly-task.md +152 -0
- package/dist/axom-patch/cli/pi-subagent-subwork.json +23 -0
- package/dist/axom-patch/cli/probes/claude-late-async-terminal.mjs +5 -1
- package/dist/axom-patch/cli/probes/pi-subagent-subwork.mjs +211 -0
- package/dist/axom-patch/cli/probes/qoder-steering.mjs +1 -1
- package/dist/axom-patch/cli/probes/qoder-subagent-origin.mjs +20 -8
- package/dist/axom-patch/cli/probes/zcode-subagent-subwork.mjs +257 -0
- package/dist/axom-patch/cli/qoder-background-terminal.md +2 -1
- package/dist/axom-patch/cli/qoder-foreground-output.md +4 -3
- package/dist/axom-patch/cli/qoder-steering.json +15 -15
- package/dist/axom-patch/cli/qoder-subagent-origin.json +21 -21
- package/dist/axom-patch/cli/runtime.json +34 -8
- package/dist/axom-patch/cli/zcode-account-limits.json +31 -0
- package/dist/axom-patch/cli/zcode-models.json +38 -0
- package/dist/axom-patch/cli/zcode-subagent-subwork.json +73 -0
- package/dist/cli.js +33 -6
- package/dist/keepers/acp/keeper.mjs +3083 -0
- package/dist/keepers/acp/manifest.json +9 -0
- package/dist/keepers/terminal/keeper.mjs +8469 -0
- package/dist/keepers/terminal/manifest.json +9 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/conpty_console_list_agent.js +25 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/eventEmitter2.js +47 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/index.js +52 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/interfaces.js +7 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/shared/conout.js +10 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/terminal.js +190 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/types.js +7 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/unixTerminal.js +351 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/utils.js +38 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/windowsConoutConnection.js +131 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/windowsPtyAgent.js +344 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/windowsTerminal.js +209 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/worker/conoutSocketWorker.js +22 -0
- package/dist/keepers/terminal/node_modules/node-pty/package.json +66 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-arm64/pty.node +0 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-arm64/spawn-helper +0 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-x64/pty.node +0 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-x64/spawn-helper +0 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/linux-arm64/pty.node +0 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/linux-x64/pty.node +0 -0
- package/dist/runtime-deps/qoder-sdk/package-lock.json +1189 -0
- package/dist/runtime-deps/qoder-sdk/package.json +8 -0
- package/dist/test/keeperKit.js +67 -0
- package/dist/workstation/acp/acpAgents.js +2163 -611
- package/dist/workstation/acp/acpClient.js +60 -497
- package/dist/workstation/acp/adoption.js +10 -396
- package/dist/workstation/acp/binaryInstall.js +100 -27
- package/dist/workstation/acp/builds/gc.js +245 -0
- package/dist/workstation/acp/builds/install/binary.js +28 -0
- package/dist/workstation/acp/builds/install/npm.js +235 -0
- package/dist/workstation/acp/builds/install/uv.js +254 -0
- package/dist/workstation/acp/builds/migrate.js +195 -0
- package/dist/workstation/acp/builds/model.js +128 -0
- package/dist/workstation/acp/builds/nodeGate.js +100 -0
- package/dist/workstation/acp/builds/store.js +352 -0
- package/dist/workstation/acp/commandTableProbe.js +68 -0
- package/dist/workstation/acp/configOptions.js +103 -1
- package/dist/workstation/acp/envSecrets.js +27 -0
- package/dist/workstation/acp/initializeResponse.js +285 -0
- package/dist/workstation/acp/installErrors.js +12 -2
- package/dist/workstation/acp/installProbe.js +1 -1
- package/dist/workstation/acp/managedPatches.js +76 -160
- package/dist/workstation/acp/outbox.js +330 -0
- package/dist/workstation/acp/patchAssets.js +20 -3
- package/dist/workstation/acp/permission.js +42 -10
- package/dist/workstation/acp/processor/checkpoint.js +87 -0
- package/dist/workstation/acp/processor/commands.js +35 -0
- package/dist/workstation/acp/processor/control.js +355 -0
- package/dist/workstation/acp/processor/emit.js +26 -0
- package/dist/workstation/acp/processor/jsonrpc.js +172 -0
- package/dist/workstation/acp/processor/memoryLog.js +49 -0
- package/dist/workstation/acp/processor/origins.js +48 -0
- package/dist/workstation/acp/processor/output.js +67 -0
- package/dist/workstation/acp/processor/projection.js +42 -0
- package/dist/workstation/acp/processor/record.js +128 -0
- package/dist/workstation/acp/processor/runtime.js +181 -0
- package/dist/workstation/acp/processor/sdkSchemas.js +61 -0
- package/dist/workstation/acp/processor/session.js +525 -0
- package/dist/workstation/acp/processor/sideChannel.js +80 -0
- package/dist/workstation/acp/processor/state.js +74 -0
- package/dist/workstation/acp/processor/step.js +459 -0
- package/dist/workstation/acp/processor/test/fixtureLog.js +65 -0
- package/dist/workstation/acp/processor/test/harness.js +120 -0
- package/dist/workstation/acp/processor/test/largeIdEcho.js +50 -0
- package/dist/workstation/acp/processor/test/rawAgent.js +70 -0
- package/dist/workstation/acp/processor/transport/generations.js +76 -0
- package/dist/workstation/acp/processor/transport/keeper.js +130 -0
- package/dist/workstation/acp/processor/transport/local.js +269 -0
- package/dist/workstation/acp/processor/transport/types.js +78 -0
- package/dist/workstation/acp/processor/updates.js +364 -0
- package/dist/workstation/acp/processorSession.js +1512 -0
- package/dist/workstation/acp/qoderStartup.js +1 -1
- package/dist/workstation/acp/registry/catalog.js +154 -32
- package/dist/workstation/acp/registry/launch.js +61 -119
- package/dist/workstation/acp/sessionUpdateMapper.js +18 -7
- package/dist/workstation/acp/steering.js +58 -0
- package/dist/workstation/acp/switching.js +114 -0
- package/dist/workstation/acp/terminalLogin.js +16 -42
- package/dist/workstation/acp/terminalLoginSpec.js +41 -0
- package/dist/workstation/acp/test/adapterFixtures.js +256 -0
- package/dist/workstation/acp/test/daemonLife.js +106 -0
- package/dist/workstation/acp/test/keeperWorld.js +267 -0
- package/dist/workstation/acp/test/memoryOutbox.js +55 -0
- package/dist/workstation/acp/test/raw/fixtures.js +196 -0
- package/dist/workstation/acp/test/raw/qoderCaptures.js +125 -0
- package/dist/workstation/acp/test/raw/sanitize.js +344 -0
- package/dist/workstation/acp/test/raw/sources.js +249 -0
- package/dist/workstation/acp/test/realAdapter.js +377 -0
- package/dist/workstation/acp/test/scriptAgent.js +206 -0
- package/dist/workstation/acp/test/sessionFrames.js +42 -0
- package/dist/workstation/acp/timedCommand.js +4 -2
- package/dist/workstation/acp/uvxInstall.js +72 -122
- package/dist/workstation/agentFlow.js +103 -0
- package/dist/workstation/agentSessions.js +1324 -474
- package/dist/workstation/channels/agent.js +7 -5
- package/dist/workstation/channels/computer.js +69 -21
- package/dist/workstation/channels/computerExecs.js +200 -0
- package/dist/workstation/channels/management.js +84 -4
- package/dist/workstation/daemon.js +288 -7
- package/dist/workstation/daemonctl.js +28 -2
- package/dist/workstation/exec/attach.js +126 -0
- package/dist/workstation/exec/command.js +40 -0
- package/dist/workstation/exec/durable.js +517 -0
- package/dist/workstation/exec/fallback.js +106 -0
- package/dist/workstation/exec/policy.js +837 -0
- package/dist/workstation/exec/shaping.js +211 -0
- package/dist/workstation/exec.js +3 -112
- package/dist/workstation/files/adapters/computerFileFrames.js +51 -0
- package/dist/workstation/files/adapters/toolRequests.js +121 -0
- package/dist/workstation/files/client.js +455 -0
- package/dist/workstation/files/errors.js +28 -0
- package/dist/workstation/files/fence.js +350 -0
- package/dist/workstation/files/find/fileIndex.js +153 -0
- package/dist/workstation/files/find/fuzzy.js +155 -0
- package/dist/workstation/files/host.js +122 -0
- package/dist/workstation/files/hostProtocol.js +2 -0
- package/dist/workstation/files/media/cache.js +620 -0
- package/dist/workstation/files/media/encoding.js +308 -0
- package/dist/workstation/files/media/estimator.js +100 -0
- package/dist/workstation/files/media/media.js +325 -0
- package/dist/workstation/files/media/packedAudio.js +23 -0
- package/dist/workstation/files/media/scheduler.js +551 -0
- package/dist/workstation/files/media/types.js +5 -0
- package/dist/workstation/files/scope.js +45 -0
- package/dist/workstation/files/structured.js +284 -0
- package/dist/workstation/{fileOps.js → files/text.js} +117 -38
- package/dist/workstation/files/uploads.js +351 -0
- package/dist/workstation/journal.js +65 -236
- package/dist/workstation/keepers/acp/client.js +101 -0
- package/dist/workstation/keepers/acp/envelope.js +62 -0
- package/dist/workstation/keepers/acp/host.js +419 -0
- package/dist/workstation/keepers/acp/main.js +18 -0
- package/dist/workstation/keepers/acp/memoryStream.js +121 -0
- package/dist/workstation/keepers/acp/params.js +137 -0
- package/dist/workstation/keepers/acp/protocol.js +66 -0
- package/dist/workstation/keepers/acp/recover.js +120 -0
- package/dist/workstation/keepers/acp/session.js +584 -0
- package/dist/workstation/keepers/acp/stderr.js +154 -0
- package/dist/workstation/keepers/kit/activity.js +103 -0
- package/dist/workstation/keepers/kit/bootstrap.js +164 -0
- package/dist/workstation/keepers/kit/buildVersion.js +51 -0
- package/dist/workstation/keepers/kit/bundle.js +120 -0
- package/dist/workstation/keepers/kit/client.js +432 -0
- package/dist/workstation/keepers/kit/identity.js +21 -0
- package/dist/workstation/keepers/kit/ipc.js +112 -0
- package/dist/workstation/keepers/kit/launchConfig.js +8 -0
- package/dist/workstation/keepers/kit/launcher.js +249 -0
- package/dist/workstation/keepers/kit/lock.js +123 -0
- package/dist/workstation/keepers/kit/orphanSweep.js +86 -0
- package/dist/workstation/keepers/kit/paths.js +75 -0
- package/dist/workstation/keepers/kit/protocol.js +43 -0
- package/dist/workstation/keepers/kit/server.js +518 -0
- package/dist/workstation/keepers/kit/streamLog.js +697 -0
- package/dist/workstation/keepers/legacy.js +296 -0
- package/dist/workstation/keepers/policy.js +576 -0
- package/dist/workstation/keepers/stop.js +34 -0
- package/dist/workstation/keepers/terminal/client.js +213 -0
- package/dist/workstation/keepers/terminal/exec.js +981 -0
- package/dist/workstation/keepers/terminal/generations.js +129 -0
- package/dist/workstation/keepers/terminal/keeper.js +62 -0
- package/dist/workstation/keepers/terminal/main.js +43 -0
- package/dist/workstation/keepers/terminal/orphanSweep.js +3 -0
- package/dist/workstation/keepers/terminal/params.js +27 -0
- package/dist/workstation/keepers/terminal/protocol.js +56 -0
- package/dist/workstation/keepers/terminal/pty.js +330 -0
- package/dist/workstation/protocol/accountLimits.js +47 -0
- package/dist/workstation/protocol/agent.js +48 -4
- package/dist/workstation/protocol/common.js +30 -0
- package/dist/workstation/protocol/computer.js +15 -4
- package/dist/workstation/protocol/management.js +50 -2
- package/dist/workstation/qoderAdapter/backgroundProcess.js +18 -6
- package/dist/workstation/qoderAdapter/backgroundTerminal.js +78 -147
- package/dist/workstation/qoderAdapter/backgroundTerminalFold.js +372 -0
- package/dist/workstation/qoderAdapter/callbackContext.js +76 -0
- package/dist/workstation/qoderAdapter/foregroundOutput.js +15 -9
- package/dist/workstation/qoderAdapter/history.js +1 -3
- package/dist/workstation/qoderAdapter/main.js +4 -2
- package/dist/workstation/qoderAdapter/sdkDriver.js +1 -2
- package/dist/workstation/qoderAdapter/sdkRuntime.js +93 -0
- package/dist/workstation/qoderAdapter/sdkRuntimeInstall.js +145 -0
- package/dist/workstation/qoderAdapter/session.js +1 -1
- package/dist/workstation/qoderAdapter/subwork.js +15 -7
- package/dist/workstation/systemMetrics.js +110 -1
- package/dist/workstation/tools/client.js +9 -1
- package/dist/workstation/tools/info.js +50 -0
- package/dist/workstation/tools/keeperProcess.js +15 -5
- package/dist/workstation/tools/router.js +55 -0
- package/dist/workstation/tools/terminalRouter.js +222 -0
- package/dist/workstation/tools/validation.js +37 -1
- package/dist/workstation/update/coordinator.js +3 -0
- package/dist/workstation/update/ptyPreflight.js +21 -0
- package/dist/workstation/update/taskRegistry.js +105 -11
- package/package.json +10 -7
- package/dist/workstation/acp/acpSession.js +0 -1475
- package/dist/workstation/acp/contract.js +0 -499
- package/dist/workstation/acp/draftInterceptor.js +0 -163
- package/dist/workstation/acp/sessionScope.js +0 -19
- package/dist/workstation/filePaths.js +0 -174
- package/dist/workstation/fileWorker.js +0 -81
- package/dist/workstation/fileWorkerClient.js +0 -121
- package/dist/workstation/qoderAdapter/taskEvent.js +0 -26
- package/dist/workstation/tools/core.js +0 -50
- package/dist/workstation/tools/files.js +0 -525
- package/dist/workstation/tools/terminal.js +0 -214
|
@@ -10,8 +10,8 @@ import { homedir } from 'node:os';
|
|
|
10
10
|
* ruling R1 deleted the interface rather than leaving an extension point with a single member. What
|
|
11
11
|
* varies per agent is no longer code but **data** — the launch spec — so the key is a **registry
|
|
12
12
|
* agent id** (`claude-acp`, `codex-acp`, …) and this class turns that id into a running subprocess
|
|
13
|
-
* by way of the ACP agent registry (`registry/`)
|
|
14
|
-
* (`
|
|
13
|
+
* by way of the ACP agent registry (`registry/`) and a processor-driven session (`processorSession.ts`, over keeper-acp or the
|
|
14
|
+
* local transport); `connectAgent` (`acpClient.ts`) serves the short-lived connections (list, delete, verify, sign-in).
|
|
15
15
|
*
|
|
16
16
|
* ## One connection per session, deliberately
|
|
17
17
|
*
|
|
@@ -31,28 +31,48 @@ import { homedir } from 'node:os';
|
|
|
31
31
|
* wrapper script; it is also what lets this module's own tests drive a real ACP subprocess without
|
|
32
32
|
* reaching the network or running `npx`.
|
|
33
33
|
*/
|
|
34
|
-
import {
|
|
34
|
+
import { RequestError } from '@agentclientprotocol/sdk';
|
|
35
35
|
import { certifiedRuntime } from '../qoderAdapter/foregroundOutput.js';
|
|
36
|
+
import { existsSync } from 'node:fs';
|
|
37
|
+
import { readdir } from 'node:fs/promises';
|
|
36
38
|
import path from 'node:path';
|
|
37
|
-
import { randomUUID } from 'node:crypto';
|
|
39
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
38
40
|
import semver from 'semver';
|
|
39
|
-
import { connectAgent,
|
|
40
|
-
import { runTerminalLogin, terminalInvocationFor } from './terminalLogin.js';
|
|
41
|
-
import {
|
|
42
|
-
import {
|
|
41
|
+
import { connectAgent, jsonRpcErrorCode, stderrShowsMissingPackageVersion } from './acpClient.js';
|
|
42
|
+
import { launchForTerminalLogin, runTerminalLogin, terminalInvocationFor } from './terminalLogin.js';
|
|
43
|
+
import { createCommandTableProbe } from './commandTableProbe.js';
|
|
44
|
+
import { deleteNativeSessions, listNativeSessions } from './adoption.js';
|
|
43
45
|
import { AcpAuth } from './auth.js';
|
|
44
|
-
import { binaryTargetFor, installBinary,
|
|
45
|
-
import {
|
|
46
|
+
import { binaryTargetFor, installBinary, platformTriple } from './binaryInstall.js';
|
|
47
|
+
import { binaryBuild } from './builds/install/binary.js';
|
|
48
|
+
import { defaultNpmCommand, ensurePristine, npmBuild } from './builds/install/npm.js';
|
|
49
|
+
import { installUvBuild } from './builds/install/uv.js';
|
|
50
|
+
import { adoptNpxCopy, findNpxCopy, legacyBinaryVersions, moveLegacyBinary, RESERVED_ROOT_NAMES } from './builds/migrate.js';
|
|
51
|
+
import { AdapterNotInstalledError, AdapterStillInstallingError, binaryBuildDir, npmPristineDir, inside } from './builds/model.js';
|
|
52
|
+
import { AdapterNodeVersionError } from './builds/nodeGate.js';
|
|
53
|
+
import { buildKey as buildKeyOf, collectBuilds, collectLegacy, processCommandLines } from './builds/gc.js';
|
|
54
|
+
import { keyGenOf, refOfSession, resolveSuperseded, restoreConfigOptions, sessionKeyFor, SWITCH_ABANDONED_NOTE, SWITCH_SUSPEND_TIMEOUT_MS, } from './switching.js';
|
|
55
|
+
import { decodeCheckpoint } from './processor/checkpoint.js';
|
|
56
|
+
import { BuildStore } from './builds/store.js';
|
|
57
|
+
import { selectPackagePatches, patchDigest } from './managedPatches.js';
|
|
58
|
+
import { nativeExtensionFor, nativeAccountLimitsVersionFor } from './patchAssets.js';
|
|
59
|
+
import { ACCOUNT_LIMITS_READ_TIMEOUT_MS } from '../protocol/accountLimits.js';
|
|
60
|
+
import { buildSessionMcpServers } from './delegationMcp.js';
|
|
61
|
+
import { overlayEnvSecrets } from './envSecrets.js';
|
|
62
|
+
import { AdoptionRefusedError, ProcessorSession } from './processorSession.js';
|
|
63
|
+
import { hostsRunningAdapter } from './processor/transport/generations.js';
|
|
64
|
+
import { isOutboxTail } from './outbox.js';
|
|
65
|
+
import { LocalTransport } from './processor/transport/local.js';
|
|
66
|
+
import { AcpKeeperUnavailableError } from '../keepers/acp/client.js';
|
|
46
67
|
import { prepareQoderStartup, QODER_MANAGED_VERSION } from './qoderStartup.js';
|
|
47
|
-
import { friendlyOrdinaryInstallError, friendlyPinnedVersionUnavailableAndFallbackFailed, logInstallFailureDetail, } from './installErrors.js';
|
|
48
|
-
import { ADAPTER_INSTALL_TIMEOUT_MS,
|
|
68
|
+
import { friendlyOrdinaryInstallError, friendlyPinnedVersionUnavailableAndFallbackFailed, friendlyUvMissingError, logInstallFailureDetail, } from './installErrors.js';
|
|
69
|
+
import { ADAPTER_INSTALL_TIMEOUT_MS, npmCacheDir, npmPackageName, npmPackageVersion, pickInstalledVersion, findOnPath, probeEnvironment, runDependencyInstall, } from './installProbe.js';
|
|
49
70
|
import { createDeferredPermissionSink, registerPermissionHandlers } from './permission.js';
|
|
50
|
-
import { catalogEntryFor, withCatalogOverrides } from './registry/catalog.js';
|
|
71
|
+
import { catalogEntryFor, catalogLaunchEnv, effectiveEntries, RECOMMENDED_RANK, withCatalogOverrides } from './registry/catalog.js';
|
|
51
72
|
import { detected } from './registry/detect.js';
|
|
52
|
-
import {
|
|
73
|
+
import { launchForBuild, withPathPrefix } from './registry/launch.js';
|
|
53
74
|
import { RegistryClient } from './registry/registryClient.js';
|
|
54
|
-
import {
|
|
55
|
-
import { installUvTool, listUvTools, uvPackageName, uvToolVersion } from './uvxInstall.js';
|
|
75
|
+
import { defaultRunUv, parseUvxSpec, UV_TOOL_INSTALL_TIMEOUT_MS } from './uvxInstall.js';
|
|
56
76
|
function errorMessage(error) {
|
|
57
77
|
return error instanceof Error ? error.message : String(error);
|
|
58
78
|
}
|
|
@@ -299,46 +319,49 @@ function toAvailableCommandWire(raw) {
|
|
|
299
319
|
* others — `verify` and `listNativeSessions` are exactly the paths a settings page depends on, and
|
|
300
320
|
* they are the ones nobody remembers.
|
|
301
321
|
*/
|
|
322
|
+
/** The same observation, off the processor's `initialize` reading (a live session's). */
|
|
323
|
+
function observationOfFacts(facts) {
|
|
324
|
+
return {
|
|
325
|
+
authMethods: facts.authMethods,
|
|
326
|
+
supportsAccountLimits: facts.supportsAccountLimits,
|
|
327
|
+
supportsSteering: facts.supportsSteering,
|
|
328
|
+
terminalLogins: new Map(facts.terminalLogins),
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
/** keeper-acp's bound on a spawn spec's lists (`params.ts` MAX_LIST). */
|
|
332
|
+
const ACP_STDERR_SECRETS_MAX = 256;
|
|
333
|
+
/** A spawn spec's environment is complete (keeper-acp never reads its own): `process.env`'s unset entries dropped. */
|
|
334
|
+
function completeEnvironment(env) {
|
|
335
|
+
const out = {};
|
|
336
|
+
for (const [key, value] of Object.entries(env))
|
|
337
|
+
if (typeof value === 'string')
|
|
338
|
+
out[key] = value;
|
|
339
|
+
return out;
|
|
340
|
+
}
|
|
341
|
+
/** `initialize` on a session open (`connectAgent`'s default, which the old path used). */
|
|
342
|
+
const DEFAULT_SESSION_INITIALIZE_TIMEOUT_MS = 30_000;
|
|
302
343
|
function observationOf(connection) {
|
|
303
344
|
return {
|
|
304
345
|
authMethods: connection.authMethods,
|
|
346
|
+
supportsAccountLimits: connection.supportsAccountLimits,
|
|
305
347
|
supportsSteering: connection.supportsSteering,
|
|
306
348
|
terminalLogins: connection.terminalLogins,
|
|
307
349
|
};
|
|
308
350
|
}
|
|
309
351
|
/**
|
|
310
|
-
* The version of
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
* answered for npx, this covers all three, and `undefined` here is a genuine "not installed" rather
|
|
314
|
-
* than "did not look" — every branch is a cheap local read this daemon always performs.
|
|
315
|
-
*
|
|
316
|
-
* An agent with more than one distribution (uncommon on the live registry, but not ruled out by the
|
|
317
|
-
* types) is checked npx-first, then uvx, then binary — the same preference order `resolveLaunch`
|
|
318
|
-
* uses to pick which one actually launches.
|
|
319
|
-
*
|
|
320
|
-
* `uvVersions` is a pre-read `uv tool list` snapshot ({@link listUvTools}), not a live call: `uv` is
|
|
321
|
-
* a real subprocess, and this function runs once per registry entry inside {@link AcpAgents.roster}'s
|
|
322
|
-
* map — spawning it per uvx agent there is exactly the per-agent-subprocess defect the roster's own
|
|
323
|
-
* doc comment rules out (39 entries, on every scan). The caller reads `uv tool list` exactly once and
|
|
324
|
-
* hands the parsed result in here for a plain map lookup.
|
|
352
|
+
* The version a new session of an agent would run on this machine: its **current build**
|
|
353
|
+
* (`builds.json`, spec §6.4 — `adapterVersion` reports current), for every distribution, uv included
|
|
354
|
+
* (Task 15b). `undefined` is a genuine "not installed".
|
|
325
355
|
*/
|
|
326
|
-
function adapterVersionFor(
|
|
327
|
-
|
|
328
|
-
if (npx)
|
|
329
|
-
return npxCacheVersion(npx.package, npxCache);
|
|
330
|
-
if (uvx)
|
|
331
|
-
return uvVersions.get(uvPackageName(uvx.package));
|
|
332
|
-
if (binary)
|
|
333
|
-
return installedBinaryVersion(agent.id, adapterRoot, agent.version);
|
|
334
|
-
return undefined;
|
|
356
|
+
function adapterVersionFor(current) {
|
|
357
|
+
return current?.version;
|
|
335
358
|
}
|
|
336
359
|
/**
|
|
337
360
|
* Whether `agent` cannot be launched on this machine **at all** — not "its `binary` archive is
|
|
338
361
|
* missing for this platform", which is what this used to check on its own, independent of whatever
|
|
339
362
|
* else the agent offers.
|
|
340
363
|
*
|
|
341
|
-
*
|
|
364
|
+
* Install dispatches npx first, then uvx, then binary, and an agent with
|
|
342
365
|
* either of the first two is launchable through it regardless of what its `binary` block does or
|
|
343
366
|
* does not cover — `kilo` and `sigit` on the live registry both carry `npx` *and* `binary`, and a
|
|
344
367
|
* platform missing their binary archive is still fully served by npx. So this derives its answer
|
|
@@ -357,6 +380,105 @@ function isUnsupportedPlatform(agent, platform, arch) {
|
|
|
357
380
|
const triple = platformTriple(platform, arch);
|
|
358
381
|
return !triple || !binaryTargetFor(agent, triple);
|
|
359
382
|
}
|
|
383
|
+
/** The version an entry asks npm for: its npx specifier's pin, else the entry's version. */
|
|
384
|
+
function requestedVersionOf(entry) {
|
|
385
|
+
const npx = entry.agent.distribution.npx;
|
|
386
|
+
return (npx && npmPackageVersion(npx.package)) || entry.agent.version;
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* How long an open waits for an install it had to start (spec §6.2) before failing with
|
|
390
|
+
* {@link AdapterStillInstallingError}. The server gives a whole open `AGENT_CHANNEL_OPEN_TIMEOUT_MS`
|
|
391
|
+
* = 120 s (`AgentChannelSession.kt`); this leaves ~30 s for the spawn, `initialize` and
|
|
392
|
+
* `session/new|load` that follow, so the reader gets this actionable error instead of a generic
|
|
393
|
+
* "open timed out". The install itself is not bounded by this — it keeps running under the agent lock
|
|
394
|
+
* with its own install budget, and a retried open waits on that lock.
|
|
395
|
+
*/
|
|
396
|
+
export const OPEN_INSTALL_BUDGET_MS = 90_000;
|
|
397
|
+
/** How long the resume offer waits for keeper-acp's session list (the offer goes out regardless). */
|
|
398
|
+
const LEFTOVER_LIST_TIMEOUT_MS = 5_000;
|
|
399
|
+
/** keeper-acp sessions rebuilt at once by the start-time adoption. */
|
|
400
|
+
const ADOPT_CONCURRENCY = 8;
|
|
401
|
+
/** The version installing `entry` asks for — what an open that has to install shows the user. */
|
|
402
|
+
function installingVersionOf(entry) {
|
|
403
|
+
const { npx, uvx } = entry.agent.distribution;
|
|
404
|
+
if (npx)
|
|
405
|
+
return requestedVersionOf(entry);
|
|
406
|
+
return (uvx && parseUvxSpec(uvx.package).version) || entry.agent.version;
|
|
407
|
+
}
|
|
408
|
+
/** The registry could not be read for a migration: tried again by the next launch rather than given up on. */
|
|
409
|
+
class RegistryUnavailableForMigration extends Error {
|
|
410
|
+
}
|
|
411
|
+
/** Pre-build binary agent directories directly under the `acp-adapters` root (`builds/migrate.ts`). */
|
|
412
|
+
async function legacyAgentDirs(root) {
|
|
413
|
+
let entries;
|
|
414
|
+
try {
|
|
415
|
+
entries = await readdir(root, { withFileTypes: true });
|
|
416
|
+
}
|
|
417
|
+
catch {
|
|
418
|
+
return [];
|
|
419
|
+
}
|
|
420
|
+
return entries.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.') && !RESERVED_ROOT_NAMES.has(entry.name)).map((entry) => entry.name);
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* {@link BridgeInfo.currentBuild}/`builds`/`switchPending` for one agent (spec §6.4): the current build (with its sessions, possibly
|
|
424
|
+
* none) and every build a live session runs. `listed` false (keeper-acp could not be listed): only `currentBuild`.
|
|
425
|
+
*/
|
|
426
|
+
export function rosterBuildFields(current, sessions, listed) {
|
|
427
|
+
const patchedBuild = (build) => build.kind === 'npm-patched' || build.patchDigest !== undefined;
|
|
428
|
+
const fields = {};
|
|
429
|
+
if (current)
|
|
430
|
+
fields.currentBuild = { id: current.buildId, version: current.version, ...(patchedBuild(current) ? { patched: true } : {}) };
|
|
431
|
+
if (!listed)
|
|
432
|
+
return fields;
|
|
433
|
+
const tallies = new Map();
|
|
434
|
+
const tally = (id, version, patched) => {
|
|
435
|
+
let entry = tallies.get(id);
|
|
436
|
+
if (!entry) {
|
|
437
|
+
entry = { id, version, patched, sessions: 0, idle: 0, busy: 0, unmovable: 0, busyReasons: new Set(), unmovableReasons: new Set() };
|
|
438
|
+
tallies.set(id, entry);
|
|
439
|
+
}
|
|
440
|
+
return entry;
|
|
441
|
+
};
|
|
442
|
+
if (current)
|
|
443
|
+
tally(current.buildId, current.version, patchedBuild(current));
|
|
444
|
+
const pendingTargets = [];
|
|
445
|
+
for (const session of sessions ?? []) {
|
|
446
|
+
const entry = tally(session.buildId, session.version, session.patched);
|
|
447
|
+
entry.sessions += 1;
|
|
448
|
+
entry[session.state] += 1;
|
|
449
|
+
const reasons = session.state === 'busy' ? entry.busyReasons : session.state === 'unmovable' ? entry.unmovableReasons : undefined;
|
|
450
|
+
for (const reason of session.reasons ?? [])
|
|
451
|
+
if (reasons && reasons.size < ROSTER_REASONS_MAX)
|
|
452
|
+
reasons.add(reason);
|
|
453
|
+
if (session.pendingTo !== undefined)
|
|
454
|
+
pendingTargets.push(session.pendingTo);
|
|
455
|
+
}
|
|
456
|
+
if (tallies.size === 0)
|
|
457
|
+
return fields;
|
|
458
|
+
// The current build first, then the rest by version (newest first), so a card reads the same on every scan.
|
|
459
|
+
const ordered = [...tallies.values()].sort((a, b) => (a.id === current?.buildId ? -1 : b.id === current?.buildId ? 1 : 0) || compareVersionsDesc(a.version, b.version) || a.id.localeCompare(b.id));
|
|
460
|
+
fields.builds = ordered.map((entry) => ({
|
|
461
|
+
id: entry.id, version: entry.version, ...(entry.patched ? { patched: true } : {}),
|
|
462
|
+
...(entry.id === current?.buildId ? { current: true } : {}),
|
|
463
|
+
sessions: entry.sessions, idle: entry.idle, busy: entry.busy, unmovable: entry.unmovable,
|
|
464
|
+
...(entry.busyReasons.size > 0 ? { busyReasons: [...entry.busyReasons] } : {}),
|
|
465
|
+
...(entry.unmovableReasons.size > 0 ? { unmovableReasons: [...entry.unmovableReasons] } : {}),
|
|
466
|
+
}));
|
|
467
|
+
if (pendingTargets.length > 0) {
|
|
468
|
+
const first = pendingTargets[0];
|
|
469
|
+
fields.switchPending = { remaining: pendingTargets.length, ...(pendingTargets.every((target) => target === first) ? { toBuild: first } : {}) };
|
|
470
|
+
}
|
|
471
|
+
return fields;
|
|
472
|
+
}
|
|
473
|
+
function compareVersionsDesc(a, b) {
|
|
474
|
+
const va = semver.parse(a, { loose: true });
|
|
475
|
+
const vb = semver.parse(b, { loose: true });
|
|
476
|
+
return va && vb ? semver.rcompare(va, vb) : 0;
|
|
477
|
+
}
|
|
478
|
+
/** Why a local-fallback session is never moved (spec §6.3 "当前限制"): it does not outlive the daemon, so its next reopen uses current. */
|
|
479
|
+
export const LOCAL_SESSION_UNMOVABLE = 'it is running without the session keeper; it will use the current build when reopened';
|
|
480
|
+
/** At most this many distinct reasons per build entry (the card explains, it does not list every session). */
|
|
481
|
+
const ROSTER_REASONS_MAX = 4;
|
|
360
482
|
export class AcpAgents {
|
|
361
483
|
sessionStartTimeoutMs;
|
|
362
484
|
registry;
|
|
@@ -365,34 +487,108 @@ export class AcpAgents {
|
|
|
365
487
|
probe;
|
|
366
488
|
runCommand;
|
|
367
489
|
/**
|
|
368
|
-
*
|
|
490
|
+
* The adapter builds root (`builds/model.ts` layout) —
|
|
369
491
|
* `<workstation config dir>/acp-adapters`, derived from the same `cacheDir` the registry cache
|
|
370
|
-
* itself lives in directly
|
|
371
|
-
* branch needs this root and nothing else in this class's options carries it).
|
|
492
|
+
* itself lives in directly.
|
|
372
493
|
*/
|
|
373
494
|
adapterRoot;
|
|
374
495
|
commandRetryFirstBroadcastMs;
|
|
375
496
|
/** Last observation per registry agent id; in-process only, by design — see {@link AuthObservation}. */
|
|
376
497
|
observed = new Map();
|
|
377
|
-
|
|
498
|
+
quotaReaders = new Map();
|
|
499
|
+
/** The `uv` uv builds are installed with; see {@link AcpAgentsOptions.runUv}. */
|
|
378
500
|
runUv;
|
|
379
|
-
/** Injectable `
|
|
380
|
-
|
|
501
|
+
/** Injectable `npm` for adapter builds; see {@link AcpAgentsOptions.npmCommand}. */
|
|
502
|
+
npmCommand;
|
|
503
|
+
nodePath;
|
|
504
|
+
binaryFetch;
|
|
505
|
+
patchRecipes;
|
|
506
|
+
/** `builds.json` and the per-agent install/switch/GC lock (spec §6.1). */
|
|
507
|
+
builds;
|
|
508
|
+
/** The registry with catalog supplements but without overrides applied — {@link effectiveEntries} applies them, keeping launchEnv apart. */
|
|
509
|
+
baseRegistry;
|
|
510
|
+
/** {@link OPEN_INSTALL_BUDGET_MS}, overridable for tests. */
|
|
511
|
+
openInstallBudgetMs;
|
|
512
|
+
cacheDir;
|
|
513
|
+
transports;
|
|
514
|
+
generations;
|
|
515
|
+
/** The once-per-daemon adoption's discovery ({@link adoptKeeperSessions}). */
|
|
516
|
+
adoption;
|
|
517
|
+
/**
|
|
518
|
+
* keeper-acp generations whose listing failed (timed out, not answering) at the adoption or a retry since (final review finding 3):
|
|
519
|
+
* their sessions are neither adopted nor orphans yet — {@link retryKeeperAdoption} lists them again, no open spawns while one
|
|
520
|
+
* remains (a spawn into a session of it would stop its running adapter), and they are never retired meanwhile.
|
|
521
|
+
*/
|
|
522
|
+
unlistedGens = new Set();
|
|
523
|
+
/** The retry in flight ({@link retryKeeperAdoption}). */
|
|
524
|
+
retrying;
|
|
525
|
+
leftoverListTimeoutMs;
|
|
526
|
+
/** Which keeper-acp generation holds a session key (discovery and leftover listings): its IPC goes there. */
|
|
527
|
+
keeperOwner = new Map();
|
|
528
|
+
/** The highest seq a keeper-acp session's outbox held when it was listed (the local fallback numbers above it, Task 21). */
|
|
529
|
+
keeperHeads = new Map();
|
|
530
|
+
/** Sessions this daemon holds (or is spawning) on an older generation, by key: that generation is never stopped meanwhile. */
|
|
531
|
+
heldOnOlder = new Map();
|
|
532
|
+
/** Refs whose session the manager is retiring to respawn it ({@link holdForReopen}): their older generation stays held. */
|
|
533
|
+
reopenHolds = new Set();
|
|
534
|
+
/** Keys this daemon spawned on keeper-acp: never adopted (they are its own). */
|
|
535
|
+
ownKeeperKeys = new Set();
|
|
536
|
+
initializeTimeoutMs;
|
|
537
|
+
/** One legacy migration per agent per process ({@link migrateAgent}); the daemon's start and any open share it. */
|
|
538
|
+
migrations = new Map();
|
|
539
|
+
/**
|
|
540
|
+
* Each ref's current keeper-acp key generation (Task 24): key `<ref>` (0) until a switch moves it to `<ref>#s<n>`. Learned from
|
|
541
|
+
* every listing (the spawn label's `keyGen` is the persisted record) and set by every switch; a ref absent here uses itself.
|
|
542
|
+
*/
|
|
543
|
+
refKeys = new Map();
|
|
544
|
+
/**
|
|
545
|
+
* keeper-acp sessions whose adapter runs but which cannot be continued (spec §5.5.6: no readable checkpoint and raw truncated, or
|
|
546
|
+
* its open never completed), by key: left running, marked degraded, and switched at the ref's next `agent_open` — the process is
|
|
547
|
+
* closed and a new one loads the native session (never `session/new`).
|
|
548
|
+
*/
|
|
549
|
+
unrecoverable = new Map();
|
|
550
|
+
/** Daemon leases on builds (opens resolving → spawning, switches, short-lived connections), `agentId\0buildId` → count (GC). */
|
|
551
|
+
leases = new Map();
|
|
552
|
+
/** When each build was last seen referenced (GC's 10-minute grace); unknown builds count from this daemon's start. */
|
|
553
|
+
buildSeen = new Map();
|
|
554
|
+
startedAt = Date.now();
|
|
555
|
+
/** Refs whose switch cannot happen (spec §6.4 `unmovable`), with why — until the session goes or its target changes. */
|
|
556
|
+
unmovableRefs = new Map();
|
|
557
|
+
/** The persistent intents (`builds.json` `switchPending`), read once and kept in step with every write. */
|
|
558
|
+
intents;
|
|
559
|
+
/** One GC at a time (a later one runs after it). */
|
|
560
|
+
collecting;
|
|
561
|
+
/** One {@link retireIdleGenerations} pass at a time, and whether another was asked for meanwhile. */
|
|
562
|
+
retiringGenerations;
|
|
563
|
+
retireAgain = false;
|
|
381
564
|
constructor(options) {
|
|
382
565
|
this.sessionStartTimeoutMs = options.sessionStartTimeoutMs ?? 60_000;
|
|
383
566
|
if (!(this.sessionStartTimeoutMs > 0) || !Number.isFinite(this.sessionStartTimeoutMs)) {
|
|
384
567
|
throw new RangeError('sessionStartTimeoutMs must be finite and positive');
|
|
385
568
|
}
|
|
386
569
|
// Through the catalog, always: an injected registry too, so tests exercise the same overlay.
|
|
387
|
-
this.
|
|
570
|
+
this.baseRegistry = options.registry ?? new RegistryClient({ cacheDir: options.cacheDir });
|
|
571
|
+
this.registry = withCatalogOverrides(this.baseRegistry);
|
|
388
572
|
this.platform = options.platform ?? process.platform;
|
|
389
573
|
this.arch = options.arch ?? process.arch;
|
|
390
574
|
this.probe = options.probe ?? probeEnvironment();
|
|
391
575
|
this.runCommand = options.runCommand ?? runDependencyInstall;
|
|
392
|
-
this.runUv = options.runUv;
|
|
393
|
-
this.
|
|
576
|
+
this.runUv = options.runUv ?? defaultRunUv;
|
|
577
|
+
this.nodePath = options.nodePath ?? process.execPath;
|
|
578
|
+
this.npmCommand = options.npmCommand ?? defaultNpmCommand(this.nodePath, this.platform);
|
|
579
|
+
this.binaryFetch = options.binaryFetch;
|
|
580
|
+
this.patchRecipes = options.patchRecipes;
|
|
394
581
|
this.commandRetryFirstBroadcastMs = options.commandRetryFirstBroadcastMs ?? COMMAND_RETRY_FIRST_BROADCAST_MS;
|
|
582
|
+
this.openInstallBudgetMs = options.openInstallBudgetMs ?? OPEN_INSTALL_BUDGET_MS;
|
|
395
583
|
this.adapterRoot = path.join(options.cacheDir, 'acp-adapters');
|
|
584
|
+
this.builds = new BuildStore(this.adapterRoot);
|
|
585
|
+
this.cacheDir = options.cacheDir;
|
|
586
|
+
this.transports = { ...options.transports };
|
|
587
|
+
this.generations = options.keeperGenerations;
|
|
588
|
+
this.initializeTimeoutMs = options.initializeTimeoutMs ?? DEFAULT_SESSION_INITIALIZE_TIMEOUT_MS;
|
|
589
|
+
this.leftoverListTimeoutMs = options.leftoverListTimeoutMs ?? LEFTOVER_LIST_TIMEOUT_MS;
|
|
590
|
+
// Read once, up front: an `agent_open` asks for a ref's intent on its way to the reply (Task 24).
|
|
591
|
+
void this.loadIntents().catch(() => undefined);
|
|
396
592
|
}
|
|
397
593
|
/**
|
|
398
594
|
* The roster reported in the `hello` frame and answered to an external-agents scan, one entry per
|
|
@@ -429,7 +625,7 @@ export class AcpAgents {
|
|
|
429
625
|
* started daemon reports neither field for anything — that absence is the truthful answer and the
|
|
430
626
|
* server contract spells out that it means "unknown", not "not authenticated".
|
|
431
627
|
*/
|
|
432
|
-
async roster() {
|
|
628
|
+
async roster(options = {}) {
|
|
433
629
|
let agents;
|
|
434
630
|
try {
|
|
435
631
|
agents = await this.registry.load();
|
|
@@ -438,42 +634,39 @@ export class AcpAgents {
|
|
|
438
634
|
console.warn(`[acp] agent registry unavailable, reporting an empty roster: ${errorMessage(error)}`);
|
|
439
635
|
return [];
|
|
440
636
|
}
|
|
441
|
-
// Read once for the whole roster, not once per agent:
|
|
442
|
-
//
|
|
443
|
-
const
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
//
|
|
448
|
-
//
|
|
449
|
-
// this once per roster build, not once per agent.
|
|
450
|
-
const uvTools = await listUvTools(this.runUv);
|
|
451
|
-
const uvVersions = new Map(uvTools.map((entry) => [entry.name, entry.version]));
|
|
452
|
-
// Hoisted for the same reason as `npxCache`/`uvTools`: it is the same value for every entry in
|
|
637
|
+
// Read once for the whole roster, not once per agent: every npm/binary agent's installed version
|
|
638
|
+
// is its current build (spec §6.4).
|
|
639
|
+
const buildsDocument = await this.builds.read().catch((error) => {
|
|
640
|
+
console.warn(`[acp] adapter builds unreadable, reporting none installed: ${errorMessage(error)}`);
|
|
641
|
+
return { schema: 1, agents: {} };
|
|
642
|
+
});
|
|
643
|
+
// Since Task 15b uv agents are builds too: building the roster spawns no `uv` (and nothing else).
|
|
644
|
+
// Hoisted for the same reason as `buildsDocument`: it is the same value for every entry in
|
|
453
645
|
// this roster (a fact about this machine, not this agent), so it is computed once rather than
|
|
454
646
|
// once per agent inside the `.map` below.
|
|
455
647
|
const triple = platformTriple(this.platform, this.arch);
|
|
456
|
-
//
|
|
457
|
-
//
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
return Promise.all(agents.map(async (agent) => {
|
|
648
|
+
// Spec §6.4: the build data is joined only for a scan (`options.sessions`) — never the hello — and only when every keeper-acp
|
|
649
|
+
// generation could be listed (`undefined`: absent, "not looked at").
|
|
650
|
+
const running = options.sessions ? await this.classifiedRunningSessions(options.sessions) : undefined;
|
|
651
|
+
return agents.map((agent) => {
|
|
461
652
|
const observation = this.observed.get(agent.id);
|
|
462
653
|
const catalog = catalogEntryFor(agent.id);
|
|
463
654
|
// Computed once and used by both `adapterInstalled` and `adapterVersion` below: they are two
|
|
464
655
|
// halves of one disk read, and deriving them separately is exactly how they drifted apart.
|
|
465
|
-
const
|
|
656
|
+
const current = this.builds.currentIn(buildsDocument, agent.id);
|
|
657
|
+
const adapterVersion = adapterVersionFor(current);
|
|
466
658
|
// One detection pass per agent, shared by `adapterInstalled`, `detected` and
|
|
467
659
|
// `missingPrerequisite`: three readings of the same look at the disk must not disagree.
|
|
468
660
|
const prerequisite = catalog?.prerequisite;
|
|
469
661
|
const prerequisiteMet = !prerequisite || detected(prerequisite.detect, this.probe, triple);
|
|
470
662
|
const inUse = detected(catalog?.detect, this.probe, triple) || (prerequisite !== undefined && prerequisiteMet);
|
|
663
|
+
// A `pathLaunch` agent has no installable artifact: its `available` is exactly "the body is on
|
|
664
|
+
// this machine" (see `registry/catalog.ts`). No build will ever exist for it, so the build
|
|
665
|
+
// halves below stay honestly absent.
|
|
666
|
+
const pathLaunchAvailable = catalog?.pathLaunch !== undefined && inUse;
|
|
471
667
|
return {
|
|
472
668
|
type: agent.id,
|
|
473
|
-
|
|
474
|
-
// (`uvVersions`) — handed through so this doesn't make `resolveLaunch` spawn `uv` again for
|
|
475
|
-
// every uvx-distributed agent (see `resolvable`'s own doc comment).
|
|
476
|
-
available: await this.resolvable(agent, uvTools),
|
|
669
|
+
available: this.resolvable(agent, current) || pathLaunchAvailable,
|
|
477
670
|
version: agent.version,
|
|
478
671
|
// Always present: the registry document itself always carries a version for every entry,
|
|
479
672
|
// unlike `adapterVersion` below, which depends on what this particular machine has done.
|
|
@@ -487,15 +680,14 @@ export class AcpAgents {
|
|
|
487
680
|
// which meant the one reader who most needed an `Update` button was the one reader who
|
|
488
681
|
// never got offered one (design §11). Which version is on disk is `adapterVersion`'s job
|
|
489
682
|
// below; whether it is behind the pin is the card's `updatable` comparison. Neither belongs
|
|
490
|
-
// here.
|
|
491
|
-
// *launch* decision in `registry/launch.ts` — not in this presence check.
|
|
683
|
+
// here.
|
|
492
684
|
//
|
|
493
|
-
//
|
|
494
|
-
//
|
|
495
|
-
//
|
|
685
|
+
// Since spec 2026-10-11 §6.4 "installed" means the agent has a **current build** (npm, binary
|
|
686
|
+
// and — since Task 15b — uv), and `adapterVersion`
|
|
687
|
+
// is that build's version — the version a new session would actually run.
|
|
496
688
|
//
|
|
497
689
|
// `prerequisiteMet` folds in the catalog's prerequisite (design §4.4): `pi-acp`'s adapter
|
|
498
|
-
//
|
|
690
|
+
// installed with no `pi` on `PATH` is not an agent this machine can add —
|
|
499
691
|
// the card would offer `Add`, the try-run would fail, and the reader would be told nothing
|
|
500
692
|
// about the missing CLI. Reporting it not-installed is what makes `Install` fetch the
|
|
501
693
|
// prerequisite too (or, with nothing to fetch, `missingPrerequisite` below explain it).
|
|
@@ -514,7 +706,11 @@ export class AcpAgents {
|
|
|
514
706
|
// machine can launch has no use for it. Absent when the machine's own platform/arch falls
|
|
515
707
|
// outside the registry's six triples (see `binaryInstall.ts`'s `platformTriple`) — `true`
|
|
516
708
|
// with no triple to quote is still the correct, honest signal in that case.
|
|
517
|
-
|
|
709
|
+
// A `pathLaunch` agent has no distribution to be "unsupported" about: its launch path is the
|
|
710
|
+
// machine's own installation (detect-driven, above), not a downloadable artifact's platform
|
|
711
|
+
// coverage — the empty-distribution supplement would otherwise read as "nothing left to
|
|
712
|
+
// launch through".
|
|
713
|
+
...(!pathLaunchAvailable && isUnsupportedPlatform(agent, this.platform, this.arch)
|
|
518
714
|
? { unsupportedPlatform: true, ...(triple ? { platformTriple: triple } : {}) }
|
|
519
715
|
: {}),
|
|
520
716
|
// The registry publishes a human-facing name for every agent; carrying it is what stops the
|
|
@@ -530,6 +726,18 @@ export class AcpAgents {
|
|
|
530
726
|
...(catalog ? { ...(catalog.unverified ? {} : { curated: true }), defaultName: catalog.defaultName } : {}),
|
|
531
727
|
...(catalog?.defaultLabel ? { defaultLabel: catalog.defaultLabel } : {}),
|
|
532
728
|
...(catalog?.recommended ? { recommended: true } : {}),
|
|
729
|
+
// 静态推荐条目的展示序(目录数组序):推荐层按它排,使目录能表达 "pi 第一、hermes 第二"
|
|
730
|
+
// 这类顺序意图 —— 名册序做不到(supplement 追加在后),字母序与本意相反。
|
|
731
|
+
...(RECOMMENDED_RANK.has(agent.id) ? { recommendedRank: RECOMMENDED_RANK.get(agent.id) } : {}),
|
|
732
|
+
...(catalog?.pathLaunch
|
|
733
|
+
? {
|
|
734
|
+
pathLaunch: {
|
|
735
|
+
command: catalog.pathLaunch.command,
|
|
736
|
+
...(catalog.pathLaunch.args ? { args: [...catalog.pathLaunch.args] } : {}),
|
|
737
|
+
...(catalog.pathLaunch.hint ? { hint: catalog.pathLaunch.hint } : {}),
|
|
738
|
+
},
|
|
739
|
+
}
|
|
740
|
+
: {}),
|
|
533
741
|
...(inUse ? { detected: true } : {}),
|
|
534
742
|
// Only when nothing here can fix it: a prerequisite with an `install` argv (Pi) is fetched by
|
|
535
743
|
// `Install`, which the card already offers because `adapterInstalled` is false.
|
|
@@ -545,8 +753,121 @@ export class AcpAgents {
|
|
|
545
753
|
// has connected to and found *not* steering-capable publishes nothing, because absent
|
|
546
754
|
// already means "do not offer it". See `BridgeInfo.supportsSteering`.
|
|
547
755
|
...(observation?.supportsSteering === true ? { supportsSteering: true } : {}),
|
|
756
|
+
// The installed native extension is known without spawning a process or reading quota.
|
|
757
|
+
// A pre-upgrade live session's initialize observation must not hide its refresh entry.
|
|
758
|
+
...(observation?.supportsAccountLimits || (current && current.launch.runtime !== 'node' && nativeExtensionFor(current.agentId, current.version)) ? { supportsAccountLimits: true } : {}),
|
|
759
|
+
...(options.sessions ? rosterBuildFields(current, running?.get(agent.id), running !== undefined) : {}),
|
|
548
760
|
};
|
|
549
|
-
})
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
/**
|
|
764
|
+
* The live sessions' builds joined with the manager's view of each (Task 25), grouped by agent; `undefined` when a keeper-acp
|
|
765
|
+
* generation could not be listed (the counts would be wrong, so none are reported).
|
|
766
|
+
*/
|
|
767
|
+
async classifiedRunningSessions(classify) {
|
|
768
|
+
const running = await this.runningSessions();
|
|
769
|
+
if (!running)
|
|
770
|
+
return undefined;
|
|
771
|
+
const byAgent = new Map();
|
|
772
|
+
for (const session of running) {
|
|
773
|
+
const state = session.local
|
|
774
|
+
? { state: 'unmovable', reasons: [LOCAL_SESSION_UNMOVABLE] }
|
|
775
|
+
: await classify(session).catch((error) => {
|
|
776
|
+
console.warn(`[acp] roster: the state of ${session.sessionRef} could not be read: ${errorMessage(error)}`);
|
|
777
|
+
return { state: 'busy', reasons: ['its state could not be read right now'] };
|
|
778
|
+
});
|
|
779
|
+
const list = byAgent.get(session.agentId) ?? [];
|
|
780
|
+
list.push({ ...session, ...state });
|
|
781
|
+
byAgent.set(session.agentId, list);
|
|
782
|
+
}
|
|
783
|
+
return byAgent;
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* Every live session's adapter process with its build (spec §6.4, Task 25): keeper-acp's process labels across every live
|
|
787
|
+
* generation, plus the local fallback's. One entry per ref (a ref a crash left with two keys is counted once, by its newest key).
|
|
788
|
+
* `undefined` when keeper-acp — any generation — cannot be listed.
|
|
789
|
+
*/
|
|
790
|
+
async runningSessions() {
|
|
791
|
+
const listed = await this.listRunningAdapters();
|
|
792
|
+
if (!listed)
|
|
793
|
+
return undefined;
|
|
794
|
+
const byRef = new Map();
|
|
795
|
+
for (const { summary, local } of listed) {
|
|
796
|
+
const ref = refOfSession(summary);
|
|
797
|
+
const { agentId, buildId, version, kind } = summary.label;
|
|
798
|
+
if (ref === undefined || typeof agentId !== 'string' || typeof buildId !== 'string')
|
|
799
|
+
continue;
|
|
800
|
+
const keyGen = keyGenOf(summary.label);
|
|
801
|
+
const previous = byRef.get(ref);
|
|
802
|
+
if (previous && previous.keyGen >= keyGen)
|
|
803
|
+
continue;
|
|
804
|
+
byRef.set(ref, {
|
|
805
|
+
keyGen,
|
|
806
|
+
session: { sessionRef: ref, agentId, buildId, version: typeof version === 'string' ? version : buildId, patched: kind === 'npm-patched', local },
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
return [...byRef.values()].map(({ session }) => session);
|
|
810
|
+
}
|
|
811
|
+
/** The adapter processes running now, in every live keeper-acp generation and the local fallback; `undefined` if any cannot be listed. */
|
|
812
|
+
async listRunningAdapters() {
|
|
813
|
+
const found = [];
|
|
814
|
+
if (this.transports.keeper) {
|
|
815
|
+
let transports;
|
|
816
|
+
if (this.generations) {
|
|
817
|
+
try {
|
|
818
|
+
const live = await this.generations.live();
|
|
819
|
+
transports = await Promise.all(live.map((gen) => this.generations.transportFor(gen)));
|
|
820
|
+
}
|
|
821
|
+
catch {
|
|
822
|
+
return undefined;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
else {
|
|
826
|
+
transports = [this.transports.keeper];
|
|
827
|
+
}
|
|
828
|
+
for (const transport of transports) {
|
|
829
|
+
try {
|
|
830
|
+
for (const summary of await withDeadline(transport.list(), this.leftoverListTimeoutMs, 'keeper-acp list', Error)) {
|
|
831
|
+
if (hostsRunningAdapter(summary))
|
|
832
|
+
found.push({ summary, local: false });
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
catch {
|
|
836
|
+
return undefined;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
if (this.transports.local) {
|
|
841
|
+
try {
|
|
842
|
+
for (const summary of await this.transports.local.list())
|
|
843
|
+
if (hostsRunningAdapter(summary))
|
|
844
|
+
found.push({ summary, local: true });
|
|
845
|
+
}
|
|
846
|
+
catch {
|
|
847
|
+
return undefined;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
return found;
|
|
851
|
+
}
|
|
852
|
+
/** The agent's current build id and every installed build id (`builds.json`), for `agent_switch_idle` (Task 25). */
|
|
853
|
+
async buildIdsOf(agentId) {
|
|
854
|
+
const entry = await this.builds.agent(agentId);
|
|
855
|
+
return { ...(entry?.current ? { current: entry.current } : {}), installed: Object.keys(entry?.builds ?? {}) };
|
|
856
|
+
}
|
|
857
|
+
/** Roster-visible change listeners (see {@link onRosterChanged}). */
|
|
858
|
+
rosterChangedListeners = [];
|
|
859
|
+
/**
|
|
860
|
+
* Subscribe to roster-visible observation changes.
|
|
861
|
+
*
|
|
862
|
+
* Fired when an agent's quota capability (`supportsAccountLimits`) is first observed by this
|
|
863
|
+
* process — the one roster field that can change mid-flight without a build changing, hence
|
|
864
|
+
* without the build-upkeep push that carries every other roster update. The daemon answers by
|
|
865
|
+
* re-scanning and pushing the roster; offline, the next hello carries it. Observations are
|
|
866
|
+
* in-memory, so a restart forgets the capability and this firing together — the next spawn
|
|
867
|
+
* re-learns both.
|
|
868
|
+
*/
|
|
869
|
+
onRosterChanged(listener) {
|
|
870
|
+
this.rosterChangedListeners.push(listener);
|
|
550
871
|
}
|
|
551
872
|
/**
|
|
552
873
|
* Record what a just-established connection revealed about one agent. Called on every connection,
|
|
@@ -558,7 +879,21 @@ export class AcpAgents {
|
|
|
558
879
|
*/
|
|
559
880
|
observe(agentId, observation) {
|
|
560
881
|
const previous = this.observed.get(agentId);
|
|
561
|
-
|
|
882
|
+
const merged = { ...previous, ...observation };
|
|
883
|
+
this.observed.set(agentId, merged);
|
|
884
|
+
if (merged.supportsAccountLimits === true && previous?.supportsAccountLimits !== true) {
|
|
885
|
+
// The quota capability was just learned mid-flight. Without a push here the server's roster
|
|
886
|
+
// copy stays at the hello/build-change snapshot, and the account UI it gates never renders
|
|
887
|
+
// (observed 2026-10-13: this daemon's first zcode spawn advertised the capability, the
|
|
888
|
+
// server's roster remained at that morning's build-change push, no scan happened, and no
|
|
889
|
+
// account UI ever appeared for the agent).
|
|
890
|
+
for (const listener of this.rosterChangedListeners) {
|
|
891
|
+
try {
|
|
892
|
+
listener();
|
|
893
|
+
}
|
|
894
|
+
catch { /* a listener must not break the observe path */ }
|
|
895
|
+
}
|
|
896
|
+
}
|
|
562
897
|
}
|
|
563
898
|
/**
|
|
564
899
|
* What one agent was last seen to advertise as ways of signing in, or `undefined` when this
|
|
@@ -584,12 +919,12 @@ export class AcpAgents {
|
|
|
584
919
|
return this.observed.get(agentId)?.authMethods;
|
|
585
920
|
}
|
|
586
921
|
/**
|
|
587
|
-
* Start one session:
|
|
588
|
-
*
|
|
922
|
+
* Start one session (Task 19, spec 2026-10-11 §5.3/§5.4): build the spawn spec, spawn the adapter in keeper-acp (the local
|
|
923
|
+
* fallback when keeper-acp is unavailable), let the processor negotiate `initialize`, then either continue in
|
|
924
|
+
* {@link AcpOpenSpec.nativeSessionId} or open a fresh one — every step a note or a write in the session's raw log.
|
|
589
925
|
*
|
|
590
|
-
* The
|
|
591
|
-
*
|
|
592
|
-
* exists to prevent one layer up.
|
|
926
|
+
* The adapter is stopped on every failure path after it was spawned — an agent left running for a session that never
|
|
927
|
+
* registered is exactly the orphan the manager's own tombstone machinery exists to prevent one layer up.
|
|
593
928
|
*/
|
|
594
929
|
async open(spec) {
|
|
595
930
|
const requestId = spec.openRequestId ?? randomUUID();
|
|
@@ -611,65 +946,34 @@ export class AcpAgents {
|
|
|
611
946
|
throw error;
|
|
612
947
|
}
|
|
613
948
|
};
|
|
949
|
+
// A successful open-time install becomes the session's own `agent_install_outcome` note (Task 20: numbered by the processor,
|
|
950
|
+
// recorded in the outbox); the caller still hears every outcome (a failure has no session to go on).
|
|
951
|
+
let installed;
|
|
952
|
+
if (!spec.command) {
|
|
953
|
+
await phase('ensure-installed', () => this.installForOpen(spec.agentId, (outcome) => {
|
|
954
|
+
if (outcome.ok)
|
|
955
|
+
installed = outcome;
|
|
956
|
+
spec.onInstallOutcome?.(outcome);
|
|
957
|
+
}));
|
|
958
|
+
}
|
|
614
959
|
let resolved = await phase('prepare-launch', () => this.resolveLaunchFor(spec.agentId, spec.command, spec.args));
|
|
960
|
+
// Spec §5.5.6 (Task 24): a session the adoption could not continue is switched by this open — its process is closed and a new
|
|
961
|
+
// one loads the native session, never falling back to `session/new`.
|
|
962
|
+
if (this.transports.keeper && spec.sessionRef !== undefined && spec.nativeSessionId) {
|
|
963
|
+
await this.adoptKeeperSessions();
|
|
964
|
+
if (this.unrecoverable.has(this.keyFor(spec.sessionRef)))
|
|
965
|
+
spec = { ...spec, requireSessionLoad: true };
|
|
966
|
+
}
|
|
967
|
+
// GC never takes the build between resolving it and the spawn (whose keeper-acp label references it from then on).
|
|
968
|
+
const releaseLease = this.leaseBuild(spec.agentId, resolved.launch.label?.buildId);
|
|
615
969
|
const startup = spec.agentId === 'qoder' && spec.delegationMcp
|
|
616
970
|
? await phase('prepare-qoder', () => prepareQoderStartup(resolved.launch, spec.delegationMcp, spec.cwd, spec.env, spec.agentEnv))
|
|
617
971
|
: undefined;
|
|
618
972
|
if (startup)
|
|
619
973
|
resolved = { launch: startup.launch, source: resolved.source };
|
|
620
|
-
|
|
621
|
-
const permissions = createDeferredPermissionSink();
|
|
622
|
-
let connection;
|
|
974
|
+
let session;
|
|
623
975
|
try {
|
|
624
|
-
|
|
625
|
-
...(startup ? { stderrSecrets: startup.secrets,
|
|
626
|
-
onProcessExit: () => { void startup.dispose().catch(() => undefined); } } : {}),
|
|
627
|
-
// Both registrations go in one hook: `ClientApp.connect()` copies its handler table once, so
|
|
628
|
-
// this is the only moment either of them can be installed (see `acpClient.ts`'s
|
|
629
|
-
// `ConnectAgentOptions.configure`).
|
|
630
|
-
//
|
|
631
|
-
// The capture goes FIRST, and the order is load-bearing rather than stylistic. The SDK awaits
|
|
632
|
-
// every handler in registration order (`jsonrpc.js`'s `processIncomingMessage`), so each one
|
|
633
|
-
// ahead of the capture costs it a microtask hop before it ever sees a `session/update` — while
|
|
634
|
-
// the response that ends the same `session/load` / `session/prompt` is applied synchronously
|
|
635
|
-
// off the reader. Registering the capture behind the three permission handlers is what made
|
|
636
|
-
// that gap wide enough to observe (measured: a turn's trailing deltas arriving after its own
|
|
637
|
-
// `stop`). `settleUpdates` is the actual fix — this is defence in depth, and it is not a
|
|
638
|
-
// substitute: nothing here forces the capture to stay first.
|
|
639
|
-
//
|
|
640
|
-
// ## Prerequisite for anything added to this hook: **every handler registered here must be
|
|
641
|
-
// ## synchronous with respect to `session/update`**
|
|
642
|
-
//
|
|
643
|
-
// This is where that property is established, so this is where it is written down —
|
|
644
|
-
// `settleUpdates` (`adoption.ts`) depends on it and cannot check it. It waits one macrotask
|
|
645
|
-
// boundary, which drains a chain of *synchronous* handlers' microtask continuations and
|
|
646
|
-
// nothing more; a handler that awaits a real macrotask (a timer, a file read, a subprocess,
|
|
647
|
-
// an HTTP call) before returning `Handled.no` moves the rest of the chain past that boundary
|
|
648
|
-
// and the wait ends with **zero** updates delivered — measured, and silent apart from
|
|
649
|
-
// `settleUpdates`' own maxRounds diagnostic, which this failure does not even trip.
|
|
650
|
-
//
|
|
651
|
-
// Both current registrations satisfy it. `capture.register` is a plain synchronous
|
|
652
|
-
// `onNotification` body. The permission handlers do block on a human answer, but only for
|
|
653
|
-
// `session/request_permission` and `elicitation/*`; for a `session/update` they fall through
|
|
654
|
-
// without awaiting anything real. Anything added here later has to clear the same bar — if it
|
|
655
|
-
// genuinely needs to await, it must do that work *after* handing the message on, not before.
|
|
656
|
-
configure: (app) => {
|
|
657
|
-
capture.register(app);
|
|
658
|
-
// **No `present` here, and that is a decision rather than an omission.** A session
|
|
659
|
-
// connection lives for hours; a `url` elicitation raised on one is not a sign-in the reader
|
|
660
|
-
// asked for, and there is no login for a card on screen to belong to. So
|
|
661
|
-
// `handleCreateElicitation` declines it rather than accepting into a void, which is an
|
|
662
|
-
// answer the agent can act on. The one connection that *does* wire both callbacks is the
|
|
663
|
-
// throwaway one {@link authenticate} opens — see {@link connectFor}. The stderr half of the
|
|
664
|
-
// same rule is enforced by the compiler: `ConnectAgentOptions.present` is typed `never`, so
|
|
665
|
-
// this call's sibling option cannot arm the sniffer on a session either.
|
|
666
|
-
//
|
|
667
|
-
// `dismiss` is a no-op for the same reason: nothing was presented, so nothing can be
|
|
668
|
-
// retracted, and an agent that sends `elicitation/complete` anyway must not crash the
|
|
669
|
-
// connection over it.
|
|
670
|
-
registerPermissionHandlers(app, permissions.sink, { dismiss: () => { } });
|
|
671
|
-
},
|
|
672
|
-
}));
|
|
976
|
+
session = await phase('initialize', () => this.startProcessorSession(resolved, spec, startup, installed));
|
|
673
977
|
}
|
|
674
978
|
catch (error) {
|
|
675
979
|
await startup?.dispose();
|
|
@@ -677,28 +981,28 @@ export class AcpAgents {
|
|
|
677
981
|
throw new Error('Managed Qoder startup could not initialize');
|
|
678
982
|
throw error;
|
|
679
983
|
}
|
|
680
|
-
|
|
984
|
+
finally {
|
|
985
|
+
releaseLease();
|
|
986
|
+
}
|
|
987
|
+
const facts = session.initializeFacts;
|
|
988
|
+
this.observe(spec.agentId, observationOfFacts(facts));
|
|
681
989
|
try {
|
|
682
|
-
if (startup && (
|
|
990
|
+
if (startup && (session.agentInfo?.name !== 'qoder-cli' || session.agentInfo.version !== QODER_MANAGED_VERSION)) {
|
|
683
991
|
throw new Error('Managed Qoder runtime identity is not certified');
|
|
684
992
|
}
|
|
685
993
|
// Qoder 1.1.61 accepts HTTP MCP at startup, but rejects that entry in ACP new/load.
|
|
686
994
|
const sessionSpec = { ...spec, managedHistoryResumeRequired: !!spec.delegationMcp || spec.managedHistoryResumeRequired,
|
|
687
995
|
...(startup ? { delegationMcp: undefined, requireSessionLoad: spec.requireSessionLoad || !!spec.nativeSessionId } : {}) };
|
|
688
|
-
const operation = spec.nativeSessionId &&
|
|
689
|
-
const started = await phase(operation, () => withDeadline(this.startSession(
|
|
996
|
+
const operation = spec.nativeSessionId && facts.capabilities.loadSession ? 'session/load' : 'session/new';
|
|
997
|
+
const started = await phase(operation, () => withDeadline(this.startSession(session, facts, sessionSpec), this.sessionStartTimeoutMs, `Agent '${spec.agentId}' ${operation}`, AgentSessionStartTimeoutError));
|
|
690
998
|
// Only here, never before: the agent accepted a session, which is the one piece of evidence
|
|
691
999
|
// ACP offers that it is not waiting on credentials. Recording it up at `initialize` time
|
|
692
1000
|
// would claim authentication for an agent about to reject `session/new` with auth_required.
|
|
693
|
-
this.observe(spec.agentId, { ...
|
|
694
|
-
|
|
695
|
-
return {
|
|
696
|
-
session: hostedSession(started.session, connection, started.history, started.configOptions, startup?.dispose),
|
|
697
|
-
nativeSessionId: started.nativeSessionId,
|
|
698
|
-
};
|
|
1001
|
+
this.observe(spec.agentId, { ...observationOfFacts(facts), authenticated: true });
|
|
1002
|
+
return { session, nativeSessionId: started.nativeSessionId };
|
|
699
1003
|
}
|
|
700
1004
|
catch (error) {
|
|
701
|
-
await
|
|
1005
|
+
await session.close('auto').catch(() => undefined);
|
|
702
1006
|
await startup?.dispose();
|
|
703
1007
|
// Marked, not merely rethrown: everything above this `try` failed before the agent said
|
|
704
1008
|
// anything, and only a failure from here on can be an `auth_required`. See
|
|
@@ -706,6 +1010,1175 @@ export class AcpAgents {
|
|
|
706
1010
|
throw new AgentRefusedSessionError(error, startup ? 'Managed Qoder session could not open' : undefined);
|
|
707
1011
|
}
|
|
708
1012
|
}
|
|
1013
|
+
/**
|
|
1014
|
+
* Spawn the adapter behind a {@link ProcessorSession} and wait for its `initialize` (spec §5.3: keeper-acp, or the local
|
|
1015
|
+
* fallback when keeper-acp cannot be reached — §5.7). The spawn spec carries the **complete** environment (keeper-acp never
|
|
1016
|
+
* reads its own), the daemon's working directory (where `connectAgent` started adapters), a managed startup's secrets for
|
|
1017
|
+
* stderr redaction and its temporary files to remove on exit; the label is opaque to the keeper.
|
|
1018
|
+
*/
|
|
1019
|
+
async startProcessorSession(resolved, spec, startup, installOutcome) {
|
|
1020
|
+
const { spawn, label: baseLabel, launch } = this.spawnSpecOf(resolved, spec, startup);
|
|
1021
|
+
const ref = spec.sessionRef;
|
|
1022
|
+
const sessionKey = ref ?? `open-${randomUUID()}`;
|
|
1023
|
+
// A ref whose earlier life ran in the daemon (the local fallback, or a migrated legacy journal) and left frames the server has
|
|
1024
|
+
// not applied continues there for this life, so they are resent from where they are; a drained one is released, its watermark
|
|
1025
|
+
// kept as the floor. Either way the processor numbers after everything that ref ever held (spec §5.5).
|
|
1026
|
+
const resumeFrom = spec.seqBase?.() ?? 0;
|
|
1027
|
+
const local = await this.localLeftover(sessionKey);
|
|
1028
|
+
let seqBase = Math.max(resumeFrom, local?.lastSeq ?? 0);
|
|
1029
|
+
const stayLocal = local !== undefined && local.lastSeq > resumeFrom && local.unacknowledged;
|
|
1030
|
+
if (local && !stayLocal)
|
|
1031
|
+
await this.localTransport().release(sessionKey).catch(() => undefined);
|
|
1032
|
+
// Sessions an earlier daemon left in keeper-acp are discovered (and adopted) before keeper-acp spawns anything for this one.
|
|
1033
|
+
if (this.transports.keeper)
|
|
1034
|
+
await this.adoptKeeperSessions();
|
|
1035
|
+
// keeper-acp's key for the ref: the ref itself, or the key its last switch moved it to (Task 24).
|
|
1036
|
+
const keyGen = ref !== undefined ? this.refKeys.get(ref) ?? 0 : 0;
|
|
1037
|
+
const keeperKey = ref !== undefined ? sessionKeyFor(ref, keyGen) : sessionKey;
|
|
1038
|
+
// A generation that could not be listed may run THIS ref's adapter — only possible when the ref already has a keeper-acp session
|
|
1039
|
+
// directory (keys are deterministic, the data dir is shared): reopening it into a new spawn would stop that adapter, so such an
|
|
1040
|
+
// open is refused until the generation is listed (the manager retried right before this frame). A brand-new ref spawns as usual:
|
|
1041
|
+
// keeper-acp itself refuses (`foreign`) a directory another live keeper holds. (final review finding 3, correction)
|
|
1042
|
+
if (this.transports.keeper && !stayLocal && ref !== undefined && this.keeperAdoptionPending()) {
|
|
1043
|
+
const candidates = [...new Set([ref, keeperKey])];
|
|
1044
|
+
let existing = true;
|
|
1045
|
+
try {
|
|
1046
|
+
existing = (await Promise.all(candidates.map((key) => this.generations?.hasSessionDir?.(key) ?? Promise.resolve(true)))).some(Boolean);
|
|
1047
|
+
}
|
|
1048
|
+
catch {
|
|
1049
|
+
existing = true;
|
|
1050
|
+
}
|
|
1051
|
+
if (existing) {
|
|
1052
|
+
throw new Error(`keeper-acp (generation ${[...this.unlistedGens].join(', ')}) is not answering; this session may still be running there. Try again shortly.`);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
const options = {
|
|
1056
|
+
spawn, seqBase, backfill: spec.backfill === true,
|
|
1057
|
+
...(installOutcome ? { installOutcome } : {}),
|
|
1058
|
+
launch,
|
|
1059
|
+
...(startup ? { onClosed: () => startup.dispose() } : {}),
|
|
1060
|
+
};
|
|
1061
|
+
let session;
|
|
1062
|
+
if (this.transports.keeper && !stayLocal) {
|
|
1063
|
+
try {
|
|
1064
|
+
// A kept session of this ref in an older generation that still runs (it hosts other adapters): the new spawn goes into it,
|
|
1065
|
+
// there — its raw log and outbox continue as any reused session's; nothing is moved or released. Moving it to the current
|
|
1066
|
+
// generation is §6.3 switching (Task 24). Every other open goes to the current generation (fix round 1, I-2/I-3).
|
|
1067
|
+
const target = await this.spawnTargetFor(keeperKey);
|
|
1068
|
+
// Spec §5.5.6 (Task 24): a session the adoption could not continue still runs its adapter — this open is its switch: the
|
|
1069
|
+
// process is closed first (keeper-acp refuses a second spawn into a running session), then the new one loads.
|
|
1070
|
+
const unrecoverable = this.unrecoverable.get(keeperKey);
|
|
1071
|
+
if (unrecoverable) {
|
|
1072
|
+
await target.transport.terminate(keeperKey).catch((error) => {
|
|
1073
|
+
console.warn(`[acp] stopping the unrecoverable session ${keeperKey} before its reopen failed: ${errorMessage(error)}`);
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
this.ownKeeperKeys.add(keeperKey);
|
|
1077
|
+
if (target.gen !== undefined)
|
|
1078
|
+
this.heldOnOlder.set(keeperKey, target.gen);
|
|
1079
|
+
else
|
|
1080
|
+
this.heldOnOlder.delete(keeperKey); // a reopen hold on an older generation that is gone now
|
|
1081
|
+
try {
|
|
1082
|
+
session = await ProcessorSession.start({ ...options, sessionKey: keeperKey, transport: target.transport,
|
|
1083
|
+
label: { ...baseLabel, ...(keyGen > 0 ? { keyGen } : {}) },
|
|
1084
|
+
onDegrade: this.recordDegraded(keeperKey, target.gen ?? 'current'),
|
|
1085
|
+
...(target.gen !== undefined ? { onClosed: () => this.olderSessionClosed(keeperKey, options.onClosed) } : {}) });
|
|
1086
|
+
}
|
|
1087
|
+
catch (error) {
|
|
1088
|
+
this.heldOnOlder.delete(keeperKey);
|
|
1089
|
+
throw error;
|
|
1090
|
+
}
|
|
1091
|
+
this.unrecoverable.delete(keeperKey);
|
|
1092
|
+
if (target.gen === undefined)
|
|
1093
|
+
this.keeperOwner.delete(keeperKey);
|
|
1094
|
+
this.keeperHeads.delete(keeperKey);
|
|
1095
|
+
}
|
|
1096
|
+
catch (error) {
|
|
1097
|
+
if (!(error instanceof AcpKeeperUnavailableError))
|
|
1098
|
+
throw error;
|
|
1099
|
+
console.warn(`[acp] keeper-acp is unavailable (${errorMessage(error)}); running agent '${spec.agentId}' in the daemon`);
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
if (!session) {
|
|
1103
|
+
// keeper-acp holds an earlier life of this ref this daemon cannot read now: its unacknowledged frames may never reach the
|
|
1104
|
+
// server. Number above everything it held (as far as the last listing knew), and say that output may be missing (Task 21).
|
|
1105
|
+
const keeperHead = stayLocal ? undefined : this.keeperHeads.get(keeperKey);
|
|
1106
|
+
const gap = keeperHead !== undefined && this.keeperOwner.has(keeperKey);
|
|
1107
|
+
if (gap)
|
|
1108
|
+
seqBase = Math.max(seqBase, keeperHead);
|
|
1109
|
+
session = await ProcessorSession.start({ ...options, sessionKey, label: baseLabel, seqBase, transport: this.localTransport(),
|
|
1110
|
+
...(gap ? { outputGap: `keeper-acp, which holds this session's earlier output (through seq ${keeperHead}), cannot be reached` } : {}) });
|
|
1111
|
+
}
|
|
1112
|
+
try {
|
|
1113
|
+
await session.initialized(this.initializeTimeoutMs);
|
|
1114
|
+
}
|
|
1115
|
+
catch (error) {
|
|
1116
|
+
await session.close('auto').catch(() => undefined);
|
|
1117
|
+
throw error;
|
|
1118
|
+
}
|
|
1119
|
+
return session;
|
|
1120
|
+
}
|
|
1121
|
+
/**
|
|
1122
|
+
* The spawn spec, label and launch diagnostics of a launch (spec §5.3): the **complete** environment (keeper-acp never reads its
|
|
1123
|
+
* own), the daemon's working directory (where `connectAgent` started adapters), a managed startup's secrets for stderr redaction and
|
|
1124
|
+
* its temporary files to remove on exit; the label is opaque to the keeper.
|
|
1125
|
+
*/
|
|
1126
|
+
spawnSpecOf(resolved, spec, startup) {
|
|
1127
|
+
const { launch } = resolved;
|
|
1128
|
+
const env = completeEnvironment(withPathPrefix({ ...process.env, ...launch.env, ...spec.env, ...spec.agentEnv }, launch.pathPrefix));
|
|
1129
|
+
const cwd = process.cwd();
|
|
1130
|
+
// keeper-acp takes at most ACP_STDERR_SECRETS_MAX; a managed startup's own secrets come first.
|
|
1131
|
+
const allSecrets = [...(startup?.secrets ?? []), ...overlayEnvSecrets(spec)];
|
|
1132
|
+
if (allSecrets.length > ACP_STDERR_SECRETS_MAX) {
|
|
1133
|
+
console.warn(`[acp] agent '${spec.agentId}': ${allSecrets.length - ACP_STDERR_SECRETS_MAX} of ${allSecrets.length} env values ` +
|
|
1134
|
+
`are not redacted from its stderr (keeper-acp takes at most ${ACP_STDERR_SECRETS_MAX})`);
|
|
1135
|
+
}
|
|
1136
|
+
const stderrSecrets = allSecrets.slice(0, ACP_STDERR_SECRETS_MAX);
|
|
1137
|
+
const spawn = {
|
|
1138
|
+
command: launch.command, args: [...launch.args], env, cwd,
|
|
1139
|
+
...(stderrSecrets.length > 0 ? { stderrSecrets } : {}),
|
|
1140
|
+
...(startup ? { removeOnExit: [startup.dir] } : {}),
|
|
1141
|
+
};
|
|
1142
|
+
const label = {
|
|
1143
|
+
sessionRef: spec.sessionRef ?? null, agentId: spec.agentId, agentKey: spec.agentKey ?? null,
|
|
1144
|
+
buildId: launch.label?.buildId ?? null, version: launch.label?.version ?? null, kind: launch.label?.kind ?? null,
|
|
1145
|
+
specDigest: createHash('sha256').update(JSON.stringify([spawn.command, spawn.args, spawn.cwd, Object.keys(env).sort()])).digest('hex').slice(0, 16),
|
|
1146
|
+
};
|
|
1147
|
+
return {
|
|
1148
|
+
spawn, label,
|
|
1149
|
+
launch: {
|
|
1150
|
+
source: resolved.source, platform: process.platform, architecture: process.arch, transport: 'stdio',
|
|
1151
|
+
executable: (findOnPath(launch.command, probeEnvironment(env)) ?? launch.command).slice(0, 4096),
|
|
1152
|
+
},
|
|
1153
|
+
};
|
|
1154
|
+
}
|
|
1155
|
+
/**
|
|
1156
|
+
* The daemon's once-per-process adoption (Task 21, spec §5.5): every keeper-acp generation that runs (and the current one when
|
|
1157
|
+
* session directories wait on disk for it) is listed; each session whose adapter still runs is adopted where it is
|
|
1158
|
+
* ({@link ProcessorSession.adopt}) — its {@link KeeperAdoption.session} resolves with the rebuilt session, or `undefined` when it
|
|
1159
|
+
* could not be continued (its adapter is stopped then, its outbox kept). Sessions whose adapter is gone stay leftovers (offered as
|
|
1160
|
+
* orphans, resent on a reopen). Resolves once the listing is done; rebuilding goes on behind it. Never rejects.
|
|
1161
|
+
*/
|
|
1162
|
+
adoptKeeperSessions() {
|
|
1163
|
+
this.adoption ??= this.discoverKeeperSessions().catch((error) => {
|
|
1164
|
+
console.warn(`[acp] adopting keeper-acp sessions failed: ${errorMessage(error)}`);
|
|
1165
|
+
return [];
|
|
1166
|
+
});
|
|
1167
|
+
return this.adoption;
|
|
1168
|
+
}
|
|
1169
|
+
async discoverKeeperSessions() {
|
|
1170
|
+
const generations = this.generations;
|
|
1171
|
+
if (!generations || !this.transports.keeper)
|
|
1172
|
+
return [];
|
|
1173
|
+
const current = await generations.current();
|
|
1174
|
+
const live = await generations.live();
|
|
1175
|
+
const gens = live.includes(current) || !await generations.hasSessionData() ? live : [...live, current];
|
|
1176
|
+
return this.adoptFrom(gens, current);
|
|
1177
|
+
}
|
|
1178
|
+
/**
|
|
1179
|
+
* Whether a keeper-acp generation could not be listed yet (see {@link unlistedGens}): the manager then retries the adoption
|
|
1180
|
+
* ({@link retryKeeperAdoption}) before it handles a frame, offers its inventory or polls. Synchronous.
|
|
1181
|
+
*/
|
|
1182
|
+
keeperAdoptionPending() { return this.unlistedGens.size > 0; }
|
|
1183
|
+
/**
|
|
1184
|
+
* Final review finding 3: lists again the keeper-acp generations whose listing failed (at the daemon's start or a retry since) and
|
|
1185
|
+
* adopts their sessions exactly as the start-time adoption does; each adoption is returned once, here. A generation that stopped
|
|
1186
|
+
* meanwhile is dropped (the current generation recovers its directories). One retry at a time; never rejects.
|
|
1187
|
+
*/
|
|
1188
|
+
retryKeeperAdoption() {
|
|
1189
|
+
if (!this.adoption || this.unlistedGens.size === 0)
|
|
1190
|
+
return Promise.resolve([]);
|
|
1191
|
+
this.retrying ??= (async () => {
|
|
1192
|
+
await this.adoption;
|
|
1193
|
+
const generations = this.generations;
|
|
1194
|
+
if (!generations || this.unlistedGens.size === 0)
|
|
1195
|
+
return [];
|
|
1196
|
+
const current = await generations.current();
|
|
1197
|
+
const live = new Set(await generations.live());
|
|
1198
|
+
for (const gen of [...this.unlistedGens])
|
|
1199
|
+
if (gen !== current && !live.has(gen))
|
|
1200
|
+
this.unlistedGens.delete(gen);
|
|
1201
|
+
return this.adoptFrom([...this.unlistedGens], current);
|
|
1202
|
+
})().catch((error) => {
|
|
1203
|
+
console.warn(`[acp] retrying the adoption of keeper-acp sessions failed: ${errorMessage(error)}`);
|
|
1204
|
+
return [];
|
|
1205
|
+
}).finally(() => { this.retrying = undefined; });
|
|
1206
|
+
return this.retrying;
|
|
1207
|
+
}
|
|
1208
|
+
/** Lists `gens` and adopts what they hold (see {@link adoptKeeperSessions}); a generation that cannot be listed is remembered. */
|
|
1209
|
+
async adoptFrom(gens, current) {
|
|
1210
|
+
const generations = this.generations;
|
|
1211
|
+
const listed = [];
|
|
1212
|
+
for (const gen of gens) {
|
|
1213
|
+
let transport;
|
|
1214
|
+
let sessions;
|
|
1215
|
+
try {
|
|
1216
|
+
transport = await generations.transportFor(gen);
|
|
1217
|
+
sessions = await withDeadline(transport.list(), this.leftoverListTimeoutMs, `keeper-acp ${gen} list`, Error);
|
|
1218
|
+
}
|
|
1219
|
+
catch (error) {
|
|
1220
|
+
// keeper-acp of the current build could not be launched or reached: the local fallback serves this daemon (spec §5.7) — it is
|
|
1221
|
+
// not a generation whose running sessions wait to be adopted (final review finding 3, correction).
|
|
1222
|
+
if (gen === current && error instanceof AcpKeeperUnavailableError) {
|
|
1223
|
+
console.warn(`[acp] keeper-acp (generation ${gen}) is unavailable; its kept sessions are not adopted: ${errorMessage(error)}`);
|
|
1224
|
+
continue;
|
|
1225
|
+
}
|
|
1226
|
+
this.unlistedGens.add(gen);
|
|
1227
|
+
console.warn(`[acp] keeper-acp generation ${gen} could not be listed; its sessions are adopted once it answers: ${errorMessage(error)}`);
|
|
1228
|
+
continue;
|
|
1229
|
+
}
|
|
1230
|
+
this.unlistedGens.delete(gen);
|
|
1231
|
+
for (const summary of sessions)
|
|
1232
|
+
if (!this.ownKeeperKeys.has(summary.sessionKey))
|
|
1233
|
+
listed.push({ gen, transport, summary });
|
|
1234
|
+
}
|
|
1235
|
+
const found = [];
|
|
1236
|
+
const queue = [];
|
|
1237
|
+
for (const { gen, transport, summary } of await this.withoutSuperseded(listed)) {
|
|
1238
|
+
const key = summary.sessionKey;
|
|
1239
|
+
this.noteKeeperSession(gen, summary);
|
|
1240
|
+
const running = summary.process.state === 'running' || summary.process.state === 'starting';
|
|
1241
|
+
// An exited adapter's session is looked at too (Task 23, spec §5.3): its raw tail may hold a running turn's real end, which is
|
|
1242
|
+
// folded and delivered before the session is reported dead — when there is nothing to finish, `adoptOne` leaves it alone.
|
|
1243
|
+
const exitedWithRaw = summary.process.state === 'exited' && summary.raw !== null;
|
|
1244
|
+
if (!running && !exitedWithRaw)
|
|
1245
|
+
continue;
|
|
1246
|
+
const ref = refOfSession(summary);
|
|
1247
|
+
if (!running && ref === undefined)
|
|
1248
|
+
continue;
|
|
1249
|
+
if (ref === undefined) {
|
|
1250
|
+
// No ref the server could reopen it under: nobody will ever drive it.
|
|
1251
|
+
console.warn(`[acp] keeper-acp session ${key} names no session ref; stopping its adapter (its outbox is kept)`);
|
|
1252
|
+
void transport.terminate(key).catch(() => null);
|
|
1253
|
+
continue;
|
|
1254
|
+
}
|
|
1255
|
+
let settle;
|
|
1256
|
+
const session = new Promise((resolve) => { settle = resolve; });
|
|
1257
|
+
queue.push(async () => { settle(await this.adoptOne(transport, gen, gen === current, summary, !running)); });
|
|
1258
|
+
found.push({
|
|
1259
|
+
sessionRef: ref, agentId: String(summary.label.agentId ?? ''), agentKey: String(summary.label.agentKey ?? ''),
|
|
1260
|
+
journalMaxSeq: this.keeperHeads.get(key) ?? 0, ...(running ? {} : { agentAlive: false }), session,
|
|
1261
|
+
});
|
|
1262
|
+
}
|
|
1263
|
+
// A few at a time: each rebuild reads a checkpoint and up to one cadence of raw.
|
|
1264
|
+
const worker = async () => { for (let next = queue.shift(); next; next = queue.shift())
|
|
1265
|
+
await next(); };
|
|
1266
|
+
void Promise.all(Array.from({ length: ADOPT_CONCURRENCY }, worker)).then(() => this.retireIdleGenerations());
|
|
1267
|
+
return found;
|
|
1268
|
+
}
|
|
1269
|
+
/**
|
|
1270
|
+
* Task 24: one keeper-acp session per ref. Two exist only when a switch was interrupted by a crash (the successor `<ref>#s<n>` is
|
|
1271
|
+
* spawned, and loaded, while the predecessor still exists; the predecessor is released last): `resolveSuperseded` keeps the newest
|
|
1272
|
+
* key whose open completed — its import of the predecessor's outbox had landed — or, when none did, the oldest. The others are
|
|
1273
|
+
* stopped and released. Every listed ref's current key generation is recorded.
|
|
1274
|
+
*/
|
|
1275
|
+
async withoutSuperseded(listed) {
|
|
1276
|
+
const byRef = new Map();
|
|
1277
|
+
const kept = [];
|
|
1278
|
+
for (const entry of listed) {
|
|
1279
|
+
const ref = refOfSession(entry.summary);
|
|
1280
|
+
if (ref === undefined) {
|
|
1281
|
+
kept.push(entry);
|
|
1282
|
+
continue;
|
|
1283
|
+
}
|
|
1284
|
+
byRef.set(ref, [...(byRef.get(ref) ?? []), entry]);
|
|
1285
|
+
}
|
|
1286
|
+
for (const [ref, entries] of byRef) {
|
|
1287
|
+
let winner = entries[0];
|
|
1288
|
+
if (entries.length > 1) {
|
|
1289
|
+
const resolved = await resolveSuperseded(entries.map((entry) => ({ ...entry, sessionKey: entry.summary.sessionKey, label: entry.summary.label })), (entry) => this.openCompleted(entry.transport, entry.summary.sessionKey));
|
|
1290
|
+
winner = entries.find((entry) => entry.summary.sessionKey === resolved.winner.sessionKey);
|
|
1291
|
+
for (const gone of resolved.superseded) {
|
|
1292
|
+
console.warn(`[acp] ${ref}: keeper-acp session ${gone.sessionKey} (generation ${gone.gen}) was superseded by ${winner.summary.sessionKey} ` +
|
|
1293
|
+
'(a switch a restart interrupted); stopping and releasing it');
|
|
1294
|
+
await gone.transport.terminate(gone.sessionKey).catch(() => null);
|
|
1295
|
+
await gone.transport.release(gone.sessionKey).catch((error) => {
|
|
1296
|
+
console.warn(`[acp] releasing the superseded session ${gone.sessionKey} failed: ${errorMessage(error)}`);
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
this.refKeys.set(ref, keyGenOf(winner.summary.label));
|
|
1301
|
+
kept.push(winner);
|
|
1302
|
+
}
|
|
1303
|
+
return kept;
|
|
1304
|
+
}
|
|
1305
|
+
/** Whether a keeper-acp session's head checkpoint says its open had completed (no load history open). */
|
|
1306
|
+
async openCompleted(transport, sessionKey) {
|
|
1307
|
+
try {
|
|
1308
|
+
const attached = await transport.attach(sessionKey);
|
|
1309
|
+
if (!attached.checkpoint)
|
|
1310
|
+
return false;
|
|
1311
|
+
// A successor the switch gave up on (its rollback took the session back) never wins, whatever its checkpoint says.
|
|
1312
|
+
if (await this.switchAbandoned(transport, sessionKey, attached))
|
|
1313
|
+
return false;
|
|
1314
|
+
const checkpoint = decodeCheckpoint(Buffer.from(attached.checkpoint.blob, 'base64').toString('utf8'));
|
|
1315
|
+
return checkpoint.state.open.status === 'open' && checkpoint.state.history === null;
|
|
1316
|
+
}
|
|
1317
|
+
catch {
|
|
1318
|
+
return false;
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
noteKeeperSession(gen, summary) {
|
|
1322
|
+
this.keeperOwner.set(summary.sessionKey, gen);
|
|
1323
|
+
const tail = isOutboxTail(summary.outbox.committedTail) ? summary.outbox.committedTail.seq : 0;
|
|
1324
|
+
this.keeperHeads.set(summary.sessionKey, Math.max(summary.outbox.lastSeq ?? 0, tail));
|
|
1325
|
+
const ref = refOfSession(summary);
|
|
1326
|
+
if (ref !== undefined && keyGenOf(summary.label) >= (this.refKeys.get(ref) ?? 0))
|
|
1327
|
+
this.refKeys.set(ref, keyGenOf(summary.label));
|
|
1328
|
+
}
|
|
1329
|
+
async adoptOne(transport, gen, current, summary, exited = false) {
|
|
1330
|
+
const key = summary.sessionKey;
|
|
1331
|
+
try {
|
|
1332
|
+
if (!current)
|
|
1333
|
+
this.heldOnOlder.set(key, gen);
|
|
1334
|
+
const options = { transport, sessionKey: key, label: summary.label, onDegrade: this.recordDegraded(key, gen),
|
|
1335
|
+
// An older generation that no longer hosts a session of this daemon (or a running adapter) is stopped once this one is gone.
|
|
1336
|
+
...(current ? {} : { onClosed: () => this.olderSessionClosed(key) }) };
|
|
1337
|
+
const session = exited ? await ProcessorSession.adopt({ ...options, allowExited: true }) : await ProcessorSession.adopt(options);
|
|
1338
|
+
if (!session) {
|
|
1339
|
+
// An exited adapter with no turn left to finish: nothing to hold; it stays a leftover for its ref's next open.
|
|
1340
|
+
this.heldOnOlder.delete(key);
|
|
1341
|
+
return undefined;
|
|
1342
|
+
}
|
|
1343
|
+
// The owner of an older generation's session is kept: a reopen after its adapter exits spawns there (fix round 1, I-2).
|
|
1344
|
+
if (current)
|
|
1345
|
+
this.keeperOwner.delete(key);
|
|
1346
|
+
return session;
|
|
1347
|
+
}
|
|
1348
|
+
catch (error) {
|
|
1349
|
+
this.heldOnOlder.delete(key);
|
|
1350
|
+
if (exited) {
|
|
1351
|
+
// Its adapter had already exited: there is nothing to stop. Its outbox stays for its ref's next open (which loads it).
|
|
1352
|
+
console.warn(`[acp] keeper-acp session ${key} (generation ${gen}, adapter exited) could not be adopted` +
|
|
1353
|
+
`${error instanceof AdoptionRefusedError ? '' : ' (keeper-acp failed)'}: ${errorMessage(error)}; keeping its outbox`);
|
|
1354
|
+
return undefined;
|
|
1355
|
+
}
|
|
1356
|
+
// Spec §5.5.6 (Task 24 replaces Task 21's interim stop): a session this daemon cannot continue — its open never completed, or
|
|
1357
|
+
// its checkpoint is unreadable and raw truncated — is marked degraded and left running; the ref's next `agent_open` switches it
|
|
1358
|
+
// (closes the process, then a new one loads the native session). Until then it is offered as an orphan; its outbox is kept.
|
|
1359
|
+
const reason = errorMessage(error);
|
|
1360
|
+
console.warn(`[acp] keeper-acp session ${key} (generation ${gen}) could not be adopted` +
|
|
1361
|
+
`${error instanceof AdoptionRefusedError ? '' : ' (keeper-acp failed)'}: ${reason}; marked degraded — its next open switches it`);
|
|
1362
|
+
this.unrecoverable.set(key, { gen, reason });
|
|
1363
|
+
this.keeperOwner.set(key, gen);
|
|
1364
|
+
return undefined;
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
/** Where a keeper spawn of `sessionKey` goes: the older generation that still holds a kept session of it, else the current one. */
|
|
1368
|
+
async spawnTargetFor(sessionKey) {
|
|
1369
|
+
const generations = this.generations;
|
|
1370
|
+
const owner = this.keeperOwner.get(sessionKey);
|
|
1371
|
+
const current = this.transports.keeper;
|
|
1372
|
+
if (!generations || owner === undefined || owner === await generations.current())
|
|
1373
|
+
return { transport: current };
|
|
1374
|
+
if (!(await generations.live()).includes(owner))
|
|
1375
|
+
return { transport: current }; // gone: the current one adopts the directory
|
|
1376
|
+
return { transport: await generations.transportFor(owner), gen: owner };
|
|
1377
|
+
}
|
|
1378
|
+
/**
|
|
1379
|
+
* The manager retires a session to respawn it at once (an `agent_open` of a keeper session whose adapter is gone, Task 21 fix
|
|
1380
|
+
* round 2): until the returned release, the older generation that holds it is not stopped — the retire's close no longer releases
|
|
1381
|
+
* the hold, the respawn's spawn takes it over (or drops it when it goes elsewhere or fails). Release after the attempt, whatever
|
|
1382
|
+
* it did.
|
|
1383
|
+
*/
|
|
1384
|
+
holdForReopen(sessionRef) {
|
|
1385
|
+
return this.holdKey(this.keyFor(sessionRef));
|
|
1386
|
+
}
|
|
1387
|
+
/** {@link holdForReopen} by key (a switch holds its predecessor's generation until it is released or rolled back to). */
|
|
1388
|
+
holdKey(sessionKey) {
|
|
1389
|
+
this.reopenHolds.add(sessionKey);
|
|
1390
|
+
let released = false;
|
|
1391
|
+
return () => {
|
|
1392
|
+
if (released)
|
|
1393
|
+
return;
|
|
1394
|
+
released = true;
|
|
1395
|
+
this.reopenHolds.delete(sessionKey);
|
|
1396
|
+
void this.retireIdleGenerations();
|
|
1397
|
+
};
|
|
1398
|
+
}
|
|
1399
|
+
// ---- switching (Task 24, spec §6.3) ---------------------------------------------------------------------------------------
|
|
1400
|
+
/** A ref's current keeper-acp key: `<ref>`, or `<ref>#s<n>` once a switch moved it (`switching.ts`). */
|
|
1401
|
+
keyFor(sessionRef) {
|
|
1402
|
+
return sessionKeyFor(sessionRef, this.refKeys.get(sessionRef) ?? 0);
|
|
1403
|
+
}
|
|
1404
|
+
/**
|
|
1405
|
+
* Where a held session runs (Task 24/25): its transport key, the keeper-acp generation hosting it (`undefined`: the current one, or
|
|
1406
|
+
* the local fallback), its build (`undefined`: a command override) and whether keeper-acp hosts it at all.
|
|
1407
|
+
*/
|
|
1408
|
+
placementOf(session) {
|
|
1409
|
+
if (!(session instanceof ProcessorSession))
|
|
1410
|
+
return undefined;
|
|
1411
|
+
const label = session.label;
|
|
1412
|
+
const gen = this.heldOnOlder.get(session.sessionKey);
|
|
1413
|
+
return {
|
|
1414
|
+
sessionKey: session.sessionKey, keeper: session.transportMode === 'keeper',
|
|
1415
|
+
...(gen !== undefined ? { gen } : {}),
|
|
1416
|
+
...(typeof label.agentId === 'string' ? { agentId: label.agentId } : {}),
|
|
1417
|
+
...(typeof label.buildId === 'string' ? { buildId: label.buildId } : {}),
|
|
1418
|
+
};
|
|
1419
|
+
}
|
|
1420
|
+
/**
|
|
1421
|
+
* Move a live keeper-hosted session onto a new adapter process (spec §6.3): the target build (`toBuild`, else the agent's current
|
|
1422
|
+
* one — a command override keeps its command) on the **current** keeper-acp generation, under a new key, build before teardown.
|
|
1423
|
+
* Called only at an `agent_open` (its `spec` carries the session's fresh spawn environment) and only for a strictly idle session
|
|
1424
|
+
* (the caller checks; this only holds the loop at a record boundary). Steps:
|
|
1425
|
+
*
|
|
1426
|
+
* 1. lease both builds; hold the predecessor's generation; hold the predecessor's loop ({@link ProcessorSession.suspend}) — its
|
|
1427
|
+
* checkpoint is the successor's initial state;
|
|
1428
|
+
* 2. spawn the successor `<ref>#s<n>` with `baseOffset` past the predecessor's raw log, the predecessor's unacknowledged outbox
|
|
1429
|
+
* entries imported first, and `initialize` it — it must advertise `loadSession`, or it is stopped and released and the
|
|
1430
|
+
* predecessor goes on untouched (`unmovable`);
|
|
1431
|
+
* 3. only now stop the predecessor's adapter (its transport session — raw, outbox — stays), then `session/load` on the successor
|
|
1432
|
+
* with `requireSessionLoad` **forced** whatever the `agent_open` said (never `session/new`), and set back every config option the
|
|
1433
|
+
* load did not restore;
|
|
1434
|
+
* 4. success: release the predecessor (the import landed in step 2) — `switched`; failure: stop and release the successor, spawn
|
|
1435
|
+
* the predecessor's build again in the predecessor's key and load there (`rolledBack`), or, when that fails too, `lost`.
|
|
1436
|
+
*
|
|
1437
|
+
* Runs under the agent's build lock (install / switch / GC are serialised per agent). Never rejects.
|
|
1438
|
+
*/
|
|
1439
|
+
async switchSession(from, spec, request) {
|
|
1440
|
+
const ref = spec.sessionRef;
|
|
1441
|
+
const nativeSessionId = (from instanceof ProcessorSession ? from.nativeSessionId : null) ?? spec.nativeSessionId;
|
|
1442
|
+
const refuse = (reason, permanent = true) => ({ kind: 'unmovable', reason, permanent });
|
|
1443
|
+
let outcome;
|
|
1444
|
+
if (!(from instanceof ProcessorSession) || from.transportMode !== 'keeper' || !this.transports.keeper) {
|
|
1445
|
+
outcome = refuse('only a keeper-hosted session can be moved');
|
|
1446
|
+
}
|
|
1447
|
+
else if (ref === undefined) {
|
|
1448
|
+
outcome = refuse('the session has no ref');
|
|
1449
|
+
}
|
|
1450
|
+
else if (!nativeSessionId) {
|
|
1451
|
+
outcome = refuse('the session has no native session to load');
|
|
1452
|
+
}
|
|
1453
|
+
else if (spec.delegationMcp || from.delegation) {
|
|
1454
|
+
outcome = refuse('a managed delegation session is not moved');
|
|
1455
|
+
}
|
|
1456
|
+
else {
|
|
1457
|
+
outcome = await this.builds.withAgentLock(spec.agentId, () => this.switchLocked(from, { ...spec, sessionRef: ref, nativeSessionId }, request))
|
|
1458
|
+
.catch((error) => refuse(`the switch failed before anything was stopped: ${errorMessage(error)}`, false));
|
|
1459
|
+
}
|
|
1460
|
+
if (ref !== undefined) {
|
|
1461
|
+
// The build the refusal was about, resolved (Task 25): a later "move" to a different build may try again.
|
|
1462
|
+
const refusedFor = async () => request.toBuild ?? (await this.builds.agent(spec.agentId).catch(() => undefined))?.current;
|
|
1463
|
+
if (outcome.kind === 'unmovable' && outcome.permanent) {
|
|
1464
|
+
const toBuild = await refusedFor();
|
|
1465
|
+
this.unmovableRefs.set(ref, { reason: outcome.reason, ...(toBuild ? { toBuild } : {}) });
|
|
1466
|
+
}
|
|
1467
|
+
else if (outcome.kind === 'rolledBack' && !outcome.retry) {
|
|
1468
|
+
const toBuild = await refusedFor();
|
|
1469
|
+
this.unmovableRefs.set(ref, { reason: outcome.error, ...(toBuild ? { toBuild } : {}) });
|
|
1470
|
+
}
|
|
1471
|
+
else if (outcome.kind === 'switched')
|
|
1472
|
+
this.unmovableRefs.delete(ref);
|
|
1473
|
+
// Fulfilled, or never possible: the intent goes. A transient refusal keeps it for the next idle moment.
|
|
1474
|
+
if (!(outcome.kind === 'unmovable' && !outcome.permanent) && !(outcome.kind === 'rolledBack' && outcome.retry)) {
|
|
1475
|
+
await this.cancelSwitch({ sessionRef: ref }).catch(() => false);
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
const what = outcome.kind === 'switched' ? `moved to ${outcome.toKey}` : outcome.kind === 'unmovable' ? `not moved: ${outcome.reason}`
|
|
1479
|
+
: outcome.kind === 'rolledBack' ? `rolled back: ${outcome.error}` : `lost: ${outcome.error}`;
|
|
1480
|
+
console.info(`[acp] switch of ${ref ?? '?'} (agent '${spec.agentId}', ${request.reason}): ${what}`);
|
|
1481
|
+
if (outcome.kind === 'switched')
|
|
1482
|
+
void this.collectBuilds().catch(() => undefined);
|
|
1483
|
+
return outcome;
|
|
1484
|
+
}
|
|
1485
|
+
async switchLocked(from, spec, request) {
|
|
1486
|
+
const ref = spec.sessionRef;
|
|
1487
|
+
const agentId = spec.agentId;
|
|
1488
|
+
const oldKey = from.sessionKey;
|
|
1489
|
+
const oldGen = this.heldOnOlder.get(oldKey);
|
|
1490
|
+
const oldTransport = oldGen === undefined ? this.transports.keeper : await this.generations.transportFor(oldGen);
|
|
1491
|
+
const current = this.transports.keeper;
|
|
1492
|
+
const fromBuildId = typeof from.label.buildId === 'string' ? from.label.buildId : undefined;
|
|
1493
|
+
let resolved;
|
|
1494
|
+
let toBuildId;
|
|
1495
|
+
try {
|
|
1496
|
+
if (spec.command) {
|
|
1497
|
+
resolved = { launch: { command: spec.command, args: spec.args, env: {} }, source: 'command-override' };
|
|
1498
|
+
}
|
|
1499
|
+
else {
|
|
1500
|
+
toBuildId = request.toBuild ?? (await this.builds.agent(agentId))?.current;
|
|
1501
|
+
if (toBuildId === undefined)
|
|
1502
|
+
return { kind: 'unmovable', reason: `agent '${agentId}' has no current build`, permanent: true };
|
|
1503
|
+
resolved = await this.launchOfBuild(agentId, toBuildId);
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
catch (error) {
|
|
1507
|
+
return { kind: 'unmovable', reason: errorMessage(error), permanent: true };
|
|
1508
|
+
}
|
|
1509
|
+
const releases = [this.leaseBuild(agentId, fromBuildId), this.leaseBuild(agentId, toBuildId), this.holdKey(oldKey)];
|
|
1510
|
+
try {
|
|
1511
|
+
const configBefore = from.configOptions.map((option) => ({ ...option }));
|
|
1512
|
+
let suspendTimer;
|
|
1513
|
+
const checkpoint = await Promise.race([
|
|
1514
|
+
from.suspend(),
|
|
1515
|
+
new Promise((resolve) => { suspendTimer = setTimeout(() => resolve(undefined), SWITCH_SUSPEND_TIMEOUT_MS); }),
|
|
1516
|
+
]).finally(() => clearTimeout(suspendTimer));
|
|
1517
|
+
if (!checkpoint) {
|
|
1518
|
+
from.resume();
|
|
1519
|
+
return { kind: 'unmovable', reason: 'its record loop could not be held at a boundary', permanent: false };
|
|
1520
|
+
}
|
|
1521
|
+
// 2. The successor, under a fresh key on the current generation.
|
|
1522
|
+
const taken = new Set((await current.list()).map((summary) => summary.sessionKey));
|
|
1523
|
+
let keyGen = Math.max(keyGenOf(from.label), this.refKeys.get(ref) ?? 0) + 1;
|
|
1524
|
+
while (taken.has(sessionKeyFor(ref, keyGen)))
|
|
1525
|
+
keyGen += 1;
|
|
1526
|
+
const newKey = sessionKeyFor(ref, keyGen);
|
|
1527
|
+
const oldRaw = (await oldTransport.list()).find((summary) => summary.sessionKey === oldKey)?.raw;
|
|
1528
|
+
const baseOffset = Math.max(checkpoint.offset + 1, oldRaw?.next ?? 0);
|
|
1529
|
+
const imports = from.outbox.pending.map((item) => ({ seq: item.seq, frame: item.frame }));
|
|
1530
|
+
let next;
|
|
1531
|
+
try {
|
|
1532
|
+
next = await this.startPlaced(resolved, spec, { transport: current, sessionKey: newKey, keyGen, seqBase: from.headSeq, switchedFrom: oldKey,
|
|
1533
|
+
seed: { checkpoint, baseOffset, imports } });
|
|
1534
|
+
}
|
|
1535
|
+
catch (error) {
|
|
1536
|
+
await current.terminate(newKey).catch(() => null);
|
|
1537
|
+
await current.release(newKey).catch(() => undefined);
|
|
1538
|
+
from.resume();
|
|
1539
|
+
return { kind: 'unmovable', reason: `the new adapter process did not start: ${errorMessage(error)}`, permanent: false };
|
|
1540
|
+
}
|
|
1541
|
+
const facts = next.initializeFacts;
|
|
1542
|
+
if (!facts.capabilities.loadSession) {
|
|
1543
|
+
// Build before teardown: the predecessor was never touched.
|
|
1544
|
+
await next.close('release').catch(() => undefined);
|
|
1545
|
+
from.resume();
|
|
1546
|
+
return { kind: 'unmovable', reason: `${facts.agentInfo?.name ?? agentId} ${facts.agentInfo?.version ?? ''} does not support session/load`.replace(/ +/g, ' '), permanent: true };
|
|
1547
|
+
}
|
|
1548
|
+
if (request.proceed && !request.proceed()) {
|
|
1549
|
+
await next.close('release').catch(() => undefined);
|
|
1550
|
+
from.resume();
|
|
1551
|
+
return { kind: 'unmovable', reason: 'the workstation is stopping', permanent: false };
|
|
1552
|
+
}
|
|
1553
|
+
// The predecessor's adapter wrote something while its loop was held (fix round 1): only the predecessor can fold it, and its
|
|
1554
|
+
// log goes with it. Not now — the predecessor folds it, the intent stays, the next idle moment tries again. Also looked up in
|
|
1555
|
+
// keeper-acp itself: an output record already appended but not yet delivered counts the same. Only adapter output (`up`)
|
|
1556
|
+
// counts (fix round 2); stderr does not hold a switch back.
|
|
1557
|
+
if (from.arrivedWhileSuspended > 0 || await this.outputPast(oldTransport, oldKey, checkpoint.offset)) {
|
|
1558
|
+
await next.close('release').catch(() => undefined);
|
|
1559
|
+
from.resume();
|
|
1560
|
+
return { kind: 'unmovable', reason: 'its adapter produced output while the switch was being prepared', permanent: false };
|
|
1561
|
+
}
|
|
1562
|
+
// 3. Only now: the predecessor's adapter stops (its raw and outbox stay, for a rollback) — with its loop still held.
|
|
1563
|
+
await from.stopAdapter();
|
|
1564
|
+
// Fix round 2: keeper-acp records what the adapter still wrote on its way out (after the check above, or on SIGTERM) into the
|
|
1565
|
+
// predecessor's raw. Only the predecessor can fold it — under its own seqs, which the successor has not used yet (it numbered
|
|
1566
|
+
// nothing it recorded: it is not registered) — so the switch is given up here: the successor is abandoned, the predecessor
|
|
1567
|
+
// folds its whole tail (output, then its exit) on its loop, and is restarted and loaded by the rollback. Nothing is lost and the
|
|
1568
|
+
// intent stays for a later idle moment.
|
|
1569
|
+
if (await this.outputPast(oldTransport, oldKey, checkpoint.offset)) {
|
|
1570
|
+
await this.abandonSuccessor(current, newKey, next, 'the previous adapter wrote output while it was being stopped');
|
|
1571
|
+
const tailEnd = ((await oldTransport.list()).find((summary) => summary.sessionKey === oldKey)?.raw?.next ?? checkpoint.offset + 1) - 1;
|
|
1572
|
+
from.resume();
|
|
1573
|
+
await from.foldedThrough(tailEnd).catch((error) => {
|
|
1574
|
+
console.warn(`[acp] ${oldKey}: folding the stopped adapter's last output failed: ${errorMessage(error)}`);
|
|
1575
|
+
});
|
|
1576
|
+
await from.close('keep');
|
|
1577
|
+
const back = await this.rollBack(from, spec, { oldKey, oldGen, oldTransport, fromBuildId, configBefore,
|
|
1578
|
+
error: 'the previous adapter wrote output while it was being stopped' });
|
|
1579
|
+
return back.kind === 'rolledBack' ? { ...back, retry: true } : back;
|
|
1580
|
+
}
|
|
1581
|
+
await from.close('keep');
|
|
1582
|
+
try {
|
|
1583
|
+
await this.loadInto(next, spec, configBefore);
|
|
1584
|
+
}
|
|
1585
|
+
catch (error) {
|
|
1586
|
+
await this.abandonSuccessor(current, newKey, next, errorMessage(error));
|
|
1587
|
+
return this.rollBack(from, spec, { oldKey, oldGen, oldTransport, fromBuildId, configBefore, error: errorMessage(error) });
|
|
1588
|
+
}
|
|
1589
|
+
// 4. The successor holds the session and the predecessor's unacknowledged frames (imported before the load): release it.
|
|
1590
|
+
this.refKeys.set(ref, keyGen);
|
|
1591
|
+
this.heldOnOlder.delete(oldKey);
|
|
1592
|
+
this.keeperOwner.delete(oldKey);
|
|
1593
|
+
this.keeperHeads.delete(oldKey);
|
|
1594
|
+
this.unrecoverable.delete(oldKey);
|
|
1595
|
+
await oldTransport.release(oldKey).catch((error) => {
|
|
1596
|
+
console.warn(`[acp] releasing ${oldKey} after its switch to ${newKey} failed (the next daemon drops it as superseded): ${errorMessage(error)}`);
|
|
1597
|
+
});
|
|
1598
|
+
return { kind: 'switched', session: next, nativeSessionId: spec.nativeSessionId, fromKey: oldKey, toKey: newKey };
|
|
1599
|
+
}
|
|
1600
|
+
finally {
|
|
1601
|
+
for (const release of releases)
|
|
1602
|
+
release();
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
/**
|
|
1606
|
+
* A successor that will not hold the session (its load failed, or it failed after loading — fix round 1): marked abandoned in its
|
|
1607
|
+
* own log FIRST (`switch_abandoned` note — a crash before the release must not let the next daemon's discovery pick it over the
|
|
1608
|
+
* rolled-back predecessor, which then holds every newer frame), then stopped and released, retried, and loudly reported when it
|
|
1609
|
+
* stays.
|
|
1610
|
+
*/
|
|
1611
|
+
async abandonSuccessor(transport, key, session, reason) {
|
|
1612
|
+
await transport.note(key, { type: SWITCH_ABANDONED_NOTE, reason: reason.slice(0, 1024) }).catch((error) => {
|
|
1613
|
+
console.error(`[acp] marking the abandoned switch successor ${key} failed: ${errorMessage(error)}`);
|
|
1614
|
+
});
|
|
1615
|
+
await session.close('release').catch(() => undefined);
|
|
1616
|
+
let last;
|
|
1617
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
1618
|
+
let still;
|
|
1619
|
+
try {
|
|
1620
|
+
still = (await transport.list()).some((summary) => summary.sessionKey === key);
|
|
1621
|
+
}
|
|
1622
|
+
catch (error) {
|
|
1623
|
+
last = error;
|
|
1624
|
+
still = true;
|
|
1625
|
+
}
|
|
1626
|
+
if (!still)
|
|
1627
|
+
return;
|
|
1628
|
+
if (attempt > 0)
|
|
1629
|
+
await new Promise((resolve) => setTimeout(resolve, 250 * attempt));
|
|
1630
|
+
await transport.terminate(key).catch(() => null);
|
|
1631
|
+
await transport.release(key).catch((error) => { last = error; });
|
|
1632
|
+
}
|
|
1633
|
+
console.error(`[acp] the abandoned switch successor ${key} could not be released (${last instanceof Error ? last.message : String(last)}); ` +
|
|
1634
|
+
'it is marked abandoned, so the next daemon drops it in favour of its predecessor');
|
|
1635
|
+
}
|
|
1636
|
+
/** Whether raw holds adapter output (`up`) past `offset` (the predecessor's checkpoint): output only it can fold (fix round 2). */
|
|
1637
|
+
async outputPast(transport, sessionKey, offset) {
|
|
1638
|
+
for (let at = offset + 1;;) {
|
|
1639
|
+
let page;
|
|
1640
|
+
try {
|
|
1641
|
+
page = await transport.readRaw(sessionKey, at);
|
|
1642
|
+
}
|
|
1643
|
+
catch {
|
|
1644
|
+
return true;
|
|
1645
|
+
} // cannot tell: do not risk it
|
|
1646
|
+
if (page.length === 0)
|
|
1647
|
+
return false;
|
|
1648
|
+
if (page.some((record) => record.kind === 'up'))
|
|
1649
|
+
return true;
|
|
1650
|
+
at = page[page.length - 1].o + 1;
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
/** Whether a switch successor was marked abandoned (fix round 1): its log holds a `switch_abandoned` note. */
|
|
1654
|
+
async switchAbandoned(transport, sessionKey, attached) {
|
|
1655
|
+
for (let at = attached.raw?.first ?? 0; at <= attached.head;) {
|
|
1656
|
+
let page;
|
|
1657
|
+
try {
|
|
1658
|
+
page = await transport.readRaw(sessionKey, at);
|
|
1659
|
+
}
|
|
1660
|
+
catch {
|
|
1661
|
+
return false;
|
|
1662
|
+
}
|
|
1663
|
+
if (page.length === 0)
|
|
1664
|
+
return false;
|
|
1665
|
+
if (page.some((record) => record.kind === 'note' && record.note.type === SWITCH_ABANDONED_NOTE))
|
|
1666
|
+
return true;
|
|
1667
|
+
at = page[page.length - 1].o + 1;
|
|
1668
|
+
}
|
|
1669
|
+
return false;
|
|
1670
|
+
}
|
|
1671
|
+
/** Step 6: the predecessor's build in the predecessor's key, loaded again; `lost` when that fails too (never `session/new`). */
|
|
1672
|
+
async rollBack(from, spec, old) {
|
|
1673
|
+
let back;
|
|
1674
|
+
try {
|
|
1675
|
+
const resolved = spec.command
|
|
1676
|
+
? { launch: { command: spec.command, args: spec.args, env: {} }, source: 'command-override' }
|
|
1677
|
+
: old.fromBuildId !== undefined ? await this.launchOfBuild(spec.agentId, old.fromBuildId)
|
|
1678
|
+
: (() => { throw new Error('the session\'s build is unknown'); })();
|
|
1679
|
+
back = await this.startPlaced(resolved, spec, { transport: old.oldTransport, sessionKey: old.oldKey, keyGen: keyGenOf(from.label),
|
|
1680
|
+
seqBase: from.headSeq, ...(old.oldGen !== undefined ? { gen: old.oldGen } : {}) });
|
|
1681
|
+
if (!back.initializeFacts.capabilities.loadSession)
|
|
1682
|
+
throw new Error('the previous build no longer advertises session/load');
|
|
1683
|
+
await this.loadInto(back, spec, old.configBefore);
|
|
1684
|
+
return { kind: 'rolledBack', session: back, nativeSessionId: spec.nativeSessionId, error: old.error };
|
|
1685
|
+
}
|
|
1686
|
+
catch (error) {
|
|
1687
|
+
await back?.close('keep').catch(() => undefined);
|
|
1688
|
+
if (old.oldGen !== undefined)
|
|
1689
|
+
this.heldOnOlder.delete(old.oldKey);
|
|
1690
|
+
return { kind: 'lost', error: `the new adapter could not load the session (${old.error}), and neither could the previous one (${errorMessage(error)})` };
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
/** `session/load` with `requireSessionLoad` forced (spec §5.5.4/§6.3: never `session/new`), then the config options set back. */
|
|
1694
|
+
async loadInto(session, spec, configBefore) {
|
|
1695
|
+
const facts = session.initializeFacts;
|
|
1696
|
+
await withDeadline(this.startSession(session, facts, { ...spec, requireSessionLoad: true, backfill: false }), this.sessionStartTimeoutMs, `Agent '${spec.agentId}' session/load`, AgentSessionStartTimeoutError);
|
|
1697
|
+
if (session.nativeSessionId !== spec.nativeSessionId)
|
|
1698
|
+
throw new Error(`session/load bound '${session.nativeSessionId}', not '${spec.nativeSessionId}'`);
|
|
1699
|
+
await restoreConfigOptions(session, configBefore, () => `switch-${randomUUID()}`, (line) => console.warn(line));
|
|
1700
|
+
}
|
|
1701
|
+
/** A session spawned where the caller says (a switch's successor, or its rollback), `initialize`d. */
|
|
1702
|
+
async startPlaced(resolved, spec, placement) {
|
|
1703
|
+
const { spawn, label, launch } = this.spawnSpecOf(resolved, spec);
|
|
1704
|
+
const key = placement.sessionKey;
|
|
1705
|
+
this.ownKeeperKeys.add(key);
|
|
1706
|
+
if (placement.gen !== undefined)
|
|
1707
|
+
this.heldOnOlder.set(key, placement.gen);
|
|
1708
|
+
const session = await ProcessorSession.start({
|
|
1709
|
+
spawn, launch, seqBase: placement.seqBase, backfill: false, transport: placement.transport, sessionKey: key,
|
|
1710
|
+
label: { ...label, ...(placement.keyGen > 0 ? { keyGen: placement.keyGen } : {}), ...(placement.switchedFrom ? { switchedFrom: placement.switchedFrom } : {}) },
|
|
1711
|
+
...(placement.seed ? { seed: placement.seed } : {}),
|
|
1712
|
+
onDegrade: this.recordDegraded(key, placement.gen ?? 'current'),
|
|
1713
|
+
...(placement.gen !== undefined ? { onClosed: () => this.olderSessionClosed(key) } : {}),
|
|
1714
|
+
});
|
|
1715
|
+
try {
|
|
1716
|
+
await session.initialized(this.initializeTimeoutMs);
|
|
1717
|
+
}
|
|
1718
|
+
catch (error) {
|
|
1719
|
+
await session.close(placement.seed ? 'release' : 'auto').catch(() => undefined);
|
|
1720
|
+
throw error;
|
|
1721
|
+
}
|
|
1722
|
+
return session;
|
|
1723
|
+
}
|
|
1724
|
+
/** The launch of one recorded build (not necessarily the current one), through the same Node gate and launch env as any open. */
|
|
1725
|
+
async launchOfBuild(agentId, buildId) {
|
|
1726
|
+
const build = (await this.builds.agent(agentId))?.builds[buildId];
|
|
1727
|
+
if (!build || !existsSync(inside(this.adapterRoot, build.dir)))
|
|
1728
|
+
throw new AdapterNotInstalledError(agentId, `build ${buildId} is not installed`);
|
|
1729
|
+
this.touchBuild(agentId, buildId);
|
|
1730
|
+
return {
|
|
1731
|
+
source: 'registry',
|
|
1732
|
+
launch: await launchForBuild(build, this.adapterRoot, {
|
|
1733
|
+
nodePath: this.nodePath, launchEnv: catalogLaunchEnv(agentId, build.registryVersion), basePath: this.probe.path,
|
|
1734
|
+
}),
|
|
1735
|
+
};
|
|
1736
|
+
}
|
|
1737
|
+
// ---- switch intents (spec §6.3 step 7) ------------------------------------------------------------------------------------
|
|
1738
|
+
/** The intents, read from `builds.json` once; every write below updates this copy too. */
|
|
1739
|
+
loadIntents() {
|
|
1740
|
+
this.intents ??= this.builds.switchIntents().then((all) => new Map(all.map(({ agentId, sessionRef, intent }) => [sessionRef, { agentId, intent }])), (error) => { this.intents = undefined; throw error; });
|
|
1741
|
+
return this.intents;
|
|
1742
|
+
}
|
|
1743
|
+
/** Every persistent switch intent (`builds.json` `switchPending`). */
|
|
1744
|
+
async switchIntents() {
|
|
1745
|
+
return [...(await this.loadIntents())].map(([sessionRef, { agentId, intent }]) => ({ agentId, sessionRef, intent }));
|
|
1746
|
+
}
|
|
1747
|
+
/**
|
|
1748
|
+
* Record that `sessionRef` (a session of `agentId`) should move — to `toBuild`, else the agent's current build at the time, and to
|
|
1749
|
+
* the current keeper-acp generation (`keeperGen: 'current'` alone: only off an older generation). Persistent: survives a restart.
|
|
1750
|
+
*/
|
|
1751
|
+
async requestSwitch(agentId, sessionRef, intent) {
|
|
1752
|
+
const record = {
|
|
1753
|
+
...(intent.toBuild !== undefined ? { toBuild: intent.toBuild } : {}), ...(intent.keeperGen ? { keeperGen: intent.keeperGen } : {}),
|
|
1754
|
+
reason: intent.reason, requestedBy: intent.requestedBy ?? 'user', requestedAt: new Date().toISOString(),
|
|
1755
|
+
};
|
|
1756
|
+
const intents = await this.loadIntents();
|
|
1757
|
+
if (!await this.builds.setSwitchIntent(agentId, sessionRef, record))
|
|
1758
|
+
throw new Error(`${this.builds.file} could not be written`);
|
|
1759
|
+
intents.set(sessionRef, { agentId, intent: record });
|
|
1760
|
+
this.unmovableRefs.delete(sessionRef);
|
|
1761
|
+
}
|
|
1762
|
+
/** Cancel the intent of one ref, or every intent of one agent. `true` when one existed. */
|
|
1763
|
+
async cancelSwitch(target) {
|
|
1764
|
+
const intents = await this.loadIntents();
|
|
1765
|
+
const refs = 'sessionRef' in target ? [target.sessionRef]
|
|
1766
|
+
: [...intents].filter(([, entry]) => entry.agentId === target.agentId).map(([ref]) => ref);
|
|
1767
|
+
if (!refs.some((ref) => intents.has(ref)))
|
|
1768
|
+
return false;
|
|
1769
|
+
const removed = await this.builds.cancelSwitchIntents(target);
|
|
1770
|
+
for (const ref of refs)
|
|
1771
|
+
intents.delete(ref);
|
|
1772
|
+
return removed;
|
|
1773
|
+
}
|
|
1774
|
+
/** Why `sessionRef` cannot be moved (spec §6.4 `unmovable`), if a switch of it found so. */
|
|
1775
|
+
unmovableReason(sessionRef) {
|
|
1776
|
+
return this.unmovableRefs.get(sessionRef)?.reason;
|
|
1777
|
+
}
|
|
1778
|
+
/** {@link unmovableReason} with the build the refusal was about (`toBuild`; absent when it could not be resolved). */
|
|
1779
|
+
unmovableFor(sessionRef) {
|
|
1780
|
+
return this.unmovableRefs.get(sessionRef);
|
|
1781
|
+
}
|
|
1782
|
+
/**
|
|
1783
|
+
* The switch a held session still owes its intent, or `undefined` (no intent, or it is fulfilled — then the intent is dropped).
|
|
1784
|
+
* A build intent is fulfilled once the session runs the target build; a keeper-generation intent once it runs on the current
|
|
1785
|
+
* generation. A command-override session only ever moves generations.
|
|
1786
|
+
*/
|
|
1787
|
+
async pendingSwitchFor(sessionRef, session) {
|
|
1788
|
+
const found = (await this.loadIntents()).get(sessionRef);
|
|
1789
|
+
if (!found)
|
|
1790
|
+
return undefined;
|
|
1791
|
+
const placement = this.placementOf(session);
|
|
1792
|
+
if (!placement?.keeper)
|
|
1793
|
+
return undefined;
|
|
1794
|
+
const { intent, agentId } = found;
|
|
1795
|
+
const onOlder = placement.gen !== undefined;
|
|
1796
|
+
const target = intent.keeperGen === 'current' && intent.toBuild === undefined ? undefined
|
|
1797
|
+
: intent.toBuild ?? (await this.builds.agent(agentId))?.current;
|
|
1798
|
+
const needsBuild = target !== undefined && placement.buildId !== undefined && placement.buildId !== target;
|
|
1799
|
+
const needsGen = intent.keeperGen === 'current' && onOlder;
|
|
1800
|
+
if (!needsBuild && !needsGen) {
|
|
1801
|
+
await this.cancelSwitch({ sessionRef }).catch(() => false);
|
|
1802
|
+
return undefined;
|
|
1803
|
+
}
|
|
1804
|
+
return { ...(needsBuild ? { toBuild: target } : {}), reason: intent.reason };
|
|
1805
|
+
}
|
|
1806
|
+
// ---- build leases and GC (spec §6.3 "GC") ----------------------------------------------------------------------------------
|
|
1807
|
+
/** A daemon lease on a build (GC never takes it while held); the release also marks it seen now. */
|
|
1808
|
+
leaseBuild(agentId, buildId) {
|
|
1809
|
+
if (buildId === undefined)
|
|
1810
|
+
return () => { };
|
|
1811
|
+
const key = buildKeyOf(agentId, buildId);
|
|
1812
|
+
this.leases.set(key, (this.leases.get(key) ?? 0) + 1);
|
|
1813
|
+
this.touchBuild(agentId, buildId);
|
|
1814
|
+
let released = false;
|
|
1815
|
+
return () => {
|
|
1816
|
+
if (released)
|
|
1817
|
+
return;
|
|
1818
|
+
released = true;
|
|
1819
|
+
const left = (this.leases.get(key) ?? 1) - 1;
|
|
1820
|
+
if (left > 0)
|
|
1821
|
+
this.leases.set(key, left);
|
|
1822
|
+
else
|
|
1823
|
+
this.leases.delete(key);
|
|
1824
|
+
this.touchBuild(agentId, buildId, Date.now());
|
|
1825
|
+
};
|
|
1826
|
+
}
|
|
1827
|
+
/**
|
|
1828
|
+
* A build was just resolved for a launch: protected until any connection started from it has necessarily ended (a login's ceiling)
|
|
1829
|
+
* — the lease of the short-lived connections (verify, list, delete, login), which end on their own.
|
|
1830
|
+
*/
|
|
1831
|
+
touchBuild(agentId, buildId, until = Date.now() + AGENT_LOGIN_CEILING_MS) {
|
|
1832
|
+
const key = buildKeyOf(agentId, buildId);
|
|
1833
|
+
this.buildSeen.set(key, Math.max(this.buildSeen.get(key) ?? 0, until));
|
|
1834
|
+
}
|
|
1835
|
+
/**
|
|
1836
|
+
* The builds adapter processes run right now: every keeper-acp generation that runs, listed (a generation that cannot be listed →
|
|
1837
|
+
* `undefined`: nothing may be collected), plus the local fallback's processes.
|
|
1838
|
+
*/
|
|
1839
|
+
async runningBuildReferences() {
|
|
1840
|
+
const listed = await this.listRunningAdapters();
|
|
1841
|
+
if (!listed)
|
|
1842
|
+
return undefined;
|
|
1843
|
+
const refs = [];
|
|
1844
|
+
for (const { summary } of listed) {
|
|
1845
|
+
const { agentId, buildId } = summary.label;
|
|
1846
|
+
if (typeof agentId === 'string' && typeof buildId === 'string')
|
|
1847
|
+
refs.push({ agentId, buildId });
|
|
1848
|
+
}
|
|
1849
|
+
return refs;
|
|
1850
|
+
}
|
|
1851
|
+
/**
|
|
1852
|
+
* Collect unused adapter builds (spec §6.3; `builds/gc.ts` has the rules) and what an older CLI left. Never while keeper-acp cannot
|
|
1853
|
+
* be listed. One at a time; never rejects (failures are logged).
|
|
1854
|
+
*/
|
|
1855
|
+
collectBuilds(options = {}) {
|
|
1856
|
+
const previous = this.collecting;
|
|
1857
|
+
let run;
|
|
1858
|
+
run = (async () => {
|
|
1859
|
+
await previous?.catch(() => undefined);
|
|
1860
|
+
try {
|
|
1861
|
+
const running = await this.runningBuildReferences();
|
|
1862
|
+
const leased = [...this.leases.keys()].map((key) => { const [agentId, buildId] = key.split('\u0000'); return { agentId: agentId, buildId: buildId }; });
|
|
1863
|
+
// A pending switch's target build is leased until the switch ran (fix round 1): GC must not take it first.
|
|
1864
|
+
for (const { agentId, intent } of await this.switchIntents().catch(() => [])) {
|
|
1865
|
+
if (intent.toBuild !== undefined)
|
|
1866
|
+
leased.push({ agentId, buildId: intent.toBuild });
|
|
1867
|
+
}
|
|
1868
|
+
const report = await collectBuilds({ store: this.builds, running, leased, lastSeen: this.buildSeen, seenByDefault: this.startedAt,
|
|
1869
|
+
now: options.now ?? Date.now(), ...(options.graceMs !== undefined ? { graceMs: options.graceMs } : {}) });
|
|
1870
|
+
if (report.skipped) {
|
|
1871
|
+
console.info(`[acp-builds] GC skipped: ${report.skipped}`);
|
|
1872
|
+
return report;
|
|
1873
|
+
}
|
|
1874
|
+
const legacyRemoved = await collectLegacy({ store: this.builds, commandLines: await processCommandLines() });
|
|
1875
|
+
return { ...report, legacyRemoved };
|
|
1876
|
+
}
|
|
1877
|
+
catch (error) {
|
|
1878
|
+
console.warn(`[acp-builds] GC failed: ${errorMessage(error)}`);
|
|
1879
|
+
return undefined;
|
|
1880
|
+
}
|
|
1881
|
+
finally {
|
|
1882
|
+
if (this.collecting === run)
|
|
1883
|
+
this.collecting = undefined;
|
|
1884
|
+
}
|
|
1885
|
+
})();
|
|
1886
|
+
this.collecting = run;
|
|
1887
|
+
return run;
|
|
1888
|
+
}
|
|
1889
|
+
/** Whether this daemon holds (or is about to respawn) a session on older generation `gen`. */
|
|
1890
|
+
holds(gen) {
|
|
1891
|
+
// A generation that could not be listed may host running adapters nobody adopted yet (final review finding 3).
|
|
1892
|
+
return this.unlistedGens.has(gen) || [...this.heldOnOlder.values()].includes(gen);
|
|
1893
|
+
}
|
|
1894
|
+
/** A session of this daemon on an older generation ended: that generation may be idle now. */
|
|
1895
|
+
olderSessionClosed(sessionKey, also) {
|
|
1896
|
+
// A reopen's retire keeps the hold: the respawn goes into the same generation right behind it (fix round 2).
|
|
1897
|
+
if (!this.reopenHolds.has(sessionKey))
|
|
1898
|
+
this.heldOnOlder.delete(sessionKey);
|
|
1899
|
+
void this.retireIdleGenerations();
|
|
1900
|
+
return also?.();
|
|
1901
|
+
}
|
|
1902
|
+
/**
|
|
1903
|
+
* A keeper-hosted session degraded (keeper-acp gone, or poisoned): its outbox head is remembered, so a later open of the ref that
|
|
1904
|
+
* has to run in the daemon numbers above it and reports the gap (fix round 1, minor 4).
|
|
1905
|
+
*/
|
|
1906
|
+
recordDegraded(sessionKey, gen) {
|
|
1907
|
+
return (headSeq) => {
|
|
1908
|
+
this.keeperHeads.set(sessionKey, Math.max(this.keeperHeads.get(sessionKey) ?? 0, headSeq));
|
|
1909
|
+
if (!this.keeperOwner.has(sessionKey))
|
|
1910
|
+
this.keeperOwner.set(sessionKey, gen);
|
|
1911
|
+
};
|
|
1912
|
+
}
|
|
1913
|
+
/**
|
|
1914
|
+
* Older keeper-acp generations that host no running adapter any more are stopped (the current one adopts what they kept). One pass
|
|
1915
|
+
* at a time (Task 24): two concurrent passes would both stop the same generation, and the second one's listing reconnects to it
|
|
1916
|
+
* while the first one asks it to stop — a pass asked for meanwhile runs once more afterwards.
|
|
1917
|
+
*/
|
|
1918
|
+
retireIdleGenerations() {
|
|
1919
|
+
if (this.retiringGenerations) {
|
|
1920
|
+
this.retireAgain = true;
|
|
1921
|
+
return this.retiringGenerations;
|
|
1922
|
+
}
|
|
1923
|
+
this.retiringGenerations = (async () => {
|
|
1924
|
+
do {
|
|
1925
|
+
this.retireAgain = false;
|
|
1926
|
+
await this.retireIdleGenerationsOnce();
|
|
1927
|
+
} while (this.retireAgain);
|
|
1928
|
+
})().finally(() => { this.retiringGenerations = undefined; });
|
|
1929
|
+
return this.retiringGenerations;
|
|
1930
|
+
}
|
|
1931
|
+
async retireIdleGenerationsOnce() {
|
|
1932
|
+
const generations = this.generations;
|
|
1933
|
+
if (!generations)
|
|
1934
|
+
return;
|
|
1935
|
+
try {
|
|
1936
|
+
const current = await generations.current();
|
|
1937
|
+
let stopped = false;
|
|
1938
|
+
for (const gen of await generations.live()) {
|
|
1939
|
+
if (gen === current)
|
|
1940
|
+
continue;
|
|
1941
|
+
// Never while a session this daemon holds (or is spawning) lives there, its adapter exited or not (fix round 1, minor 7).
|
|
1942
|
+
if (this.holds(gen))
|
|
1943
|
+
continue;
|
|
1944
|
+
// Asked again after keeper-acp's own listing: a reopen may have taken a hold meanwhile (fix round 2).
|
|
1945
|
+
if (await generations.stopIfIdle(gen, () => this.holds(gen))) {
|
|
1946
|
+
stopped = true;
|
|
1947
|
+
for (const [key, owner] of this.keeperOwner)
|
|
1948
|
+
if (owner === gen)
|
|
1949
|
+
this.keeperOwner.delete(key);
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
// The current generation adopts the kept sessions now (keeper-acp's `acp.list` adopts directories whose keeper is gone), so the
|
|
1953
|
+
// next offer lists them without waiting for its periodic adoption (fix round 1, minor 5).
|
|
1954
|
+
if (stopped && this.transports.keeper) {
|
|
1955
|
+
for (const summary of await withDeadline(this.transports.keeper.list(), this.leftoverListTimeoutMs, 'keeper-acp list', Error)) {
|
|
1956
|
+
if (!hostsRunningAdapter(summary))
|
|
1957
|
+
this.noteKeeperSession(current, summary);
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
catch (error) {
|
|
1962
|
+
console.warn(`[acp] retiring idle keeper-acp generations failed: ${errorMessage(error)}`);
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
/**
|
|
1966
|
+
* What the local transport holds under `sessionKey` from an earlier life (a fallback session's outbox, a migrated legacy journal):
|
|
1967
|
+
* the highest seq it ever held and whether frames past its watermark remain. `undefined`: nothing.
|
|
1968
|
+
*/
|
|
1969
|
+
async localLeftover(sessionKey) {
|
|
1970
|
+
let summary;
|
|
1971
|
+
try {
|
|
1972
|
+
summary = (await this.localTransport().list()).find((s) => s.sessionKey === sessionKey);
|
|
1973
|
+
}
|
|
1974
|
+
catch {
|
|
1975
|
+
return undefined;
|
|
1976
|
+
}
|
|
1977
|
+
if (!summary)
|
|
1978
|
+
return undefined;
|
|
1979
|
+
const tail = summary.outbox.committedTail;
|
|
1980
|
+
const tailSeq = typeof tail?.seq === 'number' ? tail.seq : 0;
|
|
1981
|
+
return {
|
|
1982
|
+
lastSeq: Math.max(summary.outbox.lastSeq ?? 0, tailSeq),
|
|
1983
|
+
unacknowledged: summary.outbox.next > Math.max(summary.outbox.first, summary.outbox.committed + 1),
|
|
1984
|
+
};
|
|
1985
|
+
}
|
|
1986
|
+
/**
|
|
1987
|
+
* Sessions an earlier life left in either transport that no live session holds (spec §5.5.2: the resume offer lists them as
|
|
1988
|
+
* orphans): local outboxes, and keeper-acp's sessions once this daemon may use keeper-acp (its sweep succeeded). The caller
|
|
1989
|
+
* removes the refs it holds. keeper-acp that does not answer in time contributes nothing (the offer must not wait on it).
|
|
1990
|
+
*/
|
|
1991
|
+
async leftoverSessions() {
|
|
1992
|
+
const keys = new Set();
|
|
1993
|
+
try {
|
|
1994
|
+
for (const summary of await this.localTransport().list())
|
|
1995
|
+
keys.add(summary.sessionKey);
|
|
1996
|
+
}
|
|
1997
|
+
catch (error) {
|
|
1998
|
+
console.warn(`[acp] listing local agent outboxes failed: ${errorMessage(error)}`);
|
|
1999
|
+
}
|
|
2000
|
+
if (this.transports.keeper && (this.adoption || this.ownKeeperKeys.size > 0)) {
|
|
2001
|
+
await this.adoptKeeperSessions();
|
|
2002
|
+
for (const [gen, transport] of await this.keeperTransports()) {
|
|
2003
|
+
try {
|
|
2004
|
+
for (const summary of await withDeadline(transport.list(), this.leftoverListTimeoutMs, 'keeper-acp list', Error)) {
|
|
2005
|
+
// A running adapter in a generation the adoption has not listed yet is not an orphan: it is adopted on the next retry
|
|
2006
|
+
// (final review finding 3) — offered as an orphan, the server would end its turn and reopen it into a new spawn.
|
|
2007
|
+
if (gen !== undefined && this.unlistedGens.has(gen) && hostsRunningAdapter(summary))
|
|
2008
|
+
continue;
|
|
2009
|
+
// The ref, not the key: a switched session lives under `<ref>#s<n>` (Task 24).
|
|
2010
|
+
keys.add(refOfSession(summary) ?? summary.sessionKey);
|
|
2011
|
+
if (gen !== undefined && !hostsRunningAdapter(summary))
|
|
2012
|
+
this.noteKeeperSession(gen, summary);
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
catch (error) {
|
|
2016
|
+
console.warn(`[acp] listing keeper-acp sessions${gen ? ` (generation ${gen})` : ''} for the resume offer failed: ${errorMessage(error)}`);
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
return [...keys].sort();
|
|
2021
|
+
}
|
|
2022
|
+
/**
|
|
2023
|
+
* keeper-acp's transports by generation: every running one when the generations are known (the current one also when this daemon
|
|
2024
|
+
* used it, or session directories wait for it — nothing is launched just to be listed), else the current one alone.
|
|
2025
|
+
*/
|
|
2026
|
+
async keeperTransports() {
|
|
2027
|
+
if (!this.transports.keeper)
|
|
2028
|
+
return [];
|
|
2029
|
+
const generations = this.generations;
|
|
2030
|
+
if (!generations)
|
|
2031
|
+
return [[undefined, this.transports.keeper]];
|
|
2032
|
+
try {
|
|
2033
|
+
const current = await generations.current();
|
|
2034
|
+
const gens = new Set(await generations.live());
|
|
2035
|
+
if (!gens.has(current) && (this.ownKeeperKeys.size > 0 || await generations.hasSessionData()))
|
|
2036
|
+
gens.add(current);
|
|
2037
|
+
return Promise.all([...gens].map(async (gen) => [gen, await generations.transportFor(gen)]));
|
|
2038
|
+
}
|
|
2039
|
+
catch {
|
|
2040
|
+
return [[undefined, this.transports.keeper]];
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
/** `agent_close` for a ref no live session holds: whatever an earlier life left under it goes (its adapter stopped first). */
|
|
2044
|
+
async releaseLeftover(sessionRef) {
|
|
2045
|
+
const keeper = this.transports.keeper && (this.adoption || this.ownKeeperKeys.size > 0) ? (await this.keeperTransports()).map(([, t]) => t) : [];
|
|
2046
|
+
for (const transport of [this.localTransport(), ...keeper]) {
|
|
2047
|
+
try {
|
|
2048
|
+
// Every key of the ref (`<ref>`, and `<ref>#s<n>` after a switch — Task 24).
|
|
2049
|
+
for (const summary of (await transport.list()).filter((s) => s.sessionKey === sessionRef || refOfSession(s) === sessionRef)) {
|
|
2050
|
+
const key = summary.sessionKey;
|
|
2051
|
+
this.keeperOwner.delete(key);
|
|
2052
|
+
this.keeperHeads.delete(key);
|
|
2053
|
+
this.unrecoverable.delete(key);
|
|
2054
|
+
if (summary.process.state !== 'none' && summary.process.state !== 'exited')
|
|
2055
|
+
await transport.terminate(key);
|
|
2056
|
+
await transport.release(key);
|
|
2057
|
+
}
|
|
2058
|
+
}
|
|
2059
|
+
catch (error) {
|
|
2060
|
+
console.warn(`[acp] releasing what an earlier life left for ${sessionRef} failed: ${errorMessage(error)}`);
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2063
|
+
this.refKeys.delete(sessionRef);
|
|
2064
|
+
}
|
|
2065
|
+
/**
|
|
2066
|
+
* A legacy `agents/sessions/<ref>.jsonl` (Task 20 removed the daemon journal): its entries become a local outbox under the ref,
|
|
2067
|
+
* which the resume offer lists and a reopen resends, exactly as the journal was. Done once per file; idempotent. `false`: a local
|
|
2068
|
+
* outbox for the ref already existed and was left as it is.
|
|
2069
|
+
*/
|
|
2070
|
+
async importLegacyOutbox(sessionKey, records) {
|
|
2071
|
+
const local = this.localTransport();
|
|
2072
|
+
if (!(local instanceof LocalTransport))
|
|
2073
|
+
throw new Error('The local transport cannot import an outbox');
|
|
2074
|
+
return local.importOutbox(sessionKey, records);
|
|
2075
|
+
}
|
|
2076
|
+
/** The fallback transport (spec §5.7), made on first use. */
|
|
2077
|
+
localTransport() {
|
|
2078
|
+
this.transports.local ??= new LocalTransport({ configDir: this.cacheDir });
|
|
2079
|
+
return this.transports.local;
|
|
2080
|
+
}
|
|
2081
|
+
/** Daemon shutdown: keeper-acp's connection is dropped (its adapters keep running); local adapters are stopped. */
|
|
2082
|
+
closeTransports() {
|
|
2083
|
+
this.clearQuotaReaders();
|
|
2084
|
+
this.transports.keeper?.close();
|
|
2085
|
+
this.generations?.close();
|
|
2086
|
+
this.transports.local?.close();
|
|
2087
|
+
}
|
|
2088
|
+
clearQuotaReaders(agentId) {
|
|
2089
|
+
for (const [key, reader] of this.quotaReaders)
|
|
2090
|
+
if (!agentId || reader.agentId === agentId) {
|
|
2091
|
+
this.quotaReaders.delete(key);
|
|
2092
|
+
void reader.pending.then(connection => connection.close()).catch(() => undefined);
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
/**
|
|
2096
|
+
* Continue in an already-existing native session when we have an id for one and the agent can load
|
|
2097
|
+
* it; otherwise open a new session and report the new id truthfully.
|
|
2098
|
+
*
|
|
2099
|
+
* Two things are deliberately *not* failures here. An agent that does not advertise
|
|
2100
|
+
* `loadSession` still gets a session — the manager already tolerates the id it asked for coming
|
|
2101
|
+
* back different (it warns and re-points), so refusing to open would be strictly worse than
|
|
2102
|
+
* continuing in a fresh one. A `session/load` that is advertised but rejected (a stale id the
|
|
2103
|
+
* agent has since deleted, most likely) is treated the same way, for the same reason.
|
|
2104
|
+
* Manual configuration refresh opts out of both fallbacks with `requireSessionLoad`: a failed
|
|
2105
|
+
* load must not silently replace the conversation whose settings the reader wanted to refresh.
|
|
2106
|
+
*
|
|
2107
|
+
* Every decision is the daemon's, made on the processor's `initialize` reading; each attempt is one `agent_session_open` note
|
|
2108
|
+
* and the processor's result. A load's history window is closed (`historyEnd`) as soon as its result is in — before the
|
|
2109
|
+
* manager can admit a prompt (interface-contracts: `historyEnd` before any admitted prompt). The Qoder repair goes on where
|
|
2110
|
+
* `enableBackgroundTerminalRepair` was called: after the `agent_session_open` note of a load (before its history is folded),
|
|
2111
|
+
* after the result of a new or resumed session — a resume with `restoreCalls`.
|
|
2112
|
+
*/
|
|
2113
|
+
async startSession(session, facts, spec) {
|
|
2114
|
+
const nativeSessionId = spec.nativeSessionId;
|
|
2115
|
+
const capabilities = facts.capabilities;
|
|
2116
|
+
if (nativeSessionId && spec.managedHistoryResumeRequired) {
|
|
2117
|
+
throw new Error('Managed native history replay has no certified boundary');
|
|
2118
|
+
}
|
|
2119
|
+
if (spec.delegationMcp && !capabilities.mcpHttp) {
|
|
2120
|
+
throw new Error(`Agent '${spec.agentId}' does not advertise HTTP MCP support`);
|
|
2121
|
+
}
|
|
2122
|
+
if (spec.requireSessionLoad && !nativeSessionId) {
|
|
2123
|
+
throw new Error('Refreshing settings requires an existing native session ID');
|
|
2124
|
+
}
|
|
2125
|
+
const historyHome = spec.agentEnv.HOME ?? spec.env.HOME ?? homedir();
|
|
2126
|
+
// The finished MCP list (the lease checked here, against the clock, before any note is written); the bearer is a secretRef
|
|
2127
|
+
// in the note and the down record, its value only in this daemon's memory and the adapter's stdin (spec §13).
|
|
2128
|
+
const servers = buildSessionMcpServers(spec.mcpServers, spec.delegationMcp);
|
|
2129
|
+
const redact = [];
|
|
2130
|
+
const mcpServers = servers.map((server) => {
|
|
2131
|
+
if (!('headers' in server) || !spec.delegationMcp || server.name !== 'axom-delegation')
|
|
2132
|
+
return server;
|
|
2133
|
+
return { ...server, headers: server.headers.map((header) => {
|
|
2134
|
+
const ref = session.secret(header.value);
|
|
2135
|
+
redact.push(ref);
|
|
2136
|
+
return { ...header, value: ref };
|
|
2137
|
+
}) };
|
|
2138
|
+
});
|
|
2139
|
+
const delegation = spec.delegationMcp && spec.delegation ? {
|
|
2140
|
+
expiresAtEpochMs: spec.delegationMcp.expiresAtEpochMs, leaseId: spec.delegation.leaseId,
|
|
2141
|
+
...(spec.delegation.bindingId !== undefined && spec.delegation.instanceId !== undefined
|
|
2142
|
+
? { bindingId: spec.delegation.bindingId, instanceId: spec.delegation.instanceId } : {}),
|
|
2143
|
+
} : undefined;
|
|
2144
|
+
const request = { cwd: spec.cwd, mcpServers, redact, ...(delegation ? { delegation } : {}) };
|
|
2145
|
+
const refused = (result) => (result.code !== undefined ? new RequestError(result.code, result.error) : new Error(result.error));
|
|
2146
|
+
// Qoder's load may emit old chunks after its response. Cached reconnects need context, not
|
|
2147
|
+
// another history import. Actual adoption/backfill and explicit settings loads still use load.
|
|
2148
|
+
if (nativeSessionId && certifiedRuntime(facts.agentInfo) && capabilities.resume &&
|
|
2149
|
+
!spec.backfill && !spec.requireSessionLoad && !spec.delegationMcp) {
|
|
2150
|
+
const result = await session.openSession({ ...request, mode: 'resume', sessionId: nativeSessionId });
|
|
2151
|
+
if (!result.ok)
|
|
2152
|
+
throw refused(result); // Never silently replace this cached conversation with a fresh native root.
|
|
2153
|
+
await session.enableRepair(nativeSessionId, spec.cwd, historyHome, true);
|
|
2154
|
+
return { nativeSessionId };
|
|
2155
|
+
}
|
|
2156
|
+
if (nativeSessionId) {
|
|
2157
|
+
if (!capabilities.loadSession) {
|
|
2158
|
+
if (spec.requireSessionLoad || spec.delegationMcp)
|
|
2159
|
+
throw new Error(`Agent '${spec.agentId}' does not support session/load`);
|
|
2160
|
+
console.warn(`[acp] agent '${spec.agentId}' does not support session/load; starting a new session ` +
|
|
2161
|
+
`instead of continuing '${nativeSessionId}'`);
|
|
2162
|
+
}
|
|
2163
|
+
else {
|
|
2164
|
+
const result = await session.openSession({ ...request, mode: 'load', sessionId: nativeSessionId, repair: { historyHome } });
|
|
2165
|
+
if (result.ok) {
|
|
2166
|
+
await session.endHistory();
|
|
2167
|
+
return { nativeSessionId };
|
|
2168
|
+
}
|
|
2169
|
+
if (spec.requireSessionLoad || spec.delegationMcp)
|
|
2170
|
+
throw refused(result);
|
|
2171
|
+
console.warn(`[acp] session/load for '${nativeSessionId}' on agent '${spec.agentId}' failed ` +
|
|
2172
|
+
`(${result.error}); starting a new session instead`);
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
const result = await session.openSession({ ...request, mode: 'new' });
|
|
2176
|
+
if (!result.ok)
|
|
2177
|
+
throw refused(result);
|
|
2178
|
+
if (result.nativeSessionId)
|
|
2179
|
+
await session.enableRepair(result.nativeSessionId, spec.cwd, historyHome);
|
|
2180
|
+
return { nativeSessionId: result.nativeSessionId };
|
|
2181
|
+
}
|
|
709
2182
|
/**
|
|
710
2183
|
* List one agent's native sessions for adoption, via a throwaway connection — listing happens
|
|
711
2184
|
* before any session exists, so there is nothing running to ask.
|
|
@@ -717,7 +2190,7 @@ export class AcpAgents {
|
|
|
717
2190
|
*/
|
|
718
2191
|
async listNativeSessions(spec) {
|
|
719
2192
|
const resolved = await this.resolveLaunchFor(spec.agentId, spec.command, spec.args);
|
|
720
|
-
const connection = await this.connectResolved(
|
|
2193
|
+
const connection = await this.connectResolved(resolved, spec.env, spec.agentEnv);
|
|
721
2194
|
this.observe(spec.agentId, observationOf(connection));
|
|
722
2195
|
try {
|
|
723
2196
|
const result = await listNativeSessions(connection, { cwd: spec.filterCwd });
|
|
@@ -734,7 +2207,7 @@ export class AcpAgents {
|
|
|
734
2207
|
*/
|
|
735
2208
|
async deleteNativeSessions(spec, ids) {
|
|
736
2209
|
const resolved = await this.resolveLaunchFor(spec.agentId, spec.command, spec.args);
|
|
737
|
-
const connection = await this.connectResolved(
|
|
2210
|
+
const connection = await this.connectResolved(resolved, spec.env, spec.agentEnv);
|
|
738
2211
|
this.observe(spec.agentId, observationOf(connection));
|
|
739
2212
|
try {
|
|
740
2213
|
return await deleteNativeSessions(connection, ids);
|
|
@@ -743,6 +2216,46 @@ export class AcpAgents {
|
|
|
743
2216
|
await connection.close().catch(() => undefined);
|
|
744
2217
|
}
|
|
745
2218
|
}
|
|
2219
|
+
/** Explicit quota read, called only from an admitted footer refresh. */
|
|
2220
|
+
async minimumAccountLimitsVersion(spec) {
|
|
2221
|
+
if (spec.command)
|
|
2222
|
+
return 2;
|
|
2223
|
+
const current = await this.builds.current(spec.agentId);
|
|
2224
|
+
return current ? nativeAccountLimitsVersionFor(current.agentId, current.version) : 2;
|
|
2225
|
+
}
|
|
2226
|
+
async readAccountLimits(spec) {
|
|
2227
|
+
// Readers that need interactive credential authorization retain their process-local cache.
|
|
2228
|
+
// Keeping this control connection alive performs no model calls or periodic quota queries.
|
|
2229
|
+
const cached = (await this.minimumAccountLimitsVersion(spec)) >= 3;
|
|
2230
|
+
const key = createHash('sha256').update(JSON.stringify(spec)).digest('hex');
|
|
2231
|
+
let pending = this.quotaReaders.get(key)?.pending;
|
|
2232
|
+
if (!cached || !pending) {
|
|
2233
|
+
pending = this.connectFor(spec, 30_000);
|
|
2234
|
+
if (cached) {
|
|
2235
|
+
this.quotaReaders.set(key, { agentId: spec.agentId, pending });
|
|
2236
|
+
void pending.catch(() => { if (this.quotaReaders.get(key)?.pending === pending)
|
|
2237
|
+
this.quotaReaders.delete(key); });
|
|
2238
|
+
}
|
|
2239
|
+
}
|
|
2240
|
+
const connection = await pending;
|
|
2241
|
+
try {
|
|
2242
|
+
const raw = await withDeadline(connection.connection.agent.request("_axom/account/rateLimits/read", { cwd: spec.cwd }), ACCOUNT_LIMITS_READ_TIMEOUT_MS, 'Refreshing account usage');
|
|
2243
|
+
const { accountLimitsSchema } = await import('../protocol/accountLimits.js');
|
|
2244
|
+
return accountLimitsSchema.parse(raw);
|
|
2245
|
+
}
|
|
2246
|
+
catch (error) {
|
|
2247
|
+
// A provider's RPC error can be retried with the same credential cache. A dead transport cannot.
|
|
2248
|
+
if (cached && typeof error?.code !== 'number') {
|
|
2249
|
+
this.quotaReaders.delete(key);
|
|
2250
|
+
await connection.close().catch(() => undefined);
|
|
2251
|
+
}
|
|
2252
|
+
throw error;
|
|
2253
|
+
}
|
|
2254
|
+
finally {
|
|
2255
|
+
if (!cached)
|
|
2256
|
+
await connection.close().catch(() => undefined);
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
746
2259
|
/**
|
|
747
2260
|
* Verify one agent on demand: spawn it, negotiate `initialize`, try to open a session, then throw
|
|
748
2261
|
* the whole thing away. Nothing is kept running — a verification is a question, not a resource.
|
|
@@ -765,7 +2278,8 @@ export class AcpAgents {
|
|
|
765
2278
|
* installed and would not start a session" are different answers to the user.
|
|
766
2279
|
*/
|
|
767
2280
|
async verify(spec, timeoutMs = AGENT_PROBE_TIMEOUT_MS) {
|
|
768
|
-
const
|
|
2281
|
+
const probe = createCommandTableProbe();
|
|
2282
|
+
const connection = await this.connectFor(spec, timeoutMs, undefined, probe);
|
|
769
2283
|
const authMethods = connection.authMethods;
|
|
770
2284
|
this.observe(spec.agentId, observationOf(connection));
|
|
771
2285
|
try {
|
|
@@ -773,7 +2287,7 @@ export class AcpAgents {
|
|
|
773
2287
|
let observedConfig;
|
|
774
2288
|
const started = Date.now();
|
|
775
2289
|
await withDeadline((async () => {
|
|
776
|
-
const first = await
|
|
2290
|
+
const first = await observeCommands(probe, connection, spec.cwd, COMMAND_FIRST_BROADCAST_MS);
|
|
777
2291
|
observedConfig = first.configOptions;
|
|
778
2292
|
observed = first.commands;
|
|
779
2293
|
})(), timeoutMs, `Opening a session on agent '${spec.agentId}'`);
|
|
@@ -818,14 +2332,15 @@ export class AcpAgents {
|
|
|
818
2332
|
let abandoned = false;
|
|
819
2333
|
try {
|
|
820
2334
|
return await withDeadline((async () => {
|
|
821
|
-
const
|
|
2335
|
+
const probe = createCommandTableProbe();
|
|
2336
|
+
const connection = await this.connectFor(spec, remainingMs, undefined, probe);
|
|
822
2337
|
retryConnection = connection;
|
|
823
2338
|
// The deadline may already have fired while connecting: nobody will close it after this point but us.
|
|
824
2339
|
if (abandoned) {
|
|
825
2340
|
await connection.close().catch(() => undefined);
|
|
826
2341
|
return undefined;
|
|
827
2342
|
}
|
|
828
|
-
return (await
|
|
2343
|
+
return (await observeCommands(probe, connection, spec.cwd, this.commandRetryFirstBroadcastMs)).commands;
|
|
829
2344
|
})(), remainingMs, `Looking again for agent '${spec.agentId}''s command table`);
|
|
830
2345
|
}
|
|
831
2346
|
catch (error) {
|
|
@@ -853,6 +2368,7 @@ export class AcpAgents {
|
|
|
853
2368
|
* here is `authMethods`, because `initialize` happened.
|
|
854
2369
|
*/
|
|
855
2370
|
async authenticate(spec, methodId, login = {}) {
|
|
2371
|
+
this.clearQuotaReaders(spec.agentId);
|
|
856
2372
|
const urls = createLoginUrlSink(login);
|
|
857
2373
|
// **The retraction has to cover the connect, not just the login.** The sniffer is armed inside
|
|
858
2374
|
// `connectAgent` at `spawn()`, before `initialize` — so a URL can be presented while this is
|
|
@@ -904,9 +2420,11 @@ export class AcpAgents {
|
|
|
904
2420
|
* way a session's is, because the standard shape appends to it; the `_meta` shape ignores it.
|
|
905
2421
|
*/
|
|
906
2422
|
async hostTerminalLogin(spec, terminal, present, login) {
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
2423
|
+
// The launch is resolved for both shapes, and for the same reason a session's is: its env is
|
|
2424
|
+
// the login child's environment. The `_meta` shape keeps the invocation's own command and args
|
|
2425
|
+
// (see `launchForTerminalLogin`) but rides the resolved launch's env — a login that seeded a
|
|
2426
|
+
// different credential slot than the sessions it was logging in for would be a no-op for them.
|
|
2427
|
+
const launch = launchForTerminalLogin(terminal, (await this.resolveLaunchFor(spec.agentId, spec.command, spec.args)).launch);
|
|
910
2428
|
await runTerminalLogin(terminalInvocationFor(terminal, launch, { ...spec.env, ...spec.agentEnv }), {
|
|
911
2429
|
...(present ? { present } : {}),
|
|
912
2430
|
...(login.signal ? { signal: login.signal } : {}),
|
|
@@ -939,21 +2457,24 @@ export class AcpAgents {
|
|
|
939
2457
|
* `connectAgent`'s `initialize` deadline, whose expiry kills the child. A login's own budget is
|
|
940
2458
|
* {@link AGENT_LOGIN_CEILING_MS} and is applied around `authenticate` alone.
|
|
941
2459
|
*/
|
|
942
|
-
async connectFor(spec, timeoutMs, login) {
|
|
2460
|
+
async connectFor(spec, timeoutMs, login, probe) {
|
|
943
2461
|
const resolved = await this.resolveLaunchFor(spec.agentId, spec.command, spec.args);
|
|
944
2462
|
// The same budget as the caller's, not the 30s default: `connectAgent` kills the child when it
|
|
945
2463
|
// fires, which is the only thing that stops an agent that opened stdio and never spoke.
|
|
946
|
-
if (!login)
|
|
947
|
-
return this.connectResolved(
|
|
2464
|
+
if (!login) {
|
|
2465
|
+
return this.connectResolved(resolved, spec.env, spec.agentEnv, {
|
|
2466
|
+
initializeTimeoutMs: timeoutMs, ...(probe ? { configure: (app) => probe.configure(app) } : {}),
|
|
2467
|
+
});
|
|
2468
|
+
}
|
|
948
2469
|
const options = {
|
|
949
2470
|
initializeTimeoutMs: timeoutMs,
|
|
950
2471
|
...(login.present ? { present: login.present } : {}),
|
|
951
|
-
configure: (app) => {
|
|
2472
|
+
configure: (app, adapter) => {
|
|
952
2473
|
// The same registration the session connection makes (see `open`), with the two
|
|
953
2474
|
// elicitation callbacks actually wired — this is the only connection they can be reached
|
|
954
2475
|
// on, because `authenticate` does not run on a session's connection.
|
|
955
2476
|
//
|
|
956
|
-
// The sink is deferred and never bound, deliberately: there is no
|
|
2477
|
+
// The sink is deferred and never bound, deliberately: there is no session here, so an
|
|
957
2478
|
// agent that sent `session/request_permission` on a login connection would be referring to
|
|
958
2479
|
// a session that cannot exist. `createDeferredPermissionSink` answers that by throwing a
|
|
959
2480
|
// named error, which the SDK turns into a JSON-RPC error for the agent — loud and
|
|
@@ -961,136 +2482,34 @@ export class AcpAgents {
|
|
|
961
2482
|
registerPermissionHandlers(app, createDeferredPermissionSink().sink, {
|
|
962
2483
|
present: login.present,
|
|
963
2484
|
dismiss: login.dismiss ?? (() => { }),
|
|
964
|
-
});
|
|
2485
|
+
}, adapter);
|
|
965
2486
|
},
|
|
966
2487
|
};
|
|
967
|
-
return
|
|
968
|
-
}
|
|
969
|
-
/**
|
|
970
|
-
* Continue in an already-existing native session when we have an id for one and the agent can load
|
|
971
|
-
* it; otherwise open a new session and report the new id truthfully.
|
|
972
|
-
*
|
|
973
|
-
* Two things are deliberately *not* failures here. An agent that does not advertise
|
|
974
|
-
* `loadSession` still gets a session — the manager already tolerates the id it asked for coming
|
|
975
|
-
* back different (it warns and re-points), so refusing to open would be strictly worse than
|
|
976
|
-
* continuing in a fresh one. A `session/load` that is advertised but rejected (a stale id the
|
|
977
|
-
* agent has since deleted, most likely) is treated the same way, for the same reason.
|
|
978
|
-
* Manual configuration refresh opts out of both fallbacks with `requireSessionLoad`: a failed
|
|
979
|
-
* load must not silently replace the conversation whose settings the reader wanted to refresh.
|
|
980
|
-
*
|
|
981
|
-
* `AcpSession.attach` runs **before** the replay, not after: it subscribes to the connection's live
|
|
982
|
-
* `session/update` stream, and `ReplayCapture` withholds updates from that subscription for exactly
|
|
983
|
-
* as long as the id is armed — i.e. for the whole `session/load`. Subscribing afterwards would
|
|
984
|
-
* leave a gap between the replay's `take()` and the subscription in which a live update is dropped.
|
|
985
|
-
*/
|
|
986
|
-
async startSession(connection, capture, spec) {
|
|
987
|
-
const nativeSessionId = spec.nativeSessionId;
|
|
988
|
-
if (nativeSessionId && spec.managedHistoryResumeRequired) {
|
|
989
|
-
throw new Error('Managed native history replay has no certified boundary');
|
|
990
|
-
}
|
|
991
|
-
if (spec.delegationMcp && !connection.capabilities.mcpHttp) {
|
|
992
|
-
throw new Error(`Agent '${spec.agentId}' does not advertise HTTP MCP support`);
|
|
993
|
-
}
|
|
994
|
-
if (spec.requireSessionLoad && !nativeSessionId) {
|
|
995
|
-
throw new Error('Refreshing settings requires an existing native session ID');
|
|
996
|
-
}
|
|
997
|
-
// Qoder's load may emit old chunks after its response. Cached reconnects need context, not
|
|
998
|
-
// another history import. Actual adoption/backfill and explicit settings loads still use load.
|
|
999
|
-
if (nativeSessionId && certifiedRuntime(connection.agentInfo) && connection.capabilities.resume &&
|
|
1000
|
-
!spec.backfill && !spec.requireSessionLoad && !spec.delegationMcp) {
|
|
1001
|
-
const initial = { configOptions: [] };
|
|
1002
|
-
const session = AcpSession.attach(connection, nativeSessionId, capture, initial);
|
|
1003
|
-
try {
|
|
1004
|
-
const response = await connection.connection.agent.request(methods.agent.session.resume, {
|
|
1005
|
-
sessionId: nativeSessionId, cwd: spec.cwd, mcpServers: [...(spec.mcpServers ?? [])],
|
|
1006
|
-
});
|
|
1007
|
-
initial.configOptions = normalizeConfigOptions(response.configOptions, response.modes);
|
|
1008
|
-
session.enableBackgroundTerminalRepair(spec.cwd, spec.agentEnv.HOME ?? spec.env.HOME ?? homedir(), true);
|
|
1009
|
-
return { session, nativeSessionId, history: [], configOptions: initial.configOptions };
|
|
1010
|
-
}
|
|
1011
|
-
catch (error) {
|
|
1012
|
-
await session.close().catch(() => undefined);
|
|
1013
|
-
throw error; // Never silently replace this cached conversation with a fresh native root.
|
|
1014
|
-
}
|
|
1015
|
-
}
|
|
1016
|
-
if (nativeSessionId) {
|
|
1017
|
-
if (!connection.capabilities.loadSession) {
|
|
1018
|
-
if (spec.requireSessionLoad || spec.delegationMcp)
|
|
1019
|
-
throw new Error(`Agent '${spec.agentId}' does not support session/load`);
|
|
1020
|
-
console.warn(`[acp] agent '${spec.agentId}' does not support session/load; starting a new session ` +
|
|
1021
|
-
`instead of continuing '${nativeSessionId}'`);
|
|
1022
|
-
}
|
|
1023
|
-
else {
|
|
1024
|
-
// `AcpSession.attach` reads `initial.configOptions` lazily (see its own doc comment) —
|
|
1025
|
-
// exactly so this can hand it a mutable object *before* the snapshot exists, and fill it in
|
|
1026
|
-
// below once `session/load`'s response arrives. Snapshotting eagerly here (e.g. passing
|
|
1027
|
-
// `{ configOptions: [] }` outright) would leave the attached session's `configOptions`
|
|
1028
|
-
// empty forever, because nothing would ever revisit it.
|
|
1029
|
-
const initial = { configOptions: [] };
|
|
1030
|
-
const session = AcpSession.attach(connection, nativeSessionId, capture, initial);
|
|
1031
|
-
session.enableBackgroundTerminalRepair(spec.cwd, spec.agentEnv.HOME ?? spec.env.HOME ?? homedir());
|
|
1032
|
-
try {
|
|
1033
|
-
const history = [];
|
|
1034
|
-
for await (const event of replayNativeSession(connection, capture, {
|
|
1035
|
-
cwd: spec.cwd,
|
|
1036
|
-
nativeSessionId,
|
|
1037
|
-
mcpServers: spec.mcpServers,
|
|
1038
|
-
delegationMcp: spec.delegationMcp,
|
|
1039
|
-
onLoaded: (response) => {
|
|
1040
|
-
initial.configOptions = normalizeConfigOptions(response.configOptions, response.modes);
|
|
1041
|
-
},
|
|
1042
|
-
})) {
|
|
1043
|
-
// Folded and validated either way (one code path, no second copy of the replay rules);
|
|
1044
|
-
// retained only when someone is going to ask for it, so a long history is not held for
|
|
1045
|
-
// the life of a session that will never replay it.
|
|
1046
|
-
session.observeReplayEvent(event);
|
|
1047
|
-
if (spec.backfill)
|
|
1048
|
-
history.push(event);
|
|
1049
|
-
}
|
|
1050
|
-
return { session, nativeSessionId, history, configOptions: initial.configOptions };
|
|
1051
|
-
}
|
|
1052
|
-
catch (error) {
|
|
1053
|
-
if (spec.requireSessionLoad || spec.delegationMcp) {
|
|
1054
|
-
await session.close().catch(() => undefined);
|
|
1055
|
-
throw error;
|
|
1056
|
-
}
|
|
1057
|
-
console.warn(`[acp] session/load for '${nativeSessionId}' on agent '${spec.agentId}' failed ` +
|
|
1058
|
-
`(${errorMessage(error)}); starting a new session instead`);
|
|
1059
|
-
await session.close().catch(() => undefined);
|
|
1060
|
-
}
|
|
1061
|
-
}
|
|
1062
|
-
}
|
|
1063
|
-
const opened = await AcpSession.open(connection, {
|
|
1064
|
-
cwd: spec.cwd, mcpServers: spec.mcpServers, delegationMcp: spec.delegationMcp,
|
|
1065
|
-
});
|
|
1066
|
-
opened.session.enableBackgroundTerminalRepair(spec.cwd, spec.agentEnv.HOME ?? spec.env.HOME ?? homedir());
|
|
1067
|
-
return { session: opened.session, nativeSessionId: opened.nativeSessionId, history: [], configOptions: opened.configOptions };
|
|
2488
|
+
return connectAgent(resolved.launch, spec.env, spec.agentEnv, options);
|
|
1068
2489
|
}
|
|
1069
2490
|
/**
|
|
1070
|
-
*
|
|
1071
|
-
*
|
|
2491
|
+
* Install (or Update) one agent's ACP adapter and make it the agent's **current build** — the only
|
|
2492
|
+
* thing, besides a CLI-certified change ({@link reconcileCertifiedBuilds}), that moves the pointer
|
|
2493
|
+
* new sessions launch from (spec §6.1). What the settings page's Install / Update / Config does.
|
|
1072
2494
|
*
|
|
1073
|
-
* ##
|
|
2495
|
+
* ## Builds, not caches
|
|
1074
2496
|
*
|
|
1075
|
-
*
|
|
1076
|
-
*
|
|
1077
|
-
*
|
|
1078
|
-
*
|
|
1079
|
-
*
|
|
1080
|
-
*
|
|
1081
|
-
*
|
|
2497
|
+
* npm and binary adapters are installed into daemon-owned, immutable, versioned directories under
|
|
2498
|
+
* `<configDir>/acp-adapters/` (`builds/model.ts`), staged and renamed into place whole, then
|
|
2499
|
+
* recorded in `builds.json`. A new install never touches a directory a running adapter is loading
|
|
2500
|
+
* from, and `open` never downloads anything: it launches the current build or fails with
|
|
2501
|
+
* {@link AdapterNotInstalledError}. A registry version bump only makes the card offer Update.
|
|
2502
|
+
* Install, switch and GC of one agent are serialised ({@link BuildStore.withAgentLock}), so two
|
|
2503
|
+
* concurrent Updates download once. The uvx path still installs into `uv tool dir` until Task 15b.
|
|
1082
2504
|
*
|
|
1083
2505
|
* ## Three answers, and the third one is not a failure
|
|
1084
2506
|
*
|
|
1085
|
-
* `ok` with `alreadyInstalled` means there was nothing to
|
|
2507
|
+
* `ok` with `alreadyInstalled` means there was nothing to download. `ok` without it means this call
|
|
1086
2508
|
* downloaded it. `unsupported` means there is nothing for *this machine* to fetch — the entry
|
|
1087
2509
|
* declares no launchable distribution, or it is binary-only with no archive for this platform
|
|
1088
2510
|
* triple. Reported as its own outcome rather than as an error so the page can grey the card out
|
|
1089
2511
|
* instead of offering a retry that cannot succeed.
|
|
1090
2512
|
*
|
|
1091
|
-
* All three distributions are fetched, not just npx — see {@link installAdapter}, which dispatches
|
|
1092
|
-
* npx → uvx → binary in `resolveLaunch`'s own preference order.
|
|
1093
|
-
*
|
|
1094
2513
|
* ## The catalog's `prerequisite` runs first, and can refuse to run anything at all
|
|
1095
2514
|
*
|
|
1096
2515
|
* `registry/catalog.ts` may curate one prerequisite for an agent (`pi-acp` needs the `pi` CLI,
|
|
@@ -1099,23 +2518,14 @@ export class AcpAgents {
|
|
|
1099
2518
|
*
|
|
1100
2519
|
* 1. `requiresNode`, checked against this daemon's own `process.version` (or the injected
|
|
1101
2520
|
* `nodeVersion`, for tests) — because npm only *warns*, never refuses, on an `engines`
|
|
1102
|
-
* mismatch.
|
|
1103
|
-
* the binary, and a check that stopped at presence-on-`PATH` would report the dependency
|
|
1104
|
-
* satisfied right before the agent fails at run time with an engines error nobody could trace
|
|
1105
|
-
* back to install. Failing this gate returns `nodeTooOld` and runs no command at all — not
|
|
1106
|
-
* even the detection below, since there would be nothing useful to install anyway.
|
|
2521
|
+
* mismatch. Failing this gate returns `nodeTooOld` and runs no command at all.
|
|
1107
2522
|
* 2. its `detect` (`registry/detect.ts`) — only when that finds nothing does this call the
|
|
1108
2523
|
* catalog's literal `install` argv through {@link AcpAgents.runCommand}, which is
|
|
1109
2524
|
* `execFile`-based and never a shell (see `catalog.ts`'s security note on that array).
|
|
1110
2525
|
* 3. a prerequisite with no `install` argv cannot be fetched by anyone but the reader, so a missing
|
|
1111
|
-
* one refuses with the catalog's `hint` as the error
|
|
1112
|
-
* from the roster's `missingPrerequisite` — and fetches no adapter either: an adapter on disk
|
|
1113
|
-
* whose engine is not would only turn the card into an `Add` whose try-run must fail.
|
|
1114
|
-
*
|
|
1115
|
-
* An agent outside the catalog, or one with no `prerequisite` block, skips straight to the adapter
|
|
1116
|
-
* step below — nothing here changes for `claude-acp`, `codex-acp`, or any other uncurated agent.
|
|
2526
|
+
* one refuses with the catalog's `hint` as the error and fetches no adapter either.
|
|
1117
2527
|
*
|
|
1118
|
-
* Never starts the agent
|
|
2528
|
+
* Never starts the agent.
|
|
1119
2529
|
*/
|
|
1120
2530
|
async install(agentId, options = {}) {
|
|
1121
2531
|
const dependency = catalogEntryFor(agentId)?.prerequisite;
|
|
@@ -1145,12 +2555,11 @@ export class AcpAgents {
|
|
|
1145
2555
|
}
|
|
1146
2556
|
dependencyInstalled = true;
|
|
1147
2557
|
}
|
|
1148
|
-
const
|
|
1149
|
-
|
|
1150
|
-
if (!agent) {
|
|
2558
|
+
const entry = (await this.effective()).find((candidate) => candidate.agent.id === agentId);
|
|
2559
|
+
if (!entry) {
|
|
1151
2560
|
return { ok: false, error: `Agent '${agentId}' is not in the ACP agent registry.` };
|
|
1152
2561
|
}
|
|
1153
|
-
const outcome = await this.installAdapter(
|
|
2562
|
+
const outcome = await this.builds.withAgentLock(agentId, () => this.installAdapter(entry, 'user', options.timeoutMs));
|
|
1154
2563
|
if (!outcome.ok)
|
|
1155
2564
|
return outcome;
|
|
1156
2565
|
return {
|
|
@@ -1161,69 +2570,86 @@ export class AcpAgents {
|
|
|
1161
2570
|
...(outcome.pinnedVersionUnavailable ? { pinnedVersionUnavailable: outcome.pinnedVersionUnavailable } : {}),
|
|
1162
2571
|
};
|
|
1163
2572
|
}
|
|
2573
|
+
/** The registry as Axom installs it (catalog supplements, pins, launchEnv kept apart) — see `registry/catalog.ts`. */
|
|
2574
|
+
async effective() {
|
|
2575
|
+
return effectiveEntries(await this.baseRegistry.load());
|
|
2576
|
+
}
|
|
1164
2577
|
/**
|
|
1165
|
-
*
|
|
1166
|
-
* npx → uvx → binary
|
|
1167
|
-
* launched, so `Install` always fetches the thing that will later be started.
|
|
1168
|
-
*
|
|
1169
|
-
* ## Why this exists
|
|
1170
|
-
*
|
|
1171
|
-
* {@link install} used to refuse anything without an `npx` block outright, which made
|
|
1172
|
-
* `installBinary` (`binaryInstall.ts`) and `installUvTool` (`uvxInstall.ts`) unreachable from
|
|
1173
|
-
* production: they were written, tested, and called by nothing. On the live registry snapshot that
|
|
1174
|
-
* is 18 of 39 agents — 16 binary-only and 2 uvx-only — every one of them rendering an `Install`
|
|
1175
|
-
* button whose task could only ever fail. Design §4.1's "three distributions, one model" is this
|
|
1176
|
-
* function.
|
|
1177
|
-
*
|
|
1178
|
-
* ## `unsupported` still exists, and now means something narrower
|
|
1179
|
-
*
|
|
1180
|
-
* Two cases, and neither of them is "this build has not got round to it yet":
|
|
1181
|
-
*
|
|
1182
|
-
* - the registry entry declares no launchable distribution at all, so there is nothing to fetch;
|
|
1183
|
-
* - the entry is binary-only and publishes no archive for **this machine's** platform triple —
|
|
1184
|
-
* `windows-aarch64` covers 9 of 18 binary entries (see `binaryInstall.ts`'s module doc). Checked
|
|
1185
|
-
* here rather than left to `installBinary`'s own identical check so the answer carries
|
|
1186
|
-
* `unsupported`, which is what a card renders as `Unsupported` instead of a retryable error.
|
|
1187
|
-
*
|
|
1188
|
-
* Every other failure is an ordinary `error` the reader can retry.
|
|
2578
|
+
* Install one agent's adapter through **whichever distribution it actually ships**, dispatching
|
|
2579
|
+
* npx → uvx → binary, then commit the build and make it current. The caller holds the agent's lock.
|
|
1189
2580
|
*
|
|
1190
|
-
* `
|
|
1191
|
-
*
|
|
1192
|
-
*
|
|
1193
|
-
* anything went wrong. `binaryInstall.ts` logs the fact where it is established.
|
|
2581
|
+
* `unsupported` means one of two things: the entry declares no launchable distribution at all, or
|
|
2582
|
+
* it is binary-only and publishes no archive for **this machine's** platform triple. Every other
|
|
2583
|
+
* failure is an ordinary `error` the reader can retry, and leaves the current build untouched.
|
|
1194
2584
|
*/
|
|
1195
|
-
async installAdapter(
|
|
2585
|
+
async installAdapter(entry, setBy, timeoutMs,
|
|
2586
|
+
/**
|
|
2587
|
+
* The certified reconcile's current build: if the install lands on that same build, its record is
|
|
2588
|
+
* refreshed but the pointer (and `currentSetBy`) is left alone — that is not a move.
|
|
2589
|
+
*/
|
|
2590
|
+
currentBuildId) {
|
|
2591
|
+
const agent = entry.agent;
|
|
1196
2592
|
const { npx, uvx, binary } = agent.distribution;
|
|
2593
|
+
const source = entry.pinnedBy ?? 'registry';
|
|
2594
|
+
const commit = (build) => this.builds.commit(build, { makeCurrent: build.buildId !== currentBuildId, setBy });
|
|
1197
2595
|
if (npx) {
|
|
1198
|
-
const
|
|
1199
|
-
if (!
|
|
1200
|
-
return
|
|
2596
|
+
const pristine = await this.installPristineWithFallback(agent, npx.package, timeoutMs);
|
|
2597
|
+
if (!pristine.ok)
|
|
2598
|
+
return pristine;
|
|
1201
2599
|
try {
|
|
1202
|
-
|
|
1203
|
-
|
|
2600
|
+
const build = await npmBuild({
|
|
2601
|
+
store: this.builds, agentId: agent.id, name: npmPackageName(npx.package), version: pristine.version,
|
|
2602
|
+
args: npx.args ?? [], env: npx.env ?? {}, registryVersion: entry.registryVersion,
|
|
2603
|
+
source: pristine.pinnedVersionUnavailable ? 'unpinned-fallback' : source, installedBy: setBy,
|
|
2604
|
+
nodePath: this.nodePath,
|
|
2605
|
+
...(this.patchRecipes ? { recipes: this.patchRecipes } : {}),
|
|
2606
|
+
});
|
|
2607
|
+
const recorded = pristine.pinnedVersionUnavailable
|
|
2608
|
+
? { ...build, requestedVersion: requestedVersionOf(entry) }
|
|
2609
|
+
: build;
|
|
2610
|
+
await commit(recorded);
|
|
2611
|
+
return {
|
|
2612
|
+
ok: true, alreadyInstalled: !pristine.downloaded, build: recorded,
|
|
2613
|
+
...(pristine.pinnedVersionUnavailable ? { pinnedVersionUnavailable: pristine.pinnedVersionUnavailable } : {}),
|
|
2614
|
+
};
|
|
1204
2615
|
}
|
|
1205
2616
|
catch (error) {
|
|
1206
|
-
|
|
2617
|
+
logInstallFailureDetail(agent.id, 'npx', npx.package, String(error));
|
|
2618
|
+
// The Node gate: the package's own engines.node refuses the Node it would run on. Not current.
|
|
2619
|
+
if (error instanceof AdapterNodeVersionError) {
|
|
2620
|
+
return { ok: false, error: error.message, nodeTooOld: { required: error.required, found: error.found } };
|
|
2621
|
+
}
|
|
2622
|
+
return { ok: false, error: `Adapter build preparation failed: ${errorMessage(error)}` };
|
|
1207
2623
|
}
|
|
1208
2624
|
}
|
|
1209
2625
|
if (uvx) {
|
|
1210
|
-
//
|
|
1211
|
-
//
|
|
1212
|
-
const outcome = await (
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
2626
|
+
// A per-version build under `uv/<pkg>/<version>/` (`builds/install/uv.ts`): its own UV_TOOL_DIR
|
|
2627
|
+
// and UV_TOOL_BIN_DIR, never the user's uv tools or `~/.local/bin`; launch never runs `uv`.
|
|
2628
|
+
const outcome = await installUvBuild({
|
|
2629
|
+
store: this.builds, agentId: agent.id, spec: uvx.package, args: uvx.args ?? [], env: uvx.env ?? {},
|
|
2630
|
+
registryVersion: entry.registryVersion, source, installedBy: setBy, runUv: this.runUv,
|
|
2631
|
+
timeoutMs: timeoutMs ?? UV_TOOL_INSTALL_TIMEOUT_MS,
|
|
2632
|
+
}).catch((error) => ({ ok: false, error: errorMessage(error), uvMissing: undefined }));
|
|
2633
|
+
if (!outcome.ok) {
|
|
2634
|
+
const existingVersion = (await this.builds.current(agent.id))?.version;
|
|
2635
|
+
logInstallFailureDetail(agent.id, 'uvx', uvx.package, outcome.error);
|
|
2636
|
+
// `uv` itself is absent: retrying will not help, installing uv will.
|
|
2637
|
+
if (outcome.uvMissing)
|
|
2638
|
+
return { ok: false, error: friendlyUvMissingError(agent.id, existingVersion) };
|
|
2639
|
+
return { ok: false, error: friendlyOrdinaryInstallError(agent.id, outcome.error, existingVersion) };
|
|
2640
|
+
}
|
|
2641
|
+
try {
|
|
2642
|
+
await commit(outcome.build);
|
|
2643
|
+
return { ok: true, alreadyInstalled: !outcome.downloaded, build: outcome.build };
|
|
2644
|
+
}
|
|
2645
|
+
catch (error) {
|
|
2646
|
+
return { ok: false, error: errorMessage(error) };
|
|
2647
|
+
}
|
|
1223
2648
|
}
|
|
1224
2649
|
if (binary) {
|
|
1225
2650
|
const triple = platformTriple(this.platform, this.arch);
|
|
1226
|
-
|
|
2651
|
+
const target = triple ? binaryTargetFor(agent, triple) : undefined;
|
|
2652
|
+
if (!triple || !target) {
|
|
1227
2653
|
return {
|
|
1228
2654
|
ok: false,
|
|
1229
2655
|
unsupported: true,
|
|
@@ -1234,16 +2660,26 @@ export class AcpAgents {
|
|
|
1234
2660
|
root: this.adapterRoot,
|
|
1235
2661
|
platform: this.platform,
|
|
1236
2662
|
arch: this.arch,
|
|
2663
|
+
...(this.binaryFetch ? { fetch: this.binaryFetch } : {}),
|
|
1237
2664
|
});
|
|
1238
|
-
if (outcome.ok)
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
2665
|
+
if (!outcome.ok) {
|
|
2666
|
+
// `installBinary` never touches another version's tree (it stages, then renames), so what
|
|
2667
|
+
// survives the attempt is exactly the current build.
|
|
2668
|
+
const existingVersion = (await this.builds.current(agent.id))?.version;
|
|
2669
|
+
logInstallFailureDetail(agent.id, 'binary', agent.version, outcome.error);
|
|
2670
|
+
return { ok: false, error: friendlyOrdinaryInstallError(agent.id, outcome.error, existingVersion) };
|
|
2671
|
+
}
|
|
2672
|
+
try {
|
|
2673
|
+
const build = binaryBuild({
|
|
2674
|
+
root: this.adapterRoot, agentId: agent.id, version: outcome.version, target, env: target.env ?? {},
|
|
2675
|
+
registryVersion: entry.registryVersion, source, installedBy: setBy,
|
|
2676
|
+
});
|
|
2677
|
+
await commit(build);
|
|
2678
|
+
return { ok: true, alreadyInstalled: outcome.alreadyInstalled, build };
|
|
2679
|
+
}
|
|
2680
|
+
catch (error) {
|
|
2681
|
+
return { ok: false, error: errorMessage(error) };
|
|
2682
|
+
}
|
|
1247
2683
|
}
|
|
1248
2684
|
return {
|
|
1249
2685
|
ok: false,
|
|
@@ -1252,75 +2688,342 @@ export class AcpAgents {
|
|
|
1252
2688
|
};
|
|
1253
2689
|
}
|
|
1254
2690
|
/**
|
|
1255
|
-
*
|
|
1256
|
-
*
|
|
1257
|
-
*
|
|
1258
|
-
*
|
|
1259
|
-
*
|
|
1260
|
-
* ## Why `Install`/`Update` needed this and `connectResolved` alone was not enough
|
|
1261
|
-
*
|
|
1262
|
-
* The launch-time fallback only ever fires *after* an `Install` has already put something usable
|
|
1263
|
-
* on disk — it exists to recover a session that is about to open, not to get the adapter onto the
|
|
1264
|
-
* machine in the first place. Before this method, a registry entry pinned to an npm version that
|
|
1265
|
-
* does not exist failed here, at `Install`/`Update`, every single time, and never got anywhere
|
|
1266
|
-
* near `connectResolved`: the card offered a button whose task could only ever fail, worse than
|
|
1267
|
-
* the launch-time defect it was fixed for.
|
|
2691
|
+
* The npm half of {@link installAdapter}: the pinned version into `npm/<sha16(name)>/<version>/pristine`
|
|
2692
|
+
* and, when — and only when — npm answers `ETARGET` (the registry pinned a version npm does not
|
|
2693
|
+
* have), one retry unpinned, recording the version that actually landed. This is the **only** place
|
|
2694
|
+
* that fallback exists now: a launch never resolves a version (spec §6.2 "ETARGET 回退移到安装期").
|
|
1268
2695
|
*/
|
|
1269
|
-
async
|
|
1270
|
-
const
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
2696
|
+
async installPristineWithFallback(agent, pinnedSpec, timeoutMs) {
|
|
2697
|
+
const name = npmPackageName(pinnedSpec);
|
|
2698
|
+
const version = npmPackageVersion(pinnedSpec);
|
|
2699
|
+
const budget = timeoutMs ?? ADAPTER_INSTALL_TIMEOUT_MS;
|
|
2700
|
+
const install = (pinned) => ensurePristine({
|
|
2701
|
+
store: this.builds, name, ...(pinned !== undefined ? { version: pinned } : {}), npmCommand: this.npmCommand, timeoutMs: budget,
|
|
2702
|
+
}).catch((error) => ({ ok: false, error: errorMessage(error), stderr: '' }));
|
|
2703
|
+
const outcome = await install(version);
|
|
1275
2704
|
if (outcome.ok)
|
|
1276
|
-
return
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
// exec --package=` only ever adds to the cache), so this is a fact about what survives this
|
|
1280
|
-
// attempt no matter which branch below is taken.
|
|
1281
|
-
const existingVersion = npxCacheVersion(npmPackageName(pinnedSpec), readNpxCache(this.probe));
|
|
1282
|
-
const fallback = unpinnedFallbackFor(agent, this.platform);
|
|
1283
|
-
if (!fallback || !stderrShowsMissingPackageVersion(outcome.stderr)) {
|
|
2705
|
+
return outcome;
|
|
2706
|
+
const existingVersion = (await this.builds.current(agent.id))?.version;
|
|
2707
|
+
if (version === undefined || !stderrShowsMissingPackageVersion(outcome.stderr)) {
|
|
1284
2708
|
logInstallFailureDetail(agent.id, 'npx', pinnedSpec, outcome.stderr || outcome.error);
|
|
1285
|
-
return {
|
|
1286
|
-
ok: false,
|
|
1287
|
-
error: friendlyOrdinaryInstallError(agent.id, outcome.stderr || outcome.error, existingVersion),
|
|
1288
|
-
};
|
|
2709
|
+
return { ok: false, error: friendlyOrdinaryInstallError(agent.id, outcome.stderr || outcome.error, existingVersion) };
|
|
1289
2710
|
}
|
|
1290
2711
|
// 两条都是 warn,而且都要能被一个月后读日志的人看懂——他不知道今晚发生了什么,只看得见这两行。
|
|
1291
|
-
// 措辞跟 `connectResolved` 里那两条故意保持一致,只把"启动"换成"安装"。
|
|
1292
2712
|
console.warn(`[acp] agent '${agent.id}' could not be installed at the version the ACP registry pins ` +
|
|
1293
|
-
`('${
|
|
2713
|
+
`('${pinnedSpec}'): npm has no published version matching it (npm error code ` +
|
|
1294
2714
|
'ETARGET). This is a bad entry published upstream, not a broken install on this machine.');
|
|
1295
|
-
console.warn(`[acp] retrying the install for agent '${agent.id}' once as '${
|
|
2715
|
+
console.warn(`[acp] retrying the install for agent '${agent.id}' once as '${name}', with the ` +
|
|
1296
2716
|
"version pin removed, so npm resolves whatever it currently calls 'latest'. That is " +
|
|
1297
2717
|
'deliberately NOT the version the registry asked for: if this agent then misbehaves, ' +
|
|
1298
2718
|
'suspect the version before suspecting the agent.');
|
|
1299
|
-
const
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
npxCommand: this.npxCommand,
|
|
1303
|
-
});
|
|
1304
|
-
if (!retryOutcome.ok) {
|
|
1305
|
-
// 不钉版本那次再失败,抛出去的是把两件事都说清楚的一句话:registry 钉的版本不存在,而且
|
|
1306
|
-
// 绕过去的那次尝试自己也没成 —— 而不是原来那条已经绕过去的 ETARGET。
|
|
1307
|
-
logInstallFailureDetail(agent.id, 'npx', fallback.unpinned, retryOutcome.stderr || retryOutcome.error);
|
|
2719
|
+
const retry = await install(undefined);
|
|
2720
|
+
if (!retry.ok) {
|
|
2721
|
+
logInstallFailureDetail(agent.id, 'npx', name, retry.stderr || retry.error);
|
|
1308
2722
|
return {
|
|
1309
2723
|
ok: false,
|
|
1310
|
-
error: friendlyPinnedVersionUnavailableAndFallbackFailed(agent.id,
|
|
2724
|
+
error: friendlyPinnedVersionUnavailableAndFallbackFailed(agent.id, pinnedSpec, retry.stderr || retry.error, existingVersion),
|
|
1311
2725
|
};
|
|
1312
2726
|
}
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
2727
|
+
return { ...retry, pinnedVersionUnavailable: { pinned: pinnedSpec, installedInstead: retry.version } };
|
|
2728
|
+
}
|
|
2729
|
+
/**
|
|
2730
|
+
* Move installed agents forward to what **this CLI** certifies, without anyone pressing Update
|
|
2731
|
+
* (spec §6.1 "由 Axom 更新"): an agent whose effective version is pinned by this CLI (runtime.json
|
|
2732
|
+
* override, catalog pin or supplement) and differs from its current build, or whose current
|
|
2733
|
+
* version would now be patched differently (a recipe added, changed or retired). The new build is
|
|
2734
|
+
* installed and made current with `currentSetBy: 'axom'`; running sessions keep the build they
|
|
2735
|
+
* were spawned from. Agents with no current build are left alone (nothing is installed to move),
|
|
2736
|
+
* as is every registry-only version change — that is the user's Update to make.
|
|
2737
|
+
*
|
|
2738
|
+
* Never throws: failures are logged per agent and the old current stays. Reads no registry at all
|
|
2739
|
+
* when no agent is installed, so a fresh machine starts without touching the network for this.
|
|
2740
|
+
*/
|
|
2741
|
+
async reconcileCertifiedBuilds(options = {}) {
|
|
2742
|
+
const moves = [];
|
|
2743
|
+
let document;
|
|
2744
|
+
try {
|
|
2745
|
+
document = await this.builds.read();
|
|
2746
|
+
}
|
|
2747
|
+
catch (error) {
|
|
2748
|
+
console.warn(`[acp-builds] cannot read builds: ${errorMessage(error)}`);
|
|
2749
|
+
return moves;
|
|
2750
|
+
}
|
|
2751
|
+
const installed = Object.keys(document.agents).filter((agentId) => this.builds.currentIn(document, agentId));
|
|
2752
|
+
if (!installed.length)
|
|
2753
|
+
return moves;
|
|
2754
|
+
let entries;
|
|
2755
|
+
try {
|
|
2756
|
+
entries = await this.effective();
|
|
2757
|
+
}
|
|
2758
|
+
catch (error) {
|
|
2759
|
+
console.warn(`[acp-builds] registry unavailable, skipping certified build reconcile: ${errorMessage(error)}`);
|
|
2760
|
+
return moves;
|
|
2761
|
+
}
|
|
2762
|
+
for (const agentId of installed) {
|
|
2763
|
+
const entry = entries.find((candidate) => candidate.agent.id === agentId);
|
|
2764
|
+
if (!entry)
|
|
2765
|
+
continue;
|
|
2766
|
+
try {
|
|
2767
|
+
const move = await this.builds.withAgentLock(agentId, () => this.reconcileOne(entry, options.timeoutMs));
|
|
2768
|
+
if (move)
|
|
2769
|
+
moves.push(move);
|
|
2770
|
+
}
|
|
2771
|
+
catch (error) {
|
|
2772
|
+
console.warn(`[acp-builds] agent '${agentId}': certified build reconcile failed: ${errorMessage(error)}`);
|
|
2773
|
+
}
|
|
2774
|
+
}
|
|
2775
|
+
return moves;
|
|
2776
|
+
}
|
|
2777
|
+
async reconcileOne(entry, timeoutMs) {
|
|
2778
|
+
const agentId = entry.agent.id;
|
|
2779
|
+
const current = await this.builds.current(agentId);
|
|
2780
|
+
if (!current)
|
|
2781
|
+
return undefined;
|
|
2782
|
+
// The pin this CLI certifies was already tried and npm did not have it: the current build is the
|
|
2783
|
+
// install-time fallback for exactly this pin. Trying again on every daemon start would only repeat
|
|
2784
|
+
// the same network round trip and land on the same build.
|
|
2785
|
+
const sameUnavailablePin = current.source === 'unpinned-fallback' && current.requestedVersion === requestedVersionOf(entry);
|
|
2786
|
+
if (entry.pinnedBy && entry.agent.version !== current.version && !sameUnavailablePin) {
|
|
2787
|
+
const outcome = await this.installAdapter(entry, 'axom', timeoutMs, current.buildId);
|
|
2788
|
+
if (!outcome.ok)
|
|
2789
|
+
throw new Error(outcome.error);
|
|
2790
|
+
if (!outcome.build || outcome.build.buildId === current.buildId)
|
|
2791
|
+
return undefined;
|
|
2792
|
+
console.info(`[acp-builds] agent '${agentId}': moved ${current.buildId} -> ${outcome.build.buildId} (pinned by ${entry.pinnedBy})`);
|
|
2793
|
+
return { agentId, from: current.buildId, to: outcome.build.buildId, reason: 'certified-pin' };
|
|
2794
|
+
}
|
|
2795
|
+
// Same version: does this CLI patch it differently from the build that is current?
|
|
2796
|
+
if ((current.kind !== 'npm' && current.kind !== 'npm-patched') || !current.package)
|
|
2797
|
+
return undefined;
|
|
2798
|
+
const pristine = inside(this.adapterRoot, npmPristineDir(current.package, current.version));
|
|
2799
|
+
const recipes = await selectPackagePatches(current.package, current.version, inside(path.join(pristine, 'node_modules'), current.package), this.patchRecipes);
|
|
2800
|
+
const wanted = recipes.length ? patchDigest(recipes) : undefined;
|
|
2801
|
+
if (wanted === current.patchDigest)
|
|
2802
|
+
return undefined;
|
|
2803
|
+
const build = await npmBuild({
|
|
2804
|
+
store: this.builds, agentId, name: current.package, version: current.version,
|
|
2805
|
+
args: current.launch.args, env: current.launch.env, registryVersion: current.registryVersion,
|
|
2806
|
+
source: current.source, installedBy: 'axom', nodePath: this.nodePath,
|
|
2807
|
+
...(this.patchRecipes ? { recipes: this.patchRecipes } : {}),
|
|
2808
|
+
});
|
|
2809
|
+
await this.builds.commit(build, { makeCurrent: true, setBy: 'axom' });
|
|
2810
|
+
console.info(`[acp-builds] agent '${agentId}': moved ${current.buildId} -> ${build.buildId} (patch set changed)`);
|
|
2811
|
+
return { agentId, from: current.buildId, to: build.buildId, reason: 'patch-set' };
|
|
2812
|
+
}
|
|
2813
|
+
/**
|
|
2814
|
+
* Take over what an older CLI left on this machine (spec §6.2 "迁移", `builds/migrate.ts`), for every
|
|
2815
|
+
* agent that has no current build: an `_npx` copy of exactly the effective version, or pre-build
|
|
2816
|
+
* binary directories. Run by the daemon at start, in the background; an open (or any launch) of an
|
|
2817
|
+
* agent whose share has not run yet waits on that agent's lock instead of reporting it not installed.
|
|
2818
|
+
*
|
|
2819
|
+
* Idempotent (an agent with a current build is never touched) and once per agent per process. Reads
|
|
2820
|
+
* no registry when there is nothing to migrate from (no `_npx`, no legacy directories). Never throws.
|
|
2821
|
+
*/
|
|
2822
|
+
async migrateLegacyAdapters() {
|
|
2823
|
+
const migrated = [];
|
|
2824
|
+
let document;
|
|
2825
|
+
try {
|
|
2826
|
+
document = await this.builds.read();
|
|
2827
|
+
}
|
|
2828
|
+
catch (error) {
|
|
2829
|
+
console.warn(`[acp-builds] cannot read builds, skipping migration: ${errorMessage(error)}`);
|
|
2830
|
+
return migrated;
|
|
2831
|
+
}
|
|
2832
|
+
const hasNpx = existsSync(path.join(npmCacheDir(this.probe), '_npx'));
|
|
2833
|
+
const legacy = new Set(await legacyAgentDirs(this.adapterRoot));
|
|
2834
|
+
if (!hasNpx && !legacy.size)
|
|
2835
|
+
return migrated;
|
|
2836
|
+
let entries;
|
|
2837
|
+
try {
|
|
2838
|
+
entries = await this.effective();
|
|
2839
|
+
}
|
|
2840
|
+
catch (error) {
|
|
2841
|
+
console.warn(`[acp-builds] registry unavailable, migration deferred to first open: ${errorMessage(error)}`);
|
|
2842
|
+
return migrated;
|
|
2843
|
+
}
|
|
2844
|
+
for (const entry of entries) {
|
|
2845
|
+
const agentId = entry.agent.id;
|
|
2846
|
+
if (this.builds.currentIn(document, agentId))
|
|
2847
|
+
continue;
|
|
2848
|
+
const { npx, uvx, binary } = entry.agent.distribution;
|
|
2849
|
+
if (npx ? !hasNpx : uvx || !binary || !legacy.has(agentId))
|
|
2850
|
+
continue;
|
|
2851
|
+
const result = await this.migrateAgent(agentId, entries);
|
|
2852
|
+
if (result)
|
|
2853
|
+
migrated.push(result);
|
|
2854
|
+
}
|
|
2855
|
+
return migrated;
|
|
2856
|
+
}
|
|
2857
|
+
/** {@link migrateLegacyAdapters} for one agent, at most once per process (shared by the daemon's sweep and every launch). */
|
|
2858
|
+
migrateAgent(agentId, entries) {
|
|
2859
|
+
let running = this.migrations.get(agentId);
|
|
2860
|
+
if (!running) {
|
|
2861
|
+
running = this.builds.withAgentLock(agentId, () => this.migrateOne(agentId, entries)).catch((error) => {
|
|
2862
|
+
console.warn(`[acp-builds] agent '${agentId}': migration of the previous install failed: ${errorMessage(error)}`);
|
|
2863
|
+
// A registry that could not be read is worth another try; anything else is not repeated.
|
|
2864
|
+
if (error instanceof RegistryUnavailableForMigration)
|
|
2865
|
+
this.migrations.delete(agentId);
|
|
2866
|
+
return undefined;
|
|
2867
|
+
});
|
|
2868
|
+
this.migrations.set(agentId, running);
|
|
2869
|
+
}
|
|
2870
|
+
return running;
|
|
2871
|
+
}
|
|
2872
|
+
async migrateOne(agentId, known) {
|
|
2873
|
+
if (await this.builds.current(agentId))
|
|
2874
|
+
return undefined;
|
|
2875
|
+
const npmCache = npmCacheDir(this.probe);
|
|
2876
|
+
const legacyVersions = await legacyBinaryVersions(this.adapterRoot, agentId);
|
|
2877
|
+
const unrecorded = await legacyBinaryVersions(path.join(this.adapterRoot, 'bin'), agentId);
|
|
2878
|
+
if (!legacyVersions.length && !unrecorded.length && !existsSync(path.join(npmCache, '_npx')))
|
|
2879
|
+
return undefined;
|
|
2880
|
+
let entries = known;
|
|
2881
|
+
if (!entries) {
|
|
2882
|
+
try {
|
|
2883
|
+
entries = await this.effective();
|
|
2884
|
+
}
|
|
2885
|
+
catch (error) {
|
|
2886
|
+
throw new RegistryUnavailableForMigration(errorMessage(error));
|
|
2887
|
+
}
|
|
2888
|
+
}
|
|
2889
|
+
const entry = entries.find((candidate) => candidate.agent.id === agentId);
|
|
2890
|
+
if (!entry)
|
|
2891
|
+
return undefined;
|
|
2892
|
+
const { npx, uvx, binary } = entry.agent.distribution;
|
|
2893
|
+
if (npx)
|
|
2894
|
+
return this.migrateFromNpx(entry, npx.package, npmCache);
|
|
2895
|
+
// uv: the user's own uv tool dir is not ours to take over; the first open installs a build.
|
|
2896
|
+
if (uvx || !binary)
|
|
2897
|
+
return undefined;
|
|
2898
|
+
return this.migrateLegacyBinaries(entry, legacyVersions, unrecorded);
|
|
2899
|
+
}
|
|
2900
|
+
/** npm: the effective version's `_npx` copy, as this agent's current build (pristine or patched, Node gate applied). */
|
|
2901
|
+
async migrateFromNpx(entry, spec, npmCache) {
|
|
2902
|
+
const agentId = entry.agent.id;
|
|
2903
|
+
const name = npmPackageName(spec);
|
|
2904
|
+
// Exactly what the pre-build launch ran: `npx <name>@<effective version>`.
|
|
2905
|
+
const version = requestedVersionOf(entry);
|
|
2906
|
+
const copy = await findNpxCopy(npmCache, name, version);
|
|
2907
|
+
if (!copy)
|
|
2908
|
+
return undefined;
|
|
2909
|
+
const { copied } = await adoptNpxCopy({ store: this.builds, name, version, nodeModules: copy });
|
|
2910
|
+
const npx = entry.agent.distribution.npx;
|
|
2911
|
+
const build = {
|
|
2912
|
+
...await npmBuild({
|
|
2913
|
+
store: this.builds, agentId, name, version, args: npx.args ?? [], env: npx.env ?? {},
|
|
2914
|
+
registryVersion: entry.registryVersion, source: entry.pinnedBy ?? 'registry', installedBy: 'axom',
|
|
2915
|
+
nodePath: this.nodePath, ...(this.patchRecipes ? { recipes: this.patchRecipes } : {}),
|
|
2916
|
+
}),
|
|
2917
|
+
migratedFrom: 'npx-cache',
|
|
2918
|
+
};
|
|
2919
|
+
await this.builds.commit(build, { makeCurrent: true, setBy: 'axom' });
|
|
2920
|
+
console.info(`[acp-builds] agent '${agentId}': ${name}@${version} ${copied ? 'copied from' : 'already copied from'} ${copy} as build ${build.buildId}`);
|
|
2921
|
+
return { agentId, from: 'npx-cache', current: build.buildId, builds: [build.buildId] };
|
|
2922
|
+
}
|
|
2923
|
+
/**
|
|
2924
|
+
* binary: every pre-build `acp-adapters/<agent>/<version>` renamed in place into `bin/<agent>/<version>`
|
|
2925
|
+
* and recorded (plus any `bin/<agent>/<version>` an interrupted migration renamed but never recorded);
|
|
2926
|
+
* the one the pre-build launch picked — the pinned version if present, else the highest — is current.
|
|
2927
|
+
*/
|
|
2928
|
+
async migrateLegacyBinaries(entry, legacyVersions, unrecorded) {
|
|
2929
|
+
const agent = entry.agent;
|
|
2930
|
+
const triple = platformTriple(this.platform, this.arch);
|
|
2931
|
+
const target = triple ? binaryTargetFor(agent, triple) : undefined;
|
|
2932
|
+
if (!target)
|
|
2933
|
+
return undefined;
|
|
2934
|
+
const recorded = [];
|
|
2935
|
+
const record = (version) => {
|
|
2936
|
+
try {
|
|
2937
|
+
recorded.push({
|
|
2938
|
+
...binaryBuild({
|
|
2939
|
+
root: this.adapterRoot, agentId: agent.id, version, target, env: target.env ?? {},
|
|
2940
|
+
registryVersion: entry.registryVersion, source: entry.pinnedBy ?? 'registry', installedBy: 'axom',
|
|
2941
|
+
}),
|
|
2942
|
+
migratedFrom: 'legacy-binary',
|
|
2943
|
+
});
|
|
2944
|
+
}
|
|
2945
|
+
catch (error) {
|
|
2946
|
+
console.warn(`[acp-builds] agent '${agent.id}' ${version}: not taken over: ${errorMessage(error)}`);
|
|
2947
|
+
}
|
|
1323
2948
|
};
|
|
2949
|
+
for (const version of [...legacyVersions].sort()) {
|
|
2950
|
+
let destination;
|
|
2951
|
+
try {
|
|
2952
|
+
destination = inside(this.adapterRoot, binaryBuildDir(agent.id, version));
|
|
2953
|
+
}
|
|
2954
|
+
catch {
|
|
2955
|
+
continue;
|
|
2956
|
+
}
|
|
2957
|
+
// The old install unpacked in place: a directory without its executable is a killed unpack. Left alone.
|
|
2958
|
+
const command = path.posix.normalize(target.cmd.replace(/\\/g, '/'));
|
|
2959
|
+
if (!existsSync(path.join(this.adapterRoot, agent.id, version, ...command.split('/')))) {
|
|
2960
|
+
console.warn(`[acp-builds] agent '${agent.id}' ${version}: no '${target.cmd}' in the old install; left where it is.`);
|
|
2961
|
+
continue;
|
|
2962
|
+
}
|
|
2963
|
+
if (await moveLegacyBinary(this.adapterRoot, agent.id, version, destination))
|
|
2964
|
+
record(version);
|
|
2965
|
+
}
|
|
2966
|
+
for (const version of unrecorded)
|
|
2967
|
+
if (!recorded.some((build) => build.version === version))
|
|
2968
|
+
record(version);
|
|
2969
|
+
const pick = pickInstalledVersion(recorded.map((build) => build.version), agent.version);
|
|
2970
|
+
if (pick === undefined)
|
|
2971
|
+
return undefined;
|
|
2972
|
+
for (const build of recorded)
|
|
2973
|
+
await this.builds.commit(build, { makeCurrent: false, setBy: 'axom' });
|
|
2974
|
+
const current = recorded.find((build) => build.version === pick);
|
|
2975
|
+
await this.builds.commit(current, { makeCurrent: true, setBy: 'axom' });
|
|
2976
|
+
console.info(`[acp-builds] agent '${agent.id}': took over ${recorded.map((build) => build.buildId).join(', ')}; current ${current.buildId}`);
|
|
2977
|
+
return { agentId: agent.id, from: 'legacy-binary', current: current.buildId, builds: recorded.map((build) => build.buildId) };
|
|
2978
|
+
}
|
|
2979
|
+
/**
|
|
2980
|
+
* Spec §6.2: an agent with no current build — after its migration, if any — is installed **once**,
|
|
2981
|
+
* by the first open that needs it ("Installing <version>" on the session). That is what the pre-build
|
|
2982
|
+
* npx launch did on every open; a uv tool the user had in their own uv dir is reinstalled the same
|
|
2983
|
+
* way. Only npm and uv distributions: an open never fetched a binary agent, which still fails as not
|
|
2984
|
+
* installed. Every later open launches the build and downloads nothing.
|
|
2985
|
+
*/
|
|
2986
|
+
async installForOpen(agentId, onOutcome) {
|
|
2987
|
+
if (await this.builds.current(agentId))
|
|
2988
|
+
return;
|
|
2989
|
+
await this.migrateAgent(agentId);
|
|
2990
|
+
if (await this.builds.current(agentId))
|
|
2991
|
+
return;
|
|
2992
|
+
let entry;
|
|
2993
|
+
try {
|
|
2994
|
+
entry = (await this.effective()).find((candidate) => candidate.agent.id === agentId);
|
|
2995
|
+
}
|
|
2996
|
+
catch {
|
|
2997
|
+
return;
|
|
2998
|
+
}
|
|
2999
|
+
// Not in the registry, or nothing an open may fetch: `launchSpecFor` says which.
|
|
3000
|
+
if (!entry || !(entry.agent.distribution.npx || entry.agent.distribution.uvx))
|
|
3001
|
+
return;
|
|
3002
|
+
const found = entry;
|
|
3003
|
+
const version = installingVersionOf(found);
|
|
3004
|
+
const agentName = found.agent.name || agentId;
|
|
3005
|
+
// Started once and never aborted: an open that gives up below leaves it running under the lock.
|
|
3006
|
+
const install = this.builds.withAgentLock(agentId, async () => {
|
|
3007
|
+
if (await this.builds.current(agentId))
|
|
3008
|
+
return undefined;
|
|
3009
|
+
const outcome = await this.installAdapter(found, 'user');
|
|
3010
|
+
return outcome.ok ? { ok: true, agentName, version } : { ok: false, agentName, version, error: outcome.error };
|
|
3011
|
+
}).catch((error) => ({ ok: false, agentName, version, error: errorMessage(error) }));
|
|
3012
|
+
let timer;
|
|
3013
|
+
const budget = new Promise((resolve) => { timer = setTimeout(() => resolve('budget'), this.openInstallBudgetMs); });
|
|
3014
|
+
const settled = await Promise.race([install, budget]).finally(() => clearTimeout(timer));
|
|
3015
|
+
if (settled === 'budget') {
|
|
3016
|
+
void install.then((late) => {
|
|
3017
|
+
if (late)
|
|
3018
|
+
console.info(`[acp-builds] agent '${agentId}': install of ${version} finished after its open gave up (${late.ok ? 'installed' : late.error})`);
|
|
3019
|
+
});
|
|
3020
|
+
throw new AdapterStillInstallingError(agentId, agentName, version);
|
|
3021
|
+
}
|
|
3022
|
+
if (!settled)
|
|
3023
|
+
return;
|
|
3024
|
+
onOutcome?.(settled);
|
|
3025
|
+
if (!settled.ok)
|
|
3026
|
+
throw new Error(settled.error);
|
|
1324
3027
|
}
|
|
1325
3028
|
/**
|
|
1326
3029
|
* Whether the catalog's prerequisite for this agent (`registry/catalog.ts`'s `prerequisite`) is
|
|
@@ -1340,229 +3043,78 @@ export class AcpAgents {
|
|
|
1340
3043
|
return true;
|
|
1341
3044
|
return detected(prerequisite.detect, probe, platformTriple(this.platform, this.arch));
|
|
1342
3045
|
}
|
|
1343
|
-
/**
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
3046
|
+
/**
|
|
3047
|
+
* The spawn spec a new session of `agentId` launches with: its **current build** and nothing else
|
|
3048
|
+
* — no npx, no download (spec §6.1/§6.2). The spec carries the build's `label`, and a JavaScript
|
|
3049
|
+
* build must pass the Node gate (`builds/nodeGate.ts`). An agent with no current build first waits
|
|
3050
|
+
* for its share of the legacy migration ({@link migrateLegacyAdapters}); still none → it fails with
|
|
3051
|
+
* {@link AdapterNotInstalledError} — whatever its distribution: a uv agent too launches only from its
|
|
3052
|
+
* build, never through `uv`. Only {@link open} goes on to install (once) — see `installForOpen`.
|
|
3053
|
+
*/
|
|
3054
|
+
async launchSpecFor(agentId) {
|
|
3055
|
+
let current = await this.builds.current(agentId);
|
|
3056
|
+
if (!current) {
|
|
3057
|
+
// What an older CLI left may still be on its way into the store (daemon start runs this in the
|
|
3058
|
+
// background); wait for this agent's share of it rather than report it not installed.
|
|
3059
|
+
await this.migrateAgent(agentId);
|
|
3060
|
+
current = await this.builds.current(agentId);
|
|
3061
|
+
}
|
|
3062
|
+
if (current) {
|
|
3063
|
+
this.touchBuild(agentId, current.buildId);
|
|
3064
|
+
return launchForBuild(current, this.adapterRoot, {
|
|
3065
|
+
nodePath: this.nodePath,
|
|
3066
|
+
launchEnv: catalogLaunchEnv(agentId, current.registryVersion),
|
|
3067
|
+
basePath: this.probe.path,
|
|
3068
|
+
});
|
|
3069
|
+
}
|
|
1349
3070
|
const agents = await this.registry.load();
|
|
1350
|
-
|
|
1351
|
-
if (!agent) {
|
|
3071
|
+
if (!agents.some((candidate) => candidate.id === agentId)) {
|
|
1352
3072
|
throw new Error(`Agent '${agentId}' is not in the ACP agent registry. Check the id, refresh the registry, ` +
|
|
1353
3073
|
'or give this agent an explicit command in the workstation config.');
|
|
1354
3074
|
}
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
};
|
|
3075
|
+
throw new AdapterNotInstalledError(agentId);
|
|
3076
|
+
}
|
|
3077
|
+
/** A config-supplied `command` is the whole launch spec (ruling R3); otherwise the current build decides. */
|
|
3078
|
+
async resolveLaunchFor(agentId, command, args) {
|
|
3079
|
+
if (command)
|
|
3080
|
+
return { launch: { command, args, env: {} }, source: 'command-override' };
|
|
3081
|
+
return { source: 'registry', launch: await this.launchSpecFor(agentId) };
|
|
1362
3082
|
}
|
|
1363
3083
|
/**
|
|
1364
3084
|
* Connect a launch that is not a login — every connection this class makes except
|
|
1365
|
-
* {@link connectFor}'s login one
|
|
3085
|
+
* {@link connectFor}'s login one.
|
|
1366
3086
|
*
|
|
1367
3087
|
* **`options` is {@link ConnectAgentOptions}, whose `present` is `never`**, and that is the whole
|
|
1368
3088
|
* point of this method still existing as a separate one: the stderr sniffer cannot be armed
|
|
1369
|
-
* through the route {@link open} and {@link listNativeSessions} take.
|
|
1370
|
-
* `login` parameter for `connectFor`'s benefit, which meant the same door stood open one argument
|
|
1371
|
-
* further along; `connectFor` builds its own options now.
|
|
3089
|
+
* through the route {@link open} and {@link listNativeSessions} take.
|
|
1372
3090
|
*/
|
|
1373
|
-
async connectResolved(
|
|
1374
|
-
return
|
|
3091
|
+
async connectResolved(resolved, env, agentEnv, options = {}) {
|
|
3092
|
+
return connectAgent(resolved.launch, env, agentEnv, options);
|
|
1375
3093
|
}
|
|
1376
3094
|
/**
|
|
1377
|
-
* Whether
|
|
1378
|
-
*
|
|
1379
|
-
*
|
|
1380
|
-
* `resolveLaunch`'s uvx branch. Without it, `resolveLaunch` falls back to reading `uv tool list`
|
|
1381
|
-
* itself via its default runner — a real `uv` subprocess per uvx-distributed agent, called from
|
|
1382
|
-
* inside `roster()`'s per-agent map, which is exactly the per-agent-spawn defect `adapterVersionFor`
|
|
1383
|
-
* was hoisted out of. This is the second call site that same invariant covers: building the roster
|
|
1384
|
-
* must start nothing per agent, no matter how many code paths ask "is this agent launchable".
|
|
1385
|
-
*
|
|
1386
|
-
* `resolveLaunch` itself is `async` now (`uv` is a non-blocking call — see `uvxInstall.ts`'s
|
|
1387
|
-
* `RunUv`), so this awaits it; with `uvTools` always supplied here, that branch never actually
|
|
1388
|
-
* waits on anything; it resolves off the already-hoisted snapshot exactly as synchronously as
|
|
1389
|
-
* before, just wrapped in a settled `Promise`.
|
|
3095
|
+
* Whether this agent can be launched on this machine (see {@link roster}): with a current build it
|
|
3096
|
+
* can; an npm or uv agent can be once installed (Install fetches it); a binary-only agent only with a
|
|
3097
|
+
* current build. Reads nothing and spawns nothing.
|
|
1390
3098
|
*/
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
await resolveLaunch(agent, this.platform, this.arch, this.adapterRoot, undefined, uvTools);
|
|
3099
|
+
resolvable(agent, current) {
|
|
3100
|
+
if (current)
|
|
1394
3101
|
return true;
|
|
1395
|
-
}
|
|
1396
|
-
|
|
1397
|
-
return false;
|
|
1398
|
-
}
|
|
1399
|
-
}
|
|
1400
|
-
}
|
|
1401
|
-
/**
|
|
1402
|
-
* Run `attempt` on the launch the registry pins, and — only when npm answers ETARGET — once more on
|
|
1403
|
-
* the unpinned fallback.
|
|
1404
|
-
*
|
|
1405
|
-
* Extracted from `connectResolved` so that the login path can reuse the retry **without** reusing
|
|
1406
|
-
* `connectResolved`'s options parameter, which is typed to forbid `present`. Both callers get the
|
|
1407
|
-
* same retry and the same two log lines; only one of them can arm the stderr sniffer, and it says so
|
|
1408
|
-
* in its own signature rather than by passing an extra argument the other caller could also pass.
|
|
1409
|
-
*
|
|
1410
|
-
* ## Only on ETARGET, and only once
|
|
1411
|
-
*
|
|
1412
|
-
* 只在 npm 自己说「这个版本不存在」时重试。一次冷启动要几十秒(npx 得先把适配器下下来),所以
|
|
1413
|
-
* 「失败就重试」等于把每一次真实故障的代价翻倍。这里认的是 npm 打在子进程 stderr 上的错误码,见
|
|
1414
|
-
* `acpClient.ts` 的 `isMissingPackageVersion` —— 那是证据;从一根断掉的管子倒推「大概是版本不对吧」
|
|
1415
|
-
* 是猜测,两者在日志里长得一样,后果不一样。
|
|
1416
|
-
*
|
|
1417
|
-
* 不钉版本那次再失败,抛出去的就是**它自己**的错误。把原来的 ETARGET 报上去会让读日志的人接着去
|
|
1418
|
-
* 追一个已经绕过去的问题。
|
|
1419
|
-
*/
|
|
1420
|
-
async function withUnpinnedFallback(agentId, resolved, attempt) {
|
|
1421
|
-
try {
|
|
1422
|
-
return await attempt(resolved.launch, resolved.source);
|
|
1423
|
-
}
|
|
1424
|
-
catch (error) {
|
|
1425
|
-
const fallback = resolved.fallback;
|
|
1426
|
-
if (!fallback || !isMissingPackageVersion(error))
|
|
1427
|
-
throw error;
|
|
1428
|
-
// 两条都是 warn,而且都要能被一个月后读日志的人看懂 —— 他不知道今晚发生了什么,只看得见这两行。
|
|
1429
|
-
console.warn(`[acp] agent '${agentId}' could not be launched at the version the ACP registry pins ` +
|
|
1430
|
-
`('${fallback.pinned}'): npm has no published version matching it (npm error code ` +
|
|
1431
|
-
'ETARGET). This is a bad entry published upstream, not a broken install on this machine.');
|
|
1432
|
-
console.warn(`[acp] retrying agent '${agentId}' once as '${fallback.unpinned}', with the version pin ` +
|
|
1433
|
-
"removed, so npm resolves whatever it currently calls 'latest'. That is deliberately NOT " +
|
|
1434
|
-
'the version the registry asked for: if this agent now misbehaves, suspect the version ' +
|
|
1435
|
-
'before suspecting the agent.');
|
|
1436
|
-
return await attempt(fallback.launch, 'registry-fallback');
|
|
1437
|
-
}
|
|
1438
|
-
}
|
|
1439
|
-
/**
|
|
1440
|
-
* Wrap one `AcpSession` plus the connection it runs on into the shape the manager holds.
|
|
1441
|
-
*
|
|
1442
|
-
* Two behaviours are added on top of `AcpSession`. Lifetime: `close()` must end the subprocess as
|
|
1443
|
-
* well as the session, or `agent_close` would leave a live agent behind holding its cwd and its
|
|
1444
|
-
* quota. `backfill` is attached only when there is history to replay, because its *absence* is what
|
|
1445
|
-
* the manager reads as "nothing to replay". And the config option snapshot: {@link
|
|
1446
|
-
* AcpAgentSession.configOptions} must answer with the **latest** one, not the one this session
|
|
1447
|
-
* opened with, so the cell below is what `setConfigOption` writes back into and what the getter
|
|
1448
|
-
* reads — see that field's own doc for what a stale answer costs on every reconnect.
|
|
1449
|
-
*/
|
|
1450
|
-
function hostedSession(session, connection, history, configOptions, disposeStartup) {
|
|
1451
|
-
// `AcpSession` keeps a cell of its own for the same value, but does not expose it — and the two
|
|
1452
|
-
// are written from the same place (the wrapper below is the only caller of its setter), so this
|
|
1453
|
-
// one cannot drift from it.
|
|
1454
|
-
let snapshot = configOptions;
|
|
1455
|
-
const hosted = {
|
|
1456
|
-
// Verbatim. This used to be the one place a string became a `ContentBlock[]`
|
|
1457
|
-
// (`session.prompt([{ type: 'text', text }])`) — a stopgap for as long as `agent_prompt` could
|
|
1458
|
-
// only carry text. That seam is gone: the frame now carries blocks, so wrapping here would be a
|
|
1459
|
-
// second, narrower opinion about the shape of a user message.
|
|
1460
|
-
prompt: (blocks, platformTurnId) => session.prompt(blocks, platformTurnId),
|
|
1461
|
-
// Both halves come off the live agent: the process's own exit state, and the session's own
|
|
1462
|
-
// outstanding-prompt list. Nothing here is remembered by this daemon.
|
|
1463
|
-
get liveness() {
|
|
1464
|
-
return {
|
|
1465
|
-
agentAlive: connection.agentProcessAlive() && session.readable,
|
|
1466
|
-
turnRunning: session.turnRunning,
|
|
1467
|
-
};
|
|
1468
|
-
},
|
|
1469
|
-
answer: (requestId, outcome) => session.answer(requestId, outcome),
|
|
1470
|
-
answerElicitation: (requestId, response) => session.answerElicitation(requestId, response),
|
|
1471
|
-
interrupt: () => session.interrupt(),
|
|
1472
|
-
setConfigOption: async (configId, value) => {
|
|
1473
|
-
snapshot = await session.setConfigOption(configId, value);
|
|
1474
|
-
return snapshot;
|
|
1475
|
-
},
|
|
1476
|
-
get configOptions() {
|
|
1477
|
-
return snapshot;
|
|
1478
|
-
},
|
|
1479
|
-
steer: (blocks, priority) => session.steer(blocks, priority),
|
|
1480
|
-
stopAsyncTask: (asyncTaskId) => session.stopAsyncTask(asyncTaskId),
|
|
1481
|
-
// Straight off the connection that opened this session — never inferred, and never read back
|
|
1482
|
-
// from the roster, whose entry for this agent may still be the pre-connection one.
|
|
1483
|
-
supportsSteering: connection.supportsSteering,
|
|
1484
|
-
// Same source, same rule, and likewise never defaulted: an empty list here is the agent saying
|
|
1485
|
-
// nothing about *when*, which ruling N4 reads as "the server may not name a timing".
|
|
1486
|
-
steeringTimings: connection.steeringTimings,
|
|
1487
|
-
agentInfo: connection.agentInfo,
|
|
1488
|
-
get runtimeDiagnostics() { return connection.runtimeDiagnostics; },
|
|
1489
|
-
providerTaskAdapterSelected: connection.providerTaskAdapterSelected,
|
|
1490
|
-
onProviderTaskEvent: (cb) => connection.onProviderTaskEvent(cb),
|
|
1491
|
-
providerTaskStop: (taskId, requestId) => session.providerTaskStop(taskId, requestId),
|
|
1492
|
-
onRuntimeDiagnostics: (cb) => connection.onRuntimeDiagnostics(cb),
|
|
1493
|
-
supportsImageInput: connection.capabilities.promptImage,
|
|
1494
|
-
onEvent: (cb) => session.onEvent(cb),
|
|
1495
|
-
onTurnDone: (cb) => session.onTurnDone(cb),
|
|
1496
|
-
onActivity: (cb) => session.onActivity(cb),
|
|
1497
|
-
async close() {
|
|
1498
|
-
try {
|
|
1499
|
-
await session.close();
|
|
1500
|
-
}
|
|
1501
|
-
finally {
|
|
1502
|
-
// Runs even if the session's own shutdown threw: the subprocess is the expensive thing to
|
|
1503
|
-
// leak, and nothing else in the daemon will come back for it.
|
|
1504
|
-
await connection.close().catch(() => undefined);
|
|
1505
|
-
await disposeStartup?.();
|
|
1506
|
-
}
|
|
1507
|
-
},
|
|
1508
|
-
};
|
|
1509
|
-
if (history.length > 0) {
|
|
1510
|
-
hosted.backfill = async function* backfill() {
|
|
1511
|
-
for (const event of history)
|
|
1512
|
-
yield event;
|
|
1513
|
-
};
|
|
3102
|
+
const { npx, uvx } = agent.distribution;
|
|
3103
|
+
return Boolean(npx || uvx);
|
|
1514
3104
|
}
|
|
1515
|
-
return hosted;
|
|
1516
3105
|
}
|
|
1517
3106
|
/**
|
|
1518
|
-
* Opens one session on [connection] and collects the command table the agent broadcasts
|
|
1519
|
-
*
|
|
3107
|
+
* Opens one session on [connection] (a verification's own, short-lived) and collects the command table the agent broadcasts
|
|
3108
|
+
* after `session/new` ({@link createCommandTableProbe}). `commands` undefined = no table arrived within [firstWindowMs]
|
|
3109
|
+
* (unknown, not empty).
|
|
1520
3110
|
*
|
|
1521
|
-
* The table is broadcast *after* `session/new` is answered (`acp-agent.js:752-757`), so a verification that closed
|
|
1522
|
-
*
|
|
1523
|
-
*
|
|
1524
|
-
* also keeps the updates that arrive before it knows its own session id (`acpClient.ts`).
|
|
1525
|
-
*
|
|
1526
|
-
* Settle rather than "first push wins": Claude broadcasts twice — once on `session/new` and again when the init frame
|
|
3111
|
+
* The table is broadcast *after* `session/new` is answered (`acp-agent.js:752-757`), so a verification that closed right after
|
|
3112
|
+
* opening could never see it; and it can also arrive before this client has read that answer (p3-fix7 N9), which the probe
|
|
3113
|
+
* keeps. Settle rather than "first push wins": Claude broadcasts twice — once on `session/new` and again when the init frame
|
|
1527
3114
|
* lets it filter out terminal-bound commands. 不能靠一个固定的等待:一条都没来 → 到 [firstWindowMs] 就收工;来了第一条 →
|
|
1528
3115
|
* 改用安静期([COMMAND_QUIET_MS])收尾,并受 [COMMAND_SETTLE_CEILING_MS] 兜底。
|
|
1529
3116
|
*/
|
|
1530
|
-
async function
|
|
1531
|
-
const
|
|
1532
|
-
|
|
1533
|
-
const configOptions = opened.configOptions;
|
|
1534
|
-
let commands;
|
|
1535
|
-
let finish;
|
|
1536
|
-
const settled = new Promise((resolve) => {
|
|
1537
|
-
finish = resolve;
|
|
1538
|
-
});
|
|
1539
|
-
let quietTimer;
|
|
1540
|
-
let ceilingTimer;
|
|
1541
|
-
let firstTimer = setTimeout(() => finish?.(), firstWindowMs);
|
|
1542
|
-
opened.session.onEvent((event) => {
|
|
1543
|
-
if (event.eventName !== 'available_commands_update')
|
|
1544
|
-
return;
|
|
1545
|
-
const available = event.payload.availableCommands;
|
|
1546
|
-
if (Array.isArray(available))
|
|
1547
|
-
commands = available.map(toAvailableCommandWire);
|
|
1548
|
-
// 第一条到手:等第一条的窗口作废,换成"安静就收工"+ 绝对上限。
|
|
1549
|
-
if (firstTimer !== undefined) {
|
|
1550
|
-
clearTimeout(firstTimer);
|
|
1551
|
-
firstTimer = undefined;
|
|
1552
|
-
ceilingTimer = setTimeout(() => finish?.(), COMMAND_SETTLE_CEILING_MS);
|
|
1553
|
-
}
|
|
1554
|
-
// 每再来一条就把安静计时重新起头 —— Claude 的第二条才是能用的那份。
|
|
1555
|
-
if (quietTimer !== undefined)
|
|
1556
|
-
clearTimeout(quietTimer);
|
|
1557
|
-
quietTimer = setTimeout(() => finish?.(), COMMAND_QUIET_MS);
|
|
1558
|
-
});
|
|
1559
|
-
await settled;
|
|
1560
|
-
if (firstTimer !== undefined)
|
|
1561
|
-
clearTimeout(firstTimer);
|
|
1562
|
-
if (quietTimer !== undefined)
|
|
1563
|
-
clearTimeout(quietTimer);
|
|
1564
|
-
if (ceilingTimer !== undefined)
|
|
1565
|
-
clearTimeout(ceilingTimer);
|
|
1566
|
-
await opened.session.close().catch(() => undefined);
|
|
1567
|
-
return { commands, configOptions };
|
|
3117
|
+
async function observeCommands(probe, connection, cwd, firstWindowMs) {
|
|
3118
|
+
const { raw, configOptions } = await probe.observe(connection, cwd, { firstWindowMs, quietMs: COMMAND_QUIET_MS, ceilingMs: COMMAND_SETTLE_CEILING_MS });
|
|
3119
|
+
return { commands: raw?.map(toAvailableCommandWire), configOptions };
|
|
1568
3120
|
}
|