@stixxert/pi-docker-sandbox 1.1.2 → 1.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stixxert/pi-docker-sandbox",
3
- "version": "1.1.2",
3
+ "version": "1.1.3",
4
4
  "description": "pi extension: a private docker sandbox (sbx microVM with its own daemon) as the agent's deploy target — the host's docker is never exposed.",
5
5
  "license": "Apache-2.0",
6
6
  "publishConfig": {
@@ -33,20 +33,29 @@ function asText(value: Uint8Array | string | undefined): string {
33
33
  /**
34
34
  * The characteristic signatures of "the sandbox runtime could not start".
35
35
  *
36
- * Chosen to be NARROW. Each one is a phrase the `sbx` CLI or its in-VM runtime
37
- * emits about the sandbox itself, not something a user command would plausibly
38
- * print as its own output:
36
+ * Chosen to be NARROW specifically, long enough that a *user command's* own
37
+ * stderr cannot plausibly collide with them. That specificity is load-bearing:
38
+ * a false positive REPLACES the command's real exit status with a
39
+ * `SandboxUnavailableError`, and the caller then reports that the command's
40
+ * effects are unknown — which is exactly the ambiguity that tempts an agent to
41
+ * re-run a side-effecting command. So every pattern anchors on the runtime's
42
+ * actual wrapper phrasing, never on a short phrase a project script, CI helper
43
+ * or test wrapper might print about its OWN docker/CI runtime:
39
44
  *
40
- * 1. `failed to start sandbox` — the CLI's own wrapper line for "I could not
41
- * bring this sandbox up", e.g. the reproduced
42
- * `failed to start sandbox: start runtime: request failed: 500 ...`.
45
+ * 1. `failed to start sandbox: start runtime:` — the CLI's own wrapper chained
46
+ * to the runtime start, e.g. the reproduced
47
+ * `failed to start sandbox: start runtime: request failed: 500 ...`. BOTH
48
+ * halves must appear together; the bare `failed to start sandbox` alone is
49
+ * the kind of line a user's own launcher could emit, so it is not used.
43
50
  * 2. `start runtime: request failed: 5xx` — sandboxd's runtime API rejecting
44
51
  * the start with an HTTP 5xx. Anchored on `start runtime: request failed:`
45
52
  * rather than a bare `request failed: 500`, because the bare form could
46
53
  * come from any command's stderr (a curl wrapper, an API client, ...) and
47
54
  * would then misclassify an ordinary failing command.
48
- * 3. `docker daemon failed to start` — the in-VM cause the runtime reports
49
- * (`docker daemon failed to start inside the sandbox`).
55
+ * 3. `docker daemon failed to start inside the sandbox` — the in-VM cause, as
56
+ * the FULL phrase the runtime reports. The short `docker daemon failed to
57
+ * start` alone is NOT enough: a user command or CI wrapper that manages its
58
+ * own docker could print exactly that about a local daemon.
50
59
  *
51
60
  * Matching deliberately requires BOTH a non-zero exit AND a signature on
52
61
  * *stderr* (diagnostics live there; stdout is the command's own output, where a
@@ -54,9 +63,9 @@ function asText(value: Uint8Array | string | undefined): string {
54
63
  * A signal kill (exitCode null — our own abort/timeout) is never classified.
55
64
  */
56
65
  const RUNTIME_FAILURE_SIGNATURES: readonly RegExp[] = [
57
- /failed to start sandbox\b/i,
66
+ /failed to start sandbox:\s*start runtime:/i,
58
67
  /start runtime: request failed:\s*5\d\d\b/i,
59
- /docker daemon failed to start\b/i,
68
+ /docker daemon failed to start inside the sandbox\b/i,
60
69
  ];
61
70
 
62
71
  /**
@@ -89,10 +98,11 @@ export class SandboxUnavailableError extends Error {
89
98
  constructor(target: string, stderr: string) {
90
99
  const detail = stderr.trim();
91
100
  super(
92
- `sbx sandbox "${target}" is unavailable: the sandbox runtime failed to start, so the command ` +
93
- `never ran inside it (this is not the command's own exit status).\n` +
94
- `The VM may need recreating (check \`sbx ls\`, \`sbx stop ${target}\`, or recreate it) ` +
95
- `the next tool call will try to start the sandbox again.` +
101
+ `sbx sandbox "${target}" is unavailable: the sandbox runtime failed to start, so this is not the ` +
102
+ `command's own exit status and the command's effects cannot be assumed to have happened.\n` +
103
+ `The command was not run on the host and was not retried. The VM may need recreating (check ` +
104
+ `\`sbx ls\`, \`sbx stop ${target}\`, or recreate it) — the next tool call will try to start the ` +
105
+ `sandbox again.` +
96
106
  (detail ? `\n\n${detail}` : ""),
97
107
  );
98
108
  this.name = "SandboxUnavailableError";
@@ -100,3 +110,46 @@ export class SandboxUnavailableError extends Error {
100
110
  this.stderr = detail;
101
111
  }
102
112
  }
113
+
114
+ /**
115
+ * Per-episode state for the runtime-failure notification.
116
+ *
117
+ * `withSandboxFailureHandling` (see `sandbox/index.ts`) wraps every routed tool
118
+ * call. A runtime failure should tell the user ONCE — not once per tool call in
119
+ * an otherwise broken session — and then tell them AGAIN if a later call
120
+ * succeeds and a new, distinct outage begins.
121
+ *
122
+ * The subtlety the caller cannot express on its own: a tool call may arrive
123
+ * without a UI context, so a failure cannot always be surfaced. A failure that
124
+ * cannot be surfaced must NOT consume the episode's single notification, or an
125
+ * early headless failure would suppress the one notification the user needs for
126
+ * the whole episode. Hence `claimNotification(canNotify)`: the claim is only
127
+ * used up when the notification is actually deliverable.
128
+ *
129
+ * Pure and dependency-free (like the rest of this module) so the episode's
130
+ * behaviour can be unit-tested without the pi packages.
131
+ */
132
+ export class SandboxFailureEpisode {
133
+ /** Has this episode's one notification already been delivered? */
134
+ private notified = false;
135
+
136
+ /**
137
+ * Claim this episode's single notification.
138
+ *
139
+ * @param canNotify Whether the caller can actually deliver a notification
140
+ * right now (i.e. it has a UI context). When false the claim is left
141
+ * untouched, so a later, deliverable failure in the same episode still
142
+ * notifies.
143
+ * @returns true only for the first *deliverable* failure of the episode.
144
+ */
145
+ claimNotification(canNotify: boolean): boolean {
146
+ if (this.notified || !canNotify) return false;
147
+ this.notified = true;
148
+ return true;
149
+ }
150
+
151
+ /** A successful sandbox round-trip ends the episode. */
152
+ succeeded(): void {
153
+ this.notified = false;
154
+ }
155
+ }
package/sandbox/index.ts CHANGED
@@ -41,7 +41,7 @@ import {
41
41
  createWriteToolDefinition,
42
42
  } from "@earendil-works/pi-coding-agent";
43
43
  import { armSessionLifecycle, debugEnabled, envAllowlist, teardownSandbox } from "../index.ts";
44
- import { SandboxUnavailableError } from "./failure.ts";
44
+ import { SandboxFailureEpisode, SandboxUnavailableError } from "./failure.ts";
45
45
  import {
46
46
  createBashOps,
47
47
  createEditOps,
@@ -79,8 +79,11 @@ export default function (pi: ExtensionAPI) {
79
79
  let transport: ExecTransport | undefined;
80
80
  let starting: Promise<ExecTransport | undefined> | undefined;
81
81
  let lastError: string | undefined;
82
- /** Have we already told the user about the CURRENT run of runtime failures? */
83
- let sandboxFailureReported = false;
82
+ /**
83
+ * Notification state for the CURRENT run of runtime failures. One message per
84
+ * episode, reset by a successful round-trip — see `SandboxFailureEpisode`.
85
+ */
86
+ const sandboxEpisode = new SandboxFailureEpisode();
84
87
 
85
88
  /**
86
89
  * Forget the memoised transport so the next tool call re-resolves it.
@@ -88,10 +91,18 @@ export default function (pi: ExtensionAPI) {
88
91
  * This is the whole recovery mechanism: `resolveTransport()` re-derives the
89
92
  * sandbox and (re)starts the VM, so a runtime failure is an episode rather
90
93
  * than a permanent bricking of every remaining tool call in the session.
94
+ *
95
+ * `starting` is deliberately NOT reset here. It is non-undefined only while a
96
+ * `resolveTransport()` is genuinely in flight, and that in-flight resolution
97
+ * will itself publish a fresh transport when it settles — so the next
98
+ * `ensureTransport()` awaits it, which is exactly the recovery we want.
99
+ * Clearing it here would instead let a concurrent caller launch a SECOND
100
+ * resolution, and each resolution can boot/create a VM. The resulting race is
101
+ * benign today only because every resolution targets the same per-project
102
+ * sandbox name and therefore reuses one VM; there is no reason to open it up.
91
103
  */
92
104
  function invalidateTransport(): void {
93
105
  transport = undefined;
94
- starting = undefined;
95
106
  }
96
107
 
97
108
  /**
@@ -110,18 +121,21 @@ export default function (pi: ExtensionAPI) {
110
121
  ): Promise<T> {
111
122
  try {
112
123
  const result = await run();
113
- sandboxFailureReported = false; // a success ends the episode
124
+ sandboxEpisode.succeeded(); // a success ends the episode
114
125
  return result;
115
126
  } catch (err) {
116
127
  if (!(err instanceof SandboxUnavailableError)) throw err;
117
128
  invalidateTransport();
118
- if (!sandboxFailureReported) {
119
- sandboxFailureReported = true;
129
+ // Tell the user ONCE per episode. The claim is only taken when a
130
+ // notification is actually deliverable (`ctx` present) — a failure we
131
+ // cannot surface must not swallow the episode's one message.
132
+ if (sandboxEpisode.claimNotification(ctx !== undefined)) {
120
133
  ctx?.ui.notify(
121
- `sbx sandbox "${err.target}" is unavailable — the sandbox runtime failed to start, so this tool ` +
122
- `call did not run (it was NOT run on the host and was not retried). The next tool call will try ` +
123
- `to start the sandbox again; if it keeps failing, the VM may need recreating (\`sbx ls\`, ` +
124
- `then \`sbx stop ${err.target}\`).`,
134
+ `sbx sandbox "${err.target}" is unavailable — the sandbox runtime failed to start, so this ` +
135
+ `result is not the command's own exit status and the command's effects cannot be assumed ` +
136
+ `to have happened. The command was NOT run on the host and was not retried. The next tool ` +
137
+ `call will try to start the sandbox again; if it keeps failing, the VM may need recreating ` +
138
+ `(\`sbx ls\`, then \`sbx stop ${err.target}\`).`,
125
139
  "error",
126
140
  );
127
141
  }