@sema-agent/server 7.125.1 → 7.127.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.
Files changed (275) hide show
  1. package/MIGRATION.md +229 -2
  2. package/README.md +2 -2
  3. package/README.zh-CN.md +2 -2
  4. package/USAGE.md +346 -145
  5. package/deploy/sema-up/README.md +1 -1
  6. package/deploy/sema-up/chart/templates/networkpolicy-sandbox.yaml +57 -0
  7. package/deploy/sema-up/chart/templates/sandbox-rbac.yaml +32 -1
  8. package/deploy/sema-up/chart/templates/server-v4pro.yaml +3 -2
  9. package/deploy/sema-up/chart/templates/server.yaml +2 -1
  10. package/deploy/sema-up/chart/values.yaml +22 -0
  11. package/deploy/sema-up/cluster-up.sh +10 -10
  12. package/deploy/sema-up/kube-up.sh +20 -13
  13. package/deploy/sema-up/lib/dataroot.sh +1 -1
  14. package/deploy/sema-up/lib/envf.sh +5 -5
  15. package/deploy/sema-up/lib/flagshape.sh +5 -5
  16. package/deploy/sema-up/lib/lanes.py +1 -1
  17. package/deploy/sema-up/lib/node-addrs.sh +42 -0
  18. package/deploy/sema-up/lib/parse-machines.py +2 -2
  19. package/deploy/sema-up/lib/shell-access.sh +1 -1
  20. package/deploy/sema-up/preflight.sh +4 -4
  21. package/deploy/sema-up/sema-up.sh +33 -33
  22. package/deploy/sema-up/smoke.sh +3 -3
  23. package/deploy/sema-up/stack/compose.yaml +1 -1
  24. package/deploy/sema-up/stack/sema-stack-cluster.yaml +26 -3
  25. package/dist/approval-card.d.ts +20 -4
  26. package/dist/approval-content-kind.d.ts +38 -22
  27. package/dist/approval-content-kind.js +20 -4
  28. package/dist/audit.js +3 -0
  29. package/dist/bench/s1/arms.d.ts +9 -37
  30. package/dist/bench/s1/arms.js +4 -61
  31. package/dist/bench/s1/live-deps.d.ts +4 -10
  32. package/dist/bench/s1/live-deps.js +1 -222
  33. package/dist/bench/s1/oracle.d.ts +1 -1
  34. package/dist/bench/s1/reviewer.d.ts +1 -1
  35. package/dist/bench/s1/row.d.ts +5 -7
  36. package/dist/bench/s1/row.js +0 -2
  37. package/dist/bench/s1/run-firm.d.ts +2 -2
  38. package/dist/bench/s1/run-firm.js +1 -4
  39. package/dist/bench/s1/runner-ctx.d.ts +17 -29
  40. package/dist/bench/s1/runner-ctx.js +3 -7
  41. package/dist/bench/s1/tasks.d.ts +9 -46
  42. package/dist/bench/s1/tasks.js +4 -63
  43. package/dist/boot/execution-env.d.ts +38 -26
  44. package/dist/boot/execution-env.js +87 -41
  45. package/dist/boot/leader.d.ts +2 -1
  46. package/dist/boot/memory-boundary.d.ts +1 -1
  47. package/dist/boot/memory-consolidation.d.ts +37 -3
  48. package/dist/boot/memory-consolidation.js +18 -2
  49. package/dist/boot/parked-revive-gate.d.ts +0 -3
  50. package/dist/boot/parked-revive-gate.js +2 -2
  51. package/dist/boot/resolve-spec.d.ts +0 -2
  52. package/dist/boot/resolve-spec.js +12 -17
  53. package/dist/boot/retention-lane.d.ts +12 -1
  54. package/dist/boot/retention-lane.js +2 -0
  55. package/dist/boot/runner-deps.d.ts +7 -6
  56. package/dist/boot/runner-deps.js +5 -16
  57. package/dist/boot/session-faces.d.ts +7 -2
  58. package/dist/boot/session-faces.js +37 -1
  59. package/dist/boot/stage-03-store-backend.d.ts +1 -0
  60. package/dist/boot/stage-03-store-backend.js +2 -2
  61. package/dist/boot/stage-04-stores.d.ts +1 -0
  62. package/dist/boot/stage-05-execution-env.d.ts +2 -2
  63. package/dist/boot/stage-05-execution-env.js +7 -4
  64. package/dist/boot/stage-06-runners.d.ts +2 -3
  65. package/dist/boot/stage-06-runners.js +13 -9
  66. package/dist/boot/stage-07-capability-layer.d.ts +2 -3
  67. package/dist/boot/stage-07-capability-layer.js +3 -4
  68. package/dist/boot/stage-08-reapers.d.ts +2 -3
  69. package/dist/boot/stage-08-reapers.js +2 -2
  70. package/dist/boot/stage-09-leader.d.ts +2 -3
  71. package/dist/boot/stage-09-leader.js +2 -1
  72. package/dist/boot/stage-10-http-server.d.ts +2 -3
  73. package/dist/boot/stage-10-http-server.js +2 -2
  74. package/dist/boot/stores.d.ts +1 -0
  75. package/dist/boot/stores.js +4 -3
  76. package/dist/boot/web-admin.d.ts +5 -0
  77. package/dist/boot/web-admin.js +28 -14
  78. package/dist/brain.d.ts +2 -2
  79. package/dist/brain.js +1 -1
  80. package/dist/capabilities/builtin-tools.d.ts +2 -1
  81. package/dist/capabilities/composite-members.d.ts +1 -1
  82. package/dist/capabilities/composite-members.js +1 -1
  83. package/dist/capabilities/hands-lane.d.ts +9 -6
  84. package/dist/capabilities/hands-lane.js +2 -2
  85. package/dist/capabilities/memory-notice.d.ts +27 -22
  86. package/dist/capabilities/memory-notice.js +15 -17
  87. package/dist/capabilities/scenarios.d.ts +9 -14
  88. package/dist/capabilities/scenarios.js +5 -50
  89. package/dist/capability-facts.d.ts +22 -11
  90. package/dist/capability-facts.js +13 -6
  91. package/dist/config-catalog.js +14 -5
  92. package/dist/config-provider.d.ts +5 -1
  93. package/dist/config-provider.js +23 -4
  94. package/dist/config-types.d.ts +19 -2
  95. package/dist/config.js +41 -6
  96. package/dist/deployment-governance.d.ts +19 -21
  97. package/dist/deployment-governance.js +7 -8
  98. package/dist/env-name-allowlist-knobs.d.ts +5 -5
  99. package/dist/env-name-allowlist-knobs.js +1 -0
  100. package/dist/execution-lane-caps.d.ts +12 -0
  101. package/dist/execution-lane-caps.js +18 -4
  102. package/dist/fleet/subagent-tail-bus.js +15 -5
  103. package/dist/hooks/hook-runner.d.ts +84 -30
  104. package/dist/hooks/hook-runner.js +74 -41
  105. package/dist/http/admission.js +17 -10
  106. package/dist/http/durable-run-leg.d.ts +5 -4
  107. package/dist/http/durable-run-leg.js +2 -4
  108. package/dist/http/idempotency.d.ts +28 -6
  109. package/dist/http/idempotency.js +10 -2
  110. package/dist/http/principal-gate.d.ts +14 -0
  111. package/dist/http/principal-gate.js +7 -0
  112. package/dist/http/request-guards.d.ts +2 -1
  113. package/dist/http/request-guards.js +14 -14
  114. package/dist/http/resume-legs.d.ts +2 -2
  115. package/dist/http/resume-legs.js +20 -10
  116. package/dist/http/route-ctx.d.ts +15 -7
  117. package/dist/http/routes/a2a-serve.js +11 -7
  118. package/dist/http/routes/approvals-assistant.js +18 -3
  119. package/dist/http/routes/background.d.ts +1 -1
  120. package/dist/http/routes/background.js +1 -1
  121. package/dist/http/routes/capabilities.js +10 -2
  122. package/dist/http/routes/mcp-reconnect.js +1 -1
  123. package/dist/http/routes/runs.d.ts +4 -3
  124. package/dist/http/routes/runs.js +35 -14
  125. package/dist/http/routes/session-sync.js +14 -11
  126. package/dist/http/routes/sessions.js +9 -9
  127. package/dist/http/routes/side-query.js +6 -19
  128. package/dist/http/routes/tasks.js +29 -19
  129. package/dist/http/routes/workflows.js +8 -0
  130. package/dist/http/server.d.ts +4 -0
  131. package/dist/http/server.js +3 -5
  132. package/dist/http/submit-replay.d.ts +15 -8
  133. package/dist/http/submit-replay.js +6 -0
  134. package/dist/http/wire-types.d.ts +7 -9
  135. package/dist/index.d.ts +1 -1
  136. package/dist/index.js +1 -1
  137. package/dist/internal-run-env.d.ts +30 -0
  138. package/dist/internal-run-env.js +11 -0
  139. package/dist/leader/wire.js +3 -2
  140. package/dist/lsp/manager.js +1 -1
  141. package/dist/memory-scope.d.ts +19 -7
  142. package/dist/memory-scope.js +25 -8
  143. package/dist/observability/fail-open.d.ts +31 -3
  144. package/dist/observability/fail-open.js +31 -3
  145. package/dist/observability/metrics.d.ts +0 -1
  146. package/dist/observability/metrics.js +1 -4
  147. package/dist/observability/run-terminal.js +72 -4
  148. package/dist/permission-rule-vocab.d.ts +3 -2
  149. package/dist/permission-rule-vocab.js +1 -1
  150. package/dist/plan-cache-probe.d.ts +3 -2
  151. package/dist/plugins/approval-ask-store-sql.d.ts +1 -1
  152. package/dist/plugins/caching-session-store.d.ts +42 -22
  153. package/dist/plugins/caching-session-store.js +19 -9
  154. package/dist/plugins/checkpoint-store-sql.d.ts +42 -28
  155. package/dist/plugins/checkpoint-store-sql.js +57 -11
  156. package/dist/plugins/file-run-store.d.ts +25 -21
  157. package/dist/plugins/file-run-store.js +70 -34
  158. package/dist/plugins/k8s-exec-protocol.js +3 -0
  159. package/dist/plugins/local-checkpoint-store.d.ts +8 -5
  160. package/dist/plugins/local-checkpoint-store.js +5 -15
  161. package/dist/plugins/local-session-store.d.ts +66 -7
  162. package/dist/plugins/local-session-store.js +168 -101
  163. package/dist/plugins/mailbox-recipient-lifecycle.d.ts +1 -1
  164. package/dist/plugins/memory-run-store.d.ts +19 -3
  165. package/dist/plugins/memory-run-store.js +38 -3
  166. package/dist/plugins/one-time-migrations.d.ts +165 -39
  167. package/dist/plugins/one-time-migrations.js +51 -144
  168. package/dist/plugins/pg-pool.js +11 -1
  169. package/dist/plugins/pg-session-storage.d.ts +2 -0
  170. package/dist/plugins/pg-session-storage.js +9 -5
  171. package/dist/plugins/remote-env-adb.d.ts +3 -0
  172. package/dist/plugins/remote-env-adb.js +1 -0
  173. package/dist/plugins/remote-env-device.d.ts +3 -0
  174. package/dist/plugins/remote-env-device.js +1 -0
  175. package/dist/plugins/remote-env-e2b.d.ts +4 -0
  176. package/dist/plugins/remote-env-e2b.js +2 -1
  177. package/dist/plugins/remote-env-host.d.ts +23 -1
  178. package/dist/plugins/remote-env-host.js +20 -2
  179. package/dist/plugins/remote-env-k8s.d.ts +264 -41
  180. package/dist/plugins/remote-env-k8s.js +551 -82
  181. package/dist/plugins/remote-env-local-docker.d.ts +4 -0
  182. package/dist/plugins/remote-env-local-docker.js +2 -1
  183. package/dist/plugins/remote-env-ssh.d.ts +3 -0
  184. package/dist/plugins/remote-env-ssh.js +1 -0
  185. package/dist/plugins/retention-store-sql.d.ts +4 -1
  186. package/dist/plugins/retention-store-sql.js +11 -9
  187. package/dist/plugins/run-store-sql.d.ts +44 -6
  188. package/dist/plugins/run-store-sql.js +92 -27
  189. package/dist/plugins/session-capture-record-store-sql.d.ts +2 -3
  190. package/dist/plugins/session-store.d.ts +4 -3
  191. package/dist/plugins/session-store.js +1 -1
  192. package/dist/plugins/sql-driver.d.ts +2 -1
  193. package/dist/plugins/store-backend.d.ts +12 -5
  194. package/dist/plugins/store-backend.js +13 -4
  195. package/dist/plugins/store-contracts.d.ts +20 -3
  196. package/dist/plugins/store-open-wait.js +3 -0
  197. package/dist/plugins/tidb-pool.js +15 -3
  198. package/dist/plugins/tidb-session-storage.js +3 -2
  199. package/dist/plugins/tidb-session-store.d.ts +2 -0
  200. package/dist/plugins/tidb-session-store.js +6 -2
  201. package/dist/request-key-closure.d.ts +32 -7
  202. package/dist/request-key-closure.js +25 -9
  203. package/dist/router/route-orchestration.d.ts +3 -11
  204. package/dist/router/route-orchestration.js +0 -4
  205. package/dist/rule-entry-list.js +0 -4
  206. package/dist/rules-consent.js +3 -0
  207. package/dist/run-acceptance.d.ts +51 -0
  208. package/dist/run-acceptance.js +27 -0
  209. package/dist/run-local.d.ts +5 -4
  210. package/dist/run-local.js +23 -17
  211. package/dist/runs.d.ts +7 -11
  212. package/dist/runs.js +6 -16
  213. package/dist/sandbox-egress-policy.d.ts +61 -0
  214. package/dist/sandbox-egress-policy.js +235 -0
  215. package/dist/security.d.ts +7 -0
  216. package/dist/security.js +5 -0
  217. package/dist/server-secret-env.d.ts +10 -4
  218. package/dist/server-secret-env.js +4 -1
  219. package/dist/session-error-wire.d.ts +2 -2
  220. package/dist/session-incarnation.d.ts +46 -2
  221. package/dist/session-incarnation.js +14 -3
  222. package/dist/session-sync-kernel.d.ts +10 -3
  223. package/dist/session-titler.d.ts +5 -3
  224. package/dist/spec-fields.d.ts +24 -8
  225. package/dist/spec-fields.js +21 -2
  226. package/dist/task-workflow.d.ts +6 -4
  227. package/dist/task-workflow.js +3 -2
  228. package/dist/tool-approval.d.ts +78 -6
  229. package/dist/tool-approval.js +7 -3
  230. package/dist/trace/core-keyset-guard.d.ts +6 -5
  231. package/dist/trace/engine-notice-wire.d.ts +26 -1
  232. package/dist/trace/engine-notice-wire.js +7 -2
  233. package/dist/trace/ledger-events.d.ts +9 -3
  234. package/dist/trace/ledger-sink.d.ts +7 -0
  235. package/dist/trace/ledger-sink.js +14 -4
  236. package/dist/trace/project.d.ts +57 -11
  237. package/dist/trace/project.js +54 -8
  238. package/dist/trace/self-minted-wire.d.ts +94 -2
  239. package/dist/trace/self-minted-wire.js +28 -0
  240. package/dist/trace/tool-output-media.d.ts +26 -0
  241. package/dist/trace/tool-output-media.js +57 -0
  242. package/dist/trace/wire-projection-faces.d.ts +7 -1
  243. package/dist/trace/wire-projection-faces.js +2 -1
  244. package/dist/web-admin-client/apply-effective.d.ts +13 -7
  245. package/dist/web-admin-client/apply-effective.js +11 -26
  246. package/dist/web-admin-client/apply-ledger.d.ts +2 -2
  247. package/dist/web-admin-client/candidate-gate.d.ts +39 -4
  248. package/dist/web-admin-client/candidate-gate.js +9 -2
  249. package/dist/web-admin-client/facade.d.ts +2 -2
  250. package/dist/web-admin-client/http-client.d.ts +3 -1
  251. package/dist/web-admin-client/http-client.js +4 -3
  252. package/dist/web-admin-client/read-warnings.d.ts +33 -5
  253. package/dist/web-admin-client/read-warnings.js +41 -6
  254. package/dist/web-admin-client/types.d.ts +11 -5
  255. package/dist/workspace-cache.d.ts +45 -0
  256. package/dist/workspace-cache.js +30 -0
  257. package/dist/workspace-space-commit.d.ts +116 -0
  258. package/dist/workspace-space-commit.js +117 -0
  259. package/dist/workspace-space.d.ts +75 -9
  260. package/dist/workspace-space.js +52 -1
  261. package/package.json +4 -4
  262. package/dist/boot/session-incarnation-seat.d.ts +0 -41
  263. package/dist/boot/session-incarnation-seat.js +0 -69
  264. package/dist/capabilities/code-review-council.d.ts +0 -49
  265. package/dist/capabilities/code-review-council.js +0 -111
  266. package/dist/capabilities/collab-wire.d.ts +0 -36
  267. package/dist/capabilities/collab-wire.js +0 -2
  268. package/dist/capabilities/collab-workflows.d.ts +0 -47
  269. package/dist/capabilities/collab-workflows.js +0 -90
  270. package/dist/capabilities/prompts/code-review.d.ts +0 -25
  271. package/dist/capabilities/prompts/code-review.js +0 -39
  272. package/dist/capabilities/prompts/team.d.ts +0 -10
  273. package/dist/capabilities/prompts/team.js +0 -6
  274. package/dist/capabilities/team.d.ts +0 -113
  275. package/dist/capabilities/team.js +0 -70
