@tangle-network/agent-provider-tangle 1.1.0 → 1.1.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.
@@ -71,6 +71,8 @@ export async function sandboxInstanceAsEnvironment(box, providerName, client, de
71
71
  // Usage is measured per execution, so the log collects what runs through
72
72
  // this handle and the observation reports the newest record it holds.
73
73
  const usageLog = createExecutionUsageLog();
74
+ // The single destroy this handle performs, once it has been asked for.
75
+ let destruction;
74
76
  const terminals = capabilities.interactiveTerminal?.attach === true
75
77
  ? createTangleTerminalRegistry(box)
76
78
  : undefined;
@@ -310,7 +312,31 @@ export async function sandboxInstanceAsEnvironment(box, providerName, client, de
310
312
  throw new Error("Tangle sandbox client cannot delete this environment");
311
313
  assertOptionKeys(options, ["signal"], "Tangle destroy");
312
314
  options?.signal?.throwIfAborted();
313
- await awaitWithSignal(box.delete(options), options?.signal);
315
+ // One environment has more than one owner of its end. A runtime
316
+ // that destroys on settle AND tears the executor down calls this
317
+ // twice for the same box, and the platform refuses the second
318
+ // DELETE with "A sandbox lifecycle operation is already in
319
+ // progress" while the first one's cleanup still holds the
320
+ // sandbox's lifecycle lease. Measured 2026-09-01 on
321
+ // `sandbox-b525e04a551b`: of two concurrent deletes one answered
322
+ // 200 `destroyed` and the other 409, and a third delete 0.4 s
323
+ // after the 200 was still refused. That refusal cost two
324
+ // completed agent turns their result
325
+ // (tangle-network/agent-sdk#280), because the second destroy ran
326
+ // in a caller's `finally` and its throw replaced the run's own
327
+ // outcome.
328
+ //
329
+ // So destroy is answered once per environment and every later
330
+ // caller joins that answer. This is not a retry and hides no
331
+ // platform error: a delete that fails is not remembered, and the
332
+ // next caller issues a real one.
333
+ destruction ??= awaitWithSignal(box.delete(options), options?.signal)
334
+ .then(() => undefined)
335
+ .catch((error) => {
336
+ destruction = undefined;
337
+ throw error;
338
+ });
339
+ await awaitWithSignal(destruction, options?.signal);
314
340
  options?.signal?.throwIfAborted();
315
341
  },
316
342
  }
