@sema-agent/server 7.124.1 → 7.125.1

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 (152) hide show
  1. package/MIGRATION.md +138 -2
  2. package/USAGE.md +46 -8
  3. package/dist/audit.d.ts +27 -5
  4. package/dist/audit.js +42 -8
  5. package/dist/boot/execution-env.d.ts +33 -1
  6. package/dist/boot/execution-env.js +31 -2
  7. package/dist/boot/resolve-spec.js +4 -3
  8. package/dist/boot/session-faces.d.ts +14 -1
  9. package/dist/boot/session-faces.js +6 -5
  10. package/dist/boot/stage-02-web-admin-pull.js +5 -1
  11. package/dist/boot/stage-05-execution-env.js +6 -2
  12. package/dist/boot/stage-09-leader.d.ts +1 -1
  13. package/dist/boot/stage-10-http-server.d.ts +1 -1
  14. package/dist/boot/web-admin.js +130 -140
  15. package/dist/capabilities/center-wiring.d.ts +2 -1
  16. package/dist/capabilities/composite-members.d.ts +1 -0
  17. package/dist/capabilities/composite-members.js +1 -0
  18. package/dist/capability-facts.d.ts +28 -3
  19. package/dist/capability-facts.js +20 -0
  20. package/dist/config-catalog.js +4 -1
  21. package/dist/config-d-migration.js +2 -1
  22. package/dist/config-lkg.d.ts +4 -1
  23. package/dist/config-lkg.js +16 -12
  24. package/dist/config-provider.d.ts +4 -1
  25. package/dist/config-provider.js +1 -0
  26. package/dist/config-types.d.ts +10 -3
  27. package/dist/config.js +44 -14
  28. package/dist/env-facts.d.ts +2 -2
  29. package/dist/env-facts.js +3 -2
  30. package/dist/fleet/fleet-bus.d.ts +24 -12
  31. package/dist/fleet/fleet-bus.js +4 -1
  32. package/dist/fleet/workspace-snapshot-residual.d.ts +10 -0
  33. package/dist/fleet/workspace-snapshot-residual.js +35 -0
  34. package/dist/hooks/hook-runner.d.ts +15 -27
  35. package/dist/hooks/hook-runner.js +19 -59
  36. package/dist/http/active-run-conflict.d.ts +3 -0
  37. package/dist/http/active-run-conflict.js +3 -0
  38. package/dist/http/admission.js +7 -7
  39. package/dist/http/approval-sweeps.js +2 -4
  40. package/dist/http/device-placement.js +1 -0
  41. package/dist/http/dispatch.js +1 -1
  42. package/dist/http/durable-run-leg.d.ts +20 -6
  43. package/dist/http/durable-run-leg.js +4 -4
  44. package/dist/http/idempotency.d.ts +83 -38
  45. package/dist/http/idempotency.js +38 -13
  46. package/dist/http/request-guards.d.ts +18 -0
  47. package/dist/http/request-guards.js +12 -3
  48. package/dist/http/resume-legs.js +7 -1
  49. package/dist/http/route-ctx.d.ts +3 -3
  50. package/dist/http/route-ctx.js +1 -1
  51. package/dist/http/routes/a2a-serve.js +27 -11
  52. package/dist/http/routes/capabilities.js +1 -0
  53. package/dist/http/routes/fleet.js +2 -0
  54. package/dist/http/routes/images.js +2 -2
  55. package/dist/http/routes/leader.d.ts +4 -0
  56. package/dist/http/routes/leader.js +13 -3
  57. package/dist/http/routes/observability.js +2 -0
  58. package/dist/http/routes/runs.d.ts +6 -2
  59. package/dist/http/routes/runs.js +71 -101
  60. package/dist/http/routes/session-sync.d.ts +3 -0
  61. package/dist/http/routes/session-sync.js +30 -1
  62. package/dist/http/routes/sessions-list.d.ts +11 -0
  63. package/dist/http/routes/sessions-list.js +6 -6
  64. package/dist/http/routes/sessions.d.ts +6 -1
  65. package/dist/http/routes/sessions.js +41 -10
  66. package/dist/http/routes/tasks.js +63 -21
  67. package/dist/http/run-read-face.d.ts +26 -0
  68. package/dist/http/run-read-face.js +67 -0
  69. package/dist/http/server.d.ts +4 -4
  70. package/dist/http/server.js +1 -2
  71. package/dist/http/submit-replay.d.ts +40 -0
  72. package/dist/http/submit-replay.js +22 -0
  73. package/dist/leader/endpoint.d.ts +10 -5
  74. package/dist/leader/endpoint.js +27 -5
  75. package/dist/observability/cost-quota.d.ts +51 -7
  76. package/dist/observability/cost-quota.js +35 -17
  77. package/dist/observability/fail-open.d.ts +1 -1
  78. package/dist/observability/fail-open.js +1 -1
  79. package/dist/observability/run-terminal.d.ts +5 -9
  80. package/dist/observability/run-terminal.js +2 -7
  81. package/dist/plugins/approval-ask-store-sql.js +1 -1
  82. package/dist/plugins/caching-session-store.d.ts +7 -2
  83. package/dist/plugins/caching-session-store.js +6 -0
  84. package/dist/plugins/device-store-sql.js +1 -1
  85. package/dist/plugins/ensure-index.js +22 -0
  86. package/dist/plugins/file-run-store.d.ts +24 -14
  87. package/dist/plugins/file-run-store.js +52 -12
  88. package/dist/plugins/leader-run-store-sql.d.ts +1 -11
  89. package/dist/plugins/leader-run-store-sql.js +1 -5
  90. package/dist/plugins/local-session-store.d.ts +35 -6
  91. package/dist/plugins/local-session-store.js +87 -18
  92. package/dist/plugins/memory-run-store.d.ts +13 -12
  93. package/dist/plugins/memory-run-store.js +25 -0
  94. package/dist/plugins/one-time-migrations.js +1 -1
  95. package/dist/plugins/pg-cost-quota.d.ts +1 -1
  96. package/dist/plugins/pg-cost-quota.js +2 -5
  97. package/dist/plugins/pg-pool.js +3 -0
  98. package/dist/plugins/pg-session-storage.d.ts +11 -1
  99. package/dist/plugins/pg-session-storage.js +44 -8
  100. package/dist/plugins/remote-env-k8s.d.ts +127 -12
  101. package/dist/plugins/remote-env-k8s.js +270 -27
  102. package/dist/plugins/run-store-sql.d.ts +26 -12
  103. package/dist/plugins/run-store-sql.js +50 -25
  104. package/dist/plugins/schema-consistency.d.ts +1 -13
  105. package/dist/plugins/schema-consistency.js +1 -41
  106. package/dist/plugins/schema-indexes.js +1 -0
  107. package/dist/plugins/schema-live-columns.d.ts +20 -0
  108. package/dist/plugins/schema-live-columns.js +42 -0
  109. package/dist/plugins/store-contracts.d.ts +40 -2
  110. package/dist/plugins/tidb-cost-quota.d.ts +1 -1
  111. package/dist/plugins/tidb-cost-quota.js +2 -5
  112. package/dist/plugins/tidb-pool.js +14 -3
  113. package/dist/plugins/tidb-session-store.d.ts +22 -2
  114. package/dist/plugins/tidb-session-store.js +33 -6
  115. package/dist/plugins/write-behind-counter.d.ts +12 -5
  116. package/dist/plugins/write-behind-counter.js +14 -7
  117. package/dist/prompts-domain-validate.js +3 -2
  118. package/dist/rules-consent.d.ts +2 -2
  119. package/dist/rules-consent.js +2 -2
  120. package/dist/run-local.js +31 -23
  121. package/dist/runs.d.ts +9 -0
  122. package/dist/runs.js +8 -0
  123. package/dist/runtime-governance.d.ts +3 -3
  124. package/dist/runtime-governance.js +2 -4
  125. package/dist/security.d.ts +19 -2
  126. package/dist/session-row.d.ts +53 -0
  127. package/dist/session-row.js +47 -0
  128. package/dist/session-sync.d.ts +6 -0
  129. package/dist/session-sync.js +5 -1
  130. package/dist/session-titler.d.ts +9 -5
  131. package/dist/session-titler.js +3 -1
  132. package/dist/spec-fields.js +2 -2
  133. package/dist/task-cwd.d.ts +0 -20
  134. package/dist/task-cwd.js +2 -10
  135. package/dist/tool-approval.d.ts +5 -7
  136. package/dist/trace/redact.d.ts +23 -50
  137. package/dist/trace/redact.js +42 -9
  138. package/dist/trace/self-minted-wire.d.ts +25 -0
  139. package/dist/trace/self-minted-wire.js +9 -0
  140. package/dist/web-admin-client/apply-effective.d.ts +10 -12
  141. package/dist/web-admin-client/apply-effective.js +2 -3
  142. package/dist/web-admin-client/candidate-gate.d.ts +69 -0
  143. package/dist/web-admin-client/candidate-gate.js +27 -0
  144. package/dist/web-admin-client/http-client.d.ts +2 -1
  145. package/dist/web-admin-client/http-client.js +2 -1
  146. package/dist/web-admin-client/pull-state.d.ts +6 -4
  147. package/dist/web-admin-client/read-warnings.d.ts +29 -5
  148. package/dist/web-admin-client/read-warnings.js +24 -4
  149. package/dist/web-admin-client/stage-limits.js +4 -1
  150. package/dist/workspace-space.d.ts +41 -0
  151. package/dist/workspace-space.js +5 -0
  152. package/package.json +3 -3