@@ -6,7 +6,12 @@ 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";
14
+ import { SANDBOX_IPV6_DEFAULT, sandboxEgressGaps, sandboxPodSysctls } from "../sandbox-egress-policy.js";
10
15
  import { FileError, ExecutionError, RemoteExecutionError, withRetry, RollingTailBuffer, markTruncated, } from "@sema-agent/core";
11
16
  import { fileErrorFromExec, classifyFsStderr } from "./remote-env-file-error.js";
12
17
  import { createPosixShellFs } from "./posix-shell-fs.js";
@@ -32,21 +37,84 @@ const EXEC_SUBPROTOCOLS = ["v5.channel.k8s.io", "v4.channel.k8s.io"];
32
37
  const WRITE_CHUNK_BYTES = 64 * 1024;
33
38
  import { applyPodSpecPatch } from "./k8s-exec-protocol.js";
34
39
  export { applyPodSpecPatch } from "./k8s-exec-protocol.js";
35
- function spaceLaneOf(policy, incarnation) {
40
+ const CACHE_VOLUME = "ws-cache";
41
+ function occupantKeyOf(occupant) {
42
+ if (occupant === undefined)
43
+ return { state: "unavailable", reason: "incarnation_unknown" };
44
+ if ("incarnation" in occupant)
45
+ return occupant;
46
+ if ("unread" in occupant)
47
+ return { state: "unavailable", reason: occupant.unread };
48
+ return { state: "unreadable", code: occupant.fault };
49
+ }
50
+ function cacheLaneOf(policy, occupant) {
51
+ if (policy === undefined)
52
+ return { state: "off" };
53
+ const key = occupantKeyOf(occupant);
54
+ if (!("incarnation" in key))
55
+ return key;
56
+ return { state: "open", cache: { mount: policy.mount, size: policy.size, claim: workspaceCacheClaimOf(key.incarnation), spaceKey: key.incarnation } };
57
+ }
58
+ function spaceLaneOf(policy, occupant) {
36
59
  if (policy === undefined)
37
60
  return { state: "off" };
38
61
  switch (policy.persist) {
39
62
  case "off":
40
63
  return { state: "off" };
41
- case "space":
42
- return incarnation ? { state: "open", space: { incarnation, keep: policy.keep } } : { state: "unavailable" };
64
+ case "space": {
65
+ const key = occupantKeyOf(occupant);
66
+ return "incarnation" in key ? { state: "open", space: { incarnation: key.incarnation, keep: policy.keep, fence: key.fence } } : key;
67
+ }
43
68
  default: {
44
69
  const unreachable = policy.persist;
45
70
  throw new Error(`unhandled WORKSPACE_PERSIST word: ${String(unreachable)}`);
46
71
  }
47
72
  }
48
73
  }
74
+ function spaceReadFailed(what, detail, cause) {
75
+ return { ok: false, error: new RemoteExecutionError("connect_failed", `workspace space ${what} read failed${detail}`, cause instanceof Error ? cause : undefined) };
76
+ }
77
+ function restoreFailureIsTransient(cls) {
78
+ switch (cls) {
79
+ case "timeout":
80
+ case "transport":
81
+ case "store_busy":
82
+ return true;
83
+ case "download":
84
+ case "untar":
85
+ return false;
86
+ default: {
87
+ const unreachable = cls;
88
+ throw new Error(`unhandled restore failure class: ${String(unreachable)}`);
89
+ }
90
+ }
91
+ }
92
+ const RESTORE_MAX_ATTEMPTS = { park: 1, space: 2 };
93
+ const RESTORE_DOWNLOAD_EXIT = 22;
94
+ const RESTORE_TRANSFER_EXIT = 75;
95
+ const RESTORE_STORE_BUSY_STATUSES = [408, 429, 500, 502, 503, 504];
96
+ const RESTORE_STORE_BUSY_EXIT = 69;
97
+ function restoreClassOfExit(exitCode) {
98
+ switch (exitCode) {
99
+ case RESTORE_STORE_BUSY_EXIT:
100
+ return "store_busy";
101
+ case RESTORE_DOWNLOAD_EXIT:
102
+ return "download";
103
+ case RESTORE_TRANSFER_EXIT:
104
+ return "transport";
105
+ default:
106
+ return "untar";
107
+ }
108
+ }
49
109
  const SPACE_COMMIT_POLL_MS = 1000;
110
+ class FenceCallAborted extends Error {
111
+ constructor() {
112
+ super("aborted");
113
+ }
114
+ }
115
+ function dataTimeoutMsOf(config) {
116
+ return config.dataTimeoutMs ?? WORKSPACE_SNAPSHOT_TIMEOUT_MS_DEFAULT;
117
+ }
50
118
  import { execSocketTlsOptions, exitCodeFromStatus } from "./k8s-exec-protocol.js";
51
119
  export { execSocketTlsOptions } from "./k8s-exec-protocol.js";
52
120
  export function errorMessageOf(e) {
@@ -56,6 +124,8 @@ export function errorMessageOf(e) {
56
124
  return typeof m === "string" && m ? m : String(e);
57
125
  }
58
126
  export { exitCodeFromStatus } from "./k8s-exec-protocol.js";
127
+ export const K8S_SANDBOX_NAMESPACE_DEFAULT = "sandboxes";
128
+ const SANDBOX_POD_LABELS = { app: "sema-sandbox", "managed-by": "sema-server" };
59
129
  export class RemoteK8sExecutionEnv {
60
130
  capabilities;
61
131
  cwd;
@@ -65,11 +135,17 @@ export class RemoteK8sExecutionEnv {
65
135
  handleSnapshotId;
66
136
  inFlightExecs = 0;
67
137
  connectPromise;
138
+ restoreRefusal;
68
139
  homeDir;
140
+ cdPath = "unknown";
69
141
  cfg;
70
142
  deps;
143
+ occupantIncarnation;
71
144
  spaceLane;
72
145
  spaceUnavailableSaid = false;
146
+ unreadableOccupantSaid = false;
147
+ cacheLane;
148
+ cacheUnavailableSaid = false;
73
149
  logger;
74
150
  onWorkspaceSnapshot;
75
151
  constructor(config, deps) {
@@ -78,27 +154,32 @@ export class RemoteK8sExecutionEnv {
78
154
  token: config.token,
79
155
  caCert: config.caCert,
80
156
  insecureTls: config.insecureTls ?? false,
81
- namespace: config.namespace ?? "sandboxes",
157
+ namespace: config.namespace ?? K8S_SANDBOX_NAMESPACE_DEFAULT,
82
158
  image: config.image,
83
159
  runtimeClass: config.runtimeClass ?? "kata-qemu",
84
- mountPath: config.mountPath ?? "/workspace",
160
+ mountPath: config.mountPath ?? K8S_WORKSPACE_MOUNT_DEFAULT,
85
161
  timeoutMs: config.timeoutMs ?? 30 * 60_000,
86
162
  readyTimeoutMs: config.readyTimeoutMs ?? 120_000,
87
163
  rpcTimeoutMs: config.rpcTimeoutMs ?? 30_000,
88
164
  livenessMs: config.livenessMs ?? 120_000,
89
- dataTimeoutMs: config.dataTimeoutMs ?? 5 * 60_000,
165
+ dataTimeoutMs: dataTimeoutMsOf(config),
90
166
  cpu: config.cpu ?? "1",
91
167
  memory: config.memory ?? "2Gi",
168
+ ipv6: config.ipv6 ?? SANDBOX_IPV6_DEFAULT,
92
169
  retry: { maxAttempts: config.retry?.maxAttempts ?? 3, backoffMs: config.retry?.backoffMs ?? 1000 },
93
170
  ...(config.s3Snapshot ? { s3Snapshot: config.s3Snapshot } : {}),
94
171
  ...(config.sessionId ? { sessionId: config.sessionId } : {}),
95
172
  ...(config.podSpecPatch ? { podSpecPatch: config.podSpecPatch } : {}),
96
173
  ...(config.podEnv ? { podEnv: config.podEnv } : {}),
174
+ ...(config.snapshotExclude && config.snapshotExclude.length > 0 ? { snapshotExclude: config.snapshotExclude } : {}),
97
175
  };
98
176
  this.cwd = this.cfg.mountPath;
99
177
  this.homeDir = config.homeDir;
100
178
  this.deps = deps ?? {};
101
- this.spaceLane = spaceLaneOf(config.s3Snapshot?.space, config.spaceIncarnation);
179
+ const occupant = occupantKeyOf(config.spaceOccupant);
180
+ this.occupantIncarnation = "incarnation" in occupant ? occupant.incarnation : undefined;
181
+ this.spaceLane = spaceLaneOf(config.s3Snapshot?.space, config.spaceOccupant);
182
+ this.cacheLane = cacheLaneOf(config.cache, config.spaceOccupant);
102
183
  this.logger = config.logger;
103
184
  this.onWorkspaceSnapshot = config.onWorkspaceSnapshot;
104
185
  this.capabilities = { isolation: true, suspendable: !!this.cfg.s3Snapshot, canonicalPathAuthoritative: true };
@@ -206,18 +287,23 @@ export class RemoteK8sExecutionEnv {
206
287
  case "off":
207
288
  return this.provision(signal);
208
289
  case "unavailable":
209
- this.sayUnavailable();
290
+ this.sayUnavailable(lane.reason);
291
+ return this.provision(signal);
292
+ case "unreadable":
210
293
  return this.provision(signal);
211
294
  case "open": {
295
+ if (this.restoreRefusal)
296
+ return this.restoreRefusal;
212
297
  const settled = await this.awaitSpaceCommit(lane.space, signal);
213
298
  if (!settled.ok)
214
299
  return settled;
215
300
  const latest = await this.readLatest(lane.space, signal);
216
301
  if (!latest.ok)
217
302
  return latest;
218
- if (latest.value === undefined)
219
- return this.provision(signal);
220
- return this.restoreSnapshot({ lane: "space", space: lane.space }, latest.value, { missing: "refuse", ...(signal ? { abortSignal: signal } : {}) });
303
+ const c = latest.value === undefined ? await this.provision(signal) : await this.restoreSnapshot({ lane: "space", space: lane.space }, latest.value, signal);
304
+ if (c.ok)
305
+ this.armSpace();
306
+ return c;
221
307
  }
222
308
  default: {
223
309
  const unreachable = lane;
@@ -225,27 +311,76 @@ export class RemoteK8sExecutionEnv {
225
311
  }
226
312
  }
227
313
  }
228
- sayUnavailable() {
314
+ armSpace() {
315
+ if (this.spaceLane.state === "open")
316
+ this.spaceLane.space.fence.arm(this.cfg.dataTimeoutMs);
317
+ }
318
+ async endSpace(space) {
319
+ await this.fenceCall(() => space.fence.end(), "delete").catch(() => undefined);
320
+ }
321
+ fenceCall(op, what, signal) {
322
+ return new Promise((resolve, reject) => {
323
+ if (signal?.aborted)
324
+ return reject(new FenceCallAborted());
325
+ const onAbort = () => {
326
+ clearTimeout(timer);
327
+ reject(new FenceCallAborted());
328
+ };
329
+ const timer = setTimeout(() => {
330
+ signal?.removeEventListener("abort", onAbort);
331
+ reject(new Error(`workspace space commit state ${what} did not answer within ${this.cfg.rpcTimeoutMs}ms`));
332
+ }, this.cfg.rpcTimeoutMs);
333
+ signal?.addEventListener("abort", onAbort, { once: true });
334
+ op().then((v) => {
335
+ clearTimeout(timer);
336
+ signal?.removeEventListener("abort", onAbort);
337
+ resolve(v);
338
+ }, (e) => {
339
+ clearTimeout(timer);
340
+ signal?.removeEventListener("abort", onAbort);
341
+ reject(e);
342
+ });
343
+ });
344
+ }
345
+ sayUnavailable(reason) {
229
346
  if (this.spaceUnavailableSaid)
230
347
  return;
231
348
  this.spaceUnavailableSaid = true;
232
349
  this.logger?.warn("workspace_space_unavailable", {
233
- reason: "incarnation_unknown",
350
+ reason,
234
351
  sessionId: this.cfg.sessionId,
235
- effect: "WORKSPACE_PERSIST=space, but this run's session occupant (incarnation) was not read on this replica — its /workspace is neither restored nor saved (it is never keyed by the session id instead)",
352
+ 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
353
  });
237
354
  }
355
+ refuseUnreadableOccupant(code) {
356
+ const ways = [...(this.spaceLane.state === "unreadable" ? ["WORKSPACE_PERSIST=off"] : []), ...(this.cacheLane.state === "unreadable" ? ["WORKSPACE_CACHE=off"] : [])].join(" and ");
357
+ if (!this.unreadableOccupantSaid) {
358
+ this.unreadableOccupantSaid = true;
359
+ this.logger?.warn("workspace_space_unreadable_occupant", {
360
+ sessionId: this.cfg.sessionId,
361
+ code,
362
+ 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`,
363
+ });
364
+ }
365
+ 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}`);
366
+ }
238
367
  spaceLine(event, space, fields) {
239
368
  const safe = Object.fromEntries(Object.entries(fields).map(([k, v]) => [k, typeof v === "string" ? redactIncarnations(v) : v]));
240
369
  this.logger?.warn(event, { space: incarnationLogPrefix(space.incarnation), ...safe });
241
370
  }
242
371
  provision(signal) {
372
+ const space = this.spaceLane;
373
+ const cache = this.cacheLane;
374
+ if (space.state === "unreadable")
375
+ return Promise.resolve(this.refuseUnreadableOccupant(space.code));
376
+ if (cache.state === "unreadable")
377
+ return Promise.resolve(this.refuseUnreadableOccupant(cache.code));
243
378
  const policy = {
244
379
  retryableCodes: ["connect_failed", "timeout", "transport_lost"],
245
380
  maxAttempts: this.cfg.retry.maxAttempts,
246
381
  backoffMs: (failed) => this.cfg.retry.backoffMs * failed,
247
382
  };
248
- const once = this.deps.connectOnce ?? ((s) => this.connectOnce(s));
383
+ const once = this.deps.connectOnce ?? ((s) => this.connectOnce(cache, s));
249
384
  return withRetry(() => once(signal), policy, { signal });
250
385
  }
251
386
  async ensureConnected() {
@@ -261,21 +396,133 @@ export class RemoteK8sExecutionEnv {
261
396
  }
262
397
  return ok(undefined);
263
398
  }
264
- async connectOnce(signal) {
399
+ async connectOnce(cacheLane, signal) {
265
400
  if (signal?.aborted)
266
401
  return { ok: false, error: new RemoteExecutionError("aborted", "connect aborted") };
267
402
  if (this.destroyed)
268
403
  return { ok: false, error: new RemoteExecutionError("connect_failed", "env already destroyed") };
404
+ const cache = await this.attachCache(cacheLane, signal);
405
+ const first = await this.createPod(signal, cache);
406
+ if (!("cacheMiss" in first))
407
+ return first;
408
+ this.cacheUnavailable(first.cacheMiss, first.detail, cache);
409
+ const second = await this.createPod(signal, undefined);
410
+ return "cacheMiss" in second ? { ok: false, error: new RemoteExecutionError("connect_failed", "unreachable: a pod without the cache volume was given up for it") } : second;
411
+ }
412
+ async attachCache(lane, signal) {
413
+ switch (lane.state) {
414
+ case "off":
415
+ return undefined;
416
+ case "unavailable":
417
+ 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)");
418
+ return undefined;
419
+ case "open": {
420
+ const ensured = await this.ensureCacheClaim(lane.cache, signal);
421
+ if (!ensured.ok) {
422
+ this.cacheUnavailable("claim_failed", ensured.detail, lane.cache);
423
+ return undefined;
424
+ }
425
+ return { ...lane.cache, fresh: ensured.fresh, bindDeadline: Date.now() + this.cfg.dataTimeoutMs };
426
+ }
427
+ default: {
428
+ const unreachable = lane;
429
+ throw new Error(`unhandled cache lane state: ${String(unreachable.state)}`);
430
+ }
431
+ }
432
+ }
433
+ async ensureCacheClaim(cache, signal) {
434
+ const base = `/api/v1/namespaces/${this.cfg.namespace}/persistentvolumeclaims`;
435
+ try {
436
+ const got = await this.request("GET", `${base}/${cache.claim.name}`, undefined, { signal });
437
+ if (got.status === 200)
438
+ return { ok: true, fresh: false };
439
+ if (got.status !== 404)
440
+ return { ok: false, detail: `claim read answered HTTP ${got.status}: ${JSON.stringify(got.json).slice(0, 200)}` };
441
+ const claim = {
442
+ apiVersion: "v1",
443
+ kind: "PersistentVolumeClaim",
444
+ metadata: { name: cache.claim.name, labels: { [WORKSPACE_CACHE_SPACE_LABEL]: cache.claim.label, "managed-by": "sema-server" } },
445
+ spec: { accessModes: ["ReadWriteOnce"], resources: { requests: { storage: cache.size } } },
446
+ };
447
+ const made = await this.request("POST", base, claim, { signal });
448
+ if (made.status === 200 || made.status === 201)
449
+ return { ok: true, fresh: true };
450
+ if (made.status === 409)
451
+ return { ok: true, fresh: false };
452
+ return { ok: false, detail: `claim create answered HTTP ${made.status}: ${JSON.stringify(made.json).slice(0, 200)}` };
453
+ }
454
+ catch (e) {
455
+ return { ok: false, detail: `claim request failed: ${errorMessageOf(e)}` };
456
+ }
457
+ }
458
+ async cacheClaimBound(cache, signal) {
459
+ const got = await this.request("GET", `/api/v1/namespaces/${this.cfg.namespace}/persistentvolumeclaims/${cache.claim.name}`, undefined, { signal }).catch(() => undefined);
460
+ return got?.status === 200 && got.json?.status?.phase === "Bound";
461
+ }
462
+ async reclaimSpaceCache(spaceKey, deadline) {
463
+ if (deadline.aborted)
464
+ return { ok: false, reason: "budget", detail: this.reclaimDeadlinePassed("the claim delete-by-label was not sent") };
465
+ const { label } = workspaceCacheClaimOf(spaceKey);
466
+ const selector = encodeURIComponent(`${WORKSPACE_CACHE_SPACE_LABEL}=${label}`);
467
+ try {
468
+ const r = await this.request("DELETE", `/api/v1/namespaces/${this.cfg.namespace}/persistentvolumeclaims?labelSelector=${selector}`, undefined, { timeoutMs: this.cfg.rpcTimeoutMs, signal: deadline });
469
+ if (r.status < 200 || r.status >= 300)
470
+ return { ok: false, reason: "delete_failed", detail: `claim delete-by-label answered HTTP ${r.status}: ${JSON.stringify(r.json).slice(0, 200)}` };
471
+ const items = r.json?.items;
472
+ return { ok: true, deleted: Array.isArray(items) ? items.length : 0 };
473
+ }
474
+ catch (e) {
475
+ if (deadline.aborted)
476
+ return { ok: false, reason: "budget", detail: this.reclaimDeadlinePassed("the claim delete-by-label was cut in flight") };
477
+ return { ok: false, reason: "delete_failed", detail: `claim delete-by-label failed: ${errorMessageOf(e)}` };
478
+ }
479
+ }
480
+ async egressPolicyGaps() {
481
+ const read = async (apiPath) => {
482
+ try {
483
+ const r = await this.request("GET", apiPath);
484
+ return r.status >= 200 && r.status < 300 ? { ok: true, json: r.json } : { ok: false, detail: `HTTP ${r.status}` };
485
+ }
486
+ catch (e) {
487
+ return { ok: false, detail: errorMessageOf(e) };
488
+ }
489
+ };
490
+ const nodes = await read("/api/v1/nodes");
491
+ const policies = await read(`/apis/networking.k8s.io/v1/namespaces/${this.cfg.namespace}/networkpolicies`);
492
+ const probe = { metadata: { labels: { ...SANDBOX_POD_LABELS } }, spec: {} };
493
+ if (this.cfg.podSpecPatch?.labels)
494
+ applyPodSpecPatch(probe, { labels: this.cfg.podSpecPatch.labels });
495
+ return sandboxEgressGaps({ nodes, policies, podLabels: probe.metadata.labels, ipv6: this.cfg.ipv6 });
496
+ }
497
+ reclaimDeadlinePassed(what) {
498
+ return `reclaim deadline (${this.cfg.dataTimeoutMs} ms, the snapshot time bound, one per reclaim call) passed — ${what}`;
499
+ }
500
+ cacheUnavailable(reason, detail, cache) {
501
+ if (this.cacheUnavailableSaid)
502
+ return;
503
+ this.cacheUnavailableSaid = true;
504
+ recordFailOpen("server.workspace-cache.unavailable", `reason=${reason}`);
505
+ this.logger?.warn("workspace_cache_unavailable", {
506
+ ...(cache ? { space: incarnationLogPrefix(cache.spaceKey), claim: cache.claim.name } : { sessionId: this.cfg.sessionId }),
507
+ reason,
508
+ error: redactIncarnations(detail),
509
+ 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)",
510
+ });
511
+ }
512
+ async createPod(signal, cache) {
269
513
  const ns = this.cfg.namespace;
514
+ const podEnv = { ...(cache ? workspaceCacheEnv(cache.mount) : {}), ...(this.cfg.podEnv ?? {}) };
515
+ const sysctls = sandboxPodSysctls(this.cfg.ipv6);
270
516
  const pod = {
271
517
  apiVersion: "v1",
272
518
  kind: "Pod",
273
519
  metadata: {
274
520
  generateName: "sbx-",
275
- labels: { app: "sema-sandbox", "managed-by": "sema-server" },
521
+ labels: { ...SANDBOX_POD_LABELS },
276
522
  },
277
523
  spec: {
278
524
  ...(this.cfg.runtimeClass ? { runtimeClassName: this.cfg.runtimeClass } : {}),
525
+ ...(sysctls ? { securityContext: { sysctls: sysctls.map((x) => ({ ...x })) } } : {}),
279
526
  restartPolicy: "Never",
280
527
  automountServiceAccountToken: false,
281
528
  activeDeadlineSeconds: Math.ceil(this.cfg.timeoutMs / 1000),
@@ -287,12 +534,12 @@ export class RemoteK8sExecutionEnv {
287
534
  command: this.cfg.podEnv?.SEMA_PKG_SOURCE
288
535
  ? ["sh", "-c", "[ -f /opt/sema/pkg-source.sh ] && sh /opt/sema/pkg-source.sh || true; sleep infinity"]
289
536
  : ["sh", "-c", "sleep infinity"],
290
- ...(this.cfg.podEnv && Object.keys(this.cfg.podEnv).length > 0
291
- ? { env: Object.entries(this.cfg.podEnv).map(([name, value]) => ({ name, value: String(value) })) }
292
- : {}),
537
+ ...(Object.keys(podEnv).length > 0 ? { env: Object.entries(podEnv).map(([name, value]) => ({ name, value: String(value) })) } : {}),
538
+ ...(cache ? { volumeMounts: [{ name: CACHE_VOLUME, mountPath: cache.mount }] } : {}),
293
539
  resources: { limits: { cpu: this.cfg.cpu, memory: this.cfg.memory } },
294
540
  },
295
541
  ],
542
+ ...(cache ? { volumes: [{ name: CACHE_VOLUME, persistentVolumeClaim: { claimName: cache.claim.name } }] } : {}),
296
543
  },
297
544
  };
298
545
  if (this.cfg.podSpecPatch)
@@ -344,6 +591,17 @@ export class RemoteK8sExecutionEnv {
344
591
  return { ok: false, error: new RemoteExecutionError("connect_failed", `image pull failed for ${this.cfg.image}: ${waiting.message ?? waiting.reason}`) };
345
592
  }
346
593
  if (Date.now() > deadline) {
594
+ if (cache) {
595
+ const bound = await this.cacheClaimBound(cache, signal);
596
+ if (!bound && Date.now() < cache.bindDeadline) {
597
+ await new Promise((r) => setTimeout(r, 1000));
598
+ continue;
599
+ }
600
+ await this.deletePod(name).catch(() => { });
601
+ return bound
602
+ ? { 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}` : ""})` }
603
+ : { 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)` };
604
+ }
347
605
  await this.deletePod(name).catch(() => { });
348
606
  return { ok: false, error: new RemoteExecutionError("timeout", `pod not Running within ${this.cfg.readyTimeoutMs}ms (phase=${phase ?? "?"})`) };
349
607
  }
@@ -362,6 +620,8 @@ export class RemoteK8sExecutionEnv {
362
620
  this.podIP = undefined;
363
621
  return { ok: false, error: new RemoteExecutionError("connect_failed", `workspace mkdir failed: ${mk.ok ? mk.value.stderr : mk.error.message}`) };
364
622
  }
623
+ if (cache?.fresh)
624
+ this.logger?.warn("workspace_cache_fresh", { space: incarnationLogPrefix(cache.spaceKey), claim: cache.claim.name, size: cache.size });
365
625
  return ok(this.workspaceHandle());
366
626
  }
367
627
  presignSnapshot(req) {
@@ -369,8 +629,14 @@ export class RemoteK8sExecutionEnv {
369
629
  const prefix = s3.keyPrefix ?? "kata";
370
630
  const [label, partition] = req.lane === "space" ? ["spaceIncarnation", req.space.incarnation] : ["sessionId", this.cfg.sessionId ?? "_"];
371
631
  const snapshotId = "snapshotId" in req ? req.snapshotId : undefined;
632
+ const objectName = "object" in req ? req.object : undefined;
372
633
  const safe = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
373
- const segments = [[label, partition], ...(snapshotId !== undefined ? [["snapshotId", snapshotId]] : []), ["keyPrefix", prefix]];
634
+ const segments = [
635
+ [label, partition],
636
+ ...(snapshotId !== undefined ? [["snapshotId", snapshotId]] : []),
637
+ ...(objectName !== undefined ? [["object", objectName]] : []),
638
+ ["keyPrefix", prefix],
639
+ ];
374
640
  for (const [label, seg] of segments) {
375
641
  if (!safe.test(seg))
376
642
  return { ok: false, error: new RemoteExecutionError("connect_failed", `unsafe snapshot key segment (${label}): refusing to build a traversing object key`) };
@@ -383,12 +649,86 @@ export class RemoteK8sExecutionEnv {
383
649
  ...(s3.region ? { region: s3.region } : {}),
384
650
  expiresSec: Math.min(s3.presignTtlSec ?? 600, 3600),
385
651
  };
386
- const base = req.lane === "space" ? `${prefix}/${partition}/space/` : `${prefix}/${partition}/`;
652
+ const base = this.snapshotBase(req);
387
653
  if (req.method === "LIST")
388
654
  return { ok: true, value: presignS3ListUrl({ ...common, prefix: base, maxKeys: 1000 }) };
389
- const object = "pointer" in req ? req.pointer : `${req.snapshotId}.tar.gz`;
655
+ const object = "pointer" in req ? req.pointer : "object" in req ? req.object : `${req.snapshotId}.tar.gz`;
390
656
  return { ok: true, value: presignS3Url({ ...common, key: `${base}${object}`, method: req.method }) };
391
657
  }
658
+ snapshotBase(req) {
659
+ const prefix = this.cfg.s3Snapshot.keyPrefix ?? "kata";
660
+ return req.lane === "space" ? `${prefix}/${req.space.incarnation}/space/` : `${prefix}/${this.cfg.sessionId ?? "_"}/`;
661
+ }
662
+ async reclaimSpaceSnapshots(spaceKey, deadline) {
663
+ if (!this.cfg.s3Snapshot)
664
+ return [];
665
+ const failures = [];
666
+ const deadlinePassed = (lane) => [
667
+ ...failures,
668
+ { lane, stage: "budget", detail: this.reclaimDeadlinePassed("stopped; the objects not yet deleted stay for the bucket's lifecycle rule") },
669
+ ];
670
+ const targets = [{ lane: "space", req: { lane: "space", space: { incarnation: spaceKey }, method: "LIST" } }];
671
+ if (this.cfg.sessionId !== undefined)
672
+ targets.push({ lane: "park", req: { lane: "park", method: "LIST" } });
673
+ for (const { lane, req } of targets) {
674
+ if (deadline.aborted)
675
+ return deadlinePassed(lane);
676
+ const list = this.presignSnapshot(req);
677
+ if (!list.ok)
678
+ continue;
679
+ let xml;
680
+ try {
681
+ const res = await this.storeFetch(list.value, { method: "GET", signal: deadline });
682
+ xml = await res.text();
683
+ if (!res.ok) {
684
+ failures.push({ lane, stage: "list", detail: `list HTTP ${res.status}` });
685
+ continue;
686
+ }
687
+ }
688
+ catch (e) {
689
+ if (deadline.aborted)
690
+ return deadlinePassed(lane);
691
+ failures.push({ lane, stage: "list", detail: `list: ${this.redactUrl(errorMessageOf(e), list.value)}` });
692
+ continue;
693
+ }
694
+ if (/<IsTruncated>true<\/IsTruncated>/.test(xml))
695
+ failures.push({ lane, stage: "list", detail: "listing truncated (more than 1000 objects in this partition) — deleting the objects seen; the rest stay" });
696
+ const base = this.snapshotBase(req);
697
+ const names = [...xml.matchAll(/<Contents>([\s\S]*?)<\/Contents>/g)]
698
+ .map((m) => /<Key>([^<]*)<\/Key>/.exec(m[1])?.[1] ?? "")
699
+ .filter((key) => key.startsWith(base) && !key.slice(base.length).includes("/"))
700
+ .map((key) => key.slice(base.length))
701
+ .filter((name) => lane === "space" || snapshotNameOwnedBy(name, spaceKey));
702
+ const del = [];
703
+ let outOfTime = false;
704
+ for (const name of names) {
705
+ if (deadline.aborted) {
706
+ outOfTime = true;
707
+ break;
708
+ }
709
+ const target = req.lane === "space" ? this.presignSnapshot({ lane: "space", space: req.space, method: "DELETE", object: name }) : this.presignSnapshot({ lane: "park", method: "DELETE", object: name });
710
+ if (!target.ok)
711
+ continue;
712
+ try {
713
+ const res = await this.storeFetch(target.value, { method: "DELETE", signal: deadline });
714
+ if (!res.ok && res.status !== 404)
715
+ del.push(`${name}: HTTP ${res.status}`);
716
+ }
717
+ catch (e) {
718
+ if (deadline.aborted) {
719
+ outOfTime = true;
720
+ break;
721
+ }
722
+ del.push(`${name}: ${this.redactUrl(errorMessageOf(e), target.value)}`);
723
+ }
724
+ }
725
+ if (del.length > 0)
726
+ failures.push({ lane, stage: "delete", detail: `${del.length} of ${names.length} objects not deleted — first: ${del[0]}` });
727
+ if (outOfTime)
728
+ return deadlinePassed(lane);
729
+ }
730
+ return failures;
731
+ }
392
732
  storeFetch(url, init) {
393
733
  const bound = AbortSignal.timeout(Math.max(1, Math.min(this.cfg.rpcTimeoutMs, init.timeoutMs ?? this.cfg.rpcTimeoutMs)));
394
734
  const signal = init.signal ? AbortSignal.any([init.signal, bound]) : bound;
@@ -398,7 +738,7 @@ export class RemoteK8sExecutionEnv {
398
738
  const url = this.presignSnapshot({ lane: "space", space, method: "GET", pointer });
399
739
  if (!url.ok)
400
740
  return url;
401
- const what = pointer === "latest" ? "pointer" : "commit marker";
741
+ const what = "pointer";
402
742
  let res;
403
743
  let body;
404
744
  try {
@@ -406,12 +746,12 @@ export class RemoteK8sExecutionEnv {
406
746
  body = await res.text();
407
747
  }
408
748
  catch (e) {
409
- return { ok: false, error: new RemoteExecutionError("connect_failed", `workspace space ${what} read failed: ${this.redactUrl(errorMessageOf(e), url.value)}`, e instanceof Error ? e : undefined) };
749
+ return spaceReadFailed(what, `: ${this.redactUrl(errorMessageOf(e), url.value)}`, e);
410
750
  }
411
751
  if (res.status === 404)
412
752
  return ok(undefined);
413
753
  if (!res.ok)
414
- return { ok: false, error: new RemoteExecutionError("connect_failed", `workspace space ${what} read failed (HTTP ${res.status})`) };
754
+ return spaceReadFailed(what, ` (HTTP ${res.status})`);
415
755
  return ok(body.trim());
416
756
  }
417
757
  async readLatest(space, signal) {
@@ -423,7 +763,7 @@ export class RemoteK8sExecutionEnv {
423
763
  const url = this.presignSnapshot({ lane: "space", space, method, pointer });
424
764
  if (!url.ok)
425
765
  return url;
426
- const what = pointer === "latest" ? "pointer" : "commit marker";
766
+ const what = "pointer";
427
767
  try {
428
768
  const res = await this.storeFetch(url.value, { method, ...(body === null ? {} : { body }), ...(timeoutMs !== undefined ? { timeoutMs } : {}) });
429
769
  if (!res.ok && !(method === "DELETE" && res.status === 404))
@@ -436,17 +776,30 @@ export class RemoteK8sExecutionEnv {
436
776
  }
437
777
  async awaitSpaceCommit(space, signal) {
438
778
  for (;;) {
439
- const marker = await this.readPointer(space, "committing", signal);
440
- if (!marker.ok)
441
- return marker;
442
- if (marker.value === undefined)
779
+ let row;
780
+ try {
781
+ row = await this.fenceCall(() => space.fence.read(), "read", signal);
782
+ }
783
+ catch (e) {
784
+ if (e instanceof FenceCallAborted)
785
+ return { ok: false, error: new RemoteExecutionError("aborted", "connect aborted while waiting for the previous run's workspace commit") };
786
+ return spaceReadFailed("commit state", `: ${redactIncarnations(errorMessageOf(e))} — refusing to guess whether a commit is in flight`, e);
787
+ }
788
+ if (row === undefined)
443
789
  return ok(undefined);
444
- const startedAt = /^\d+$/.test(marker.value) ? Number(marker.value) : NaN;
445
- if (!Number.isFinite(startedAt))
446
- return { ok: false, error: new RemoteExecutionError("connect_failed", "workspace space commit marker is malformed (its body is not an epoch-ms timestamp) — refusing to guess whether a commit is in flight") };
447
- const ageMs = Date.now() - startedAt;
448
- if (ageMs >= this.cfg.dataTimeoutMs) {
449
- this.spaceLine("workspace_commit_abandoned", space, { ageMs });
790
+ if (!spaceCommitAlive(row)) {
791
+ let gone;
792
+ try {
793
+ gone = await this.fenceCall(() => space.fence.abandon(row.taskId), "delete", signal);
794
+ }
795
+ catch (e) {
796
+ if (e instanceof FenceCallAborted)
797
+ return { ok: false, error: new RemoteExecutionError("aborted", "connect aborted while waiting for the previous run's workspace commit") };
798
+ 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) };
799
+ }
800
+ if (!gone)
801
+ continue;
802
+ this.spaceLine("workspace_commit_abandoned", space, { remainingMs: row.remainingMs, state: row.state });
450
803
  return ok(undefined);
451
804
  }
452
805
  await new Promise((resolveWait) => {
@@ -503,12 +856,15 @@ export class RemoteK8sExecutionEnv {
503
856
  async suspendVM(_options) {
504
857
  if (!this.cfg.s3Snapshot)
505
858
  return unsupported("suspendVM");
506
- return this.uploadSnapshot({ lane: "park" });
859
+ const r = await this.uploadSnapshot({ lane: "park" });
860
+ if (r.ok && this.spaceLane.state === "open")
861
+ await this.endSpace(this.spaceLane.space);
862
+ return r;
507
863
  }
508
864
  async uploadSnapshot(target) {
509
865
  if (this.inFlightExecs > 0)
510
866
  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 = `kata-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
867
+ const snapshotId = mintSnapshotId(this.occupantIncarnation);
512
868
  if (!this.podName)
513
869
  return ok(snapshotId);
514
870
  const presigned = this.presignSnapshot({ ...target, method: "PUT", snapshotId });
@@ -516,7 +872,8 @@ export class RemoteK8sExecutionEnv {
516
872
  return presigned;
517
873
  const url = presigned.value;
518
874
  const tmp = "/tmp/ws-snap.tgz";
519
- const cmd = `tar czf ${tmp} -C ${shellQuote(this.cfg.mountPath)} . && curl -fsS -X PUT -T ${tmp} ${shellQuote(url)}; ` +
875
+ const excludes = (this.cfg.snapshotExclude ?? []).map((item) => ` --exclude=${shellQuote(`./${item}`)}`).join("");
876
+ const cmd = `tar czf ${tmp}${excludes} -C ${shellQuote(this.cfg.mountPath)} . && curl -fsS -X PUT -T ${tmp} ${shellQuote(url)}; ` +
520
877
  `rc=$?; rm -f ${tmp}; exit $rc`;
521
878
  const r = await this.runExec(cmd, { timeoutMs: this.cfg.dataTimeoutMs, livenessMs: 0 });
522
879
  if (!r.ok)
@@ -532,40 +889,94 @@ export class RemoteK8sExecutionEnv {
532
889
  async resumeVM(snapshotId, options) {
533
890
  if (!this.cfg.s3Snapshot)
534
891
  return unsupported("resumeVM");
535
- const r = await this.restoreSnapshot({ lane: "park" }, snapshotId, { missing: "empty", ...(options?.abortSignal ? { abortSignal: options.abortSignal } : {}) });
536
- if (r.ok)
537
- this.handleSnapshotId = snapshotId;
538
- return r.ok ? ok(this.workspaceHandle()) : r;
892
+ const r = await this.restoreSnapshot({ lane: "park" }, snapshotId, options?.abortSignal);
893
+ if (!r.ok)
894
+ return r;
895
+ this.handleSnapshotId = snapshotId;
896
+ this.armSpace();
897
+ return ok(this.workspaceHandle());
539
898
  }
540
- async restoreSnapshot(target, snapshotId, opts) {
899
+ async restoreSnapshot(target, snapshotId, abortSignal) {
541
900
  const presigned = this.presignSnapshot({ ...target, method: "GET", snapshotId });
542
901
  if (!presigned.ok)
543
902
  return presigned;
544
- const c = await this.provision(opts.abortSignal);
545
- if (!c.ok)
546
- return c;
547
903
  const url = presigned.value;
548
- const cmd = `code=$(curl -s -o /tmp/ws.tgz -w '%{http_code}' ${shellQuote(url)}); ` +
904
+ const cmd = `code=$(curl -s -o /tmp/ws.tgz -w '%{http_code}' ${shellQuote(url)}); rc=$?; ` +
905
+ `if [ "$rc" != "0" ]; then echo "snapshot GET got no whole answer (curl exit $rc, HTTP $code)" >&2; exit ${RESTORE_TRANSFER_EXIT}; fi; ` +
549
906
  `if [ "$code" = "404" ]; then echo EMPTY_SNAPSHOT; exit 0; fi; ` +
550
- `if [ "$code" != "200" ]; then echo "snapshot GET HTTP $code" >&2; exit 22; fi; ` +
907
+ `case "$code" in ${RESTORE_STORE_BUSY_STATUSES.join("|")}) echo "snapshot GET HTTP $code" >&2; exit ${RESTORE_STORE_BUSY_EXIT};; esac; ` +
908
+ `if [ "$code" != "200" ]; then echo "snapshot GET HTTP $code" >&2; exit ${RESTORE_DOWNLOAD_EXIT}; fi; ` +
551
909
  `tar xzf /tmp/ws.tgz -C ${shellQuote(this.cfg.mountPath)} && rm -f /tmp/ws.tgz`;
552
- const r = await this.runExec(cmd, { timeoutMs: this.cfg.dataTimeoutMs, livenessMs: 0 });
553
- const failed = !r.ok
554
- ? new RemoteExecutionError("connect_failed", `workspace snapshot restore failed: ${this.redactUrl(r.error.message, url)}`, r.error)
555
- : r.value.exitCode !== 0
556
- ? new RemoteExecutionError("connect_failed", `workspace snapshot restore exited ${r.value.exitCode}: ${this.redactUrl(r.value.stderr, url).slice(0, 300)}`)
557
- : opts.missing === "refuse" && r.value.stdout.includes("EMPTY_SNAPSHOT")
558
- ? new RemoteExecutionError("connect_failed", `workspace snapshot ${snapshotId} is named by the space pointer but its archive is missing (HTTP 404) — refusing to start on an empty workspace (delete the space's latest pointer, or WORKSPACE_PERSIST=off)`)
910
+ for (let attempt = 1;; attempt++) {
911
+ const c = await this.provision(abortSignal);
912
+ if (!c.ok)
913
+ return c;
914
+ const r = await this.runExec(cmd, { timeoutMs: this.cfg.dataTimeoutMs, livenessMs: 0 });
915
+ const failure = !r.ok
916
+ ? { cls: r.error.code === "timeout" ? "timeout" : "transport", detail: `workspace snapshot restore failed: ${this.redactUrl(r.error.message, url)}`, cause: r.error }
917
+ : r.value.exitCode !== 0
918
+ ? { cls: restoreClassOfExit(r.value.exitCode), detail: `workspace snapshot restore exited ${r.value.exitCode}: ${this.redactUrl(r.value.stderr, url).slice(0, 300)}` }
559
919
  : undefined;
560
- if (failed) {
920
+ if (!failure) {
921
+ if (target.lane === "space" && r.ok && r.value.stdout.includes("EMPTY_SNAPSHOT"))
922
+ await this.dropDanglingLatest(target.space, snapshotId);
923
+ return ok(this.workspaceHandle());
924
+ }
561
925
  const name = this.podName;
562
926
  this.podName = undefined;
563
927
  this.podIP = undefined;
564
928
  if (name)
565
929
  await this.deletePod(name).catch(() => { });
566
- return { ok: false, error: failed };
930
+ if (attempt < RESTORE_MAX_ATTEMPTS[target.lane] && restoreFailureIsTransient(failure.cls)) {
931
+ if (await this.restorePause(abortSignal))
932
+ continue;
933
+ return { ok: false, error: new RemoteExecutionError("aborted", "connect aborted while retrying the workspace restore") };
934
+ }
935
+ switch (target.lane) {
936
+ case "park":
937
+ return { ok: false, error: new RemoteExecutionError("connect_failed", failure.detail, failure.cause) };
938
+ case "space": {
939
+ const tries = attempt === 1 ? "1 attempt" : `${attempt} attempts`;
940
+ this.spaceLine("workspace_restore_failed", target.space, { snapshotId, class: failure.cls, attempts: attempt, boundMs: this.cfg.dataTimeoutMs, error: failure.detail });
941
+ this.restoreRefusal = {
942
+ ok: false,
943
+ error: new RemoteExecutionError("connect_failed", `${failure.detail} — the workspace's latest snapshot (${snapshotId}) is in the store but did not come back (${failure.cls}, ${tries}, bound ${this.cfg.dataTimeoutMs}ms each), ` +
944
+ "so this run does not start on an empty /workspace. Ways out: raise WORKSPACE_SNAPSHOT_TIMEOUT_MS if the archive is large, " +
945
+ "or set WORKSPACE_PERSIST=off for one run (it starts from an empty /workspace and deletes no snapshot)", failure.cause),
946
+ };
947
+ return this.restoreRefusal;
948
+ }
949
+ default: {
950
+ const unreachable = target;
951
+ throw new Error(`unhandled snapshot lane: ${String(unreachable.lane)}`);
952
+ }
953
+ }
567
954
  }
568
- return ok(this.workspaceHandle());
955
+ }
956
+ restorePause(signal) {
957
+ return new Promise((resolve) => {
958
+ if (signal?.aborted)
959
+ return resolve(false);
960
+ const onAbort = () => {
961
+ clearTimeout(timer);
962
+ resolve(false);
963
+ };
964
+ const timer = setTimeout(() => {
965
+ signal?.removeEventListener("abort", onAbort);
966
+ resolve(true);
967
+ }, this.cfg.retry.backoffMs);
968
+ signal?.addEventListener("abort", onAbort, { once: true });
969
+ });
970
+ }
971
+ async dropDanglingLatest(space, snapshotId) {
972
+ const prefix = this.cfg.s3Snapshot?.keyPrefix ?? "kata";
973
+ const dropped = await this.writePointer(space, "latest", null);
974
+ this.spaceLine("workspace_latest_dangling", space, {
975
+ key: `${prefix}/${space.incarnation}/space/${snapshotId}.tar.gz`,
976
+ deleted: dropped.ok,
977
+ ...(dropped.ok ? {} : { error: dropped.error.message }),
978
+ 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",
979
+ });
569
980
  }
570
981
  async postResumeInit() {
571
982
  if (!this.cfg.s3Snapshot)
@@ -590,20 +1001,23 @@ export class RemoteK8sExecutionEnv {
590
1001
  await this.disposeBackgroundShells().catch(() => { });
591
1002
  this.destroyed = true;
592
1003
  const lane = this.spaceLane;
593
- if (this.podName) {
594
- switch (lane.state) {
595
- case "open":
1004
+ switch (lane.state) {
1005
+ case "open":
1006
+ if (this.podName)
596
1007
  await this.persistWorkspace(lane.space);
597
- break;
598
- case "unavailable":
599
- this.sayUnavailable();
600
- break;
601
- case "off":
602
- break;
603
- default: {
604
- const unreachable = lane;
605
- void unreachable;
606
- }
1008
+ else
1009
+ await this.endSpace(lane.space);
1010
+ break;
1011
+ case "unavailable":
1012
+ if (this.podName)
1013
+ this.sayUnavailable(lane.reason);
1014
+ break;
1015
+ case "off":
1016
+ case "unreadable":
1017
+ break;
1018
+ default: {
1019
+ const unreachable = lane;
1020
+ void unreachable;
607
1021
  }
608
1022
  }
609
1023
  const name = this.podName;
@@ -620,10 +1034,18 @@ export class RemoteK8sExecutionEnv {
620
1034
  this.spaceLine("workspace_snapshot_failed", space, { stage, code: error.code, error: error.message });
621
1035
  this.onWorkspaceSnapshot?.("failed");
622
1036
  };
623
- const startedAt = Date.now();
624
- const marked = await this.writePointer(space, "committing", String(startedAt));
625
- if (!marked.ok)
626
- return failed("marker", marked.error);
1037
+ let claimed;
1038
+ try {
1039
+ claimed = await this.fenceCall(() => space.fence.begin(), "write");
1040
+ }
1041
+ catch (e) {
1042
+ await this.endSpace(space);
1043
+ return failed("fence", new RemoteExecutionError("connect_failed", `workspace space commit state write failed: ${redactIncarnations(errorMessageOf(e))}`));
1044
+ }
1045
+ if (!claimed) {
1046
+ await this.endSpace(space);
1047
+ 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)"));
1048
+ }
627
1049
  try {
628
1050
  let snap;
629
1051
  try {
@@ -634,9 +1056,16 @@ export class RemoteK8sExecutionEnv {
634
1056
  }
635
1057
  if (!snap.ok)
636
1058
  return failed("upload", snap.error);
637
- const remaining = startedAt + this.cfg.dataTimeoutMs - Date.now();
1059
+ let deadline;
1060
+ try {
1061
+ deadline = await this.fenceCall(() => space.fence.publish(), "write");
1062
+ }
1063
+ catch (e) {
1064
+ return failed("fence", new RemoteExecutionError("connect_failed", `workspace space commit state write failed: ${redactIncarnations(errorMessageOf(e))}`));
1065
+ }
1066
+ const remaining = deadline === undefined ? 0 : deadline - Date.now();
638
1067
  if (remaining <= 0)
639
- return failed("pointer", new RemoteExecutionError("timeout", `the commit outlived its bound (${this.cfg.dataTimeoutMs}ms since its marker) — not publishing latest (a next run may already have restored the previous one)`));
1068
+ 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
1069
  const moved = await this.writePointer(space, "latest", snap.value, remaining);
641
1070
  if (!moved.ok)
642
1071
  return failed("pointer", moved.error);
@@ -644,9 +1073,7 @@ export class RemoteK8sExecutionEnv {
644
1073
  await this.pruneSpace(space, snap.value).catch((e) => this.spaceLine("workspace_snapshot_prune_failed", space, { detail: errorMessageOf(e) }));
645
1074
  }
646
1075
  finally {
647
- const cleared = await this.writePointer(space, "committing", null);
648
- if (!cleared.ok)
649
- this.spaceLine("workspace_commit_marker_stuck", space, { error: cleared.error.message });
1076
+ await this.endSpace(space);
650
1077
  }
651
1078
  }
652
1079
  execUrl(command) {
@@ -1208,6 +1635,48 @@ export class RemoteK8sExecutionEnv {
1208
1635
  return dir ? this.resolve(dir) : undefined;
1209
1636
  }
1210
1637
  }
1638
+ export function createSpaceReclaimer(config, deps) {
1639
+ return {
1640
+ async reclaim(spaces) {
1641
+ const deadline = AbortSignal.timeout(dataTimeoutMsOf(config));
1642
+ for (const { sessionId, spaceKey } of spaces) {
1643
+ if (spaceKey === "")
1644
+ continue;
1645
+ const env = new RemoteK8sExecutionEnv({ ...config, sessionId }, deps);
1646
+ if (config.cache !== undefined) {
1647
+ const r = await env.reclaimSpaceCache(spaceKey, deadline).catch((e) => ({ ok: false, reason: "delete_failed", detail: errorMessageOf(e) }));
1648
+ if (!r.ok) {
1649
+ recordFailOpen("server.workspace-cache.reclaim-failed", `reason=${r.reason} label=${workspaceCacheClaimOf(spaceKey).label}`);
1650
+ config.logger?.warn("workspace_cache_reclaim_failed", {
1651
+ reason: r.reason,
1652
+ space: incarnationLogPrefix(spaceKey),
1653
+ label: workspaceCacheClaimOf(spaceKey).label,
1654
+ error: redactIncarnations(r.detail),
1655
+ effect: `the ended space's cache volume stays — delete it by label ${WORKSPACE_CACHE_SPACE_LABEL}=<label> (the deletion it belongs to went ahead)`,
1656
+ });
1657
+ }
1658
+ }
1659
+ if (config.s3Snapshot !== undefined) {
1660
+ const failures = await env.reclaimSpaceSnapshots(spaceKey, deadline).catch((e) => [{ lane: "space", stage: "delete", detail: errorMessageOf(e) }]);
1661
+ for (const f of failures) {
1662
+ recordFailOpen("server.workspace-space.reclaim-failed", `lane=${f.lane} stage=${f.stage}`);
1663
+ config.logger?.warn("workspace_space_reclaim_failed", {
1664
+ space: incarnationLogPrefix(spaceKey),
1665
+ sessionId,
1666
+ lane: f.lane,
1667
+ stage: f.stage,
1668
+ error: redactIncarnations(f.detail),
1669
+ 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`,
1670
+ });
1671
+ }
1672
+ }
1673
+ }
1674
+ },
1675
+ };
1676
+ }
1677
+ export async function checkSandboxEgressPolicy(config, deps) {
1678
+ return new RemoteK8sExecutionEnv(config, deps).egressPolicyGaps();
1679
+ }
1211
1680
  export function k8sExecutionEnvFactory(config, deps) {
1212
1681
  return (ctx) => new RemoteK8sExecutionEnv({ ...config, sessionId: ctx.sessionId }, deps);
1213
1682
  }