@sema-agent/server 7.125.0 → 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.
Files changed (248) hide show
  1. package/MIGRATION.md +199 -2
  2. package/README.md +2 -2
  3. package/README.zh-CN.md +2 -2
  4. package/USAGE.md +263 -140
  5. package/deploy/sema-up/README.md +1 -1
  6. package/deploy/sema-up/chart/templates/networkpolicy-sandbox.yaml +47 -0
  7. package/deploy/sema-up/chart/templates/sandbox-rbac.yaml +5 -1
  8. package/deploy/sema-up/chart/templates/server-v4pro.yaml +2 -2
  9. package/deploy/sema-up/chart/templates/server.yaml +1 -1
  10. package/deploy/sema-up/chart/values.yaml +12 -0
  11. package/deploy/sema-up/cluster-up.sh +10 -10
  12. package/deploy/sema-up/kube-up.sh +11 -10
  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/parse-machines.py +2 -2
  18. package/deploy/sema-up/lib/shell-access.sh +1 -1
  19. package/deploy/sema-up/preflight.sh +4 -4
  20. package/deploy/sema-up/sema-up.sh +33 -33
  21. package/deploy/sema-up/smoke.sh +3 -3
  22. package/deploy/sema-up/stack/compose.yaml +1 -1
  23. package/deploy/sema-up/stack/sema-stack-cluster.yaml +6 -2
  24. package/dist/approval-card.d.ts +20 -4
  25. package/dist/approval-content-kind.d.ts +38 -22
  26. package/dist/approval-content-kind.js +20 -4
  27. package/dist/audit.js +2 -0
  28. package/dist/bench/s1/arms.d.ts +9 -37
  29. package/dist/bench/s1/arms.js +4 -61
  30. package/dist/bench/s1/live-deps.d.ts +4 -10
  31. package/dist/bench/s1/live-deps.js +1 -222
  32. package/dist/bench/s1/oracle.d.ts +1 -1
  33. package/dist/bench/s1/reviewer.d.ts +1 -1
  34. package/dist/bench/s1/row.d.ts +5 -7
  35. package/dist/bench/s1/row.js +0 -2
  36. package/dist/bench/s1/run-firm.d.ts +2 -2
  37. package/dist/bench/s1/run-firm.js +1 -4
  38. package/dist/bench/s1/runner-ctx.d.ts +17 -29
  39. package/dist/bench/s1/runner-ctx.js +3 -7
  40. package/dist/bench/s1/tasks.d.ts +9 -46
  41. package/dist/bench/s1/tasks.js +4 -63
  42. package/dist/boot/execution-env.d.ts +38 -26
  43. package/dist/boot/execution-env.js +71 -38
  44. package/dist/boot/memory-boundary.d.ts +1 -1
  45. package/dist/boot/parked-revive-gate.d.ts +0 -3
  46. package/dist/boot/parked-revive-gate.js +2 -2
  47. package/dist/boot/resolve-spec.d.ts +0 -2
  48. package/dist/boot/resolve-spec.js +12 -17
  49. package/dist/boot/retention-lane.d.ts +12 -1
  50. package/dist/boot/retention-lane.js +2 -0
  51. package/dist/boot/runner-deps.d.ts +5 -5
  52. package/dist/boot/runner-deps.js +5 -16
  53. package/dist/boot/session-faces.d.ts +5 -1
  54. package/dist/boot/session-faces.js +37 -1
  55. package/dist/boot/stage-03-store-backend.d.ts +1 -0
  56. package/dist/boot/stage-03-store-backend.js +2 -2
  57. package/dist/boot/stage-04-stores.d.ts +1 -0
  58. package/dist/boot/stage-05-execution-env.d.ts +2 -2
  59. package/dist/boot/stage-05-execution-env.js +7 -4
  60. package/dist/boot/stage-06-runners.d.ts +2 -3
  61. package/dist/boot/stage-06-runners.js +8 -7
  62. package/dist/boot/stage-07-capability-layer.d.ts +2 -3
  63. package/dist/boot/stage-07-capability-layer.js +3 -4
  64. package/dist/boot/stage-08-reapers.d.ts +2 -3
  65. package/dist/boot/stage-08-reapers.js +2 -2
  66. package/dist/boot/stage-09-leader.d.ts +2 -3
  67. package/dist/boot/stage-09-leader.js +2 -1
  68. package/dist/boot/stage-10-http-server.d.ts +2 -3
  69. package/dist/boot/stage-10-http-server.js +2 -2
  70. package/dist/boot/stores.d.ts +1 -0
  71. package/dist/boot/stores.js +4 -3
  72. package/dist/boot/web-admin.d.ts +5 -0
  73. package/dist/boot/web-admin.js +28 -14
  74. package/dist/brain.d.ts +2 -2
  75. package/dist/brain.js +1 -1
  76. package/dist/capabilities/builtin-tools.d.ts +2 -1
  77. package/dist/capabilities/composite-members.d.ts +1 -1
  78. package/dist/capabilities/composite-members.js +1 -1
  79. package/dist/capabilities/hands-lane.d.ts +9 -6
  80. package/dist/capabilities/hands-lane.js +2 -2
  81. package/dist/capabilities/memory-notice.d.ts +27 -22
  82. package/dist/capabilities/memory-notice.js +15 -17
  83. package/dist/capabilities/scenarios.d.ts +9 -14
  84. package/dist/capabilities/scenarios.js +5 -50
  85. package/dist/capability-facts.d.ts +22 -11
  86. package/dist/capability-facts.js +13 -6
  87. package/dist/config-catalog.js +11 -4
  88. package/dist/config-provider.d.ts +5 -1
  89. package/dist/config-provider.js +23 -4
  90. package/dist/config-types.d.ts +14 -1
  91. package/dist/config.js +39 -6
  92. package/dist/deployment-governance.d.ts +19 -21
  93. package/dist/deployment-governance.js +7 -8
  94. package/dist/env-name-allowlist-knobs.d.ts +5 -5
  95. package/dist/env-name-allowlist-knobs.js +1 -0
  96. package/dist/execution-lane-caps.js +2 -2
  97. package/dist/hooks/hook-runner.d.ts +84 -30
  98. package/dist/hooks/hook-runner.js +74 -41
  99. package/dist/http/admission.js +12 -9
  100. package/dist/http/durable-run-leg.d.ts +5 -4
  101. package/dist/http/durable-run-leg.js +1 -1
  102. package/dist/http/idempotency.d.ts +28 -6
  103. package/dist/http/idempotency.js +10 -2
  104. package/dist/http/principal-gate.d.ts +14 -0
  105. package/dist/http/principal-gate.js +7 -0
  106. package/dist/http/request-guards.d.ts +2 -1
  107. package/dist/http/request-guards.js +14 -14
  108. package/dist/http/resume-legs.d.ts +2 -2
  109. package/dist/http/resume-legs.js +5 -4
  110. package/dist/http/route-ctx.d.ts +8 -6
  111. package/dist/http/routes/a2a-serve.js +11 -7
  112. package/dist/http/routes/approvals-assistant.js +18 -3
  113. package/dist/http/routes/background.d.ts +1 -1
  114. package/dist/http/routes/background.js +1 -1
  115. package/dist/http/routes/capabilities.js +10 -2
  116. package/dist/http/routes/mcp-reconnect.js +1 -1
  117. package/dist/http/routes/runs.d.ts +4 -3
  118. package/dist/http/routes/runs.js +12 -13
  119. package/dist/http/routes/session-sync.js +14 -11
  120. package/dist/http/routes/sessions.js +9 -9
  121. package/dist/http/routes/side-query.js +6 -19
  122. package/dist/http/routes/tasks.js +12 -11
  123. package/dist/http/server.d.ts +4 -0
  124. package/dist/http/server.js +2 -2
  125. package/dist/http/submit-replay.d.ts +15 -8
  126. package/dist/http/submit-replay.js +6 -0
  127. package/dist/http/wire-types.d.ts +7 -9
  128. package/dist/index.d.ts +0 -1
  129. package/dist/index.js +0 -1
  130. package/dist/internal-run-env.d.ts +30 -0
  131. package/dist/internal-run-env.js +11 -0
  132. package/dist/leader/wire.js +3 -2
  133. package/dist/memory-scope.d.ts +19 -7
  134. package/dist/memory-scope.js +25 -8
  135. package/dist/observability/fail-open.d.ts +15 -3
  136. package/dist/observability/fail-open.js +15 -3
  137. package/dist/observability/metrics.d.ts +0 -1
  138. package/dist/observability/metrics.js +0 -3
  139. package/dist/observability/run-terminal.js +69 -4
  140. package/dist/permission-rule-vocab.d.ts +3 -2
  141. package/dist/permission-rule-vocab.js +1 -1
  142. package/dist/plan-cache-probe.d.ts +3 -2
  143. package/dist/plugins/approval-ask-store-sql.d.ts +1 -1
  144. package/dist/plugins/caching-session-store.d.ts +3 -0
  145. package/dist/plugins/caching-session-store.js +3 -0
  146. package/dist/plugins/checkpoint-store-sql.d.ts +42 -28
  147. package/dist/plugins/checkpoint-store-sql.js +57 -11
  148. package/dist/plugins/file-run-store.d.ts +25 -21
  149. package/dist/plugins/file-run-store.js +70 -34
  150. package/dist/plugins/local-checkpoint-store.d.ts +8 -5
  151. package/dist/plugins/local-checkpoint-store.js +5 -15
  152. package/dist/plugins/local-session-store.d.ts +66 -7
  153. package/dist/plugins/local-session-store.js +168 -101
  154. package/dist/plugins/mailbox-recipient-lifecycle.d.ts +1 -1
  155. package/dist/plugins/memory-run-store.d.ts +19 -3
  156. package/dist/plugins/memory-run-store.js +38 -3
  157. package/dist/plugins/one-time-migrations.d.ts +141 -39
  158. package/dist/plugins/one-time-migrations.js +32 -143
  159. package/dist/plugins/pg-pool.js +11 -1
  160. package/dist/plugins/pg-session-storage.d.ts +2 -0
  161. package/dist/plugins/pg-session-storage.js +9 -5
  162. package/dist/plugins/remote-env-adb.d.ts +3 -0
  163. package/dist/plugins/remote-env-adb.js +1 -0
  164. package/dist/plugins/remote-env-device.d.ts +3 -0
  165. package/dist/plugins/remote-env-device.js +1 -0
  166. package/dist/plugins/remote-env-e2b.d.ts +4 -0
  167. package/dist/plugins/remote-env-e2b.js +2 -1
  168. package/dist/plugins/remote-env-host.d.ts +12 -0
  169. package/dist/plugins/remote-env-host.js +11 -1
  170. package/dist/plugins/remote-env-k8s.d.ts +227 -39
  171. package/dist/plugins/remote-env-k8s.js +450 -75
  172. package/dist/plugins/remote-env-local-docker.d.ts +4 -0
  173. package/dist/plugins/remote-env-local-docker.js +2 -1
  174. package/dist/plugins/remote-env-ssh.d.ts +3 -0
  175. package/dist/plugins/remote-env-ssh.js +1 -0
  176. package/dist/plugins/retention-store-sql.d.ts +4 -1
  177. package/dist/plugins/retention-store-sql.js +11 -9
  178. package/dist/plugins/run-store-sql.d.ts +44 -6
  179. package/dist/plugins/run-store-sql.js +92 -27
  180. package/dist/plugins/session-capture-record-store-sql.d.ts +2 -3
  181. package/dist/plugins/sql-driver.d.ts +2 -1
  182. package/dist/plugins/store-backend.js +1 -1
  183. package/dist/plugins/store-contracts.d.ts +17 -3
  184. package/dist/plugins/tidb-pool.js +15 -3
  185. package/dist/plugins/tidb-session-storage.js +3 -2
  186. package/dist/plugins/tidb-session-store.d.ts +2 -0
  187. package/dist/plugins/tidb-session-store.js +6 -2
  188. package/dist/request-key-closure.d.ts +12 -0
  189. package/dist/request-key-closure.js +3 -3
  190. package/dist/router/route-orchestration.d.ts +3 -11
  191. package/dist/router/route-orchestration.js +0 -4
  192. package/dist/rule-entry-list.js +0 -4
  193. package/dist/rules-consent.js +3 -0
  194. package/dist/run-local.d.ts +5 -4
  195. package/dist/run-local.js +23 -17
  196. package/dist/runs.js +1 -0
  197. package/dist/security.d.ts +7 -0
  198. package/dist/security.js +5 -0
  199. package/dist/server-secret-env.d.ts +10 -4
  200. package/dist/server-secret-env.js +4 -1
  201. package/dist/session-incarnation.d.ts +46 -2
  202. package/dist/session-incarnation.js +14 -3
  203. package/dist/session-sync-kernel.d.ts +10 -3
  204. package/dist/spec-fields.d.ts +24 -8
  205. package/dist/spec-fields.js +21 -2
  206. package/dist/task-workflow.d.ts +6 -4
  207. package/dist/task-workflow.js +3 -2
  208. package/dist/tool-approval.d.ts +78 -6
  209. package/dist/tool-approval.js +7 -3
  210. package/dist/trace/core-keyset-guard.d.ts +4 -4
  211. package/dist/trace/engine-notice-wire.d.ts +26 -1
  212. package/dist/trace/engine-notice-wire.js +7 -2
  213. package/dist/trace/project.d.ts +8 -3
  214. package/dist/trace/project.js +15 -1
  215. package/dist/trace/self-minted-wire.d.ts +104 -2
  216. package/dist/trace/self-minted-wire.js +35 -0
  217. package/dist/web-admin-client/apply-effective.d.ts +13 -7
  218. package/dist/web-admin-client/apply-effective.js +11 -26
  219. package/dist/web-admin-client/apply-ledger.d.ts +2 -2
  220. package/dist/web-admin-client/candidate-gate.d.ts +39 -4
  221. package/dist/web-admin-client/candidate-gate.js +9 -2
  222. package/dist/web-admin-client/facade.d.ts +2 -2
  223. package/dist/web-admin-client/http-client.d.ts +3 -1
  224. package/dist/web-admin-client/http-client.js +4 -3
  225. package/dist/web-admin-client/read-warnings.d.ts +33 -5
  226. package/dist/web-admin-client/read-warnings.js +41 -6
  227. package/dist/web-admin-client/types.d.ts +11 -5
  228. package/dist/workspace-cache.d.ts +45 -0
  229. package/dist/workspace-cache.js +30 -0
  230. package/dist/workspace-space-commit.d.ts +116 -0
  231. package/dist/workspace-space-commit.js +117 -0
  232. package/dist/workspace-space.d.ts +75 -9
  233. package/dist/workspace-space.js +52 -1
  234. package/package.json +4 -4
  235. package/dist/boot/session-incarnation-seat.d.ts +0 -41
  236. package/dist/boot/session-incarnation-seat.js +0 -69
  237. package/dist/capabilities/code-review-council.d.ts +0 -49
  238. package/dist/capabilities/code-review-council.js +0 -111
  239. package/dist/capabilities/collab-wire.d.ts +0 -36
  240. package/dist/capabilities/collab-wire.js +0 -2
  241. package/dist/capabilities/collab-workflows.d.ts +0 -47
  242. package/dist/capabilities/collab-workflows.js +0 -90
  243. package/dist/capabilities/prompts/code-review.d.ts +0 -25
  244. package/dist/capabilities/prompts/code-review.js +0 -39
  245. package/dist/capabilities/prompts/team.d.ts +0 -10
  246. package/dist/capabilities/prompts/team.js +0 -6
  247. package/dist/capabilities/team.d.ts +0 -113
  248. 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