@@ -1,3 +1,4 @@
1
+ import type { WorkspaceSyncCapability } from "../workspace-space.js";
1
2
  import { FileError, ExecutionError, RemoteExecutionError, type ExecutionEnv, type RemoteExecutionEnv, type WorkspaceHandle, type FileInfo, type Result, type SymlinkChain, type OutputChunk, type ExecStreamOptions, type RemoteConnectConfig, type SnapshotId, type SessionToken, type VmLifecycleOptions, type ExecutionEnvFactory, type BackgroundShellCapability, type BackgroundShellId, type BackgroundShellError, type BackgroundSpawnOptions, type BackgroundPoll } from "@sema-agent/core";
2
3
  import type { PodSpecPatch } from "./k8s-exec-protocol.js";
3
4
  type ExecOpts = Parameters<ExecutionEnv["exec"]>[1];
@@ -27,7 +28,8 @@ export interface K8sEnvConfig {
27
28
  homeDir?: string;
28
29
  /** Workspace root inside the pod; created on connect. Default "/workspace". */
29
30
  mountPath?: string;
30
- /** Pod lifetime (activeDeadlineSeconds) — the leak guard. Default 30min. */
31
+ /** Pod lifetime (`activeDeadlineSeconds`; env `K8S_TIMEOUT_MS`) — the whole-pod leak guard, and the ceiling background-shell
32
+ * timeouts are clamped to. NOT a per-command timeout: each command's bound comes from its tool call. Default 30min. */
31
33
  timeoutMs?: number;
32
34
  /** Pod schedule+pull+start bound. Default 120s (image pull can be slow once per node). */
33
35
  readyTimeoutMs?: number;
@@ -55,6 +57,12 @@ export interface K8sEnvConfig {
55
57
  * (never the access/secret key) — a credential reaching code that runs untrusted work is the [ref] lethal
56
58
  * trifecta. The presigned URL is used only inside an adapter-issued exec at suspend/resume time, so it never
57
59
  * reaches an agent turn.
60
+ *
61
+ * WORKSPACE SPACE (`workspacePersist`, default `off` — opt-in `space`): the same store also carries the workspace ACROSS
62
+ * RUNS, in its own sub-prefix `<prefix>/<spaceIncarnation>/space/` — `space/latest` (body = the newest snapshotId) names the
63
+ * snapshot `connect()` restores; `destroy()` snapshots into `space/`, moves `latest`, keeps the newest `workspaceSnapshotKeep`
64
+ * (the same one upload / one download as suspend / resume, see `SnapshotTarget`). The pointer read / write, the listing and the deletes are
65
+ * WORKER-side requests to `endpoint` (the snapshot-BLOB offload dials the same `MINIO_ENDPOINT` from the worker).
58
66
  */
59
67
  s3Snapshot?: {
60
68
  endpoint: string;
@@ -64,9 +72,36 @@ export interface K8sEnvConfig {
64
72
  region?: string;
65
73
  keyPrefix?: string;
66
74
  presignTtlSec?: number;
75
+ /** The workspace-space policy (the capability fact `workspaceSpace`'s witness, handed in by the factory; the adapter
76
+ * never resolves defaults itself). Absent or `persist: "off"` ⇒ no persistence across runs. */
77
+ space?: WorkspaceSyncCapability;
67
78
  };
68
- /** Set by the factory from the task's ExecutionEnvFactoryContext — namespaces snapshot keys per session. */
79
+ /**
80
+ * The PARK lane's partition (`<prefix>/<sessionId>/<snapshotId>.tar.gz`), set by {@link k8sExecutionEnvFactory} from
81
+ * `ExecutionEnvFactoryContext.sessionId` — the key form parked runs have always used, so stored parks stay readable.
82
+ * Why the session id is right HERE: a park archive is only ever read back by its own parked run's checkpoint (the
83
+ * snapshotId lives in that checkpoint; deleting the session deletes its checkpoints) — the partition only namespaces,
84
+ * it never decides who reads. Segment shape is validated in exactly one place ({@link RemoteK8sExecutionEnv.presignSnapshot}).
85
+ */
69
86
  sessionId?: string;
87
+ /**
88
+ * The SPACE lane's partition (`<prefix>/<spaceIncarnation>/space/…`): the session OCCUPANT's incarnation token
89
+ * (`session-incarnation.ts`, the one owner of "is this the same occupant"), handed in per run by the factory binding
90
+ * (`boot/execution-env.ts`) from the same seat the memory engine reads. Why not the session id: the space lane is read
91
+ * by whoever runs next in the session, so its partition decides WHO reads — a session id deleted and registered again
92
+ * (by anyone) is a new occupant and starts from an empty workspace. Absent while persistence is on ⇒ the space lane
93
+ * does not open for this env (connect restores nothing, destroy saves nothing) and one `workspace_space_unavailable`
94
+ * line says so — never a fallback to the session id. Never logged whole: operator lines carry `incarnationLogPrefix`.
95
+ */
96
+ spaceIncarnation?: string;
97
+ /** Operator log seat (the e2b adapter's shape): `workspace_snapshot_failed` / `workspace_snapshot_prune_failed` /
98
+ * `workspace_space_unavailable` / `workspace_commit_abandoned` / `workspace_commit_marker_stuck`. */
99
+ logger?: {
100
+ warn(event: string, fields?: Record<string, unknown>): void;
101
+ };
102
+ /** Run-end outcome seat — called once per `destroy()` that attempted a space snapshot (the factory's caller binds it
103
+ * to the run, so the run's terminal frame can carry `workspaceSnapshot: "failed"`). */
104
+ onWorkspaceSnapshot?: (outcome: "saved" | "failed") => void;
70
105
  /**
71
106
  * 🔴 ADDITIVE pod-spec patch (a SERVICE-side adapter seam, NOT a core seam: the K8s pod shape is
72
107
  * provider-specific, so it stays in the adapter per [ref]). A deployment supplies extra volumes / volumeMounts
@@ -112,6 +147,8 @@ export interface K8sEnvDeps {
112
147
  }>;
113
148
  openExec?: (url: string, headers: Record<string, string>) => ExecSocket;
114
149
  connectOnce?: (signal?: AbortSignal) => Promise<ConnectResult>;
150
+ /** Worker-side object-store requests (the space pointer, listing, deletes). Default: global `fetch`. */
151
+ fetch?: typeof fetch;
115
152
  }
116
153
  export { execSocketTlsOptions } from "./k8s-exec-protocol.js";
117
154
  /** `Error`-or-event → message. Bun's ws shim emits browser-style ErrorEvents (NOT instanceof Error) whose
@@ -150,6 +187,12 @@ export declare class RemoteK8sExecutionEnv implements RemoteExecutionEnv, Backgr
150
187
  readonly homeDir?: string | undefined;
151
188
  private readonly cfg;
152
189
  private readonly deps;
190
+ /** This env's space lane (resolved once from the policy + the occupant's incarnation; see {@link SpaceLane}). */
191
+ private readonly spaceLane;
192
+ /** `workspace_space_unavailable` is said once per env. */
193
+ private spaceUnavailableSaid;
194
+ private readonly logger?;
195
+ private readonly onWorkspaceSnapshot?;
153
196
  constructor(config: K8sEnvConfig, deps?: K8sEnvDeps);
154
197
  /** 166-T2 — ExecutionEnv 寿命声明:pod `activeDeadlineSeconds = ceil(timeoutMs/1000)` 是平台真硬死线
155
198
  * (k8s 到点杀 pod,无 keepalive 续期机制)⇒ 恒声明,core 在死线−60s 处 suspend(基建合格)或响亮
@@ -163,7 +206,23 @@ export declare class RemoteK8sExecutionEnv implements RemoteExecutionEnv, Backgr
163
206
  /** Control-plane REST call (pod create/get/delete), bounded by rpcTimeoutMs. Injectable for tests. */
164
207
  private request;
165
208
  private openExec;
209
+ /**
210
+ * Connect = the workspace SPACE's restore point. Space lane open ⇒ first wait out a commit still in flight
211
+ * ({@link awaitSpaceCommit}), then read `<prefix>/<incarnation>/space/latest`: present ⇒
212
+ * restore it through {@link restoreSnapshot} (the one tar download, shared with `resumeVM`; a missing archive is
213
+ * refused); absent (404) ⇒ a fresh pod; a read that FAILS ⇒ a loud
214
+ * `connect_failed` — never a silent fresh workspace (a run that believes it resumed while it did not is the worst form).
215
+ * Persistence off / no store ⇒ a fresh pod, exactly as before. Persistence on but the occupant unknown ⇒ a fresh pod
216
+ * and one `workspace_space_unavailable` line (no restore by any other key).
217
+ */
166
218
  connect(config?: RemoteConnectConfig): Promise<ConnectResult>;
219
+ /** The space lane is on but this env's occupant is unknown — said once per env (at connect, or at a destroy with a pod). */
220
+ private sayUnavailable;
221
+ /** Every space-lane operator line: the space is named by its incarnation's log prefix only, and free text (a curl
222
+ * stderr, a fetch error) passes {@link redactIncarnations} — the full token never reaches a log body. */
223
+ private spaceLine;
224
+ /** A fresh pod (create → Running → workspace root), bounded retry. Shared by {@link connect} and {@link resumeVM}. */
225
+ private provision;
167
226
  /**
168
227
  * Lazy-connect on first use — 🔴 core NEVER calls `connect()` (verified: the Runner only calls `resumeVM` on
169
228
  * the resume path), so an adapter that demands an explicit connect can never work through the production
@@ -173,14 +232,45 @@ export declare class RemoteK8sExecutionEnv implements RemoteExecutionEnv, Backgr
173
232
  private ensureConnected;
174
233
  private connectOnce;
175
234
  /**
176
- * Build a validated, presigned URL for a snapshot object — `<prefix>/<sessionId>/<snapshotId>.tar.gz`.
177
- * 🔴 sessionId comes from the client (`body.sessionId`, security.ts) and is NOT charset-validated upstream, so
178
- * each path segment is allowlisted here (`^[A-Za-z0-9][A-Za-z0-9._-]*$`) before it enters the object key: a
179
- * segment of `..` / `../x` / one containing `/` would otherwise let one tenant's snapshot key normalize into
180
- * another tenant's prefix (cross-tenant read on resume / clobber on suspend). Returns a typed error instead of
181
- * signing a traversing key. Folds the old snapshotKey()+s3Presign() pair so the two call sites can't diverge.
235
+ * Build a validated, presigned URL for a snapshot object of either lane (see {@link SnapshotTarget}) — the ONE place a
236
+ * snapshot object key is spelled, and the one place each lane's partition is chosen (park = the session id, space = the
237
+ * occupant's incarnation carried by the request).
238
+ * 🔴 the park partition is the session id, which comes from the client (`body.sessionId`, security.ts) and is NOT
239
+ * charset-validated upstream, so each path segment is allowlisted here (`^[A-Za-z0-9][A-Za-z0-9._-]*$`) before it enters
240
+ * the object key: a segment of `..` / `../x` / one containing `/` would otherwise let one tenant's snapshot key normalize into
241
+ * another tenant's prefix (cross-tenant read on resume / clobber on suspend). The space partition (a minted v4 uuid) goes
242
+ * through the same one allowlist. Returns a typed error instead of signing a traversing key. Folds the old
243
+ * snapshotKey()+s3Presign() pair so the two call sites can't diverge.
182
244
  */
183
245
  private presignSnapshot;
246
+ /** Worker-side object-store request, bounded by the control-plane RPC wall-clock — or a tighter caller deadline — and
247
+ * the caller's abort. */
248
+ private storeFetch;
249
+ /** Read one space pointer object: its trimmed body, or `undefined` = absent (404); any other failure is a typed
250
+ * `connect_failed` naming the pointer. */
251
+ private readPointer;
252
+ /** Read the space's `latest` pointer. The body's shape is checked where it is used — restoreSnapshot → presignSnapshot
253
+ * (the one segment check). */
254
+ private readLatest;
255
+ /** PUT one space pointer object (a single-object PUT replaces the object atomically) or DELETE it; `timeoutMs` = a
256
+ * caller deadline tighter than the RPC bound. */
257
+ private writePointer;
258
+ /**
259
+ * Wait out the previous run's space commit. `space/committing` present and younger than the upload's own wall-clock
260
+ * bound (`dataTimeoutMs` — the SAME number `uploadSnapshot` gives its tar + curl exec) ⇒ re-read every
261
+ * {@link SPACE_COMMIT_POLL_MS} until it is gone or has aged past that bound. A marker at / past the bound = a commit
262
+ * whose worker died inside it (a live upload fails at the bound and clears its marker) ⇒ one
263
+ * `workspace_commit_abandoned` line; `latest` still names the previous COMPLETE snapshot. A marker read that fails, or
264
+ * a body that is not an epoch-ms timestamp, is a loud `connect_failed` (the same form as a failed `latest` read).
265
+ */
266
+ private awaitSpaceCommit;
267
+ /**
268
+ * Keep the newest `keep` snapshots of this space (the one `latest` now names always among them); delete the rest.
269
+ * Works inside the `space/` sub-prefix only — park archives are never in its view. The bucket listing is the only
270
+ * record (no index). A failure loses nothing (the space just keeps more objects) —
271
+ * it is reported, never thrown.
272
+ */
273
+ private pruneSpace;
184
274
  /** Redact a presigned URL (and any lingering signature) from text before it enters an error/log — the URL is a
185
275
  * short-lived bearer credential and must never reach a durable store (secret boundary). */
186
276
  private redactUrl;
@@ -200,10 +290,15 @@ export declare class RemoteK8sExecutionEnv implements RemoteExecutionEnv, Backgr
200
290
  ok: false;
201
291
  error: RemoteExecutionError;
202
292
  }>;
293
+ /**
294
+ * The ONE tar upload (both lanes): tar the workspace to a pod-local file, presigned PUT into the lane's namespace,
295
+ * release the pod. `suspendVM` = lane `park`; the run-end space snapshot = lane `space` (its open space).
296
+ */
297
+ private uploadSnapshot;
203
298
  /**
204
299
  * Resume a workspace snapshot onto a FRESH pod (resume = executionEnvFactory rebuild + resumeVM):
205
- * provision a pod, then download + untar the snapshot into the workspace. A missing object (empty/plan-only
206
- * snapshot) resumes as a clean workspace.
300
+ * provision a pod, then download + untar the snapshot into the workspace ({@link restoreSnapshot}, lane `park`). A
301
+ * missing object (empty/plan-only snapshot) resumes as a clean workspace.
207
302
  *
208
303
  * [ref]①(priorHandle 根保真)——k8s 走**诚实臂**而非绑定臂:pod 的 workspace volume 挂在**当前**
209
304
  * 配置根(cfg.mountPath),往 priorHandle.mountPath 绑=往容器 rootfs untar 大工作区,ephemeral-storage
@@ -212,6 +307,14 @@ export declare class RemoteK8sExecutionEnv implements RemoteExecutionEnv, Backgr
212
307
  * 变更窗的分歧由 core 侧 fail-closed reopen 兜底,审批不烧错根。
213
308
  */
214
309
  resumeVM(snapshotId: SnapshotId, options?: VmLifecycleOptions): Promise<ConnectResult>;
310
+ /**
311
+ * The ONE tar download (both lanes): provision a FRESH pod, download + untar the lane's archive into the workspace.
312
+ * `missing: "empty"` (park: a plan-only suspend has no object) tolerates a 404 as a clean workspace; `missing:
313
+ * "refuse"` (space: its pointer is only ever written after a successful upload ⇒ a 404 there is lost data) refuses it.
314
+ * A restore that does not complete releases the pod it provisioned: no tool and no run-end snapshot may ever see a
315
+ * torn or empty-by-accident workspace.
316
+ */
317
+ private restoreSnapshot;
215
318
  /**
216
319
  * Honest post-resume consistency ([ref] §5 council #8): the workspace FILES are restored byte-for-byte
217
320
  * from the snapshot, so read-before-edit hashing already lines up — there's nothing to re-fetch. In-VM
@@ -227,9 +330,21 @@ export declare class RemoteK8sExecutionEnv implements RemoteExecutionEnv, Backgr
227
330
  }>;
228
331
  reconnect(_sessionToken: SessionToken): Promise<ConnectResult>;
229
332
  private deletePod;
230
- /** Delete the sandbox pod — the workspace is DISPOSABLE (vs SSH/ADB disconnect-only). Idempotent, never throws. */
333
+ /**
334
+ * Delete the sandbox pod. Idempotent, never throws. Space lane open and a pod exists ⇒ FIRST the run-end space
335
+ * snapshot ({@link persistWorkspace}); its failure is loud and never blocks the delete (the pod lifetime is the
336
+ * leak guard). A run that never connected has no workspace to save — `latest` stays where it was. Space lane
337
+ * unavailable (occupant unknown) ⇒ nothing is saved (the once-per-env line says so).
338
+ */
231
339
  destroy(): Promise<void>;
232
340
  cleanup(): Promise<void>;
341
+ /**
342
+ * Run-end space snapshot: PUT the `committing` marker → the one tar upload ({@link uploadSnapshot}, lane `space` — it
343
+ * also releases the pod) → move `latest` → keep N → DELETE the marker (always, saved or failed).
344
+ * Upload or pointer failure ⇒ one `workspace_snapshot_failed` line + the `failed` outcome; `latest` keeps naming the
345
+ * previous snapshot, so the next run restores THAT — never an empty workspace dressed up as a restore.
346
+ */
347
+ private persistWorkspace;
233
348
  private execUrl;
234
349
  /**
235
350
  * Run a command over the exec WebSocket. Frames: byte0=channel (1 stdout / 2 stderr / 3 status JSON with the
@@ -347,5 +462,5 @@ export declare class RemoteK8sExecutionEnv implements RemoteExecutionEnv, Backgr
347
462
  * created lazily on connect and deleted by `destroy()` (Runner-owned lifetime; `activeDeadlineSeconds` is the
348
463
  * leak guard if destroy never runs).
349
464
  */
350
- export declare function k8sExecutionEnvFactory(config: K8sEnvConfig): ExecutionEnvFactory;
465
+ export declare function k8sExecutionEnvFactory(config: K8sEnvConfig, deps?: K8sEnvDeps): ExecutionEnvFactory;
351
466
  //# sourceMappingURL=remote-env-k8s.d.ts.map
@@ -5,7 +5,8 @@ import http from "node:http";
5
5
  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
- import { presignS3Url } from "./s3-presign.js";
8
+ import { presignS3Url, presignS3ListUrl } from "./s3-presign.js";
9
+ import { incarnationLogPrefix, redactIncarnations } from "../session-incarnation.js";
9
10
  import { FileError, ExecutionError, RemoteExecutionError, withRetry, RollingTailBuffer, markTruncated, } from "@sema-agent/core";
10
11
  import { fileErrorFromExec, classifyFsStderr } from "./remote-env-file-error.js";
11
12
  import { createPosixShellFs } from "./posix-shell-fs.js";
@@ -31,6 +32,21 @@ const EXEC_SUBPROTOCOLS = ["v5.channel.k8s.io", "v4.channel.k8s.io"];
31
32
  const WRITE_CHUNK_BYTES = 64 * 1024;
32
33
  import { applyPodSpecPatch } from "./k8s-exec-protocol.js";
33
34
  export { applyPodSpecPatch } from "./k8s-exec-protocol.js";
35
+ function spaceLaneOf(policy, incarnation) {
36
+ if (policy === undefined)
37
+ return { state: "off" };
38
+ switch (policy.persist) {
39
+ case "off":
40
+ return { state: "off" };
41
+ case "space":
42
+ return incarnation ? { state: "open", space: { incarnation, keep: policy.keep } } : { state: "unavailable" };
43
+ default: {
44
+ const unreachable = policy.persist;
45
+ throw new Error(`unhandled WORKSPACE_PERSIST word: ${String(unreachable)}`);
46
+ }
47
+ }
48
+ }
49
+ const SPACE_COMMIT_POLL_MS = 1000;
34
50
  import { execSocketTlsOptions, exitCodeFromStatus } from "./k8s-exec-protocol.js";
35
51
  export { execSocketTlsOptions } from "./k8s-exec-protocol.js";
36
52
  export function errorMessageOf(e) {
@@ -52,6 +68,10 @@ export class RemoteK8sExecutionEnv {
52
68
  homeDir;
53
69
  cfg;
54
70
  deps;
71
+ spaceLane;
72
+ spaceUnavailableSaid = false;
73
+ logger;
74
+ onWorkspaceSnapshot;
55
75
  constructor(config, deps) {
56
76
  this.cfg = {
57
77
  apiUrl: config.apiUrl,
@@ -78,6 +98,9 @@ export class RemoteK8sExecutionEnv {
78
98
  this.cwd = this.cfg.mountPath;
79
99
  this.homeDir = config.homeDir;
80
100
  this.deps = deps ?? {};
101
+ this.spaceLane = spaceLaneOf(config.s3Snapshot?.space, config.spaceIncarnation);
102
+ this.logger = config.logger;
103
+ this.onWorkspaceSnapshot = config.onWorkspaceSnapshot;
81
104
  this.capabilities = { isolation: true, suspendable: !!this.cfg.s3Snapshot, canonicalPathAuthoritative: true };
82
105
  const bgMax = Math.max(1, Math.min(K8S_BG_MAX_TIMEOUT_SEC, Math.floor(this.cfg.timeoutMs / 1000)));
83
106
  this.backgroundCapabilities = {
@@ -177,13 +200,53 @@ export class RemoteK8sExecutionEnv {
177
200
  });
178
201
  }
179
202
  async connect(config) {
203
+ const signal = config?.abortSignal;
204
+ const lane = this.spaceLane;
205
+ switch (lane.state) {
206
+ case "off":
207
+ return this.provision(signal);
208
+ case "unavailable":
209
+ this.sayUnavailable();
210
+ return this.provision(signal);
211
+ case "open": {
212
+ const settled = await this.awaitSpaceCommit(lane.space, signal);
213
+ if (!settled.ok)
214
+ return settled;
215
+ const latest = await this.readLatest(lane.space, signal);
216
+ if (!latest.ok)
217
+ 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 } : {}) });
221
+ }
222
+ default: {
223
+ const unreachable = lane;
224
+ throw new Error(`unhandled space lane state: ${String(unreachable.state)}`);
225
+ }
226
+ }
227
+ }
228
+ sayUnavailable() {
229
+ if (this.spaceUnavailableSaid)
230
+ return;
231
+ this.spaceUnavailableSaid = true;
232
+ this.logger?.warn("workspace_space_unavailable", {
233
+ reason: "incarnation_unknown",
234
+ 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)",
236
+ });
237
+ }
238
+ spaceLine(event, space, fields) {
239
+ const safe = Object.fromEntries(Object.entries(fields).map(([k, v]) => [k, typeof v === "string" ? redactIncarnations(v) : v]));
240
+ this.logger?.warn(event, { space: incarnationLogPrefix(space.incarnation), ...safe });
241
+ }
242
+ provision(signal) {
180
243
  const policy = {
181
244
  retryableCodes: ["connect_failed", "timeout", "transport_lost"],
182
245
  maxAttempts: this.cfg.retry.maxAttempts,
183
246
  backoffMs: (failed) => this.cfg.retry.backoffMs * failed,
184
247
  };
185
248
  const once = this.deps.connectOnce ?? ((s) => this.connectOnce(s));
186
- return withRetry(() => once(config?.abortSignal), policy, { signal: config?.abortSignal });
249
+ return withRetry(() => once(signal), policy, { signal });
187
250
  }
