@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 +1 -1
- package/sandbox/failure.ts +67 -14
- package/sandbox/index.ts +25 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stixxert/pi-docker-sandbox",
|
|
3
|
-
"version": "1.1.
|
|
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": {
|
package/sandbox/failure.ts
CHANGED
|
@@ -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
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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
|
|
41
|
-
*
|
|
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
|
|
49
|
-
*
|
|
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
|
|
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
|
|
93
|
-
`
|
|
94
|
-
`The
|
|
95
|
-
|
|
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
|
-
/**
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
|
|
119
|
-
|
|
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
|
|
122
|
-
`
|
|
123
|
-
`to
|
|
124
|
-
`
|
|
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
|
}
|