- function spaceLaneOf(policy, incarnation) {
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
- return incarnation ? { state: "open", space: { incarnation, keep: policy.keep } } : { state: "unavailable" };
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 ?? "/workspace",
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.dataTimeoutMs ?? 5 * 60_000,
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
- this.spaceLane = spaceLaneOf(config.s3Snapshot?.space, config.spaceIncarnation);
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
- 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 } : {}) });
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
- sayUnavailable() {
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: "incarnation_unknown",
312
+ reason,
234
313
  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)",
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
- ...(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
- : {}),
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 = [[label, partition], ...(snapshotId !== undefined ? [["snapshotId", snapshotId]] : []), ["keyPrefix", prefix]];
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.lane === "space" ? `${prefix}/${partition}/space/` : `${prefix}/${partition}/`;
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 = pointer === "latest" ? "pointer" : "commit marker";
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 { ok: false, error: new RemoteExecutionError("connect_failed", `workspace space ${what} read failed: ${this.redactUrl(errorMessageOf(e), url.value)}`, e instanceof Error ? e : undefined) };
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 { ok: false, error: new RemoteExecutionError("connect_failed", `workspace space ${what} read failed (HTTP ${res.status})`) };
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 = pointer === "latest" ? "pointer" : "commit marker";
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
- const marker = await this.readPointer(space, "committing", signal);
440
- if (!marker.ok)
441
- return marker;
442
- if (marker.value === undefined)
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
- 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 });
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
- return this.uploadSnapshot({ lane: "park" });
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 = `kata-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
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 cmd = `tar czf ${tmp} -C ${shellQuote(this.cfg.mountPath)} . && curl -fsS -X PUT -T ${tmp} ${shellQuote(url)}; ` +
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, { missing: "empty", ...(options?.abortSignal ? { abortSignal: options.abortSignal } : {}) });
536
- if (r.ok)
537
- this.handleSnapshotId = snapshotId;
538
- return r.ok ? ok(this.workspaceHandle()) : r;
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, opts) {
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(opts.abortSignal);
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 22; fi; ` +
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 failed = !r.ok
554
- ? new RemoteExecutionError("connect_failed", `workspace snapshot restore failed: ${this.redactUrl(r.error.message, url)}`, r.error)
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
- ? 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)`)
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
- return { ok: false, error: failed };
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
- if (this.podName) {
594
- switch (lane.state) {
595
- case "open":
913
+ switch (lane.state) {
914
+ case "open":
915
+ if (this.podName)
596
916
  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
- }
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
- const startedAt = Date.now();
624
- const marked = await this.writePointer(space, "committing", String(startedAt));
625
- if (!marked.ok)
626
- return failed("marker", marked.error);
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
- const remaining = startedAt + this.cfg.dataTimeoutMs - Date.now();
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("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)`));
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
- const cleared = await this.writePointer(space, "committing", null);
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
  }