188
251
  async ensureConnected() {
189
252
  if (this.podName)
@@ -301,28 +364,138 @@ export class RemoteK8sExecutionEnv {
301
364
  }
302
365
  return ok(this.workspaceHandle());
303
366
  }
304
- presignSnapshot(method, snapshotId) {
367
+ presignSnapshot(req) {
305
368
  const s3 = this.cfg.s3Snapshot;
306
369
  const prefix = s3.keyPrefix ?? "kata";
307
- const session = this.cfg.sessionId ?? "_";
370
+ const [label, partition] = req.lane === "space" ? ["spaceIncarnation", req.space.incarnation] : ["sessionId", this.cfg.sessionId ?? "_"];
371
+ const snapshotId = "snapshotId" in req ? req.snapshotId : undefined;
308
372
  const safe = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
309
- for (const [label, seg] of [["sessionId", session], ["snapshotId", snapshotId], ["keyPrefix", prefix]]) {
373
+ const segments = [[label, partition], ...(snapshotId !== undefined ? [["snapshotId", snapshotId]] : []), ["keyPrefix", prefix]];
374
+ for (const [label, seg] of segments) {
310
375
  if (!safe.test(seg))
311
376
  return { ok: false, error: new RemoteExecutionError("connect_failed", `unsafe snapshot key segment (${label}): refusing to build a traversing object key`) };
312
377
  }
313
- return {
314
- ok: true,
315
- value: presignS3Url({
316
- endpoint: s3.endpoint,
317
- bucket: s3.bucket,
318
- key: `${prefix}/${session}/${snapshotId}.tar.gz`,
319
- method,
320
- accessKey: s3.accessKey,
321
- secretKey: s3.secretKey,
322
- ...(s3.region ? { region: s3.region } : {}),
323
- expiresSec: Math.min(s3.presignTtlSec ?? 600, 3600),
324
- }),
378
+ const common = {
379
+ endpoint: s3.endpoint,
380
+ bucket: s3.bucket,
381
+ accessKey: s3.accessKey,
382
+ secretKey: s3.secretKey,
383
+ ...(s3.region ? { region: s3.region } : {}),
384
+ expiresSec: Math.min(s3.presignTtlSec ?? 600, 3600),
325
385
  };
386
+ const base = req.lane === "space" ? `${prefix}/${partition}/space/` : `${prefix}/${partition}/`;
387
+ if (req.method === "LIST")
388
+ return { ok: true, value: presignS3ListUrl({ ...common, prefix: base, maxKeys: 1000 }) };
389
+ const object = "pointer" in req ? req.pointer : `${req.snapshotId}.tar.gz`;
390
+ return { ok: true, value: presignS3Url({ ...common, key: `${base}${object}`, method: req.method }) };
391
+ }
392
+ storeFetch(url, init) {
393
+ const bound = AbortSignal.timeout(Math.max(1, Math.min(this.cfg.rpcTimeoutMs, init.timeoutMs ?? this.cfg.rpcTimeoutMs)));
394
+ const signal = init.signal ? AbortSignal.any([init.signal, bound]) : bound;
395
+ return (this.deps.fetch ?? fetch)(url, { method: init.method, ...(init.body !== undefined ? { body: init.body } : {}), signal });
396
+ }
397
+ async readPointer(space, pointer, signal) {
398
+ const url = this.presignSnapshot({ lane: "space", space, method: "GET", pointer });
399
+ if (!url.ok)
400
+ return url;
401
+ const what = pointer === "latest" ? "pointer" : "commit marker";
402
+ let res;
403
+ let body;
404
+ try {
405
+ res = await this.storeFetch(url.value, { method: "GET", ...(signal ? { signal } : {}) });
406
+ body = await res.text();
407
+ }
408
+ 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) };
410
+ }
411
+ if (res.status === 404)
412
+ return ok(undefined);
413
+ if (!res.ok)
414
+ return { ok: false, error: new RemoteExecutionError("connect_failed", `workspace space ${what} read failed (HTTP ${res.status})`) };
415
+ return ok(body.trim());
416
+ }
417
+ async readLatest(space, signal) {
418
+ const r = await this.readPointer(space, "latest", signal);
419
+ return r.ok ? ok(r.value === undefined ? undefined : r.value) : r;
420
+ }
421
+ async writePointer(space, pointer, body, timeoutMs) {
422
+ const method = body === null ? "DELETE" : "PUT";
423
+ const url = this.presignSnapshot({ lane: "space", space, method, pointer });
424
+ if (!url.ok)
425
+ return url;
426
+ const what = pointer === "latest" ? "pointer" : "commit marker";
427
+ try {
428
+ const res = await this.storeFetch(url.value, { method, ...(body === null ? {} : { body }), ...(timeoutMs !== undefined ? { timeoutMs } : {}) });
429
+ if (!res.ok && !(method === "DELETE" && res.status === 404))
430
+ return { ok: false, error: new RemoteExecutionError("connect_failed", `workspace space ${what} ${method === "PUT" ? "write" : "delete"} failed (HTTP ${res.status})`) };
431
+ }
432
+ catch (e) {
433
+ return { ok: false, error: new RemoteExecutionError("connect_failed", `workspace space ${what} ${method === "PUT" ? "write" : "delete"} failed: ${this.redactUrl(errorMessageOf(e), url.value)}`, e instanceof Error ? e : undefined) };
434
+ }
435
+ return ok(undefined);
436
+ }
437
+ async awaitSpaceCommit(space, signal) {
438
+ for (;;) {
439
+ const marker = await this.readPointer(space, "committing", signal);
440
+ if (!marker.ok)
441
+ return marker;
442
+ if (marker.value === undefined)
443
+ 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 });
450
+ return ok(undefined);
451
+ }
452
+ await new Promise((resolveWait) => {
453
+ const t = setTimeout(resolveWait, SPACE_COMMIT_POLL_MS);
454
+ signal?.addEventListener("abort", () => { clearTimeout(t); resolveWait(); }, { once: true });
455
+ });
456
+ if (signal?.aborted)
457
+ return { ok: false, error: new RemoteExecutionError("aborted", "connect aborted while waiting for the previous run's workspace commit") };
458
+ }
459
+ }
460
+ async pruneSpace(space, newest) {
461
+ const list = this.presignSnapshot({ lane: "space", space, method: "LIST" });
462
+ if (!list.ok)
463
+ return;
464
+ const failed = (detail) => this.spaceLine("workspace_snapshot_prune_failed", space, { detail });
465
+ let xml;
466
+ try {
467
+ const res = await this.storeFetch(list.value, { method: "GET" });
468
+ xml = await res.text();
469
+ if (!res.ok)
470
+ return failed(`list HTTP ${res.status}`);
471
+ }
472
+ catch (e) {
473
+ return failed(`list: ${this.redactUrl(errorMessageOf(e), list.value)}`);
474
+ }
475
+ if (/<IsTruncated>true<\/IsTruncated>/.test(xml))
476
+ return failed("listing truncated (more than 1000 objects under this space) — refusing to prune from a partial view");
477
+ const tail = ".tar.gz";
478
+ const archives = [...xml.matchAll(/<Contents>([\s\S]*?)<\/Contents>/g)]
479
+ .map((m) => ({ key: /<Key>([^<]*)<\/Key>/.exec(m[1])?.[1] ?? "", at: Date.parse(/<LastModified>([^<]*)<\/LastModified>/.exec(m[1])?.[1] ?? "") }))
480
+ .filter((o) => o.key.endsWith(tail))
481
+ .map((o) => ({ ...o, id: o.key.slice(o.key.lastIndexOf("/") + 1, -tail.length) }));
482
+ const mine = archives.find((o) => o.id === newest);
483
+ if (!mine || !Number.isFinite(mine.at))
484
+ return failed("the snapshot just written is not in the listing (or has no LastModified) — refusing to prune without an age anchor");
485
+ const older = archives.filter((o) => Number.isFinite(o.at) && o.at < mine.at).sort((a, b) => b.at - a.at || (a.key < b.key ? 1 : -1));
486
+ for (const o of older.slice(Math.max(0, space.keep - 1))) {
487
+ const del = this.presignSnapshot({ lane: "space", space, method: "DELETE", snapshotId: o.id });
488
+ if (!del.ok)
489
+ continue;
490
+ try {
491
+ const res = await this.storeFetch(del.value, { method: "DELETE" });
492
+ if (!res.ok && res.status !== 404)
493
+ failed(`delete ${o.id}: HTTP ${res.status}`);
494
+ }
495
+ catch (e) {
496
+ failed(`delete ${o.id}: ${this.redactUrl(errorMessageOf(e), del.value)}`);
497
+ }
498
+ }
326
499
  }
