@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
|
@@ -0,0 +1,837 @@
|
|
|
1
|
+
// The daemon's exec POLICY (spec §4.2, §8.2, §14; analysis E §3.1, §3.6, §6.3–6.4) on top of keeper-terminal's exec mechanism:
|
|
2
|
+
// every `exec_request` becomes `[shell, '-lc', command]` with the daemon-built environment and a deadline from the daemon's own
|
|
3
|
+
// clock, runs in keeper-terminal when it is available and compatible (durable: it survives a daemon restart), else in this process
|
|
4
|
+
// (the fallback, not durable), at most 32 at a time. Results are shaped by bytes (head + tail, UTF-8 safe). Cancel = SIGTERM, 5 s,
|
|
5
|
+
// SIGKILL. A restarted daemon recovers the execs it finds in keeper-terminal from their checkpoints.
|
|
6
|
+
//
|
|
7
|
+
// Wire ≤ 4: today's COMPUTER frames unchanged (exec_request → streamed exec_output → exec_exit with the same fields); an exec dies
|
|
8
|
+
// with its channel, so a keeper exec is killed on channel close and counts as a shell task exactly like an in-process one (`run`).
|
|
9
|
+
// Wire ≥ 5 (`keeperExecsOutliveTheChannel`, spec §4.3, §8.2): the keeper exec id IS the server's exec id, an exec is owned by this
|
|
10
|
+
// policy rather than by a channel (`startOrFind`/`find`/`cancel`/`acknowledge`; channels attach to it, exec/attach.ts), it is
|
|
11
|
+
// never a shell task, and a restarted daemon keeps it for the server to re-attach.
|
|
12
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
13
|
+
import { KeeperFrameTooLargeError, KeeperNotConnectedError, KeeperReplacementRequiredError, KeeperRequestError } from '../keepers/kit/client.js';
|
|
14
|
+
import { KeeperLaunchError } from '../keepers/terminal/client.js';
|
|
15
|
+
import { EXEC_CAPABILITY, EXEC_SIGNALS, EXEC_TERMINAL_TTL_MS, ExecErrorCode } from '../keepers/terminal/protocol.js';
|
|
16
|
+
import { execArgv, execCwd, execDeadline, execEnvironment } from './command.js';
|
|
17
|
+
import { DurableExec, EXEC_KILL_GRACE_MS } from './durable.js';
|
|
18
|
+
import { startExec as startInProcess } from './fallback.js';
|
|
19
|
+
/** Policy cap on commands running at once (spec §14; keeper-terminal's own hard limit is 128). Before this task: unlimited. */
|
|
20
|
+
export const EXEC_CONCURRENCY_LIMIT = 32;
|
|
21
|
+
/** keeper-terminal keeps this much of each stream's beginning however much is dropped (covers the 32 KiB result head). */
|
|
22
|
+
export const EXEC_RETAIN_HEAD_BYTES = 64 * 1024;
|
|
23
|
+
/** Uncommitted output keeper-terminal keeps per exec before dropping the oldest of it. */
|
|
24
|
+
export const EXEC_MAX_UNCOMMITTED_BYTES = 16 * 1024 * 1024;
|
|
25
|
+
/** Activity label until the server sends one (wire v5 `label`): never the command line. */
|
|
26
|
+
export const EXEC_DEFAULT_LABEL = 'Command';
|
|
27
|
+
/** Answer timeout of exec.start and of the exec.status probe after an unanswered start (capped by the time left to the deadline). */
|
|
28
|
+
export const EXEC_START_TIMEOUT_MS = 5000;
|
|
29
|
+
const MIN_CALL_TIMEOUT_MS = 500;
|
|
30
|
+
export const EXEC_LIMIT_MESSAGE = `Workstation is already running ${EXEC_CONCURRENCY_LIMIT} commands; wait for one to finish`;
|
|
31
|
+
export const EXEC_UNCONFIRMED_MESSAGE = 'Workstation could not confirm whether the command started; it may be running. Check its effects before running it again.';
|
|
32
|
+
/** The COMPUTER wire version that makes execs re-attachable (spec §4.3). */
|
|
33
|
+
export const DURABLE_EXEC_WIRE_VERSION = 5;
|
|
34
|
+
/**
|
|
35
|
+
* THE switch (spec §4.3 "compat", §8.2): does an exec running in keeper-terminal outlive the computer channel that started it?
|
|
36
|
+
*
|
|
37
|
+
* `false` under wire ≤ 4: the server treats a closed channel as the end of the exec and an agent may start the command again, so
|
|
38
|
+
* the daemon kills it on channel close (as it always did) and, since a daemon restart closes the channel, it counts in
|
|
39
|
+
* `activeShellTasks`. `true` from wire v5 on (exec_started/attach/ack): a keeper exec is never a shell task, is admitted while an
|
|
40
|
+
* update drains, survives a force update and a channel close, and a restarted daemon keeps it running for the server to re-attach.
|
|
41
|
+
*/
|
|
42
|
+
export function keeperExecsOutliveTheChannel(wireVersion) {
|
|
43
|
+
return wireVersion >= DURABLE_EXEC_WIRE_VERSION;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Prefix of the keeper exec ids of wire ≤ 4 execs (`run`): those die with their channel. Any other exec id is a wire v5 server id
|
|
47
|
+
* (`keeperExecIdOf`), which is how a restarted daemon tells the two apart when an exec has no checkpoint yet (it died between
|
|
48
|
+
* `exec.start` and the first checkpoint).
|
|
49
|
+
*/
|
|
50
|
+
export const CHANNEL_BOUND_EXEC_PREFIX = 'd4-';
|
|
51
|
+
const channelBound = (execId) => execId.startsWith(CHANNEL_BOUND_EXEC_PREFIX);
|
|
52
|
+
/** keeper-terminal's `jobId` bound (keepers/terminal/exec.ts): a longer one would make it refuse the start. */
|
|
53
|
+
export const EXEC_JOB_ID_CHARS = 256;
|
|
54
|
+
/** keeper-terminal's exec id grammar (keepers/terminal/exec.ts). */
|
|
55
|
+
const KEEPER_EXEC_ID = /^[A-Za-z0-9_.:-]{1,128}$/;
|
|
56
|
+
/**
|
|
57
|
+
* Wire v5: the keeper exec id of the server's exec id — the id itself (the server sends UUIDs), or a fixed hash of an id the keeper
|
|
58
|
+
* would not accept. Deterministic, so a restarted daemon finds the exec again from the server's id alone.
|
|
59
|
+
*/
|
|
60
|
+
export function keeperExecIdOf(serverId) {
|
|
61
|
+
const usable = KEEPER_EXEC_ID.test(serverId) && serverId !== '.' && serverId !== '..' && !channelBound(serverId);
|
|
62
|
+
return usable ? serverId : `h-${createHash('sha256').update(serverId).digest('hex').slice(0, 40)}`;
|
|
63
|
+
}
|
|
64
|
+
const isExecSignal = (value) => value !== undefined && EXEC_SIGNALS.includes(value);
|
|
65
|
+
function errorCode(error) {
|
|
66
|
+
return typeof error === 'object' && error !== null && typeof error.code === 'string' ? error.code : undefined;
|
|
67
|
+
}
|
|
68
|
+
const messageOf = (error) => (error instanceof Error ? error.message : String(error));
|
|
69
|
+
/** The keeper refused the request, or it never left this process: certainly nothing started. */
|
|
70
|
+
function startedNothing(error) {
|
|
71
|
+
return error instanceof KeeperRequestError || error instanceof KeeperLaunchError || error instanceof KeeperNotConnectedError
|
|
72
|
+
|| error instanceof KeeperFrameTooLargeError || error instanceof KeeperReplacementRequiredError;
|
|
73
|
+
}
|
|
74
|
+
const ABORTED = Symbol('aborted');
|
|
75
|
+
/** `work`, or ABORTED as soon as `signal` aborts (work keeps going; the caller settles its late result). */
|
|
76
|
+
function untilAborted(work, signal) {
|
|
77
|
+
if (!signal)
|
|
78
|
+
return work;
|
|
79
|
+
if (signal.aborted)
|
|
80
|
+
return Promise.resolve(ABORTED);
|
|
81
|
+
return new Promise((resolve, reject) => {
|
|
82
|
+
const onAbort = () => resolve(ABORTED);
|
|
83
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
84
|
+
work.then((value) => { signal.removeEventListener('abort', onAbort); resolve(value); }, (error) => { signal.removeEventListener('abort', onAbort); reject(error); });
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
const exitFrame = (id, stderr, exitCode = null) => ({ t: 'exec_exit', id, exitCode, stdout: '', stderr, timedOut: false, truncated: false });
|
|
88
|
+
/** Appended to stderr when keeper-terminal died while the command ran (its successor stopped the command or could not verify it). */
|
|
89
|
+
export const KEEPER_LOST_NOTE = '\n[workstation] keeper-terminal exited while the command was running; the command did not finish normally.';
|
|
90
|
+
export const KEEPER_STOPPED_NOTE = '\n[workstation] keeper-terminal was stopped while the command was running; the command was stopped.';
|
|
91
|
+
export const unknownOutcomeNote = (reason) => `\n[workstation] Lost track of the command (${reason}); it may still be running or may have finished. Check its effects before running it again.`;
|
|
92
|
+
/** The `exec_exit` frame of a durable outcome: today's field set, today's `exitCode:null` for timed out / cancelled / not finished. */
|
|
93
|
+
export function durableExitFrame(id, outcome) {
|
|
94
|
+
let { stderr } = outcome;
|
|
95
|
+
if (outcome.state === 'unknown') {
|
|
96
|
+
return { t: 'exec_exit', id, exitCode: null, stdout: outcome.stdout, stderr: stderr + unknownOutcomeNote(outcome.reason), timedOut: false, truncated: outcome.truncated };
|
|
97
|
+
}
|
|
98
|
+
const { exit } = outcome;
|
|
99
|
+
if (exit.reason === 'spawn_failed')
|
|
100
|
+
stderr += exit.error ?? 'Command could not be started';
|
|
101
|
+
else if (exit.reason === 'keeper_lost')
|
|
102
|
+
stderr += KEEPER_LOST_NOTE;
|
|
103
|
+
else if (exit.reason === 'keeper_stopped' && !outcome.cancelled && !exit.timedOut)
|
|
104
|
+
stderr += KEEPER_STOPPED_NOTE;
|
|
105
|
+
const finished = !exit.timedOut && !outcome.cancelled && exit.reason === undefined;
|
|
106
|
+
return { t: 'exec_exit', id, exitCode: finished ? exit.code : null, stdout: outcome.stdout, stderr, timedOut: exit.timedOut, truncated: outcome.truncated };
|
|
107
|
+
}
|
|
108
|
+
/** How often `find` asks keeper-terminal before it reports the exec's state undecidable ({@link ExecStateUndecidableError}). */
|
|
109
|
+
const FIND_ATTEMPTS = 3;
|
|
110
|
+
/**
|
|
111
|
+
* Bound of one keeper-terminal presence probe in recovery and `find`: a real connect and hello. A keeper that does not answer within
|
|
112
|
+
* it is "could not tell", never "not there" (a loaded machine, a daemon that just restarted).
|
|
113
|
+
*/
|
|
114
|
+
export const EXEC_PROBE_TIMEOUT_MS = 3000;
|
|
115
|
+
/**
|
|
116
|
+
* `find` could not tell whether keeper-terminal runs the exec (a keeper that does not answer, a listing that failed): the caller
|
|
117
|
+
* must not answer `unknown` — the exec may well be running — but ask again later (exec_attach defers its answer).
|
|
118
|
+
*/
|
|
119
|
+
export class ExecStateUndecidableError extends Error {
|
|
120
|
+
constructor(message) { super(message); this.name = 'ExecStateUndecidableError'; }
|
|
121
|
+
}
|
|
122
|
+
const PENDING_ACKS_LIMIT = 1024;
|
|
123
|
+
/** Fallback execs remembered for re-attach (Task 26); ended ones are evicted first, oldest first. */
|
|
124
|
+
export const FALLBACK_RECORDS_LIMIT = 1024;
|
|
125
|
+
/**
|
|
126
|
+
* An ended fallback record keeps its exit (its shaped output, up to ~256 KiB) this long — far past the server's 90 s reconnect grace —
|
|
127
|
+
* and at most {@link FALLBACK_OUTPUT_RECORDS_LIMIT} ended records keep one at all. Past either it becomes a tombstone: still answered
|
|
128
|
+
* (exited, never "unknown", never run again), but with a short note instead of the output.
|
|
129
|
+
*/
|
|
130
|
+
export const FALLBACK_RECORD_TTL_MS = 10 * 60_000;
|
|
131
|
+
export const FALLBACK_OUTPUT_RECORDS_LIMIT = 64;
|
|
132
|
+
export const FALLBACK_EXPIRED_NOTE = '[workstation] This command ran in the workstation daemon (not durable); its output is no longer kept.';
|
|
133
|
+
/** Exec ids this daemon was asked about (attach) or sent to keeper-terminal: a later request for one is a repeat (Task 26). */
|
|
134
|
+
const SEEN_IDS_LIMIT = 4096;
|
|
135
|
+
const FIND_RETRY_MS = 500;
|
|
136
|
+
export class ExecPolicy {
|
|
137
|
+
options;
|
|
138
|
+
now;
|
|
139
|
+
log;
|
|
140
|
+
limit;
|
|
141
|
+
killGraceMs;
|
|
142
|
+
/** run() calls in progress (keeper or in-process). */
|
|
143
|
+
running = 0;
|
|
144
|
+
/** Durable execs this daemon follows outside a run(): detached ones and those recovered after a restart. */
|
|
145
|
+
followed = new Map();
|
|
146
|
+
/** Exec ids between `exec.start` and the run that follows them (recovery must not take them). */
|
|
147
|
+
starting = new Set();
|
|
148
|
+
inRun = new Set();
|
|
149
|
+
/** keeper-terminal processes whose execs were recovered (a keeper started later, or restarted, gets recovered too). */
|
|
150
|
+
recoveredPids = new Set();
|
|
151
|
+
recovering;
|
|
152
|
+
/** Wire v5: starts in flight by keeper exec id (a repeated request joins them), exec_cancel received meanwhile. */
|
|
153
|
+
startingDurable = new Map();
|
|
154
|
+
pendingCancels = new Map();
|
|
155
|
+
/** Restores in flight by keeper exec id (recovery and `find` share them: never two followers for one exec). */
|
|
156
|
+
loading = new Map();
|
|
157
|
+
frameTooLargeLogged = false;
|
|
158
|
+
/** exec_ack for execs not followed yet (by keeper exec id, highest offset), applied on adoption. Insertion-ordered, bounded. */
|
|
159
|
+
pendingAcks = new Map();
|
|
160
|
+
/** exec_cancel for execs `find` could not decide about (by keeper exec id): applied when recovery adopts them. Bounded. */
|
|
161
|
+
cancelOnAdopt = new Map();
|
|
162
|
+
/** Wire v5 fallback execs this daemon ran, by keeper exec id (insertion-ordered, bounded): see {@link FallbackExecRecord}. */
|
|
163
|
+
fallbackExecs = new Map();
|
|
164
|
+
/**
|
|
165
|
+
* Keeper exec ids whose start may have reached keeper-terminal, or that a server asked to re-attach (insertion-ordered, bounded).
|
|
166
|
+
* A request for one of them is a REPEAT: if keeper-terminal cannot be reached right now it may still be running there, so a repeat
|
|
167
|
+
* never falls back to this process (Task 26) — the answer is "unconfirmed" instead of a possible second run.
|
|
168
|
+
*/
|
|
169
|
+
seenIds = new Set();
|
|
170
|
+
constructor(options = {}) {
|
|
171
|
+
this.options = options;
|
|
172
|
+
this.now = options.now ?? Date.now;
|
|
173
|
+
this.log = options.log ?? (() => { });
|
|
174
|
+
this.limit = options.limit ?? EXEC_CONCURRENCY_LIMIT;
|
|
175
|
+
this.killGraceMs = options.killGraceMs ?? EXEC_KILL_GRACE_MS;
|
|
176
|
+
}
|
|
177
|
+
/** Commands running now: in a run(), plus durable ones followed without one (detached, recovered and still being stopped). */
|
|
178
|
+
get activeCount() {
|
|
179
|
+
let followed = 0;
|
|
180
|
+
for (const exec of this.followed.values())
|
|
181
|
+
if (!exec.finished)
|
|
182
|
+
followed += 1;
|
|
183
|
+
return this.running + followed;
|
|
184
|
+
}
|
|
185
|
+
/** A durable exec followed outside a run (recovered or detached), for the server to re-attach (wire v5, Task 12). */
|
|
186
|
+
followedExec(execId) { return this.followed.get(execId); }
|
|
187
|
+
/** Where a new exec starts; null = no keeper-terminal at all. */
|
|
188
|
+
async servingKeeper() {
|
|
189
|
+
const generations = this.options.generations;
|
|
190
|
+
if (generations)
|
|
191
|
+
return generations.serving();
|
|
192
|
+
return this.options.keeper ?? null;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Every keeper-terminal generation this daemon can route to, the serving one first (none is started here). Rejects when the other
|
|
196
|
+
* generations cannot be listed: "no older generation" is never assumed (an older one may run the exec asked about).
|
|
197
|
+
*/
|
|
198
|
+
async allKeepers() {
|
|
199
|
+
const generations = this.options.generations;
|
|
200
|
+
if (!generations)
|
|
201
|
+
return this.options.keeper ? [this.options.keeper] : [];
|
|
202
|
+
const [serving, others] = await Promise.all([generations.serving(), generations.others()]);
|
|
203
|
+
return [serving, ...others.filter((keeper) => keeper !== serving)];
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* The hello of `keeper` when it runs; null only when it is provably absent (or, for a keeper without `probe`, when `runningHello`
|
|
207
|
+
* says so). Throws {@link ExecStateUndecidableError} when it could not be told.
|
|
208
|
+
*/
|
|
209
|
+
async presentHello(keeper) {
|
|
210
|
+
if (!keeper.probe)
|
|
211
|
+
return keeper.runningHello();
|
|
212
|
+
const presence = await keeper.probe(this.options.probeTimeoutMs ?? EXEC_PROBE_TIMEOUT_MS);
|
|
213
|
+
if (presence.state === 'running')
|
|
214
|
+
return presence.hello;
|
|
215
|
+
if (presence.state === 'absent')
|
|
216
|
+
return null;
|
|
217
|
+
throw new ExecStateUndecidableError(`keeper-terminal could not be reached: ${presence.reason}`);
|
|
218
|
+
}
|
|
219
|
+
markSeen(execId) {
|
|
220
|
+
this.seenIds.delete(execId);
|
|
221
|
+
this.seenIds.add(execId);
|
|
222
|
+
if (this.seenIds.size > SEEN_IDS_LIMIT)
|
|
223
|
+
this.seenIds.delete(this.seenIds.values().next().value);
|
|
224
|
+
}
|
|
225
|
+
durableOptions() {
|
|
226
|
+
return { ...this.options.durable, now: this.now, killGraceMs: this.killGraceMs, log: this.log };
|
|
227
|
+
}
|
|
228
|
+
/** `ComputerExecutor`: runs one `exec_request` and answers its `exec_exit`. Never rejects. */
|
|
229
|
+
async run(req, onOutput, signal, hooks = {}) {
|
|
230
|
+
if (this.activeCount >= this.limit)
|
|
231
|
+
return exitFrame(req.id, EXEC_LIMIT_MESSAGE);
|
|
232
|
+
this.running += 1;
|
|
233
|
+
try {
|
|
234
|
+
return await this.execute(req, onOutput, signal, hooks);
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
return exitFrame(req.id, messageOf(error));
|
|
238
|
+
}
|
|
239
|
+
finally {
|
|
240
|
+
this.running -= 1;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
async execute(req, onOutput, signal, hooks) {
|
|
244
|
+
const receivedAtMs = this.now();
|
|
245
|
+
const deadlineEpochMs = execDeadline(req, receivedAtMs);
|
|
246
|
+
const env = execEnvironment(this.options.env?.() ?? process.env, req);
|
|
247
|
+
if (signal?.aborted)
|
|
248
|
+
return exitFrame(req.id, '');
|
|
249
|
+
const execId = this.options.newExecId?.() ?? `${hooks.outlivesChannel ? '' : CHANNEL_BOUND_EXEC_PREFIX}${randomUUID()}`;
|
|
250
|
+
const starting = this.start(req, env, deadlineEpochMs, { execId, outlivesChannel: hooks.outlivesChannel === true });
|
|
251
|
+
// exec_cancel, a channel close or a force stop answers at once, however long keeper-terminal takes to answer the start.
|
|
252
|
+
const started = await untilAborted(starting, signal);
|
|
253
|
+
if (started === ABORTED) {
|
|
254
|
+
void starting.then((late) => {
|
|
255
|
+
if (late.kind !== 'durable')
|
|
256
|
+
return;
|
|
257
|
+
this.stopUnowned(late.exec); // it started after all: stop it like the abort would have
|
|
258
|
+
}, (error) => this.log(`exec ${req.id}: ${messageOf(error)}`));
|
|
259
|
+
return exitFrame(req.id, '');
|
|
260
|
+
}
|
|
261
|
+
if (started.kind === 'exit')
|
|
262
|
+
return started.exit;
|
|
263
|
+
if (started.kind === 'fallback') {
|
|
264
|
+
if (this.options.keeper || this.options.generations)
|
|
265
|
+
this.log(`exec ${req.id}: running in the daemon (not durable): ${started.why}`);
|
|
266
|
+
return this.runInProcess(req, env, deadlineEpochMs, onOutput, signal, hooks);
|
|
267
|
+
}
|
|
268
|
+
return this.follow(req, started.exec, onOutput, signal, hooks);
|
|
269
|
+
}
|
|
270
|
+
/** Answer timeout of exec.start / exec.status: a few seconds, never longer than the time left before the command's deadline. */
|
|
271
|
+
callTimeout(deadlineEpochMs) {
|
|
272
|
+
const base = this.options.startTimeoutMs ?? EXEC_START_TIMEOUT_MS;
|
|
273
|
+
return deadlineEpochMs === undefined ? base : Math.max(MIN_CALL_TIMEOUT_MS, Math.min(base, deadlineEpochMs - this.now()));
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* keeper-terminal when it is reachable and serves exec. The fallback only when the keeper certainly did not start the command:
|
|
277
|
+
* the request never left (launch/connect failure, not connected, frame too large, protocol window) or the keeper refused it.
|
|
278
|
+
* Any other failure (connection lost or closed after the write, timeout) is settled by `exec.status`; an `exec_not_found` counts
|
|
279
|
+
* only from the very keeper process that received the start. Undecidable: an error result, never a second run, and the exec id is
|
|
280
|
+
* left to recovery (which stops it if it did start).
|
|
281
|
+
*/
|
|
282
|
+
async start(req, env, deadlineEpochMs, how) {
|
|
283
|
+
const { execId, outlivesChannel, wire } = how;
|
|
284
|
+
const keeper = await this.servingKeeper();
|
|
285
|
+
if (!keeper)
|
|
286
|
+
return { kind: 'fallback', why: 'no keeper-terminal' };
|
|
287
|
+
let hello;
|
|
288
|
+
try {
|
|
289
|
+
hello = (await keeper.ensure()).hello;
|
|
290
|
+
}
|
|
291
|
+
catch (error) {
|
|
292
|
+
// A repeat may be running in a keeper-terminal this daemon cannot reach right now: running it here too could run it twice.
|
|
293
|
+
if (how.repeat) {
|
|
294
|
+
this.log(`exec ${req.id}: repeated request while keeper-terminal is unreachable: ${messageOf(error)}`);
|
|
295
|
+
return { kind: 'exit', exit: exitFrame(req.id, EXEC_UNCONFIRMED_MESSAGE) };
|
|
296
|
+
}
|
|
297
|
+
return { kind: 'fallback', why: `keeper-terminal unavailable: ${messageOf(error)}` };
|
|
298
|
+
}
|
|
299
|
+
if (!hello?.capabilities.includes(EXEC_CAPABILITY))
|
|
300
|
+
return { kind: 'fallback', why: 'keeper-terminal does not serve exec' };
|
|
301
|
+
if (!this.recoveredPids.has(hello.pid))
|
|
302
|
+
void this.recover(); // a keeper process this daemon has not recovered yet
|
|
303
|
+
const params = {
|
|
304
|
+
execId, argv: execArgv(req), cwd: execCwd(req), env,
|
|
305
|
+
...(deadlineEpochMs !== undefined ? { deadlineEpochMs } : {}),
|
|
306
|
+
killGraceMs: this.killGraceMs, retainHeadBytes: EXEC_RETAIN_HEAD_BYTES, maxUncommittedBytes: EXEC_MAX_UNCOMMITTED_BYTES,
|
|
307
|
+
onKeeperLoss: 'kill', label: how.label || EXEC_DEFAULT_LABEL,
|
|
308
|
+
// keeper-terminal refuses a jobId beyond its bound; it only labels activity items, so an oversized one is dropped, not fatal.
|
|
309
|
+
...(how.jobId && how.jobId.length <= EXEC_JOB_ID_CHARS ? { jobId: how.jobId } : {}),
|
|
310
|
+
};
|
|
311
|
+
this.starting.add(execId); // until follow() (or a late stop) owns it: recovery must never take it
|
|
312
|
+
let owned = false;
|
|
313
|
+
try {
|
|
314
|
+
const before = await how.beforeStart?.(keeper);
|
|
315
|
+
if (before) {
|
|
316
|
+
owned = before.kind === 'durable';
|
|
317
|
+
return before;
|
|
318
|
+
}
|
|
319
|
+
if (wire)
|
|
320
|
+
this.markSeen(execId); // from here on it may exist in keeper-terminal
|
|
321
|
+
const descriptor = await keeper.execStart(params, { timeoutMs: this.callTimeout(deadlineEpochMs) });
|
|
322
|
+
owned = true;
|
|
323
|
+
return { kind: 'durable', exec: DurableExec.follow(keeper, descriptor, { outlivesChannel, ...(wire ? { wire } : {}) }, this.durableOptions()) };
|
|
324
|
+
}
|
|
325
|
+
catch (error) {
|
|
326
|
+
// Another keeper-terminal generation owns an exec of this id (spec §3.1: two generations may coexist): it exists, so it is
|
|
327
|
+
// never started again here and never run in this process.
|
|
328
|
+
if (errorCode(error) === ExecErrorCode.foreign) {
|
|
329
|
+
this.log(`exec ${req.id}: owned by another keeper-terminal generation`);
|
|
330
|
+
return { kind: 'exit', exit: exitFrame(req.id, EXEC_UNCONFIRMED_MESSAGE) };
|
|
331
|
+
}
|
|
332
|
+
if (startedNothing(error)) {
|
|
333
|
+
if (error instanceof KeeperFrameTooLargeError && !this.frameTooLargeLogged) {
|
|
334
|
+
this.frameTooLargeLogged = true; // once per daemon: every such command runs in the daemon, not durable
|
|
335
|
+
this.log(`exec.start frames over keeper-terminal's inbound cap (a very large command or environment) run in the daemon, not durable`);
|
|
336
|
+
}
|
|
337
|
+
return { kind: 'fallback', why: `exec.start refused: ${messageOf(error)}` };
|
|
338
|
+
}
|
|
339
|
+
try {
|
|
340
|
+
const status = await keeper.execStatus(execId, { timeoutMs: this.callTimeout(deadlineEpochMs) });
|
|
341
|
+
const exec = await DurableExec.restore(keeper, status, { ...this.durableOptions(), outlivesChannel, ...(wire ? { wire } : {}) });
|
|
342
|
+
owned = true;
|
|
343
|
+
return { kind: 'durable', exec };
|
|
344
|
+
}
|
|
345
|
+
catch (probe) {
|
|
346
|
+
if (errorCode(probe) === 'exec_not_found') {
|
|
347
|
+
const now = await keeper.runningHello().catch(() => null);
|
|
348
|
+
if (now && now.pid === hello.pid && now.gen === hello.gen)
|
|
349
|
+
return { kind: 'fallback', why: `exec.start failed: ${messageOf(error)}` };
|
|
350
|
+
}
|
|
351
|
+
this.log(`exec ${req.id}: start unconfirmed: ${messageOf(error)}; ${messageOf(probe)}`);
|
|
352
|
+
this.recoverLater();
|
|
353
|
+
return { kind: 'exit', exit: exitFrame(req.id, EXEC_UNCONFIRMED_MESSAGE) };
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
finally {
|
|
357
|
+
if (!owned)
|
|
358
|
+
this.starting.delete(execId);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* An exec this daemon no longer follows may still run in keeper-terminal (an unconfirmed start, a keeper that stopped
|
|
363
|
+
* answering): the next time the daemon reaches the keeper it runs recovery again, which stops and deletes every exec nobody owns.
|
|
364
|
+
*/
|
|
365
|
+
recoverLater() { this.recoveredPids.clear(); }
|
|
366
|
+
async follow(req, exec, onOutput, signal, hooks) {
|
|
367
|
+
this.inRun.add(exec.execId);
|
|
368
|
+
this.starting.delete(exec.execId);
|
|
369
|
+
hooks.onStarted?.({ durable: true, execId: exec.execId, ...(exec.pid !== null ? { pid: exec.pid } : {}), startedAtMs: this.now() });
|
|
370
|
+
exec.attach(req.streaming && onOutput ? (stream, text) => onOutput(stream, text) : undefined);
|
|
371
|
+
const onAbort = () => { void exec.cancel(); };
|
|
372
|
+
if (signal?.aborted)
|
|
373
|
+
onAbort();
|
|
374
|
+
else
|
|
375
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
376
|
+
try {
|
|
377
|
+
const outcome = await exec.outcome;
|
|
378
|
+
if (outcome.state === 'unknown')
|
|
379
|
+
this.recoverLater();
|
|
380
|
+
// Wire ≤ 4: the result goes out first; the keeper deletes the exec afterwards (wire v5 waits for the server's ack instead).
|
|
381
|
+
setImmediate(() => { void exec.release(); });
|
|
382
|
+
return durableExitFrame(req.id, outcome);
|
|
383
|
+
}
|
|
384
|
+
finally {
|
|
385
|
+
signal?.removeEventListener('abort', onAbort);
|
|
386
|
+
this.inRun.delete(exec.execId);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
runInProcess(req, env, deadlineEpochMs, onOutput, signal, hooks) {
|
|
390
|
+
// Never spawn what is already cancelled or already past its deadline.
|
|
391
|
+
if (signal?.aborted)
|
|
392
|
+
return Promise.resolve(exitFrame(req.id, ''));
|
|
393
|
+
if (deadlineEpochMs !== undefined && deadlineEpochMs <= this.now())
|
|
394
|
+
return Promise.resolve({ ...exitFrame(req.id, ''), timedOut: true });
|
|
395
|
+
const running = startInProcess(req, onOutput, { env, deadlineEpochMs, now: this.now });
|
|
396
|
+
hooks.onStarted?.({ durable: false, ...(running.pid !== undefined ? { pid: running.pid } : {}), startedAtMs: this.now() });
|
|
397
|
+
const onAbort = () => { void running.cancel(); }; // an in-process exec dies with its channel, whatever the wire
|
|
398
|
+
if (signal?.aborted)
|
|
399
|
+
onAbort();
|
|
400
|
+
else
|
|
401
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
402
|
+
return running.result.finally(() => signal?.removeEventListener('abort', onAbort));
|
|
403
|
+
}
|
|
404
|
+
/** Follows `exec` outside any run only to stop it (SIGTERM, grace, SIGKILL; or completing a cancel already begun) and delete it. */
|
|
405
|
+
stopUnowned(exec) {
|
|
406
|
+
const { execId } = exec;
|
|
407
|
+
this.starting.delete(execId);
|
|
408
|
+
this.followed.set(execId, exec);
|
|
409
|
+
void exec.cancel();
|
|
410
|
+
void exec.outcome.then((outcome) => {
|
|
411
|
+
if (outcome.state === 'unknown')
|
|
412
|
+
this.recoverLater();
|
|
413
|
+
return exec.release();
|
|
414
|
+
}).finally(() => { if (this.followed.get(execId) === exec)
|
|
415
|
+
this.followed.delete(execId); });
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Daemon start (and whenever the daemon reaches a keeper-terminal process it has not recovered, or after it lost track of an
|
|
419
|
+
* exec): follows every exec keeper-terminal still has that nobody here owns. Each is restored from its checkpoint. One that does
|
|
420
|
+
* not outlive its channel (all of them under wire ≤ 4) lost its channel and the server already reported it ended: it is stopped
|
|
421
|
+
* (SIGTERM, grace, SIGKILL, also completing a cancel the previous daemon began) and deleted once ended. One that outlives its
|
|
422
|
+
* channel stays followed for the server to re-attach. Starts no keeper. One recovery at a time (callers join the one in flight).
|
|
423
|
+
*/
|
|
424
|
+
recover() {
|
|
425
|
+
this.recovering ??= this.recoverOnce().finally(() => { this.recovering = undefined; });
|
|
426
|
+
return this.recovering;
|
|
427
|
+
}
|
|
428
|
+
async recoverOnce() {
|
|
429
|
+
let keepers;
|
|
430
|
+
try {
|
|
431
|
+
keepers = await this.allKeepers();
|
|
432
|
+
}
|
|
433
|
+
catch (error) {
|
|
434
|
+
this.log(`exec recovery: ${messageOf(error)}`);
|
|
435
|
+
this.recoverLater();
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
// Every running generation: an older one keeps the execs it runs (they are followed through its own client, spec §8.3).
|
|
439
|
+
for (const keeper of keepers)
|
|
440
|
+
await this.recoverFrom(keeper);
|
|
441
|
+
}
|
|
442
|
+
async recoverFrom(keeper) {
|
|
443
|
+
let ids;
|
|
444
|
+
let pid;
|
|
445
|
+
try {
|
|
446
|
+
// Absent (proven): nothing to recover there. Not answering: thrown, so recovery runs again later (a slow keeper is not a gone one).
|
|
447
|
+
const hello = await this.presentHello(keeper);
|
|
448
|
+
if (!hello?.capabilities.includes(EXEC_CAPABILITY))
|
|
449
|
+
return;
|
|
450
|
+
pid = hello.pid;
|
|
451
|
+
ids = (await keeper.execList()).execs.map((exec) => exec.execId);
|
|
452
|
+
}
|
|
453
|
+
catch (error) {
|
|
454
|
+
this.log(`exec recovery: ${messageOf(error)}`);
|
|
455
|
+
this.recoverLater();
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
// Marked only once the list is in hand: a failed list leaves this keeper process to the next start's recovery.
|
|
459
|
+
this.recoveredPids.add(pid);
|
|
460
|
+
for (const execId of ids) {
|
|
461
|
+
if (this.owned(execId))
|
|
462
|
+
continue;
|
|
463
|
+
try {
|
|
464
|
+
await this.load(execId, keeper);
|
|
465
|
+
}
|
|
466
|
+
catch (error) {
|
|
467
|
+
this.log(`exec recovery ${execId}: ${messageOf(error)}`);
|
|
468
|
+
this.recoverLater();
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
/** Followed, being started or in a run: somebody here owns it. */
|
|
473
|
+
owned(execId) {
|
|
474
|
+
return this.starting.has(execId) || this.inRun.has(execId) || this.followed.has(execId) || this.startingDurable.has(execId);
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* Restores `execId` from keeper-terminal and follows it (recovery and `find` share this; one restore per exec at a time): one
|
|
478
|
+
* that outlives its channel (wire v5) for the server to re-attach, any other (its channel died with the daemon that started it,
|
|
479
|
+
* and the server already reported it ended) only to stop it (SIGTERM, grace, SIGKILL, also completing a cancel the previous daemon
|
|
480
|
+
* began) and delete it once ended. null: the keeper does not know it. Throws when the keeper could not tell. Starts no keeper.
|
|
481
|
+
*/
|
|
482
|
+
load(execId, keeper) {
|
|
483
|
+
const inFlight = this.loading.get(execId);
|
|
484
|
+
if (inFlight)
|
|
485
|
+
return inFlight;
|
|
486
|
+
const loading = (async () => {
|
|
487
|
+
let status;
|
|
488
|
+
try {
|
|
489
|
+
status = await keeper.execStatus(execId);
|
|
490
|
+
}
|
|
491
|
+
catch (error) {
|
|
492
|
+
if (errorCode(error) === ExecErrorCode.notFound)
|
|
493
|
+
return null;
|
|
494
|
+
throw error;
|
|
495
|
+
}
|
|
496
|
+
// Without a checkpoint (the daemon died between exec.start and the first one) the id tells: a channel-bound wire ≤ 4 exec is
|
|
497
|
+
// stopped, anything else is a wire v5 exec the server may still re-attach (conservatively non-streaming: it gets the exit).
|
|
498
|
+
const exec = await DurableExec.restore(keeper, status, {
|
|
499
|
+
...this.durableOptions(), outlivesChannel: !channelBound(execId), ...(channelBound(execId) ? {} : { wire: { streaming: false } }),
|
|
500
|
+
});
|
|
501
|
+
const owner = this.followed.get(execId);
|
|
502
|
+
if (owner || this.starting.has(execId) || this.inRun.has(execId) || this.startingDurable.has(execId)) {
|
|
503
|
+
exec.abandon();
|
|
504
|
+
return owner ?? null;
|
|
505
|
+
}
|
|
506
|
+
if (exec.outlivesChannel)
|
|
507
|
+
this.adopt(execId, exec);
|
|
508
|
+
else
|
|
509
|
+
this.stopUnowned(exec);
|
|
510
|
+
return exec;
|
|
511
|
+
})().finally(() => { this.loading.delete(execId); });
|
|
512
|
+
this.loading.set(execId, loading);
|
|
513
|
+
return loading;
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* Wire v5: this policy follows `exec` for the server (no channel owns it). Once ended and not released by the server's ack it is
|
|
517
|
+
* forgotten after keeper-terminal's own log TTL (keeper-terminal deletes it then anyway).
|
|
518
|
+
*/
|
|
519
|
+
adopt(execId, exec) {
|
|
520
|
+
this.followed.set(execId, exec);
|
|
521
|
+
const cancel = this.cancelOnAdopt.get(execId);
|
|
522
|
+
if (cancel) {
|
|
523
|
+
this.cancelOnAdopt.delete(execId);
|
|
524
|
+
void exec.cancel(cancel);
|
|
525
|
+
}
|
|
526
|
+
const acked = this.pendingAcks.get(execId);
|
|
527
|
+
if (acked !== undefined) {
|
|
528
|
+
this.pendingAcks.delete(execId);
|
|
529
|
+
this.applyAck(execId, exec, acked);
|
|
530
|
+
// A restored exec learns its final head only once it has processed its exit record: the ack may be the final one.
|
|
531
|
+
if (!exec.finished)
|
|
532
|
+
void exec.outcome.then(() => { if (this.followed.get(execId) === exec)
|
|
533
|
+
this.applyAck(execId, exec, acked); });
|
|
534
|
+
}
|
|
535
|
+
void exec.outcome.then((outcome) => {
|
|
536
|
+
if (outcome.state === 'unknown')
|
|
537
|
+
this.recoverLater();
|
|
538
|
+
const timer = setTimeout(() => { if (this.followed.get(execId) === exec)
|
|
539
|
+
this.followed.delete(execId); }, EXEC_TERMINAL_TTL_MS);
|
|
540
|
+
timer.unref();
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
// ---- COMPUTER wire v5 (spec §4.3): execs the server can re-attach to ------------------------------------------------
|
|
544
|
+
/**
|
|
545
|
+
* An `exec_request` under wire v5. The keeper exec id is the server's id ({@link keeperExecIdOf}): an id this daemon or
|
|
546
|
+
* keeper-terminal already knows is that exec (attach; never a second run). Otherwise it starts in keeper-terminal (durable) when
|
|
547
|
+
* it can; the fallback is left to the caller (`run`), which decides whether to admit it (spec §8.2: refused while draining).
|
|
548
|
+
* Never rejects.
|
|
549
|
+
*/
|
|
550
|
+
startOrFind(req) {
|
|
551
|
+
const receivedAtMs = this.now();
|
|
552
|
+
const execId = keeperExecIdOf(req.id);
|
|
553
|
+
// Synchronous up to the registration below: two requests for one id can never both start it.
|
|
554
|
+
const inFlight = this.startingDurable.get(execId);
|
|
555
|
+
if (inFlight)
|
|
556
|
+
return inFlight.then((first) => this.repeated(req, first));
|
|
557
|
+
const followed = this.followed.get(execId);
|
|
558
|
+
if (followed)
|
|
559
|
+
return Promise.resolve({ kind: 'durable', exec: followed, existed: true });
|
|
560
|
+
// It ran (or runs) in this process: never again. Its exit answers the repeat once it ended (a closed channel already stopped it).
|
|
561
|
+
const fallback = this.fallbackOf(req.id);
|
|
562
|
+
if (fallback)
|
|
563
|
+
return fallback.exit.then((exit) => ({ kind: 'exit', exit: { ...exit, id: req.id } }));
|
|
564
|
+
const repeat = this.seenIds.has(execId);
|
|
565
|
+
const starting = (async () => {
|
|
566
|
+
const loaded = await this.loading.get(execId)?.catch(() => null); // recovery is restoring it right now
|
|
567
|
+
if (loaded)
|
|
568
|
+
return { kind: 'durable', exec: loaded, existed: true };
|
|
569
|
+
if (this.activeCount >= this.limit)
|
|
570
|
+
return { kind: 'exit', exit: exitFrame(req.id, EXEC_LIMIT_MESSAGE) };
|
|
571
|
+
const deadlineEpochMs = execDeadline(req, receivedAtMs);
|
|
572
|
+
const env = execEnvironment(this.options.env?.() ?? process.env, req);
|
|
573
|
+
return await this.startDurable(req, execId, env, deadlineEpochMs, repeat);
|
|
574
|
+
})().catch((error) => ({ kind: 'exit', exit: exitFrame(req.id, messageOf(error)) }));
|
|
575
|
+
this.startingDurable.set(execId, starting);
|
|
576
|
+
void starting.finally(() => { if (this.startingDurable.get(execId) === starting)
|
|
577
|
+
this.startingDurable.delete(execId); });
|
|
578
|
+
return starting;
|
|
579
|
+
}
|
|
580
|
+
/** A request repeated while the first was being started: the same durable exec, never a second run of anything. */
|
|
581
|
+
repeated(req, first) {
|
|
582
|
+
if (first.kind === 'durable')
|
|
583
|
+
return { ...first, existed: true };
|
|
584
|
+
if (first.kind === 'exit')
|
|
585
|
+
return { kind: 'exit', exit: { ...first.exit, id: req.id } };
|
|
586
|
+
return { kind: 'exit', exit: exitFrame(req.id, 'The same command is already running in the daemon for another request') };
|
|
587
|
+
}
|
|
588
|
+
/** An exec this daemon follows or is starting under `execId`. */
|
|
589
|
+
async known(execId) {
|
|
590
|
+
const followed = this.followed.get(execId);
|
|
591
|
+
if (followed)
|
|
592
|
+
return followed;
|
|
593
|
+
const starting = this.startingDurable.get(execId);
|
|
594
|
+
if (starting) {
|
|
595
|
+
const started = await starting;
|
|
596
|
+
return started.kind === 'durable' ? started.exec : null;
|
|
597
|
+
}
|
|
598
|
+
const loading = this.loading.get(execId);
|
|
599
|
+
if (loading)
|
|
600
|
+
return loading.catch(() => null);
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
async startDurable(req, execId, env, deadlineEpochMs, repeat) {
|
|
604
|
+
this.running += 1;
|
|
605
|
+
let started;
|
|
606
|
+
let existed = false;
|
|
607
|
+
try {
|
|
608
|
+
started = await this.start(req, env, deadlineEpochMs, {
|
|
609
|
+
execId, outlivesChannel: true, wire: { streaming: req.streaming }, label: req.label, jobId: req.jobId, repeat,
|
|
610
|
+
// Already in keeper-terminal (a repeated request after a daemon restart, before recovery reached it) — in the serving
|
|
611
|
+
// generation or an older one still running (routed by owner, Task 26): that exec.
|
|
612
|
+
beforeStart: async (serving) => {
|
|
613
|
+
// Other generations are asked best-effort (final review finding 1, correction): one that cannot be listed or reached
|
|
614
|
+
// (slow, hung, outside the protocol window) is skipped, never a reason to refuse every new exec — keeper-terminal's own
|
|
615
|
+
// `exec.start` refuses an id another live generation holds (`exec_foreign`, exec dirs are shared), which `start` answers as
|
|
616
|
+
// unconfirmed. `find` and recovery keep the strict rule.
|
|
617
|
+
let others;
|
|
618
|
+
try {
|
|
619
|
+
others = (await this.allKeepers()).filter((keeper) => keeper !== serving);
|
|
620
|
+
}
|
|
621
|
+
catch (error) {
|
|
622
|
+
this.log(`exec ${req.id}: could not list keeper-terminal generations (relying on keeper-terminal's foreign guard): ${messageOf(error)}`);
|
|
623
|
+
others = [];
|
|
624
|
+
}
|
|
625
|
+
for (const keeper of [serving, ...others]) {
|
|
626
|
+
try {
|
|
627
|
+
if (keeper !== serving && !(await this.presentHello(keeper))?.capabilities.includes(EXEC_CAPABILITY))
|
|
628
|
+
continue;
|
|
629
|
+
const status = await keeper.execStatus(execId, { timeoutMs: this.callTimeout(deadlineEpochMs) });
|
|
630
|
+
const exec = await DurableExec.restore(keeper, status, { ...this.durableOptions(), outlivesChannel: true, wire: { streaming: req.streaming } });
|
|
631
|
+
existed = true;
|
|
632
|
+
return { kind: 'durable', exec };
|
|
633
|
+
}
|
|
634
|
+
catch (error) {
|
|
635
|
+
// Only an explicit "not found" proves it does not run there.
|
|
636
|
+
if (errorCode(error) === ExecErrorCode.notFound)
|
|
637
|
+
continue;
|
|
638
|
+
if (keeper !== serving) {
|
|
639
|
+
this.log(`exec ${req.id}: an older keeper-terminal generation could not tell (skipped): ${messageOf(error)}`);
|
|
640
|
+
continue;
|
|
641
|
+
}
|
|
642
|
+
// The serving generation could not tell (not connected, refused, timed out): no start, and certainly no in-process copy.
|
|
643
|
+
this.log(`exec ${req.id}: could not tell whether it already runs: ${messageOf(error)}`);
|
|
644
|
+
return { kind: 'exit', exit: exitFrame(req.id, EXEC_UNCONFIRMED_MESSAGE) };
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
return null;
|
|
648
|
+
},
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
finally {
|
|
652
|
+
this.running -= 1;
|
|
653
|
+
}
|
|
654
|
+
if (started.kind !== 'durable')
|
|
655
|
+
this.pendingCancels.delete(execId);
|
|
656
|
+
if (started.kind === 'exit')
|
|
657
|
+
return started;
|
|
658
|
+
if (started.kind === 'fallback') {
|
|
659
|
+
if (this.options.keeper || this.options.generations)
|
|
660
|
+
this.log(`exec ${req.id}: running in the daemon (not durable): ${started.why}`);
|
|
661
|
+
return {
|
|
662
|
+
kind: 'fallback', why: started.why,
|
|
663
|
+
run: (onOutput, signal, onStarted) => this.runFallback(req, env, deadlineEpochMs, onOutput, signal, onStarted),
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
const { exec } = started;
|
|
667
|
+
this.starting.delete(execId);
|
|
668
|
+
this.adopt(execId, exec);
|
|
669
|
+
const cancel = this.pendingCancels.get(execId);
|
|
670
|
+
if (cancel) {
|
|
671
|
+
this.pendingCancels.delete(execId);
|
|
672
|
+
void exec.cancel(cancel);
|
|
673
|
+
}
|
|
674
|
+
return { kind: 'durable', exec, existed };
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* The in-process fallback of a v5 request, counted against the concurrency limit while it runs. Remembered once it started
|
|
678
|
+
* ({@link fallbackOf}), so another channel's re-attach or repeated request learns it ran here instead of starting it again.
|
|
679
|
+
*/
|
|
680
|
+
async runFallback(req, env, deadlineEpochMs, onOutput, signal, onStarted) {
|
|
681
|
+
if (this.activeCount >= this.limit)
|
|
682
|
+
return exitFrame(req.id, EXEC_LIMIT_MESSAGE);
|
|
683
|
+
const execId = keeperExecIdOf(req.id);
|
|
684
|
+
let settle;
|
|
685
|
+
const record = { done: false, endedAt: 0, exit: new Promise((resolve) => { settle = resolve; }) };
|
|
686
|
+
let remembered = false;
|
|
687
|
+
const remember = () => {
|
|
688
|
+
if (remembered)
|
|
689
|
+
return;
|
|
690
|
+
remembered = true;
|
|
691
|
+
this.fallbackExecs.delete(execId);
|
|
692
|
+
this.fallbackExecs.set(execId, record);
|
|
693
|
+
this.trimFallbacks();
|
|
694
|
+
};
|
|
695
|
+
this.running += 1;
|
|
696
|
+
let exit;
|
|
697
|
+
try {
|
|
698
|
+
exit = await this.runInProcess(req, env, deadlineEpochMs, onOutput, signal, { onStarted: (info) => { remember(); onStarted(info); } });
|
|
699
|
+
}
|
|
700
|
+
catch (error) {
|
|
701
|
+
exit = exitFrame(req.id, messageOf(error));
|
|
702
|
+
}
|
|
703
|
+
finally {
|
|
704
|
+
this.running -= 1;
|
|
705
|
+
}
|
|
706
|
+
record.done = true;
|
|
707
|
+
record.endedAt = this.now();
|
|
708
|
+
settle({ ...exit, cancelled: signal.aborted });
|
|
709
|
+
this.trimFallbacks();
|
|
710
|
+
return exit;
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* Bounds {@link fallbackExecs}: ended records past {@link FALLBACK_RECORD_TTL_MS}, and the oldest ended ones beyond
|
|
714
|
+
* {@link FALLBACK_OUTPUT_RECORDS_LIMIT}, drop their output (tombstones); then the oldest ended records go, then the oldest of all.
|
|
715
|
+
*/
|
|
716
|
+
trimFallbacks() {
|
|
717
|
+
const ended = [...this.fallbackExecs].filter(([, record]) => record.done && !record.tombstone);
|
|
718
|
+
const now = this.now();
|
|
719
|
+
ended.forEach(([id, record], index) => {
|
|
720
|
+
if (now - record.endedAt <= FALLBACK_RECORD_TTL_MS && ended.length - index <= FALLBACK_OUTPUT_RECORDS_LIMIT)
|
|
721
|
+
return;
|
|
722
|
+
const tombstone = {
|
|
723
|
+
done: true, endedAt: record.endedAt, tombstone: true,
|
|
724
|
+
exit: record.exit.then((exit) => ({ t: 'exec_exit', id: exit.id, exitCode: null, stdout: '', stderr: FALLBACK_EXPIRED_NOTE, timedOut: false, truncated: false, cancelled: exit.cancelled })),
|
|
725
|
+
};
|
|
726
|
+
this.fallbackExecs.set(id, tombstone); // same position: insertion order is kept
|
|
727
|
+
});
|
|
728
|
+
while (this.fallbackExecs.size > FALLBACK_RECORDS_LIMIT) {
|
|
729
|
+
let victim;
|
|
730
|
+
for (const [id, record] of this.fallbackExecs)
|
|
731
|
+
if (record.done) {
|
|
732
|
+
victim = id;
|
|
733
|
+
break;
|
|
734
|
+
}
|
|
735
|
+
this.fallbackExecs.delete(victim ?? this.fallbackExecs.keys().next().value);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
/** See {@link DurableExecHost.fallbackOf}. */
|
|
739
|
+
fallbackOf(serverId) {
|
|
740
|
+
this.trimFallbacks();
|
|
741
|
+
return this.fallbackExecs.get(keeperExecIdOf(serverId));
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* `exec_attach` / `exec_status`: the exec with the server's id `serverId`, followed here or restored from keeper-terminal (after a
|
|
745
|
+
* daemon restart, before recovery reached it). null = unknown, and only when that is proven: every keeper-terminal generation was
|
|
746
|
+
* reached and none knows it (never started, deleted by the TTL or released), or none runs at all. Never starts a keeper. A keeper
|
|
747
|
+
* that cannot answer (slow, not responding, a listing that failed) is asked again a few times; still undecided, this throws
|
|
748
|
+
* {@link ExecStateUndecidableError} — never null, which would make the server give the exec up while it may be running.
|
|
749
|
+
*/
|
|
750
|
+
async find(serverId) {
|
|
751
|
+
const execId = keeperExecIdOf(serverId);
|
|
752
|
+
// A server asks about it: whatever this answers, a later request for the same id is a repeat (see `seenIds`).
|
|
753
|
+
this.markSeen(execId);
|
|
754
|
+
const known = await this.known(execId);
|
|
755
|
+
if (known)
|
|
756
|
+
return known;
|
|
757
|
+
for (let attempt = 1;; attempt += 1) {
|
|
758
|
+
try {
|
|
759
|
+
// Every running generation, by owner (Task 26): an exec an older keeper-terminal runs is found there.
|
|
760
|
+
for (const keeper of await this.allKeepers()) {
|
|
761
|
+
const hello = await this.presentHello(keeper);
|
|
762
|
+
if (!hello?.capabilities.includes(EXEC_CAPABILITY))
|
|
763
|
+
continue; // provably absent, or serves no exec: holds none
|
|
764
|
+
const exec = await this.load(execId, keeper);
|
|
765
|
+
if (exec)
|
|
766
|
+
return exec;
|
|
767
|
+
}
|
|
768
|
+
return null;
|
|
769
|
+
}
|
|
770
|
+
catch (error) {
|
|
771
|
+
if (attempt >= (this.options.findAttempts ?? FIND_ATTEMPTS)) {
|
|
772
|
+
this.log(`exec ${serverId}: state undecided: ${messageOf(error)}`);
|
|
773
|
+
this.recoverLater();
|
|
774
|
+
throw new ExecStateUndecidableError(`Could not tell whether the command still runs: ${messageOf(error)}`);
|
|
775
|
+
}
|
|
776
|
+
await new Promise((resolve) => setTimeout(resolve, FIND_RETRY_MS));
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
/**
|
|
781
|
+
* `exec_cancel`: SIGTERM (or the given signal), SIGKILL after the grace. Idempotent; silent for an unknown or ended exec. A cancel
|
|
782
|
+
* that arrives while the exec is still being started applies as soon as it runs.
|
|
783
|
+
*/
|
|
784
|
+
async cancel(serverId, signal) {
|
|
785
|
+
const execId = keeperExecIdOf(serverId);
|
|
786
|
+
const first = isExecSignal(signal) ? signal : 'SIGTERM';
|
|
787
|
+
const starting = this.startingDurable.get(execId);
|
|
788
|
+
if (starting) {
|
|
789
|
+
this.pendingCancels.set(execId, first); // applied the moment the start lands …
|
|
790
|
+
const started = await starting;
|
|
791
|
+
this.pendingCancels.delete(execId);
|
|
792
|
+
if (started.kind === 'durable')
|
|
793
|
+
await started.exec.cancel(first); // … or here (idempotent)
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
let exec;
|
|
797
|
+
try {
|
|
798
|
+
exec = await this.find(serverId);
|
|
799
|
+
}
|
|
800
|
+
catch (error) {
|
|
801
|
+
// Undecided: the cancel is kept and applied the moment recovery adopts the exec (never dropped while it may be running).
|
|
802
|
+
this.cancelOnAdopt.delete(execId);
|
|
803
|
+
this.cancelOnAdopt.set(execId, first);
|
|
804
|
+
if (this.cancelOnAdopt.size > PENDING_ACKS_LIMIT)
|
|
805
|
+
this.cancelOnAdopt.delete(this.cancelOnAdopt.keys().next().value);
|
|
806
|
+
throw error;
|
|
807
|
+
}
|
|
808
|
+
await exec?.cancel(first);
|
|
809
|
+
}
|
|
810
|
+
/**
|
|
811
|
+
* `exec_ack`: the server persisted the output through `throughOffset`. Commits follow (exec/durable.ts); an ack that reaches the
|
|
812
|
+
* final head of an ended exec (the `exec_status` sent before its `exec_exit`) releases it: keeper-terminal deletes it.
|
|
813
|
+
*/
|
|
814
|
+
acknowledge(serverId, throughOffset) {
|
|
815
|
+
const execId = keeperExecIdOf(serverId);
|
|
816
|
+
const exec = this.followed.get(execId);
|
|
817
|
+
if (exec) {
|
|
818
|
+
this.applyAck(execId, exec, throughOffset);
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
// Not followed (yet): a restarted daemon whose recovery has not reached it. Kept (bounded) and applied when it is adopted.
|
|
822
|
+
if (throughOffset <= (this.pendingAcks.get(execId) ?? -2))
|
|
823
|
+
return;
|
|
824
|
+
this.pendingAcks.delete(execId);
|
|
825
|
+
this.pendingAcks.set(execId, throughOffset);
|
|
826
|
+
if (this.pendingAcks.size > PENDING_ACKS_LIMIT)
|
|
827
|
+
this.pendingAcks.delete(this.pendingAcks.keys().next().value);
|
|
828
|
+
}
|
|
829
|
+
applyAck(execId, exec, throughOffset) {
|
|
830
|
+
exec.acknowledge(throughOffset);
|
|
831
|
+
if (exec.finished && throughOffset >= exec.finalHead) {
|
|
832
|
+
if (this.followed.get(execId) === exec)
|
|
833
|
+
this.followed.delete(execId);
|
|
834
|
+
void exec.release();
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
}
|