@sema-agent/server 7.125.1 → 7.126.0
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/MIGRATION.md +195 -2
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/USAGE.md +263 -140
- package/deploy/sema-up/README.md +1 -1
- package/deploy/sema-up/chart/templates/networkpolicy-sandbox.yaml +47 -0
- package/deploy/sema-up/chart/templates/sandbox-rbac.yaml +5 -1
- package/deploy/sema-up/chart/templates/server-v4pro.yaml +2 -2
- package/deploy/sema-up/chart/templates/server.yaml +1 -1
- package/deploy/sema-up/chart/values.yaml +12 -0
- package/deploy/sema-up/cluster-up.sh +10 -10
- package/deploy/sema-up/kube-up.sh +11 -10
- package/deploy/sema-up/lib/dataroot.sh +1 -1
- package/deploy/sema-up/lib/envf.sh +5 -5
- package/deploy/sema-up/lib/flagshape.sh +5 -5
- package/deploy/sema-up/lib/lanes.py +1 -1
- package/deploy/sema-up/lib/parse-machines.py +2 -2
- package/deploy/sema-up/lib/shell-access.sh +1 -1
- package/deploy/sema-up/preflight.sh +4 -4
- package/deploy/sema-up/sema-up.sh +33 -33
- package/deploy/sema-up/smoke.sh +3 -3
- package/deploy/sema-up/stack/compose.yaml +1 -1
- package/deploy/sema-up/stack/sema-stack-cluster.yaml +6 -2
- package/dist/approval-card.d.ts +20 -4
- package/dist/approval-content-kind.d.ts +38 -22
- package/dist/approval-content-kind.js +20 -4
- package/dist/audit.js +2 -0
- package/dist/bench/s1/arms.d.ts +9 -37
- package/dist/bench/s1/arms.js +4 -61
- package/dist/bench/s1/live-deps.d.ts +4 -10
- package/dist/bench/s1/live-deps.js +1 -222
- package/dist/bench/s1/oracle.d.ts +1 -1
- package/dist/bench/s1/reviewer.d.ts +1 -1
- package/dist/bench/s1/row.d.ts +5 -7
- package/dist/bench/s1/row.js +0 -2
- package/dist/bench/s1/run-firm.d.ts +2 -2
- package/dist/bench/s1/run-firm.js +1 -4
- package/dist/bench/s1/runner-ctx.d.ts +17 -29
- package/dist/bench/s1/runner-ctx.js +3 -7
- package/dist/bench/s1/tasks.d.ts +9 -46
- package/dist/bench/s1/tasks.js +4 -63
- package/dist/boot/execution-env.d.ts +38 -26
- package/dist/boot/execution-env.js +71 -38
- package/dist/boot/memory-boundary.d.ts +1 -1
- package/dist/boot/parked-revive-gate.d.ts +0 -3
- package/dist/boot/parked-revive-gate.js +2 -2
- package/dist/boot/resolve-spec.d.ts +0 -2
- package/dist/boot/resolve-spec.js +12 -17
- package/dist/boot/retention-lane.d.ts +12 -1
- package/dist/boot/retention-lane.js +2 -0
- package/dist/boot/runner-deps.d.ts +5 -5
- package/dist/boot/runner-deps.js +5 -16
- package/dist/boot/session-faces.d.ts +5 -1
- package/dist/boot/session-faces.js +37 -1
- package/dist/boot/stage-03-store-backend.d.ts +1 -0
- package/dist/boot/stage-03-store-backend.js +2 -2
- package/dist/boot/stage-04-stores.d.ts +1 -0
- package/dist/boot/stage-05-execution-env.d.ts +2 -2
- package/dist/boot/stage-05-execution-env.js +7 -4
- package/dist/boot/stage-06-runners.d.ts +2 -3
- package/dist/boot/stage-06-runners.js +8 -7
- package/dist/boot/stage-07-capability-layer.d.ts +2 -3
- package/dist/boot/stage-07-capability-layer.js +3 -4
- package/dist/boot/stage-08-reapers.d.ts +2 -3
- package/dist/boot/stage-08-reapers.js +2 -2
- package/dist/boot/stage-09-leader.d.ts +2 -3
- package/dist/boot/stage-09-leader.js +2 -1
- package/dist/boot/stage-10-http-server.d.ts +2 -3
- package/dist/boot/stage-10-http-server.js +2 -2
- package/dist/boot/stores.d.ts +1 -0
- package/dist/boot/stores.js +4 -3
- package/dist/boot/web-admin.d.ts +5 -0
- package/dist/boot/web-admin.js +28 -14
- package/dist/brain.d.ts +2 -2
- package/dist/brain.js +1 -1
- package/dist/capabilities/builtin-tools.d.ts +2 -1
- package/dist/capabilities/composite-members.d.ts +1 -1
- package/dist/capabilities/composite-members.js +1 -1
- package/dist/capabilities/hands-lane.d.ts +9 -6
- package/dist/capabilities/hands-lane.js +2 -2
- package/dist/capabilities/memory-notice.d.ts +27 -22
- package/dist/capabilities/memory-notice.js +15 -17
- package/dist/capabilities/scenarios.d.ts +9 -14
- package/dist/capabilities/scenarios.js +5 -50
- package/dist/capability-facts.d.ts +22 -11
- package/dist/capability-facts.js +13 -6
- package/dist/config-catalog.js +11 -4
- package/dist/config-provider.d.ts +5 -1
- package/dist/config-provider.js +23 -4
- package/dist/config-types.d.ts +14 -1
- package/dist/config.js +39 -6
- package/dist/deployment-governance.d.ts +19 -21
- package/dist/deployment-governance.js +7 -8
- package/dist/env-name-allowlist-knobs.d.ts +5 -5
- package/dist/env-name-allowlist-knobs.js +1 -0
- package/dist/execution-lane-caps.js +2 -2
- package/dist/hooks/hook-runner.d.ts +84 -30
- package/dist/hooks/hook-runner.js +74 -41
- package/dist/http/admission.js +12 -9
- package/dist/http/durable-run-leg.d.ts +5 -4
- package/dist/http/durable-run-leg.js +1 -1
- package/dist/http/idempotency.d.ts +28 -6
- package/dist/http/idempotency.js +10 -2
- package/dist/http/principal-gate.d.ts +14 -0
- package/dist/http/principal-gate.js +7 -0
- package/dist/http/request-guards.d.ts +2 -1
- package/dist/http/request-guards.js +14 -14
- package/dist/http/resume-legs.d.ts +2 -2
- package/dist/http/resume-legs.js +5 -4
- package/dist/http/route-ctx.d.ts +8 -6
- package/dist/http/routes/a2a-serve.js +11 -7
- package/dist/http/routes/approvals-assistant.js +18 -3
- package/dist/http/routes/background.d.ts +1 -1
- package/dist/http/routes/background.js +1 -1
- package/dist/http/routes/capabilities.js +10 -2
- package/dist/http/routes/mcp-reconnect.js +1 -1
- package/dist/http/routes/runs.d.ts +4 -3
- package/dist/http/routes/runs.js +12 -13
- package/dist/http/routes/session-sync.js +14 -11
- package/dist/http/routes/sessions.js +9 -9
- package/dist/http/routes/side-query.js +6 -19
- package/dist/http/routes/tasks.js +12 -11
- package/dist/http/server.d.ts +4 -0
- package/dist/http/server.js +2 -2
- package/dist/http/submit-replay.d.ts +15 -8
- package/dist/http/submit-replay.js +6 -0
- package/dist/http/wire-types.d.ts +7 -9
- package/dist/index.d.ts +0 -1
- package/dist/index.js +0 -1
- package/dist/internal-run-env.d.ts +30 -0
- package/dist/internal-run-env.js +11 -0
- package/dist/leader/wire.js +3 -2
- package/dist/memory-scope.d.ts +19 -7
- package/dist/memory-scope.js +25 -8
- package/dist/observability/fail-open.d.ts +15 -3
- package/dist/observability/fail-open.js +15 -3
- package/dist/observability/metrics.d.ts +0 -1
- package/dist/observability/metrics.js +0 -3
- package/dist/observability/run-terminal.js +69 -4
- package/dist/permission-rule-vocab.d.ts +3 -2
- package/dist/permission-rule-vocab.js +1 -1
- package/dist/plan-cache-probe.d.ts +3 -2
- package/dist/plugins/approval-ask-store-sql.d.ts +1 -1
- package/dist/plugins/caching-session-store.d.ts +3 -0
- package/dist/plugins/caching-session-store.js +3 -0
- package/dist/plugins/checkpoint-store-sql.d.ts +42 -28
- package/dist/plugins/checkpoint-store-sql.js +57 -11
- package/dist/plugins/file-run-store.d.ts +25 -21
- package/dist/plugins/file-run-store.js +70 -34
- package/dist/plugins/local-checkpoint-store.d.ts +8 -5
- package/dist/plugins/local-checkpoint-store.js +5 -15
- package/dist/plugins/local-session-store.d.ts +66 -7
- package/dist/plugins/local-session-store.js +168 -101
- package/dist/plugins/mailbox-recipient-lifecycle.d.ts +1 -1
- package/dist/plugins/memory-run-store.d.ts +19 -3
- package/dist/plugins/memory-run-store.js +38 -3
- package/dist/plugins/one-time-migrations.d.ts +141 -39
- package/dist/plugins/one-time-migrations.js +32 -143
- package/dist/plugins/pg-pool.js +11 -1
- package/dist/plugins/pg-session-storage.d.ts +2 -0
- package/dist/plugins/pg-session-storage.js +9 -5
- package/dist/plugins/remote-env-adb.d.ts +3 -0
- package/dist/plugins/remote-env-adb.js +1 -0
- package/dist/plugins/remote-env-device.d.ts +3 -0
- package/dist/plugins/remote-env-device.js +1 -0
- package/dist/plugins/remote-env-e2b.d.ts +4 -0
- package/dist/plugins/remote-env-e2b.js +2 -1
- package/dist/plugins/remote-env-host.d.ts +12 -0
- package/dist/plugins/remote-env-host.js +11 -1
- package/dist/plugins/remote-env-k8s.d.ts +227 -39
- package/dist/plugins/remote-env-k8s.js +450 -75
- package/dist/plugins/remote-env-local-docker.d.ts +4 -0
- package/dist/plugins/remote-env-local-docker.js +2 -1
- package/dist/plugins/remote-env-ssh.d.ts +3 -0
- package/dist/plugins/remote-env-ssh.js +1 -0
- package/dist/plugins/retention-store-sql.d.ts +4 -1
- package/dist/plugins/retention-store-sql.js +11 -9
- package/dist/plugins/run-store-sql.d.ts +44 -6
- package/dist/plugins/run-store-sql.js +92 -27
- package/dist/plugins/session-capture-record-store-sql.d.ts +2 -3
- package/dist/plugins/sql-driver.d.ts +2 -1
- package/dist/plugins/store-backend.js +1 -1
- package/dist/plugins/store-contracts.d.ts +17 -3
- package/dist/plugins/tidb-pool.js +15 -3
- package/dist/plugins/tidb-session-storage.js +3 -2
- package/dist/plugins/tidb-session-store.d.ts +2 -0
- package/dist/plugins/tidb-session-store.js +6 -2
- package/dist/request-key-closure.d.ts +12 -0
- package/dist/request-key-closure.js +3 -3
- package/dist/router/route-orchestration.d.ts +3 -11
- package/dist/router/route-orchestration.js +0 -4
- package/dist/rule-entry-list.js +0 -4
- package/dist/rules-consent.js +3 -0
- package/dist/run-local.d.ts +5 -4
- package/dist/run-local.js +23 -17
- package/dist/runs.js +1 -0
- package/dist/security.d.ts +7 -0
- package/dist/security.js +5 -0
- package/dist/server-secret-env.d.ts +10 -4
- package/dist/server-secret-env.js +4 -1
- package/dist/session-incarnation.d.ts +46 -2
- package/dist/session-incarnation.js +14 -3
- package/dist/session-sync-kernel.d.ts +10 -3
- package/dist/spec-fields.d.ts +24 -8
- package/dist/spec-fields.js +21 -2
- package/dist/task-workflow.d.ts +6 -4
- package/dist/task-workflow.js +3 -2
- package/dist/tool-approval.d.ts +78 -6
- package/dist/tool-approval.js +7 -3
- package/dist/trace/core-keyset-guard.d.ts +4 -4
- package/dist/trace/engine-notice-wire.d.ts +26 -1
- package/dist/trace/engine-notice-wire.js +7 -2
- package/dist/trace/project.d.ts +8 -3
- package/dist/trace/project.js +15 -1
- package/dist/trace/self-minted-wire.d.ts +104 -2
- package/dist/trace/self-minted-wire.js +35 -0
- package/dist/web-admin-client/apply-effective.d.ts +13 -7
- package/dist/web-admin-client/apply-effective.js +11 -26
- package/dist/web-admin-client/apply-ledger.d.ts +2 -2
- package/dist/web-admin-client/candidate-gate.d.ts +39 -4
- package/dist/web-admin-client/candidate-gate.js +9 -2
- package/dist/web-admin-client/facade.d.ts +2 -2
- package/dist/web-admin-client/http-client.d.ts +3 -1
- package/dist/web-admin-client/http-client.js +4 -3
- package/dist/web-admin-client/read-warnings.d.ts +33 -5
- package/dist/web-admin-client/read-warnings.js +41 -6
- package/dist/web-admin-client/types.d.ts +11 -5
- package/dist/workspace-cache.d.ts +45 -0
- package/dist/workspace-cache.js +30 -0
- package/dist/workspace-space-commit.d.ts +116 -0
- package/dist/workspace-space-commit.js +117 -0
- package/dist/workspace-space.d.ts +75 -9
- package/dist/workspace-space.js +52 -1
- package/package.json +4 -4
- package/dist/boot/session-incarnation-seat.d.ts +0 -41
- package/dist/boot/session-incarnation-seat.js +0 -69
- package/dist/capabilities/code-review-council.d.ts +0 -49
- package/dist/capabilities/code-review-council.js +0 -111
- package/dist/capabilities/collab-wire.d.ts +0 -36
- package/dist/capabilities/collab-wire.js +0 -2
- package/dist/capabilities/collab-workflows.d.ts +0 -47
- package/dist/capabilities/collab-workflows.js +0 -90
- package/dist/capabilities/prompts/code-review.d.ts +0 -25
- package/dist/capabilities/prompts/code-review.js +0 -39
- package/dist/capabilities/prompts/team.d.ts +0 -10
- package/dist/capabilities/prompts/team.js +0 -6
- package/dist/capabilities/team.d.ts +0 -113
- package/dist/capabilities/team.js +0 -70
|
@@ -6,7 +6,11 @@ import { StringDecoder } from "node:string_decoder";
|
|
|
6
6
|
import WebSocket from "ws";
|
|
7
7
|
import { buildCwdEnvCommand, createCwdGuardedCall, shellQuote, kindFromMode, numEnvOr } from "./remote-shell.js";
|
|
8
8
|
import { presignS3Url, presignS3ListUrl } from "./s3-presign.js";
|
|
9
|
+
import { K8S_WORKSPACE_MOUNT_DEFAULT, WORKSPACE_SNAPSHOT_TIMEOUT_MS_DEFAULT, mintSnapshotId, snapshotNameOwnedBy } from "../workspace-space.js";
|
|
10
|
+
import { spaceCommitAlive } from "../workspace-space-commit.js";
|
|
9
11
|
import { incarnationLogPrefix, redactIncarnations } from "../session-incarnation.js";
|
|
12
|
+
import { WORKSPACE_CACHE_SPACE_LABEL, workspaceCacheClaimOf, workspaceCacheEnv } from "../workspace-cache.js";
|
|
13
|
+
import { recordFailOpen } from "../observability/fail-open.js";
|
|
10
14
|
import { FileError, ExecutionError, RemoteExecutionError, withRetry, RollingTailBuffer, markTruncated, } from "@sema-agent/core";
|
|
11
15
|
import { fileErrorFromExec, classifyFsStderr } from "./remote-env-file-error.js";
|
|
12
16
|
import { createPosixShellFs } from "./posix-shell-fs.js";
|
|
@@ -32,21 +36,53 @@ const EXEC_SUBPROTOCOLS = ["v5.channel.k8s.io", "v4.channel.k8s.io"];
|
|
|
32
36
|
const WRITE_CHUNK_BYTES = 64 * 1024;
|
|
33
37
|
import { applyPodSpecPatch } from "./k8s-exec-protocol.js";
|
|
34
38
|
export { applyPodSpecPatch } from "./k8s-exec-protocol.js";
|
|
35
|
-
|
|
39
|
+
const CACHE_VOLUME = "ws-cache";
|
|
40
|
+
function occupantKeyOf(occupant) {
|
|
41
|
+
if (occupant === undefined)
|
|
42
|
+
return { state: "unavailable", reason: "incarnation_unknown" };
|
|
43
|
+
if ("incarnation" in occupant)
|
|
44
|
+
return occupant;
|
|
45
|
+
if ("unread" in occupant)
|
|
46
|
+
return { state: "unavailable", reason: occupant.unread };
|
|
47
|
+
return { state: "unreadable", code: occupant.fault };
|
|
48
|
+
}
|
|
49
|
+
function cacheLaneOf(policy, occupant) {
|
|
50
|
+
if (policy === undefined)
|
|
51
|
+
return { state: "off" };
|
|
52
|
+
const key = occupantKeyOf(occupant);
|
|
53
|
+
if (!("incarnation" in key))
|
|
54
|
+
return key;
|
|
55
|
+
return { state: "open", cache: { mount: policy.mount, size: policy.size, claim: workspaceCacheClaimOf(key.incarnation), spaceKey: key.incarnation } };
|
|
56
|
+
}
|
|
57
|
+
function spaceLaneOf(policy, occupant) {
|
|
36
58
|
if (policy === undefined)
|
|
37
59
|
return { state: "off" };
|
|
38
60
|
switch (policy.persist) {
|
|
39
61
|
case "off":
|
|
40
62
|
return { state: "off" };
|
|
41
|
-
case "space":
|
|
42
|
-
|
|
63
|
+
case "space": {
|
|
64
|
+
const key = occupantKeyOf(occupant);
|
|
65
|
+
return "incarnation" in key ? { state: "open", space: { incarnation: key.incarnation, keep: policy.keep, fence: key.fence } } : key;
|
|
66
|
+
}
|
|
43
67
|
default: {
|
|
44
68
|
const unreachable = policy.persist;
|
|
45
69
|
throw new Error(`unhandled WORKSPACE_PERSIST word: ${String(unreachable)}`);
|
|
46
70
|
}
|
|
47
71
|
}
|
|
48
72
|
}
|
|
73
|
+
function spaceReadFailed(what, detail, cause) {
|
|
74
|
+
return { ok: false, error: new RemoteExecutionError("connect_failed", `workspace space ${what} read failed${detail}`, cause instanceof Error ? cause : undefined) };
|
|
75
|
+
}
|
|
76
|
+
const RESTORE_DOWNLOAD_EXIT = 22;
|
|
49
77
|
const SPACE_COMMIT_POLL_MS = 1000;
|
|
78
|
+
class FenceCallAborted extends Error {
|
|
79
|
+
constructor() {
|
|
80
|
+
super("aborted");
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function dataTimeoutMsOf(config) {
|
|
84
|
+
return config.dataTimeoutMs ?? WORKSPACE_SNAPSHOT_TIMEOUT_MS_DEFAULT;
|
|
85
|
+
}
|
|
50
86
|
import { execSocketTlsOptions, exitCodeFromStatus } from "./k8s-exec-protocol.js";
|
|
51
87
|
export { execSocketTlsOptions } from "./k8s-exec-protocol.js";
|
|
52
88
|
export function errorMessageOf(e) {
|
|
@@ -66,10 +102,15 @@ export class RemoteK8sExecutionEnv {
|
|
|
66
102
|
inFlightExecs = 0;
|
|
67
103
|
connectPromise;
|
|
68
104
|
homeDir;
|
|
105
|
+
cdPath = "unknown";
|
|
69
106
|
cfg;
|
|
70
107
|
deps;
|
|
108
|
+
occupantIncarnation;
|
|
71
109
|
spaceLane;
|
|
72
110
|
spaceUnavailableSaid = false;
|
|
111
|
+
unreadableOccupantSaid = false;
|
|
112
|
+
cacheLane;
|
|
113
|
+
cacheUnavailableSaid = false;
|
|
73
114
|
logger;
|
|
74
115
|
onWorkspaceSnapshot;
|
|
75
116
|
constructor(config, deps) {
|
|
@@ -81,12 +122,12 @@ export class RemoteK8sExecutionEnv {
|
|
|
81
122
|
namespace: config.namespace ?? "sandboxes",
|
|
82
123
|
image: config.image,
|
|
83
124
|
runtimeClass: config.runtimeClass ?? "kata-qemu",
|
|
84
|
-
mountPath: config.mountPath ??
|
|
125
|
+
mountPath: config.mountPath ?? K8S_WORKSPACE_MOUNT_DEFAULT,
|
|
85
126
|
timeoutMs: config.timeoutMs ?? 30 * 60_000,
|
|
86
127
|
readyTimeoutMs: config.readyTimeoutMs ?? 120_000,
|
|
87
128
|
rpcTimeoutMs: config.rpcTimeoutMs ?? 30_000,
|
|
88
129
|
livenessMs: config.livenessMs ?? 120_000,
|
|
89
|
-
dataTimeoutMs: config
|
|
130
|
+
dataTimeoutMs: dataTimeoutMsOf(config),
|
|
90
131
|
cpu: config.cpu ?? "1",
|
|
91
132
|
memory: config.memory ?? "2Gi",
|
|
92
133
|
retry: { maxAttempts: config.retry?.maxAttempts ?? 3, backoffMs: config.retry?.backoffMs ?? 1000 },
|
|
@@ -94,11 +135,15 @@ export class RemoteK8sExecutionEnv {
|
|
|
94
135
|
...(config.sessionId ? { sessionId: config.sessionId } : {}),
|
|
95
136
|
...(config.podSpecPatch ? { podSpecPatch: config.podSpecPatch } : {}),
|
|
96
137
|
...(config.podEnv ? { podEnv: config.podEnv } : {}),
|
|
138
|
+
...(config.snapshotExclude && config.snapshotExclude.length > 0 ? { snapshotExclude: config.snapshotExclude } : {}),
|
|
97
139
|
};
|
|
98
140
|
this.cwd = this.cfg.mountPath;
|
|
99
141
|
this.homeDir = config.homeDir;
|
|
100
142
|
this.deps = deps ?? {};
|
|
101
|
-
|
|
143
|
+
const occupant = occupantKeyOf(config.spaceOccupant);
|
|
144
|
+
this.occupantIncarnation = "incarnation" in occupant ? occupant.incarnation : undefined;
|
|
145
|
+
this.spaceLane = spaceLaneOf(config.s3Snapshot?.space, config.spaceOccupant);
|
|
146
|
+
this.cacheLane = cacheLaneOf(config.cache, config.spaceOccupant);
|
|
102
147
|
this.logger = config.logger;
|
|
103
148
|
this.onWorkspaceSnapshot = config.onWorkspaceSnapshot;
|
|
104
149
|
this.capabilities = { isolation: true, suspendable: !!this.cfg.s3Snapshot, canonicalPathAuthoritative: true };
|
|
@@ -206,7 +251,9 @@ export class RemoteK8sExecutionEnv {
|
|
|
206
251
|
case "off":
|
|
207
252
|
return this.provision(signal);
|
|
208
253
|
case "unavailable":
|
|
209
|
-
this.sayUnavailable();
|
|
254
|
+
this.sayUnavailable(lane.reason);
|
|
255
|
+
return this.provision(signal);
|
|
256
|
+
case "unreadable":
|
|
210
257
|
return this.provision(signal);
|
|
211
258
|
case "open": {
|
|
212
259
|
const settled = await this.awaitSpaceCommit(lane.space, signal);
|
|
@@ -215,9 +262,10 @@ export class RemoteK8sExecutionEnv {
|
|
|
215
262
|
const latest = await this.readLatest(lane.space, signal);
|
|
216
263
|
if (!latest.ok)
|
|
217
264
|
return latest;
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
265
|
+
const c = latest.value === undefined ? await this.provision(signal) : await this.restoreSnapshot({ lane: "space", space: lane.space }, latest.value, signal);
|
|
266
|
+
if (c.ok)
|
|
267
|
+
this.armSpace();
|
|
268
|
+
return c;
|
|
221
269
|
}
|
|
222
270
|
default: {
|
|
223
271
|
const unreachable = lane;
|
|
@@ -225,27 +273,76 @@ export class RemoteK8sExecutionEnv {
|
|
|
225
273
|
}
|
|
226
274
|
}
|
|
227
275
|
}
|
|
228
|
-
|
|
276
|
+
armSpace() {
|
|
277
|
+
if (this.spaceLane.state === "open")
|
|
278
|
+
this.spaceLane.space.fence.arm(this.cfg.dataTimeoutMs);
|
|
279
|
+
}
|
|
280
|
+
async endSpace(space) {
|
|
281
|
+
await this.fenceCall(() => space.fence.end(), "delete").catch(() => undefined);
|
|
282
|
+
}
|
|
283
|
+
fenceCall(op, what, signal) {
|
|
284
|
+
return new Promise((resolve, reject) => {
|
|
285
|
+
if (signal?.aborted)
|
|
286
|
+
return reject(new FenceCallAborted());
|
|
287
|
+
const onAbort = () => {
|
|
288
|
+
clearTimeout(timer);
|
|
289
|
+
reject(new FenceCallAborted());
|
|
290
|
+
};
|
|
291
|
+
const timer = setTimeout(() => {
|
|
292
|
+
signal?.removeEventListener("abort", onAbort);
|
|
293
|
+
reject(new Error(`workspace space commit state ${what} did not answer within ${this.cfg.rpcTimeoutMs}ms`));
|
|
294
|
+
}, this.cfg.rpcTimeoutMs);
|
|
295
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
296
|
+
op().then((v) => {
|
|
297
|
+
clearTimeout(timer);
|
|
298
|
+
signal?.removeEventListener("abort", onAbort);
|
|
299
|
+
resolve(v);
|
|
300
|
+
}, (e) => {
|
|
301
|
+
clearTimeout(timer);
|
|
302
|
+
signal?.removeEventListener("abort", onAbort);
|
|
303
|
+
reject(e);
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
sayUnavailable(reason) {
|
|
229
308
|
if (this.spaceUnavailableSaid)
|
|
230
309
|
return;
|
|
231
310
|
this.spaceUnavailableSaid = true;
|
|
232
311
|
this.logger?.warn("workspace_space_unavailable", {
|
|
233
|
-
reason
|
|
312
|
+
reason,
|
|
234
313
|
sessionId: this.cfg.sessionId,
|
|
235
|
-
effect: "WORKSPACE_PERSIST=space, but
|
|
314
|
+
effect: "WORKSPACE_PERSIST=space, but the session store has no occupant (incarnation) for this run's session — its /workspace is neither restored nor saved (it is never keyed by the session id instead)",
|
|
236
315
|
});
|
|
237
316
|
}
|
|
317
|
+
refuseUnreadableOccupant(code) {
|
|
318
|
+
const ways = [...(this.spaceLane.state === "unreadable" ? ["WORKSPACE_PERSIST=off"] : []), ...(this.cacheLane.state === "unreadable" ? ["WORKSPACE_CACHE=off"] : [])].join(" and ");
|
|
319
|
+
if (!this.unreadableOccupantSaid) {
|
|
320
|
+
this.unreadableOccupantSaid = true;
|
|
321
|
+
this.logger?.warn("workspace_space_unreadable_occupant", {
|
|
322
|
+
sessionId: this.cfg.sessionId,
|
|
323
|
+
code,
|
|
324
|
+
effect: `the session store did not answer for this run's session occupant (incarnation) — this run is refused rather than started on a workspace that is neither restored nor saved; ${ways} runs without it`,
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
return spaceReadFailed("occupant", ` (${code}) — refusing to start this run on a workspace that could be neither restored nor saved; retry the run once the session store answers, or set ${ways}`);
|
|
328
|
+
}
|
|
238
329
|
spaceLine(event, space, fields) {
|
|
239
330
|
const safe = Object.fromEntries(Object.entries(fields).map(([k, v]) => [k, typeof v === "string" ? redactIncarnations(v) : v]));
|
|
240
331
|
this.logger?.warn(event, { space: incarnationLogPrefix(space.incarnation), ...safe });
|
|
241
332
|
}
|
|
242
333
|
provision(signal) {
|
|
334
|
+
const space = this.spaceLane;
|
|
335
|
+
const cache = this.cacheLane;
|
|
336
|
+
if (space.state === "unreadable")
|
|
337
|
+
return Promise.resolve(this.refuseUnreadableOccupant(space.code));
|
|
338
|
+
if (cache.state === "unreadable")
|
|
339
|
+
return Promise.resolve(this.refuseUnreadableOccupant(cache.code));
|
|
243
340
|
const policy = {
|
|
244
341
|
retryableCodes: ["connect_failed", "timeout", "transport_lost"],
|
|
245
342
|
maxAttempts: this.cfg.retry.maxAttempts,
|
|
246
343
|
backoffMs: (failed) => this.cfg.retry.backoffMs * failed,
|
|
247
344
|
};
|
|
248
|
-
const once = this.deps.connectOnce ?? ((s) => this.connectOnce(s));
|
|
345
|
+
const once = this.deps.connectOnce ?? ((s) => this.connectOnce(cache, s));
|
|
249
346
|
return withRetry(() => once(signal), policy, { signal });
|
|
250
347
|
}
|
|
251
348
|
async ensureConnected() {
|
|
@@ -261,12 +358,105 @@ export class RemoteK8sExecutionEnv {
|
|
|
261
358
|
}
|
|
262
359
|
return ok(undefined);
|
|
263
360
|
}
|
|
264
|
-
async connectOnce(signal) {
|
|
361
|
+
async connectOnce(cacheLane, signal) {
|
|
265
362
|
if (signal?.aborted)
|
|
266
363
|
return { ok: false, error: new RemoteExecutionError("aborted", "connect aborted") };
|
|
267
364
|
if (this.destroyed)
|
|
268
365
|
return { ok: false, error: new RemoteExecutionError("connect_failed", "env already destroyed") };
|
|
366
|
+
const cache = await this.attachCache(cacheLane, signal);
|
|
367
|
+
const first = await this.createPod(signal, cache);
|
|
368
|
+
if (!("cacheMiss" in first))
|
|
369
|
+
return first;
|
|
370
|
+
this.cacheUnavailable(first.cacheMiss, first.detail, cache);
|
|
371
|
+
const second = await this.createPod(signal, undefined);
|
|
372
|
+
return "cacheMiss" in second ? { ok: false, error: new RemoteExecutionError("connect_failed", "unreachable: a pod without the cache volume was given up for it") } : second;
|
|
373
|
+
}
|
|
374
|
+
async attachCache(lane, signal) {
|
|
375
|
+
switch (lane.state) {
|
|
376
|
+
case "off":
|
|
377
|
+
return undefined;
|
|
378
|
+
case "unavailable":
|
|
379
|
+
this.cacheUnavailable(lane.reason, "the session store has no occupant (incarnation) for this run's session — no space key to find its cache volume by (never keyed by the session id instead)");
|
|
380
|
+
return undefined;
|
|
381
|
+
case "open": {
|
|
382
|
+
const ensured = await this.ensureCacheClaim(lane.cache, signal);
|
|
383
|
+
if (!ensured.ok) {
|
|
384
|
+
this.cacheUnavailable("claim_failed", ensured.detail, lane.cache);
|
|
385
|
+
return undefined;
|
|
386
|
+
}
|
|
387
|
+
return { ...lane.cache, fresh: ensured.fresh, bindDeadline: Date.now() + this.cfg.dataTimeoutMs };
|
|
388
|
+
}
|
|
389
|
+
default: {
|
|
390
|
+
const unreachable = lane;
|
|
391
|
+
throw new Error(`unhandled cache lane state: ${String(unreachable.state)}`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
async ensureCacheClaim(cache, signal) {
|
|
396
|
+
const base = `/api/v1/namespaces/${this.cfg.namespace}/persistentvolumeclaims`;
|
|
397
|
+
try {
|
|
398
|
+
const got = await this.request("GET", `${base}/${cache.claim.name}`, undefined, { signal });
|
|
399
|
+
if (got.status === 200)
|
|
400
|
+
return { ok: true, fresh: false };
|
|
401
|
+
if (got.status !== 404)
|
|
402
|
+
return { ok: false, detail: `claim read answered HTTP ${got.status}: ${JSON.stringify(got.json).slice(0, 200)}` };
|
|
403
|
+
const claim = {
|
|
404
|
+
apiVersion: "v1",
|
|
405
|
+
kind: "PersistentVolumeClaim",
|
|
406
|
+
metadata: { name: cache.claim.name, labels: { [WORKSPACE_CACHE_SPACE_LABEL]: cache.claim.label, "managed-by": "sema-server" } },
|
|
407
|
+
spec: { accessModes: ["ReadWriteOnce"], resources: { requests: { storage: cache.size } } },
|
|
408
|
+
};
|
|
409
|
+
const made = await this.request("POST", base, claim, { signal });
|
|
410
|
+
if (made.status === 200 || made.status === 201)
|
|
411
|
+
return { ok: true, fresh: true };
|
|
412
|
+
if (made.status === 409)
|
|
413
|
+
return { ok: true, fresh: false };
|
|
414
|
+
return { ok: false, detail: `claim create answered HTTP ${made.status}: ${JSON.stringify(made.json).slice(0, 200)}` };
|
|
415
|
+
}
|
|
416
|
+
catch (e) {
|
|
417
|
+
return { ok: false, detail: `claim request failed: ${errorMessageOf(e)}` };
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
async cacheClaimBound(cache, signal) {
|
|
421
|
+
const got = await this.request("GET", `/api/v1/namespaces/${this.cfg.namespace}/persistentvolumeclaims/${cache.claim.name}`, undefined, { signal }).catch(() => undefined);
|
|
422
|
+
return got?.status === 200 && got.json?.status?.phase === "Bound";
|
|
423
|
+
}
|
|
424
|
+
async reclaimSpaceCache(spaceKey, deadline) {
|
|
425
|
+
if (deadline.aborted)
|
|
426
|
+
return { ok: false, reason: "budget", detail: this.reclaimDeadlinePassed("the claim delete-by-label was not sent") };
|
|
427
|
+
const { label } = workspaceCacheClaimOf(spaceKey);
|
|
428
|
+
const selector = encodeURIComponent(`${WORKSPACE_CACHE_SPACE_LABEL}=${label}`);
|
|
429
|
+
try {
|
|
430
|
+
const r = await this.request("DELETE", `/api/v1/namespaces/${this.cfg.namespace}/persistentvolumeclaims?labelSelector=${selector}`, undefined, { timeoutMs: this.cfg.rpcTimeoutMs, signal: deadline });
|
|
431
|
+
if (r.status < 200 || r.status >= 300)
|
|
432
|
+
return { ok: false, reason: "delete_failed", detail: `claim delete-by-label answered HTTP ${r.status}: ${JSON.stringify(r.json).slice(0, 200)}` };
|
|
433
|
+
const items = r.json?.items;
|
|
434
|
+
return { ok: true, deleted: Array.isArray(items) ? items.length : 0 };
|
|
435
|
+
}
|
|
436
|
+
catch (e) {
|
|
437
|
+
if (deadline.aborted)
|
|
438
|
+
return { ok: false, reason: "budget", detail: this.reclaimDeadlinePassed("the claim delete-by-label was cut in flight") };
|
|
439
|
+
return { ok: false, reason: "delete_failed", detail: `claim delete-by-label failed: ${errorMessageOf(e)}` };
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
reclaimDeadlinePassed(what) {
|
|
443
|
+
return `reclaim deadline (${this.cfg.dataTimeoutMs} ms, the snapshot time bound, one per reclaim call) passed — ${what}`;
|
|
444
|
+
}
|
|
445
|
+
cacheUnavailable(reason, detail, cache) {
|
|
446
|
+
if (this.cacheUnavailableSaid)
|
|
447
|
+
return;
|
|
448
|
+
this.cacheUnavailableSaid = true;
|
|
449
|
+
recordFailOpen("server.workspace-cache.unavailable", `reason=${reason}`);
|
|
450
|
+
this.logger?.warn("workspace_cache_unavailable", {
|
|
451
|
+
...(cache ? { space: incarnationLogPrefix(cache.spaceKey), claim: cache.claim.name } : { sessionId: this.cfg.sessionId }),
|
|
452
|
+
reason,
|
|
453
|
+
error: redactIncarnations(detail),
|
|
454
|
+
effect: "this run starts without the build-cache volume and without the cache env (tools use the image's own cache directories; builds re-download)",
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
async createPod(signal, cache) {
|
|
269
458
|
const ns = this.cfg.namespace;
|
|
459
|
+
const podEnv = { ...(cache ? workspaceCacheEnv(cache.mount) : {}), ...(this.cfg.podEnv ?? {}) };
|
|
270
460
|
const pod = {
|
|
271
461
|
apiVersion: "v1",
|
|
272
462
|
kind: "Pod",
|
|
@@ -287,12 +477,12 @@ export class RemoteK8sExecutionEnv {
|
|
|
287
477
|
command: this.cfg.podEnv?.SEMA_PKG_SOURCE
|
|
288
478
|
? ["sh", "-c", "[ -f /opt/sema/pkg-source.sh ] && sh /opt/sema/pkg-source.sh || true; sleep infinity"]
|
|
289
479
|
: ["sh", "-c", "sleep infinity"],
|
|
290
|
-
...(
|
|
291
|
-
|
|
292
|
-
: {}),
|
|
480
|
+
...(Object.keys(podEnv).length > 0 ? { env: Object.entries(podEnv).map(([name, value]) => ({ name, value: String(value) })) } : {}),
|
|
481
|
+
...(cache ? { volumeMounts: [{ name: CACHE_VOLUME, mountPath: cache.mount }] } : {}),
|
|
293
482
|
resources: { limits: { cpu: this.cfg.cpu, memory: this.cfg.memory } },
|
|
294
483
|
},
|
|
295
484
|
],
|
|
485
|
+
...(cache ? { volumes: [{ name: CACHE_VOLUME, persistentVolumeClaim: { claimName: cache.claim.name } }] } : {}),
|
|
296
486
|
},
|
|
297
487
|
};
|
|
298
488
|
if (this.cfg.podSpecPatch)
|
|
@@ -344,6 +534,17 @@ export class RemoteK8sExecutionEnv {
|
|
|
344
534
|
return { ok: false, error: new RemoteExecutionError("connect_failed", `image pull failed for ${this.cfg.image}: ${waiting.message ?? waiting.reason}`) };
|
|
345
535
|
}
|
|
346
536
|
if (Date.now() > deadline) {
|
|
537
|
+
if (cache) {
|
|
538
|
+
const bound = await this.cacheClaimBound(cache, signal);
|
|
539
|
+
if (!bound && Date.now() < cache.bindDeadline) {
|
|
540
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
await this.deletePod(name).catch(() => { });
|
|
544
|
+
return bound
|
|
545
|
+
? { cacheMiss: "claim_unmountable", detail: `the pod carrying the bound claim ${cache.claim.name} was not Running within ${this.cfg.readyTimeoutMs}ms (phase=${phase ?? "?"}${waiting?.reason ? `, ${waiting.reason}` : ""})` }
|
|
546
|
+
: { cacheMiss: "claim_unbound", detail: `the claim ${cache.claim.name} was not bound within ${this.cfg.dataTimeoutMs}ms (no default StorageClass, or its provisioner is down)` };
|
|
547
|
+
}
|
|
347
548
|
await this.deletePod(name).catch(() => { });
|
|
348
549
|
return { ok: false, error: new RemoteExecutionError("timeout", `pod not Running within ${this.cfg.readyTimeoutMs}ms (phase=${phase ?? "?"})`) };
|
|
349
550
|
}
|
|
@@ -362,6 +563,8 @@ export class RemoteK8sExecutionEnv {
|
|
|
362
563
|
this.podIP = undefined;
|
|
363
564
|
return { ok: false, error: new RemoteExecutionError("connect_failed", `workspace mkdir failed: ${mk.ok ? mk.value.stderr : mk.error.message}`) };
|
|
364
565
|
}
|
|
566
|
+
if (cache?.fresh)
|
|
567
|
+
this.logger?.warn("workspace_cache_fresh", { space: incarnationLogPrefix(cache.spaceKey), claim: cache.claim.name, size: cache.size });
|
|
365
568
|
return ok(this.workspaceHandle());
|
|
366
569
|
}
|
|
367
570
|
presignSnapshot(req) {
|
|
@@ -369,8 +572,14 @@ export class RemoteK8sExecutionEnv {
|
|
|
369
572
|
const prefix = s3.keyPrefix ?? "kata";
|
|
370
573
|
const [label, partition] = req.lane === "space" ? ["spaceIncarnation", req.space.incarnation] : ["sessionId", this.cfg.sessionId ?? "_"];
|
|
371
574
|
const snapshotId = "snapshotId" in req ? req.snapshotId : undefined;
|
|
575
|
+
const objectName = "object" in req ? req.object : undefined;
|
|
372
576
|
const safe = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
373
|
-
const segments = [
|
|
577
|
+
const segments = [
|
|
578
|
+
[label, partition],
|
|
579
|
+
...(snapshotId !== undefined ? [["snapshotId", snapshotId]] : []),
|
|
580
|
+
...(objectName !== undefined ? [["object", objectName]] : []),
|
|
581
|
+
["keyPrefix", prefix],
|
|
582
|
+
];
|
|
374
583
|
for (const [label, seg] of segments) {
|
|
375
584
|
if (!safe.test(seg))
|
|
376
585
|
return { ok: false, error: new RemoteExecutionError("connect_failed", `unsafe snapshot key segment (${label}): refusing to build a traversing object key`) };
|
|
@@ -383,12 +592,86 @@ export class RemoteK8sExecutionEnv {
|
|
|
383
592
|
...(s3.region ? { region: s3.region } : {}),
|
|
384
593
|
expiresSec: Math.min(s3.presignTtlSec ?? 600, 3600),
|
|
385
594
|
};
|
|
386
|
-
const base = req
|
|
595
|
+
const base = this.snapshotBase(req);
|
|
387
596
|
if (req.method === "LIST")
|
|
388
597
|
return { ok: true, value: presignS3ListUrl({ ...common, prefix: base, maxKeys: 1000 }) };
|
|
389
|
-
const object = "pointer" in req ? req.pointer : `${req.snapshotId}.tar.gz`;
|
|
598
|
+
const object = "pointer" in req ? req.pointer : "object" in req ? req.object : `${req.snapshotId}.tar.gz`;
|
|
390
599
|
return { ok: true, value: presignS3Url({ ...common, key: `${base}${object}`, method: req.method }) };
|
|
391
600
|
}
|
|
601
|
+
snapshotBase(req) {
|
|
602
|
+
const prefix = this.cfg.s3Snapshot.keyPrefix ?? "kata";
|
|
603
|
+
return req.lane === "space" ? `${prefix}/${req.space.incarnation}/space/` : `${prefix}/${this.cfg.sessionId ?? "_"}/`;
|
|
604
|
+
}
|
|
605
|
+
async reclaimSpaceSnapshots(spaceKey, deadline) {
|
|
606
|
+
if (!this.cfg.s3Snapshot)
|
|
607
|
+
return [];
|
|
608
|
+
const failures = [];
|
|
609
|
+
const deadlinePassed = (lane) => [
|
|
610
|
+
...failures,
|
|
611
|
+
{ lane, stage: "budget", detail: this.reclaimDeadlinePassed("stopped; the objects not yet deleted stay for the bucket's lifecycle rule") },
|
|
612
|
+
];
|
|
613
|
+
const targets = [{ lane: "space", req: { lane: "space", space: { incarnation: spaceKey }, method: "LIST" } }];
|
|
614
|
+
if (this.cfg.sessionId !== undefined)
|
|
615
|
+
targets.push({ lane: "park", req: { lane: "park", method: "LIST" } });
|
|
616
|
+
for (const { lane, req } of targets) {
|
|
617
|
+
if (deadline.aborted)
|
|
618
|
+
return deadlinePassed(lane);
|
|
619
|
+
const list = this.presignSnapshot(req);
|
|
620
|
+
if (!list.ok)
|
|
621
|
+
continue;
|
|
622
|
+
let xml;
|
|
623
|
+
try {
|
|
624
|
+
const res = await this.storeFetch(list.value, { method: "GET", signal: deadline });
|
|
625
|
+
xml = await res.text();
|
|
626
|
+
if (!res.ok) {
|
|
627
|
+
failures.push({ lane, stage: "list", detail: `list HTTP ${res.status}` });
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
catch (e) {
|
|
632
|
+
if (deadline.aborted)
|
|
633
|
+
return deadlinePassed(lane);
|
|
634
|
+
failures.push({ lane, stage: "list", detail: `list: ${this.redactUrl(errorMessageOf(e), list.value)}` });
|
|
635
|
+
continue;
|
|
636
|
+
}
|
|
637
|
+
if (/<IsTruncated>true<\/IsTruncated>/.test(xml))
|
|
638
|
+
failures.push({ lane, stage: "list", detail: "listing truncated (more than 1000 objects in this partition) — deleting the objects seen; the rest stay" });
|
|
639
|
+
const base = this.snapshotBase(req);
|
|
640
|
+
const names = [...xml.matchAll(/<Contents>([\s\S]*?)<\/Contents>/g)]
|
|
641
|
+
.map((m) => /<Key>([^<]*)<\/Key>/.exec(m[1])?.[1] ?? "")
|
|
642
|
+
.filter((key) => key.startsWith(base) && !key.slice(base.length).includes("/"))
|
|
643
|
+
.map((key) => key.slice(base.length))
|
|
644
|
+
.filter((name) => lane === "space" || snapshotNameOwnedBy(name, spaceKey));
|
|
645
|
+
const del = [];
|
|
646
|
+
let outOfTime = false;
|
|
647
|
+
for (const name of names) {
|
|
648
|
+
if (deadline.aborted) {
|
|
649
|
+
outOfTime = true;
|
|
650
|
+
break;
|
|
651
|
+
}
|
|
652
|
+
const target = req.lane === "space" ? this.presignSnapshot({ lane: "space", space: req.space, method: "DELETE", object: name }) : this.presignSnapshot({ lane: "park", method: "DELETE", object: name });
|
|
653
|
+
if (!target.ok)
|
|
654
|
+
continue;
|
|
655
|
+
try {
|
|
656
|
+
const res = await this.storeFetch(target.value, { method: "DELETE", signal: deadline });
|
|
657
|
+
if (!res.ok && res.status !== 404)
|
|
658
|
+
del.push(`${name}: HTTP ${res.status}`);
|
|
659
|
+
}
|
|
660
|
+
catch (e) {
|
|
661
|
+
if (deadline.aborted) {
|
|
662
|
+
outOfTime = true;
|
|
663
|
+
break;
|
|
664
|
+
}
|
|
665
|
+
del.push(`${name}: ${this.redactUrl(errorMessageOf(e), target.value)}`);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
if (del.length > 0)
|
|
669
|
+
failures.push({ lane, stage: "delete", detail: `${del.length} of ${names.length} objects not deleted — first: ${del[0]}` });
|
|
670
|
+
if (outOfTime)
|
|
671
|
+
return deadlinePassed(lane);
|
|
672
|
+
}
|
|
673
|
+
return failures;
|
|
674
|
+
}
|
|
392
675
|
storeFetch(url, init) {
|
|
393
676
|
const bound = AbortSignal.timeout(Math.max(1, Math.min(this.cfg.rpcTimeoutMs, init.timeoutMs ?? this.cfg.rpcTimeoutMs)));
|
|
394
677
|
const signal = init.signal ? AbortSignal.any([init.signal, bound]) : bound;
|
|
@@ -398,7 +681,7 @@ export class RemoteK8sExecutionEnv {
|
|
|
398
681
|
const url = this.presignSnapshot({ lane: "space", space, method: "GET", pointer });
|
|
399
682
|
if (!url.ok)
|
|
400
683
|
return url;
|
|
401
|
-
const what =
|
|
684
|
+
const what = "pointer";
|
|
402
685
|
let res;
|
|
403
686
|
let body;
|
|
404
687
|
try {
|
|
@@ -406,12 +689,12 @@ export class RemoteK8sExecutionEnv {
|
|
|
406
689
|
body = await res.text();
|
|
407
690
|
}
|
|
408
691
|
catch (e) {
|
|
409
|
-
return
|
|
692
|
+
return spaceReadFailed(what, `: ${this.redactUrl(errorMessageOf(e), url.value)}`, e);
|
|
410
693
|
}
|
|
411
694
|
if (res.status === 404)
|
|
412
695
|
return ok(undefined);
|
|
413
696
|
if (!res.ok)
|
|
414
|
-
return
|
|
697
|
+
return spaceReadFailed(what, ` (HTTP ${res.status})`);
|
|
415
698
|
return ok(body.trim());
|
|
416
699
|
}
|
|
417
700
|
async readLatest(space, signal) {
|
|
@@ -423,7 +706,7 @@ export class RemoteK8sExecutionEnv {
|
|
|
423
706
|
const url = this.presignSnapshot({ lane: "space", space, method, pointer });
|
|
424
707
|
if (!url.ok)
|
|
425
708
|
return url;
|
|
426
|
-
const what =
|
|
709
|
+
const what = "pointer";
|
|
427
710
|
try {
|
|
428
711
|
const res = await this.storeFetch(url.value, { method, ...(body === null ? {} : { body }), ...(timeoutMs !== undefined ? { timeoutMs } : {}) });
|
|
429
712
|
if (!res.ok && !(method === "DELETE" && res.status === 404))
|
|
@@ -436,17 +719,30 @@ export class RemoteK8sExecutionEnv {
|
|
|
436
719
|
}
|
|
437
720
|
async awaitSpaceCommit(space, signal) {
|
|
438
721
|
for (;;) {
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
722
|
+
let row;
|
|
723
|
+
try {
|
|
724
|
+
row = await this.fenceCall(() => space.fence.read(), "read", signal);
|
|
725
|
+
}
|
|
726
|
+
catch (e) {
|
|
727
|
+
if (e instanceof FenceCallAborted)
|
|
728
|
+
return { ok: false, error: new RemoteExecutionError("aborted", "connect aborted while waiting for the previous run's workspace commit") };
|
|
729
|
+
return spaceReadFailed("commit state", `: ${redactIncarnations(errorMessageOf(e))} — refusing to guess whether a commit is in flight`, e);
|
|
730
|
+
}
|
|
731
|
+
if (row === undefined)
|
|
443
732
|
return ok(undefined);
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
733
|
+
if (!spaceCommitAlive(row)) {
|
|
734
|
+
let gone;
|
|
735
|
+
try {
|
|
736
|
+
gone = await this.fenceCall(() => space.fence.abandon(row.taskId), "delete", signal);
|
|
737
|
+
}
|
|
738
|
+
catch (e) {
|
|
739
|
+
if (e instanceof FenceCallAborted)
|
|
740
|
+
return { ok: false, error: new RemoteExecutionError("aborted", "connect aborted while waiting for the previous run's workspace commit") };
|
|
741
|
+
return { ok: false, error: new RemoteExecutionError("connect_failed", `workspace space commit state write failed: ${redactIncarnations(errorMessageOf(e))} — refusing to guess whether the previous commit is gone`, e instanceof Error ? e : undefined) };
|
|
742
|
+
}
|
|
743
|
+
if (!gone)
|
|
744
|
+
continue;
|
|
745
|
+
this.spaceLine("workspace_commit_abandoned", space, { remainingMs: row.remainingMs, state: row.state });
|
|
450
746
|
return ok(undefined);
|
|
451
747
|
}
|
|
452
748
|
await new Promise((resolveWait) => {
|
|
@@ -503,12 +799,15 @@ export class RemoteK8sExecutionEnv {
|
|
|
503
799
|
async suspendVM(_options) {
|
|
504
800
|
if (!this.cfg.s3Snapshot)
|
|
505
801
|
return unsupported("suspendVM");
|
|
506
|
-
|
|
802
|
+
const r = await this.uploadSnapshot({ lane: "park" });
|
|
803
|
+
if (r.ok && this.spaceLane.state === "open")
|
|
804
|
+
await this.endSpace(this.spaceLane.space);
|
|
805
|
+
return r;
|
|
507
806
|
}
|
|
508
807
|
async uploadSnapshot(target) {
|
|
509
808
|
if (this.inFlightExecs > 0)
|
|
510
809
|
return { ok: false, error: new RemoteExecutionError("command_in_flight", "cannot suspend while a command is in flight (would snapshot a torn workspace)") };
|
|
511
|
-
const snapshotId =
|
|
810
|
+
const snapshotId = mintSnapshotId(this.occupantIncarnation);
|
|
512
811
|
if (!this.podName)
|
|
513
812
|
return ok(snapshotId);
|
|
514
813
|
const presigned = this.presignSnapshot({ ...target, method: "PUT", snapshotId });
|
|
@@ -516,7 +815,8 @@ export class RemoteK8sExecutionEnv {
|
|
|
516
815
|
return presigned;
|
|
517
816
|
const url = presigned.value;
|
|
518
817
|
const tmp = "/tmp/ws-snap.tgz";
|
|
519
|
-
const
|
|
818
|
+
const excludes = (this.cfg.snapshotExclude ?? []).map((item) => ` --exclude=${shellQuote(`./${item}`)}`).join("");
|
|
819
|
+
const cmd = `tar czf ${tmp}${excludes} -C ${shellQuote(this.cfg.mountPath)} . && curl -fsS -X PUT -T ${tmp} ${shellQuote(url)}; ` +
|
|
520
820
|
`rc=$?; rm -f ${tmp}; exit $rc`;
|
|
521
821
|
const r = await this.runExec(cmd, { timeoutMs: this.cfg.dataTimeoutMs, livenessMs: 0 });
|
|
522
822
|
if (!r.ok)
|
|
@@ -532,41 +832,61 @@ export class RemoteK8sExecutionEnv {
|
|
|
532
832
|
async resumeVM(snapshotId, options) {
|
|
533
833
|
if (!this.cfg.s3Snapshot)
|
|
534
834
|
return unsupported("resumeVM");
|
|
535
|
-
const r = await this.restoreSnapshot({ lane: "park" }, snapshotId,
|
|
536
|
-
if (r.ok)
|
|
537
|
-
|
|
538
|
-
|
|
835
|
+
const r = await this.restoreSnapshot({ lane: "park" }, snapshotId, options?.abortSignal);
|
|
836
|
+
if (!r.ok)
|
|
837
|
+
return r;
|
|
838
|
+
this.handleSnapshotId = snapshotId;
|
|
839
|
+
this.armSpace();
|
|
840
|
+
return ok(this.workspaceHandle());
|
|
539
841
|
}
|
|
540
|
-
async restoreSnapshot(target, snapshotId,
|
|
842
|
+
async restoreSnapshot(target, snapshotId, abortSignal) {
|
|
541
843
|
const presigned = this.presignSnapshot({ ...target, method: "GET", snapshotId });
|
|
542
844
|
if (!presigned.ok)
|
|
543
845
|
return presigned;
|
|
544
|
-
const c = await this.provision(
|
|
846
|
+
const c = await this.provision(abortSignal);
|
|
545
847
|
if (!c.ok)
|
|
546
848
|
return c;
|
|
547
849
|
const url = presigned.value;
|
|
548
850
|
const cmd = `code=$(curl -s -o /tmp/ws.tgz -w '%{http_code}' ${shellQuote(url)}); ` +
|
|
549
851
|
`if [ "$code" = "404" ]; then echo EMPTY_SNAPSHOT; exit 0; fi; ` +
|
|
550
|
-
`if [ "$code" != "200" ]; then echo "snapshot GET HTTP $code" >&2; exit
|
|
852
|
+
`if [ "$code" != "200" ]; then echo "snapshot GET HTTP $code" >&2; exit ${RESTORE_DOWNLOAD_EXIT}; fi; ` +
|
|
551
853
|
`tar xzf /tmp/ws.tgz -C ${shellQuote(this.cfg.mountPath)} && rm -f /tmp/ws.tgz`;
|
|
552
854
|
const r = await this.runExec(cmd, { timeoutMs: this.cfg.dataTimeoutMs, livenessMs: 0 });
|
|
553
|
-
const
|
|
554
|
-
?
|
|
855
|
+
const failure = !r.ok
|
|
856
|
+
? { cls: r.error.code === "timeout" ? "timeout" : "transport", detail: `workspace snapshot restore failed: ${this.redactUrl(r.error.message, url)}`, cause: r.error }
|
|
555
857
|
: r.value.exitCode !== 0
|
|
556
|
-
?
|
|
557
|
-
:
|
|
558
|
-
|
|
559
|
-
: undefined;
|
|
560
|
-
if (failed) {
|
|
858
|
+
? { cls: r.value.exitCode === RESTORE_DOWNLOAD_EXIT ? "download" : "untar", detail: `workspace snapshot restore exited ${r.value.exitCode}: ${this.redactUrl(r.value.stderr, url).slice(0, 300)}` }
|
|
859
|
+
: undefined;
|
|
860
|
+
if (failure) {
|
|
561
861
|
const name = this.podName;
|
|
562
862
|
this.podName = undefined;
|
|
563
863
|
this.podIP = undefined;
|
|
564
864
|
if (name)
|
|
565
865
|
await this.deletePod(name).catch(() => { });
|
|
566
|
-
|
|
866
|
+
if (target.lane === "park")
|
|
867
|
+
return { ok: false, error: new RemoteExecutionError("connect_failed", failure.detail, failure.cause) };
|
|
868
|
+
this.spaceLine("workspace_restore_failed", target.space, { snapshotId, class: failure.cls, boundMs: this.cfg.dataTimeoutMs, error: failure.detail });
|
|
869
|
+
return {
|
|
870
|
+
ok: false,
|
|
871
|
+
error: new RemoteExecutionError("connect_failed", `${failure.detail} — the workspace's latest snapshot (${snapshotId}) is in the store but did not come back (${failure.cls}, bound ${this.cfg.dataTimeoutMs}ms), ` +
|
|
872
|
+
"so this run does not start on an empty /workspace. Ways out: raise WORKSPACE_SNAPSHOT_TIMEOUT_MS if the archive is large, " +
|
|
873
|
+
"or set WORKSPACE_PERSIST=off for one run (it starts from an empty /workspace and deletes no snapshot)", failure.cause),
|
|
874
|
+
};
|
|
567
875
|
}
|
|
876
|
+
if (target.lane === "space" && r.ok && r.value.stdout.includes("EMPTY_SNAPSHOT"))
|
|
877
|
+
await this.dropDanglingLatest(target.space, snapshotId);
|
|
568
878
|
return ok(this.workspaceHandle());
|
|
569
879
|
}
|
|
880
|
+
async dropDanglingLatest(space, snapshotId) {
|
|
881
|
+
const prefix = this.cfg.s3Snapshot?.keyPrefix ?? "kata";
|
|
882
|
+
const dropped = await this.writePointer(space, "latest", null);
|
|
883
|
+
this.spaceLine("workspace_latest_dangling", space, {
|
|
884
|
+
key: `${prefix}/${space.incarnation}/space/${snapshotId}.tar.gz`,
|
|
885
|
+
deleted: dropped.ok,
|
|
886
|
+
...(dropped.ok ? {} : { error: dropped.error.message }),
|
|
887
|
+
effect: "the space's latest snapshot is gone (removed by a bucket lifecycle rule or by hand) — this run starts from an empty /workspace; its run-end snapshot becomes the new latest",
|
|
888
|
+
});
|
|
889
|
+
}
|
|
570
890
|
async postResumeInit() {
|
|
571
891
|
if (!this.cfg.s3Snapshot)
|
|
572
892
|
return unsupported("postResumeInit");
|
|
@@ -590,20 +910,23 @@ export class RemoteK8sExecutionEnv {
|
|
|
590
910
|
await this.disposeBackgroundShells().catch(() => { });
|
|
591
911
|
this.destroyed = true;
|
|
592
912
|
const lane = this.spaceLane;
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
913
|
+
switch (lane.state) {
|
|
914
|
+
case "open":
|
|
915
|
+
if (this.podName)
|
|
596
916
|
await this.persistWorkspace(lane.space);
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
917
|
+
else
|
|
918
|
+
await this.endSpace(lane.space);
|
|
919
|
+
break;
|
|
920
|
+
case "unavailable":
|
|
921
|
+
if (this.podName)
|
|
922
|
+
this.sayUnavailable(lane.reason);
|
|
923
|
+
break;
|
|
924
|
+
case "off":
|
|
925
|
+
case "unreadable":
|
|
926
|
+
break;
|
|
927
|
+
default: {
|
|
928
|
+
const unreachable = lane;
|
|
929
|
+
void unreachable;
|
|
607
930
|
}
|
|
608
931
|
}
|
|
609
932
|
const name = this.podName;
|
|
@@ -620,10 +943,18 @@ export class RemoteK8sExecutionEnv {
|
|
|
620
943
|
this.spaceLine("workspace_snapshot_failed", space, { stage, code: error.code, error: error.message });
|
|
621
944
|
this.onWorkspaceSnapshot?.("failed");
|
|
622
945
|
};
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
946
|
+
let claimed;
|
|
947
|
+
try {
|
|
948
|
+
claimed = await this.fenceCall(() => space.fence.begin(), "write");
|
|
949
|
+
}
|
|
950
|
+
catch (e) {
|
|
951
|
+
await this.endSpace(space);
|
|
952
|
+
return failed("fence", new RemoteExecutionError("connect_failed", `workspace space commit state write failed: ${redactIncarnations(errorMessageOf(e))}`));
|
|
953
|
+
}
|
|
954
|
+
if (!claimed) {
|
|
955
|
+
await this.endSpace(space);
|
|
956
|
+
return failed("fence", new RemoteExecutionError("timeout", "the commit fence is lost (this run's commit row is past its deadline, or another run's live commit holds the space) — not saving (a next run may already have restored the previous snapshot)"));
|
|
957
|
+
}
|
|
627
958
|
try {
|
|
628
959
|
let snap;
|
|
629
960
|
try {
|
|
@@ -634,9 +965,16 @@ export class RemoteK8sExecutionEnv {
|
|
|
634
965
|
}
|
|
635
966
|
if (!snap.ok)
|
|
636
967
|
return failed("upload", snap.error);
|
|
637
|
-
|
|
968
|
+
let deadline;
|
|
969
|
+
try {
|
|
970
|
+
deadline = await this.fenceCall(() => space.fence.publish(), "write");
|
|
971
|
+
}
|
|
972
|
+
catch (e) {
|
|
973
|
+
return failed("fence", new RemoteExecutionError("connect_failed", `workspace space commit state write failed: ${redactIncarnations(errorMessageOf(e))}`));
|
|
974
|
+
}
|
|
975
|
+
const remaining = deadline === undefined ? 0 : deadline - Date.now();
|
|
638
976
|
if (remaining <= 0)
|
|
639
|
-
return failed("
|
|
977
|
+
return failed("fence", new RemoteExecutionError("timeout", "the commit lost its fence before publishing (its row is past its deadline, or another run judged it abandoned and took the space) — not publishing latest (a next run may already have restored the previous one)"));
|
|
640
978
|
const moved = await this.writePointer(space, "latest", snap.value, remaining);
|
|
641
979
|
if (!moved.ok)
|
|
642
980
|
return failed("pointer", moved.error);
|
|
@@ -644,9 +982,7 @@ export class RemoteK8sExecutionEnv {
|
|
|
644
982
|
await this.pruneSpace(space, snap.value).catch((e) => this.spaceLine("workspace_snapshot_prune_failed", space, { detail: errorMessageOf(e) }));
|
|
645
983
|
}
|
|
646
984
|
finally {
|
|
647
|
-
|
|
648
|
-
if (!cleared.ok)
|
|
649
|
-
this.spaceLine("workspace_commit_marker_stuck", space, { error: cleared.error.message });
|
|
985
|
+
await this.endSpace(space);
|
|
650
986
|
}
|
|
651
987
|
}
|
|
652
988
|
execUrl(command) {
|
|
@@ -1208,6 +1544,45 @@ export class RemoteK8sExecutionEnv {
|
|
|
1208
1544
|
return dir ? this.resolve(dir) : undefined;
|
|
1209
1545
|
}
|
|
1210
1546
|
}
|
|
1547
|
+
export function createSpaceReclaimer(config, deps) {
|
|
1548
|
+
return {
|
|
1549
|
+
async reclaim(spaces) {
|
|
1550
|
+
const deadline = AbortSignal.timeout(dataTimeoutMsOf(config));
|
|
1551
|
+
for (const { sessionId, spaceKey } of spaces) {
|
|
1552
|
+
if (spaceKey === "")
|
|
1553
|
+
continue;
|
|
1554
|
+
const env = new RemoteK8sExecutionEnv({ ...config, sessionId }, deps);
|
|
1555
|
+
if (config.cache !== undefined) {
|
|
1556
|
+
const r = await env.reclaimSpaceCache(spaceKey, deadline).catch((e) => ({ ok: false, reason: "delete_failed", detail: errorMessageOf(e) }));
|
|
1557
|
+
if (!r.ok) {
|
|
1558
|
+
recordFailOpen("server.workspace-cache.reclaim-failed", `reason=${r.reason} label=${workspaceCacheClaimOf(spaceKey).label}`);
|
|
1559
|
+
config.logger?.warn("workspace_cache_reclaim_failed", {
|
|
1560
|
+
reason: r.reason,
|
|
1561
|
+
space: incarnationLogPrefix(spaceKey),
|
|
1562
|
+
label: workspaceCacheClaimOf(spaceKey).label,
|
|
1563
|
+
error: redactIncarnations(r.detail),
|
|
1564
|
+
effect: `the ended space's cache volume stays — delete it by label ${WORKSPACE_CACHE_SPACE_LABEL}=<label> (the deletion it belongs to went ahead)`,
|
|
1565
|
+
});
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
if (config.s3Snapshot !== undefined) {
|
|
1569
|
+
const failures = await env.reclaimSpaceSnapshots(spaceKey, deadline).catch((e) => [{ lane: "space", stage: "delete", detail: errorMessageOf(e) }]);
|
|
1570
|
+
for (const f of failures) {
|
|
1571
|
+
recordFailOpen("server.workspace-space.reclaim-failed", `lane=${f.lane} stage=${f.stage}`);
|
|
1572
|
+
config.logger?.warn("workspace_space_reclaim_failed", {
|
|
1573
|
+
space: incarnationLogPrefix(spaceKey),
|
|
1574
|
+
sessionId,
|
|
1575
|
+
lane: f.lane,
|
|
1576
|
+
stage: f.stage,
|
|
1577
|
+
error: redactIncarnations(f.detail),
|
|
1578
|
+
effect: `the ended space's ${f.lane === "space" ? "workspace snapshots" : "park snapshots"} may stay in the bucket (unreadable through the API; the bucket's lifecycle rule is the fallback) — the deletion it belongs to went ahead`,
|
|
1579
|
+
});
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
},
|
|
1584
|
+
};
|
|
1585
|
+
}
|
|
1211
1586
|
export function k8sExecutionEnvFactory(config, deps) {
|
|
1212
1587
|
return (ctx) => new RemoteK8sExecutionEnv({ ...config, sessionId: ctx.sessionId }, deps);
|
|
1213
1588
|
}
|