327
500
  redactUrl(text, url) {
328
501
  return text.split(url).join("<presigned-url>").replace(/X-Amz-Signature=[0-9a-f]+/gi, "X-Amz-Signature=<redacted>");
@@ -330,12 +503,15 @@ export class RemoteK8sExecutionEnv {
330
503
  async suspendVM(_options) {
331
504
  if (!this.cfg.s3Snapshot)
332
505
  return unsupported("suspendVM");
506
+ return this.uploadSnapshot({ lane: "park" });
507
+ }
508
+ async uploadSnapshot(target) {
333
509
  if (this.inFlightExecs > 0)
334
510
  return { ok: false, error: new RemoteExecutionError("command_in_flight", "cannot suspend while a command is in flight (would snapshot a torn workspace)") };
335
511
  const snapshotId = `kata-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
336
512
  if (!this.podName)
337
513
  return ok(snapshotId);
338
- const presigned = this.presignSnapshot("PUT", snapshotId);
514
+ const presigned = this.presignSnapshot({ ...target, method: "PUT", snapshotId });
339
515
  if (!presigned.ok)
340
516
  return presigned;
341
517
  const url = presigned.value;
@@ -356,10 +532,16 @@ export class RemoteK8sExecutionEnv {
356
532
  async resumeVM(snapshotId, options) {
357
533
  if (!this.cfg.s3Snapshot)
358
534
  return unsupported("resumeVM");
359
- const presigned = this.presignSnapshot("GET", snapshotId);
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;
539
+ }
540
+ async restoreSnapshot(target, snapshotId, opts) {
541
+ const presigned = this.presignSnapshot({ ...target, method: "GET", snapshotId });
360
542
  if (!presigned.ok)
361
543
  return presigned;
362
- const c = await this.connect({ abortSignal: options?.abortSignal });
544
+ const c = await this.provision(opts.abortSignal);
363
545
  if (!c.ok)
364
546
  return c;
365
547
  const url = presigned.value;
@@ -368,11 +550,21 @@ export class RemoteK8sExecutionEnv {
368
550
  `if [ "$code" != "200" ]; then echo "snapshot GET HTTP $code" >&2; exit 22; fi; ` +
369
551
  `tar xzf /tmp/ws.tgz -C ${shellQuote(this.cfg.mountPath)} && rm -f /tmp/ws.tgz`;
370
552
  const r = await this.runExec(cmd, { timeoutMs: this.cfg.dataTimeoutMs, livenessMs: 0 });
371
- if (!r.ok)
372
- return { ok: false, error: new RemoteExecutionError("connect_failed", `workspace snapshot restore failed: ${this.redactUrl(r.error.message, url)}`, r.error) };
373
- if (r.value.exitCode !== 0)
374
- return { ok: false, error: new RemoteExecutionError("connect_failed", `workspace snapshot restore exited ${r.value.exitCode}: ${this.redactUrl(r.value.stderr, url).slice(0, 300)}`) };
375
- this.handleSnapshotId = snapshotId;
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)`)
559
+ : undefined;
560
+ if (failed) {
561
+ const name = this.podName;
562
+ this.podName = undefined;
563
+ this.podIP = undefined;
564
+ if (name)
565
+ await this.deletePod(name).catch(() => { });
566
+ return { ok: false, error: failed };
567
+ }
376
568
  return ok(this.workspaceHandle());
377
569
  }
378
570
  async postResumeInit() {
@@ -397,6 +589,23 @@ export class RemoteK8sExecutionEnv {
397
589
  async destroy() {
398
590
  await this.disposeBackgroundShells().catch(() => { });
399
591
  this.destroyed = true;
592
+ const lane = this.spaceLane;
593
+ if (this.podName) {
594
+ switch (lane.state) {
595
+ case "open":
596
+ 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
+ }
607
+ }
608
+ }
400
609
  const name = this.podName;
401
610
  this.podName = undefined;
402
611
  this.podIP = undefined;
@@ -406,6 +615,40 @@ export class RemoteK8sExecutionEnv {
406
615
  async cleanup() {
407
616
  return this.destroy();
408
617
  }
618
+ async persistWorkspace(space) {
619
+ const failed = (stage, error) => {
620
+ this.spaceLine("workspace_snapshot_failed", space, { stage, code: error.code, error: error.message });
621
+ this.onWorkspaceSnapshot?.("failed");
622
+ };
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);
627
+ try {
628
+ let snap;
629
+ try {
630
+ snap = await this.uploadSnapshot({ lane: "space", space });
631
+ }
632
+ catch (e) {
633
+ return failed("upload", new RemoteExecutionError("unknown", errorMessageOf(e)));
634
+ }
635
+ if (!snap.ok)
636
+ return failed("upload", snap.error);
637
+ const remaining = startedAt + this.cfg.dataTimeoutMs - Date.now();
638
+ 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)`));
640
+ const moved = await this.writePointer(space, "latest", snap.value, remaining);
641
+ if (!moved.ok)
642
+ return failed("pointer", moved.error);
643
+ this.onWorkspaceSnapshot?.("saved");
644
+ await this.pruneSpace(space, snap.value).catch((e) => this.spaceLine("workspace_snapshot_prune_failed", space, { detail: errorMessageOf(e) }));
645
+ }
646
+ 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 });
650
+ }
651
+ }
409
652
  execUrl(command) {
410
653
  const { apiUrl, token } = this.creds();
411
654
  const base = apiUrl.replace(/^http/, "ws");
@@ -965,7 +1208,7 @@ export class RemoteK8sExecutionEnv {
965
1208
  return dir ? this.resolve(dir) : undefined;
966
1209
  }
967
1210
  }
968
- export function k8sExecutionEnvFactory(config) {
969
- return (ctx) => new RemoteK8sExecutionEnv({ ...config, sessionId: ctx.sessionId });
1211
+ export function k8sExecutionEnvFactory(config, deps) {
1212
+ return (ctx) => new RemoteK8sExecutionEnv({ ...config, sessionId: ctx.sessionId }, deps);
970
1213
  }
971
1214
  //# sourceMappingURL=remote-env-k8s.js.map