@@ -11,6 +11,9 @@ export function sandboxInstanceAsExactProcessEnvironment(box, providerName) {
11
11
  const process = box.process;
12
12
  const fs = box.fs;
13
13
  const destroy = box.delete.bind(box);
14
+ // The same single delete this environment performs, once it is asked for.
15
+ // See the destroy() below for why one is all the platform accepts.
16
+ let destruction;
14
17
  return {
15
18
  id: box.id,
16
19
  provider: providerName,
@@ -177,7 +180,22 @@ export function sandboxInstanceAsExactProcessEnvironment(box, providerName) {
177
180
  async destroy(options = {}) {
178
181
  assertSignalOptions(options, "Tangle exact process destroy");
179
182
  options.signal?.throwIfAborted();
180
- await awaitWithSignal(destroy(), options.signal);
183
+ // Deleting one sandbox twice is refused, not repeated: the platform
184
+ // holds a per-sandbox lifecycle lease past the first DELETE's own
185
+ // response while its deferred cleanup runs, so a second DELETE inside
186
+ // that window answers "A sandbox lifecycle operation is already in
187
+ // progress". Measured 2026-09-01 (issue #280) on the streaming
188
+ // environment, which has the same shape and the same platform route.
189
+ // This environment is destroyed once and later callers join that
190
+ // answer; a delete that fails is not remembered, so nothing hides a
191
+ // real error.
192
+ destruction ??= awaitWithSignal(destroy(), options.signal)
193
+ .then(() => undefined)
194
+ .catch((error) => {
195
+ destruction = undefined;
196
+ throw error;
197
+ });
198
+ await awaitWithSignal(destruction, options.signal);
181
199
  options.signal?.throwIfAborted();
182
200
  },
183
201
  };
@@ -14,18 +14,18 @@ export declare const DEFAULT_TANGLE_READY_TIMEOUT_MS = 120000;
14
14
  * A response that already reports `running` skips that wait, and `running`
15
15
  * alone is not usable: the SDK's own `waitFor` treats the target as reached
16
16
  * only when `filesystemIncarnationReadiness` is `ready`, because the box's
17
- * filesystem is still being built until then. So `create()` can return a box
18
- * that reports `running` while the platform still holds a lifecycle operation
19
- * on it, and the first turn lands on that lock.
17
+ * filesystem is still being built until then. An environment composed before
18
+ * that is an environment that cannot accept a turn.
20
19
  *
21
- * Measured against that mechanism, 2026-09-01, discovery-lab#467: one box
22
- * (`sandbox-97943ce9526d`) came back from `create()`, and the first
23
- * `environment.stream()` failed 78 seconds later with "A sandbox lifecycle
24
- * operation is already in progress". That string is the platform's, not the
25
- * SDK's, and n is 1, so the incarnation window is the mechanism this wait
26
- * addresses rather than a proven cause. A lifecycle lock held by a genuinely
27
- * concurrent operation is a different failure, and no client-side wait
28
- * prevents it.
20
+ * WHAT THIS WAIT IS NOT FOR. An earlier version of this comment claimed the
21
+ * wait also closed the platform's "A sandbox lifecycle operation is already in
22
+ * progress", and that claim is withdrawn: measured 2026-09-01 (issue #280),
23
+ * that refusal comes from `DELETE /v1/sandboxes/:id`, not from a turn. The
24
+ * platform guards exactly three routes with the per-sandbox lifecycle lease
25
+ * resume, stop and delete and the runtime path a prompt takes is not one of
26
+ * them, so no readiness wait here can prevent it and none is trying to. The
27
+ * collision is answered where it happens, in `destroy()`
28
+ * (`tangle-environment.ts`).
29
29
  *
30
30
  * Readiness also decides what the environment can claim. Composing an
31
31
  * environment reads the sandbox's deployment capability document once, and a
@@ -15,18 +15,18 @@ export const DEFAULT_TANGLE_READY_TIMEOUT_MS = 120_000;
15
15
  * A response that already reports `running` skips that wait, and `running`
16
16
  * alone is not usable: the SDK's own `waitFor` treats the target as reached
17
17
  * only when `filesystemIncarnationReadiness` is `ready`, because the box's
18
- * filesystem is still being built until then. So `create()` can return a box
19
- * that reports `running` while the platform still holds a lifecycle operation
20
- * on it, and the first turn lands on that lock.
18
+ * filesystem is still being built until then. An environment composed before
19
+ * that is an environment that cannot accept a turn.
21
20
  *
22
- * Measured against that mechanism, 2026-09-01, discovery-lab#467: one box
23
- * (`sandbox-97943ce9526d`) came back from `create()`, and the first
24
- * `environment.stream()` failed 78 seconds later with "A sandbox lifecycle
25
- * operation is already in progress". That string is the platform's, not the
26
- * SDK's, and n is 1, so the incarnation window is the mechanism this wait
27
- * addresses rather than a proven cause. A lifecycle lock held by a genuinely
28
- * concurrent operation is a different failure, and no client-side wait
29
- * prevents it.
21
+ * WHAT THIS WAIT IS NOT FOR. An earlier version of this comment claimed the
22
+ * wait also closed the platform's "A sandbox lifecycle operation is already in
23
+ * progress", and that claim is withdrawn: measured 2026-09-01 (issue #280),
24
+ * that refusal comes from `DELETE /v1/sandboxes/:id`, not from a turn. The
25
+ * platform guards exactly three routes with the per-sandbox lifecycle lease
26
+ * resume, stop and delete and the runtime path a prompt takes is not one of
27
+ * them, so no readiness wait here can prevent it and none is trying to. The
28
+ * collision is answered where it happens, in `destroy()`
29
+ * (`tangle-environment.ts`).
30
30
  *
31
31
  * Readiness also decides what the environment can claim. Composing an
32
32
  * environment reads the sandbox's deployment capability document once, and a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/agent-provider-tangle",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "description": "AgentEnvironmentProvider adapter for Tangle sandboxes",
5
5
  "type": "module",
6
6
  "license": "MIT",