@fungi.computer/mycelium 0.1.0 → 0.2.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.
package/README.md CHANGED
@@ -102,6 +102,33 @@ Access checks and authorization gates run before the operation. Product
102
102
  membership, resource ownership and final external access checks remain host
103
103
  responsibilities.
104
104
 
105
+ Each `execute` call runs in a fresh sandbox, not a REPL: no variable, function
106
+ or global survives between calls. Redefine helpers in every call, or persist
107
+ state to a file and read it back.
108
+
109
+ ### Operation identity within execute
110
+
111
+ Every registered-operation call receives a frozen `InvocationContext` with a
112
+ zero-based `operationOrdinal`. One counter spans all registered methods and
113
+ modules in an execute. Allocation happens synchronously when the binding is
114
+ called, before asynchronous admission or operation work, so sequential calls and
115
+ concurrently started calls have distinct positions. Forage's built-in discovery
116
+ helpers do not consume registered-operation positions.
117
+
118
+ The counter resets for each execute attempt. Replaying the same bound-call
119
+ sequence gives the same ordinals even though `executionId` is fresh. A host that
120
+ needs effect deduplication combines its stored Session and command identities
121
+ with `toolCallId` and `operationOrdinal`; an ordinal alone is not a globally
122
+ unique identity. Different call sequences are not promised identical positions,
123
+ and the effect owner must still compare the intended operation and payload on
124
+ replay. This metadata grants neither authority nor permission to replay unsafe
125
+ work.
126
+
127
+ Direct `invokeModuleOperation` callers supply their own context; outside an
128
+ execute there is no enclosing call sequence, so `operationOrdinal` may be
129
+ absent, just as `toolCallId` may be absent. A host requiring execute identity
130
+ must refuse a write with missing identity rather than invent a fallback.
131
+
105
132
  ## Access labels and grants
106
133
 
107
134
  Each operation's author labels what it does: `access: "read"` observes,
@@ -193,6 +220,44 @@ bounded `sandboxFailureMessage` projection with a reference. The host receives
193
220
  operator evidence through `onSandboxFailure` and the existing reporting path.
194
221
  Preserve that evidence without sending host diagnostics to the guest.
195
222
 
223
+ ### Sandbox admission
224
+
225
+ The host-only `execution` options accept `sandboxCapacity` (a positive safe
226
+ integer) and `admissionWaitMs` (an integer from 1 to 900,000 milliseconds,
227
+ default 5,000). Guests still submit only `code`. An omitted capacity derives
228
+ from available cores, clamped to 2..32; hosts without a portable core
229
+ observation use 2. Node captures its affinity-aware core count once per runtime
230
+ through `node:os.availableParallelism()`, behind a platform-conditional module
231
+ outside the Workers import graph. Explicit valid capacity overrides bypass
232
+ observation and are not clamped, including values above 32.
233
+
234
+ `normalizeSandboxAdmissionPolicy(options, availableCores?)` validates and
235
+ resolves these host facts. It does not reserve a slot or start a sandbox. Both
236
+ public runtime facades validate and capture the policy when the runtime opens.
237
+
238
+ Admission belongs to the existing runtime-wide backing-work tracker, not each
239
+ lease, group, or revision. A full pool waits until a slot opens or the earlier
240
+ of the admission-wait deadline and the **original invocation deadline**.
241
+ Admission-wait expiry yields `SandboxBusy`, stable code `sandbox-busy`, with
242
+ safe `capacity` and `waitedMs` facts; the submitted program has not run.
243
+ Invocation-deadline expiry remains `DeadlineExceeded`, including when both
244
+ deadlines coincide. Caller cancellation remains `Cancelled`. Queueing does not
245
+ restart the execution timeout. No automatic replay is authorized.
246
+
247
+ The runtime uses one maintained Effect semaphore across source revisions and
248
+ interruptible deadline APIs. Queued work is registered before waiting.
249
+ Cancellation removes queued work; no expired/cancelled waiter may start later. A
250
+ permit remains owned until actual backing settlement, including after an
251
+ unacknowledged abort. Waiting does not hold the lifecycle/revision gate needed
252
+ for release or close.
253
+
254
+ `SandboxBusy` is admitted by the existing owned-error decoder, safe transport,
255
+ host wire and operator-event grammar; it is contention, not an `Unknown` health
256
+ alert. The execute tool retains its safe typed cause and an actionable coded
257
+ message without changing Mule's error contract. Unrelated foreign failures stay
258
+ `Unknown`: do not classify diagnostic prose, unwrap arbitrary cause chains, or
259
+ mislabel an arbitrary adapter `AbortError` as caller-authorized cancellation.
260
+
196
261
  An Effect host with a Promise sandbox uses `fromTypedSandbox`, which waits for
197
262
  that Promise to settle when interrupted. The concrete sandbox and broker decide
198
263
  how backing work is stopped. Hosts supply the Worker Loader binding and
@@ -287,13 +352,11 @@ grants. The MCP server independently checks the stored principal. The Session's
287
352
  `call` must carry `McpExecutionContext.access` into its ordinary Mycelium
288
353
  execution, including direct tools, without persisting request callbacks.
289
354
 
290
- MCP execution requires approval for every write-level operation not covered by
291
- the catalog snapshot's `approvedWrites`, including unlabelled operations. Read
292
- operations retain their declared confirmation policy. `approvedWrites` names
293
- granted modules whose writes a person already approved for that principal; it is
294
- read from the current snapshot at each call, and an operation labelled `confirm`
295
- still asks. Form elicitation runs before the operation, and authority is
296
- refreshed again after acceptance; missing or declined approval refuses
355
+ MCP does not force confirmation for every write. The host's durable approval
356
+ owner decides whether a write applies, waits for approval or is denied; the
357
+ catalog's grants remain authority ceilings, not approval decisions. Explicit
358
+ operation-level confirmation labels still use form elicitation and refresh
359
+ authority after acceptance. Missing or declined explicit confirmation refuses
297
360
  execution. The server supports MCP `2025-11-25` Streamable HTTP so an approval
298
361
  can suspend the existing execution without replaying already-completed
299
362
  operations. Approval requests contain the module, operation and access label,
package/dist/access.d.ts CHANGED
@@ -65,7 +65,8 @@ export type AccessRefusal = Readonly<{
65
65
  * Host-supplied access data and the one way to ask a person. Every field is
66
66
  * optional: hosted defaults allow unnamed modules, and no `confirm` means an
67
67
  * operation requiring approval is refused, never run unasked. MCP catalogs
68
- * explicitly default unnamed modules to none and require approval for writes.
68
+ * explicitly default unnamed modules to none. Native hosts own durable approval
69
+ * policy for admitted writes; explicit author confirmation still uses this port.
69
70
  */
70
71
  export type AccessOptions = Readonly<{
71
72
  /** Grant for unnamed modules; MCP catalogs use none. Defaults to write for hosted callers. */
@@ -87,12 +88,6 @@ export type AccessOptions = Readonly<{
87
88
  beforeCall?: (signal: AbortSignal) => Promise<void>;
88
89
  /** Require approval for every write, including operations without an explicit confirm label. */
89
90
  confirmWrites?: boolean;
90
- /**
91
- * Read, at every call, the module ids whose writes a person already
92
- * approved for this caller. Such a write needs no per-call `confirm`; an
93
- * operation its author labelled `confirm` still asks, and grants still apply.
94
- */
95
- approvedWrites?: (context: AccessContext) => readonly string[];
96
91
  /** Ask a person to approve one call; resolve true to run it. */
97
92
  confirm?: ((request: ConfirmRequest, signal: AbortSignal) => Promise<boolean>) | undefined;
98
93
  /** Operator-only observer of every refusal; never changes the decision. */
package/dist/access.js CHANGED
@@ -100,9 +100,6 @@ async function decide(options, context, label, input, signal) {
100
100
  if (!label.confirm &&
101
101
  !(options.confirmWrites === true && label.access !== "read"))
102
102
  return undefined;
103
- if (!label.confirm &&
104
- options.approvedWrites?.(context).includes(label.module) === true)
105
- return undefined;
106
103
  if (options.confirm === undefined)
107
104
  return refusal("confirmation-unavailable", label);
108
105
  const approved = await options.confirm({
@@ -6,13 +6,14 @@ import type { CompiledOperation } from "./compiler.js";
6
6
  import { type BackingWorkGroup, type SerializedSandboxValue } from "./internal/execution-lifecycle.js";
7
7
  import { type MyceliumError } from "./errors.js";
8
8
  import type { EffectSandbox } from "./runtime.js";
9
- /** Host-selectable execution limits; neither budget can be disabled. */
9
+ import type { SandboxAdmissionOptions } from "./sandbox-admission.js";
10
+ /** Host execution limits plus the reserved, host-only admission contract. */
10
11
  export type ExecutionOptions = Readonly<{
11
12
  /** Total execution deadline in milliseconds. */
12
13
  timeoutMs?: number;
13
14
  /** Maximum acknowledgement wait after cancellation in milliseconds. */
14
15
  abortGraceMs?: number;
15
- }>;
16
+ }> & SandboxAdmissionOptions;
16
17
  /** The Effect-owned execute implementation shared by both public projections. */
17
18
  export type ExecuteDoor = Readonly<{
18
19
  readonly effectTool: MuleTool;
@@ -1,18 +1,20 @@
1
1
  import { isSandboxFailure } from "./sandbox-boundary.js";
2
- import { ResultTooLarge, sandboxFailureMessage } from "./sandbox-failure.js";
2
+ import { ResultTooLarge, SandboxBusy, sandboxFailureMessage, } from "./sandbox-failure.js";
3
3
  import { nanoid } from "@fungi.computer/cairn";
4
4
  import { forageDescribeInput, forageSearchInput, forageListInput, } from "@fungi.computer/forage";
5
5
  import { Type, } from "@fungi.computer/mule/effect";
6
6
  import * as Effect from "effect/Effect";
7
+ import * as Fiber from "effect/Fiber";
7
8
  import * as Schema from "effect/Schema";
8
9
  import * as Stream from "effect/Stream";
9
10
  import { checkAccess } from "./access.js";
10
11
  import { normalizeExecutionPolicy, runExecution, } from "./internal/execution-lifecycle.js";
11
12
  import { ImageCollector } from "./image-collector.js";
13
+ import { OutputCollector } from "./output-collector.js";
12
14
  import { InvalidOperationInput } from "./errors.js";
13
15
  /** The one outer Mule input authority for the Mycelium execute door. */
14
16
  const ExecuteInputSchema = Schema.Struct({ code: Schema.String });
15
- const executeDescription = "Run async JavaScript in the sandbox. To enumerate all available operations, use forage.list({limit:50}) and follow nextCursor until null; optionally filter by namespace. Use await forage.search({ query: ... }) with short operation keywords, such as read or shell. Search matches operation names and descriptions; if no results match, retry with one relevant keyword. Then use await forage.describe({ path: ... }) to inspect the input before calling an operation. Describe known paths directly. For multiline shell commands, put the command in a JavaScript template literal, for example const command = `pwd\\nprintf done`; a physical newline inside a double-quoted JavaScript string is invalid source. Code is evaluated as JavaScript before shell commands run: if a shell command needs a backslash, write it doubled in the JavaScript string (for example, `\\\\(` passes `\\(` to the shell; `\\(` becomes `(`). For file edits, prefer Files operations when available. Explicitly return the final value. For shell or process results, inspect status, exitCode, and stderr when present before treating the operation as successful. Each execute call runs in a fresh sandbox: no variable, function, or other global survives from a previous call, so redefine any helpers you need every time, or persist state to a file and read it back instead.";
17
+ const executeDescription = 'Run async JavaScript in the sandbox. Each call is a fresh sandbox, not a REPL: nothing survives between calls, so redefine helpers or persist state to a file. Never shadow injected module names with local bindings. Find operations with await forage.search({ query }) using one short keyword such as read or shell, or page them all with forage.list({limit:50}), optionally by namespace, until nextCursor is null. Inspect input with await forage.describe({ path }) before calling; describe known paths directly. Call an operation as await env.<path>({ ... }) with the exact dotted path, for example await env.computers.local.exec({ command: "pwd" }); a bare computers.local.exec is not defined. Put multiline shell commands in a template literal. Code is JavaScript first, so double each backslash the shell needs (`\\\\(` passes `\\(`; `\\(` becomes `(`). For file edits, prefer Files operations when available. Explicitly return the final value. Before treating a shell or process result as success, check status, exitCode and stderr.';
16
18
  /** One bound operation: its labels are checked against the host's grants first. */
17
19
  function guardedInvoke(entry, context, access, sessionId) {
18
20
  const label = {
@@ -27,13 +29,19 @@ function guardedInvoke(entry, context, access, sessionId) {
27
29
  };
28
30
  }
29
31
  function operationBindings(operations, context, access, sessionId) {
32
+ // One counter per execute, shared by every bound method. Allocate before
33
+ // awaiting access or operation work, not in asynchronous completion order.
34
+ let nextOperationOrdinal = 0;
30
35
  const groups = new Map();
31
36
  for (const entry of operations) {
32
37
  const namespace = entry.namespace;
33
38
  const operationName = entry.name;
34
39
  const group = groups.get(namespace) ??
35
40
  new Map();
36
- group.set(operationName, guardedInvoke(entry, context, access, sessionId));
41
+ group.set(operationName, (input) => guardedInvoke(entry, Object.freeze({
42
+ ...context,
43
+ operationOrdinal: nextOperationOrdinal++,
44
+ }), access, sessionId)(input));
37
45
  groups.set(namespace, group);
38
46
  }
39
47
  return Object.freeze(Object.fromEntries([...groups].map(([namespace, operations]) => [
@@ -65,9 +73,10 @@ function bindings(operations, catalog, context, access, sessionId) {
65
73
  export function createExecuteDoor(operations, catalog, sandbox, backingWork, modules, namespacePaths, sessionId, access) {
66
74
  const policy = sandbox.execution ?? normalizeExecutionPolicy(undefined);
67
75
  const imageCollector = new ImageCollector();
68
- const run = (executionId, code, signal, toolCallId) => runExecution({
76
+ const run = (executionId, code, signal, toolCallId, output) => runExecution({
69
77
  executionId,
70
78
  toolCallId,
79
+ output,
71
80
  images: Object.freeze({
72
81
  push: (image) => imageCollector.push(executionId, image),
73
82
  }),
@@ -92,10 +101,10 @@ export function createExecuteDoor(operations, catalog, sandbox, backingWork, mod
92
101
  * The Mule projection: after the run, drain the collector once and append
93
102
  * its images after the text, on the oversized-result path too.
94
103
  */
95
- const executeTerminal = (code, toolCallId) => Effect.suspend(() => {
104
+ const executeTerminal = (code, toolCallId, output) => Effect.suspend(() => {
96
105
  const executionId = nanoid();
97
106
  const drain = () => imageCollector.drain(executionId);
98
- return run(executionId, code, undefined, toolCallId).pipe(Effect.matchEffect({
107
+ return run(executionId, code, undefined, toolCallId, output).pipe(Effect.matchEffect({
99
108
  onSuccess: (value) => Effect.succeed(terminal({ text: value.text, isError: false }, drain())),
100
109
  onFailure: (error) => {
101
110
  const images = drain();
@@ -117,7 +126,26 @@ export function createExecuteDoor(operations, catalog, sandbox, backingWork, mod
117
126
  parameters: Type.Object({ code: Type.String() }, { additionalProperties: false }),
118
127
  },
119
128
  prepare: (call) => Schema.decodeUnknownEffect(ExecuteInputSchema)(call.arguments).pipe(Effect.map(({ code }) => ({
120
- execute: () => Stream.fromEffect(executeTerminal(code, call.id)),
129
+ execute: () => Stream.unwrap(Effect.gen(function* () {
130
+ const collector = yield* OutputCollector.make();
131
+ yield* Effect.addFinalizer(() => Effect.sync(() => {
132
+ collector.dispose();
133
+ }));
134
+ // Start execution in this stream's scope before starting readers:
135
+ // admission/deadlines must not wait for an output subscriber.
136
+ const execution = yield* Effect.forkScoped(executeTerminal(code, call.id, {
137
+ write: (stream, bytes) => {
138
+ collector.write(stream, bytes);
139
+ },
140
+ }), { startImmediately: true });
141
+ return Stream.merge(collector.updates, Stream.fromEffect(Fiber.await(execution)).pipe(Stream.flatMap((exit) => {
142
+ const finalOutput = collector.finish();
143
+ // Observe completion as data so diagnostics precede either
144
+ // outcome. Re-yield the original Exit (including its Cause);
145
+ // Mule alone projects failures into error terminal results.
146
+ return Stream.concat(Stream.fromIterable(finalOutput === undefined ? [] : [finalOutput]), Stream.fromEffect(exit));
147
+ })), { haltStrategy: "right" }).pipe(Stream.takeUntil((part) => part.type === "result"));
148
+ })),
121
149
  })), Effect.mapError(() => ({ message: "Invalid execute arguments" }))),
122
150
  };
123
151
  return Object.freeze({ effectTool, execute });
@@ -126,7 +154,7 @@ export function createExecuteDoor(operations, catalog, sandbox, backingWork, mod
126
154
  * Project one finished run into Mule's only terminal part. Each collected
127
155
  * image becomes an {@link ImageContent} part after the text, the same
128
156
  * `MuleToolResult.content` every other tool result uses; its path, size and
129
- * type go into `details`.
157
+ * type go into application `data`, separate from the model content.
130
158
  */
131
159
  function terminal(head, collected) {
132
160
  const images = collected.map((image) => ({
@@ -140,7 +168,7 @@ function terminal(head, collected) {
140
168
  ];
141
169
  if (collected.length === 0)
142
170
  return { type: "result", result: { content, isError: head.isError } };
143
- const details = {
171
+ const data = {
144
172
  images: collected.map((image) => ({
145
173
  bytes: image.bytes,
146
174
  mimeType: image.mimeType,
@@ -149,15 +177,16 @@ function terminal(head, collected) {
149
177
  };
150
178
  return {
151
179
  type: "result",
152
- result: { content, details, isError: head.isError },
180
+ result: { content, data, isError: head.isError },
153
181
  };
154
182
  }
155
183
  function toMuleError(error) {
156
- return {
184
+ const result = {
157
185
  _tag: "MuleError",
158
186
  phase: "tool",
159
187
  message: isSandboxFailure(error)
160
188
  ? sandboxFailureMessage(error)
161
189
  : error.message,
162
190
  };
191
+ return error instanceof SandboxBusy ? { ...result, cause: error } : result;
163
192
  }
@@ -18,7 +18,7 @@ export type CollectedImage = Readonly<{
18
18
  /** Base64-encoded image bytes. */
19
19
  data: string;
20
20
  /** MIME type of the collected image. */
21
- mimeType: "image/jpeg" | "image/png" | "image/webp";
21
+ mimeType: "image/jpeg" | "image/png" | "image/gif" | "image/webp";
22
22
  /** Path the image was read from. */
23
23
  path: string;
24
24
  /** Byte length of the collected image payload. */
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { sandboxFailure, isSandboxFailure, sandboxOperatorValue, } from "./sandbox-boundary.js";
2
2
  import type { ForageCatalog } from "@fungi.computer/forage";
3
+ export { normalizeSandboxAdmissionPolicy, type SandboxAdmissionOptions, type SandboxAdmissionPolicy, } from "./sandbox-admission.js";
3
4
  import type { MuleTool } from "@fungi.computer/mule/effect";
4
5
  import type { Sandbox, MyceliumJsonValue } from "./sandbox.js";
5
6
  export type { MyceliumJsonValue, SandboxValue, SandboxBinding, SandboxRequest, Sandbox, } from "./sandbox.js";
@@ -13,7 +14,7 @@ import type { Access, AccessOptions, LabelledTool } from "./access.js";
13
14
  import { type ModuleRegistration, type OperationGate, type OperationRegistration } from "./registration.js";
14
15
  export type { CapabilitySession, CapabilitySource, CapabilitySourceAcquisition, CapabilitySourceContext, CapabilitySourceSnapshot, } from "./capability-source.js";
15
16
  export { InvalidOperationInput, InvalidOperationOutput, MyceliumClosedError, MyceliumDefinitionError, MyceliumSourceError, type MyceliumError, type MyceliumSourcePhase, } from "./errors.js";
16
- export { SourceInvalid, GuestThrew, DeadlineExceeded, Cancelled, DispatchLost, SandboxCrashed, BadResponse, ResultTooLarge, ResultNotJson, CapabilityFailed, Unknown, sandboxFailureMessage, type SandboxFailure, type CapabilityDiagnostic, type CapabilityFailureFields, } from "./sandbox-failure.js";
17
+ export { SourceInvalid, GuestThrew, DeadlineExceeded, SandboxBusy, Cancelled, DispatchLost, SandboxCrashed, BadResponse, ResultTooLarge, ResultNotJson, CapabilityFailed, Unknown, sandboxFailureMessage, type SandboxFailure, type CapabilityDiagnostic, type CapabilityFailureFields, } from "./sandbox-failure.js";
17
18
  export { SandboxFailureEventSchema, type SandboxFailureEvent, } from "./sandbox-failure-event.js";
18
19
  export type { ExecutionOptions } from "./execute-tool.js";
19
20
  export { GrantSchema, ToolAccessSchema, ToolConfirmSchema, ToolPolicySchema, } from "./access.js";
@@ -27,6 +28,13 @@ export type InvocationContext = Readonly<{
27
28
  executionId: string;
28
29
  /** Enclosing model tool call; absent for direct operation/execute calls. */
29
30
  toolCallId?: string;
31
+ /**
32
+ * Zero-based registered-operation call position within this execute.
33
+ * Assigned before asynchronous admission, across all methods and modules.
34
+ * Replaying the same call sequence produces the same positions. Absent
35
+ * outside execute-bound invocation; not an authorization or replay grant.
36
+ */
37
+ operationOrdinal?: number;
30
38
  /** Enclosing sandbox program supplied by the host; never an operation argument. */
31
39
  toolCallCode?: string;
32
40
  /** Absolute epoch-millisecond execute deadline; null for direct calls outside execute. */
@@ -40,6 +48,11 @@ export type InvocationContext = Readonly<{
40
48
  * where no tool result would carry the image.
41
49
  */
42
50
  images?: ExecutionImages;
51
+ /** Process-local stdout/stderr tail sink; absent outside an execute tool run. */
52
+ output?: Readonly<{
53
+ /** Append raw bytes to the execution-owned bounded stdout/stderr tail. */
54
+ write: (stream: "stdout" | "stderr", bytes: Uint8Array) => void;
55
+ }>;
43
56
  }>;
44
57
  /** Inputs used to construct the Promise-facing runtime. */
45
58
  export type MyceliumOptions = Readonly<{
package/dist/index.js CHANGED
@@ -1,8 +1,9 @@
1
1
  export { sandboxFailure, isSandboxFailure, sandboxOperatorValue, } from "./sandbox-boundary.js";
2
+ export { normalizeSandboxAdmissionPolicy, } from "./sandbox-admission.js";
2
3
  import { openPromiseRuntime } from "./promise-facade.js";
3
4
  import { defineModule, defineOperation, gateModule, grantModuleAs, } from "./registration.js";
4
5
  export { InvalidOperationInput, InvalidOperationOutput, MyceliumClosedError, MyceliumDefinitionError, MyceliumSourceError, } from "./errors.js";
5
- export { SourceInvalid, GuestThrew, DeadlineExceeded, Cancelled, DispatchLost, SandboxCrashed, BadResponse, ResultTooLarge, ResultNotJson, CapabilityFailed, Unknown, sandboxFailureMessage, } from "./sandbox-failure.js";
6
+ export { SourceInvalid, GuestThrew, DeadlineExceeded, SandboxBusy, Cancelled, DispatchLost, SandboxCrashed, BadResponse, ResultTooLarge, ResultNotJson, CapabilityFailed, Unknown, sandboxFailureMessage, } from "./sandbox-failure.js";
6
7
  export { SandboxFailureEventSchema, } from "./sandbox-failure-event.js";
7
8
  export { GrantSchema, ToolAccessSchema, ToolConfirmSchema, ToolPolicySchema, } from "./access.js";
8
9
  export { MAX_EXECUTION_IMAGES, } from "./image-collector.js";
@@ -2,7 +2,7 @@ import { execFileSync } from "node:child_process";
2
2
  import { URL } from "node:url";
3
3
  // Node-only variant of build-identity.ts, selected only by the package's
4
4
  // `#build-identity` internal import (package.json `imports`, `node`
5
- // condition) -- a literal Node process (apps/exec, services/agent-host,
5
+ // condition) -- a literal Node process (apps/exec,
6
6
  // runtimes/local's non-bundled dist, this package's own tests) always
7
7
  // activates that condition; no bundler build target in this repo does
8
8
  // (demo and the Electron probe build with esbuild `platform: "browser"`;
@@ -1,12 +1,13 @@
1
1
  import { type SandboxFailure } from "../sandbox-failure.js";
2
2
  import * as Effect from "effect/Effect";
3
3
  import * as Fiber from "effect/Fiber";
4
+ import { type SandboxAdmissionPolicy } from "../sandbox-admission.js";
4
5
  import type { ExecutionOptions } from "../execute-tool.js";
5
6
  import { type MyceliumError } from "../errors.js";
6
7
  import type { InvocationContext, SandboxValue } from "../index.js";
7
8
  import type { ExecutionImages } from "../image-collector.js";
8
9
  import type { EffectSandbox } from "../runtime.js";
9
- export type ExecutionPolicy = Readonly<{
10
+ export type ExecutionPolicy = SandboxAdmissionPolicy & Readonly<{
10
11
  readonly timeoutMs: number;
11
12
  readonly abortGraceMs: number;
12
13
  }>;
@@ -18,7 +19,7 @@ export type BackingWorkTracker = Readonly<{
18
19
  readonly createGroup: () => BackingWorkGroup;
19
20
  }>;
20
21
  export declare function normalizeExecutionPolicy(value: ExecutionOptions | undefined): ExecutionPolicy;
21
- export declare function createBackingWorkTracker(): BackingWorkTracker;
22
+ export declare function createBackingWorkTracker(capacity: number): Effect.Effect<BackingWorkTracker>;
22
23
  export type SerializedSandboxValue = Readonly<{
23
24
  readonly text: string;
24
25
  }>;
@@ -29,6 +30,7 @@ export type ExecutionLifecycleRequest = Readonly<{
29
30
  readonly executionId: string;
30
31
  /** This run's slot in the door's collector, exposed through the context. */
31
32
  readonly images: ExecutionImages;
33
+ readonly output?: InvocationContext["output"];
32
34
  readonly code: string;
33
35
  readonly callerSignal: AbortSignal | undefined;
34
36
  readonly policy: ExecutionPolicy;
@@ -1,27 +1,28 @@
1
1
  import { sandboxFailure, isSandboxFailure, sandboxOperatorValue, sandboxValueKind, } from "../sandbox-boundary.js";
2
2
  import { sandboxBuildSha } from "#build-identity";
3
+ import { availableHostCores } from "#host-cores";
3
4
  import * as Metric from "effect/Metric";
4
- import { referenceSandboxFailure, DeadlineExceeded, Cancelled, ResultTooLarge, ResultNotJson, CapabilityFailed, } from "../sandbox-failure.js";
5
+ import { referenceSandboxFailure, DeadlineExceeded, Cancelled, ResultTooLarge, ResultNotJson, CapabilityFailed, SandboxBusy, } from "../sandbox-failure.js";
5
6
  import { ForageError } from "@fungi.computer/forage";
6
7
  import * as Cause from "effect/Cause";
7
8
  import * as Clock from "effect/Clock";
8
9
  import * as Effect from "effect/Effect";
9
10
  import * as Fiber from "effect/Fiber";
11
+ import * as Semaphore from "effect/Semaphore";
10
12
  import { z } from "zod";
13
+ import { normalizeSandboxAdmissionPolicy, sandboxAdmissionOptionsSchema, } from "../sandbox-admission.js";
11
14
  import { InvalidOperationInput, MyceliumDefinitionError, } from "../errors.js";
12
15
  const jsonValueSchema = z.json();
13
16
  const jsonScalarSchema = z.lazy(() => z.union([z.null(), z.string(), z.boolean(), z.number()]));
14
17
  const jsonRecordSchema = z.lazy(() => z.record(z.string(), z.unknown()));
15
- const executionOptionsSchema = z.lazy(() => z
16
- .object({
18
+ const executionOptionsSchema = z.lazy(() => sandboxAdmissionOptionsSchema
19
+ .extend({
17
20
  timeoutMs: z.number().int().min(1).max(900_000).optional(),
18
21
  abortGraceMs: z.number().int().min(1).max(30_000).optional(),
19
22
  })
20
- .strict()
21
23
  .optional());
22
24
  const maximumJsonDepth = 64;
23
25
  const maximumSerializedJsonBytes = 256 * 1024;
24
- const maximumBackingExecutions = 8;
25
26
  const utf8 = new TextEncoder();
26
27
  const resumeIdle = (notify) => {
27
28
  notify(Effect.succeed(undefined));
@@ -34,50 +35,44 @@ export function normalizeExecutionPolicy(value) {
34
35
  return Object.freeze({
35
36
  timeoutMs: parsed.data?.timeoutMs ?? 180_000,
36
37
  abortGraceMs: parsed.data?.abortGraceMs ?? 5_000,
38
+ ...normalizeSandboxAdmissionPolicy({
39
+ sandboxCapacity: parsed.data?.sandboxCapacity,
40
+ admissionWaitMs: parsed.data?.admissionWaitMs,
41
+ }, parsed.data?.sandboxCapacity === undefined
42
+ ? availableHostCores()
43
+ : undefined),
37
44
  });
38
45
  }
39
- export function createBackingWorkTracker() {
40
- let active = 0;
41
- return Object.freeze({
42
- createGroup: () => {
43
- let groupActive = 0;
44
- const waiters = new Set();
45
- const awaitIdle = Effect.callback((resume) => {
46
- if (groupActive === 0) {
47
- resume(Effect.succeed(undefined));
48
- return Effect.sync(() => undefined);
49
- }
50
- waiters.add(resume);
51
- return Effect.sync(() => waiters.delete(resume));
52
- });
53
- return Object.freeze({
54
- start: (effect) => Effect.uninterruptibleMask((restore) => Effect.sync(() => {
55
- if (active >= maximumBackingExecutions)
56
- return false;
57
- active += 1;
58
- groupActive += 1;
59
- return true;
60
- }).pipe(Effect.flatMap((reserved) => reserved
61
- ? Effect.forkDetach(restore(effect).pipe(Effect.ensuring(Effect.sync(() => {
62
- active -= 1;
46
+ export function createBackingWorkTracker(capacity) {
47
+ return Effect.gen(function* () {
48
+ const permits = yield* Semaphore.make(capacity);
49
+ return Object.freeze({
50
+ createGroup: () => {
51
+ let groupActive = 0;
52
+ const waiters = new Set();
53
+ const awaitIdle = Effect.callback((resume) => {
54
+ if (groupActive === 0) {
55
+ resume(Effect.succeed(undefined));
56
+ return Effect.sync(() => undefined);
57
+ }
58
+ waiters.add(resume);
59
+ return Effect.sync(() => waiters.delete(resume));
60
+ });
61
+ return Object.freeze({
62
+ start: (effect) => Effect.uninterruptibleMask((restore) => Effect.sync(() => {
63
+ groupActive += 1;
64
+ }).pipe(Effect.andThen(Effect.forkDetach(restore(permits.withPermits(1)(effect)).pipe(Effect.ensuring(Effect.sync(() => {
63
65
  groupActive -= 1;
64
66
  if (groupActive === 0) {
65
67
  const pending = [...waiters];
66
68
  waiters.clear();
67
69
  pending.forEach(resumeIdle);
68
70
  }
69
- }))))
70
- : Effect.fail(new CapabilityFailed({
71
- namespace: "execute",
72
- operation: "admission",
73
- cause: {
74
- kind: "Unavailable",
75
- message: "Sandbox execution capacity unavailable",
76
- },
77
- }))))),
78
- awaitIdle,
79
- });
80
- },
71
+ }))))))),
72
+ awaitIdle,
73
+ });
74
+ },
75
+ });
81
76
  });
82
77
  }
83
78
  function normalizeJson(value, depth = 0) {
@@ -178,13 +173,25 @@ export function runExecution(request) {
178
173
  return Clock.currentTimeMillis.pipe(Effect.flatMap((startedAt) => Effect.suspend(() => {
179
174
  const executionId = request.executionId;
180
175
  const controller = new AbortController();
176
+ let queuedAt = startedAt;
177
+ let admission = {
178
+ kind: "waiting",
179
+ };
181
180
  let cancellation;
182
181
  const cancel = (phase) => {
183
- if (cancellation === undefined) {
182
+ if (cancellation === undefined && admission.kind !== "refused") {
184
183
  cancellation = phase;
185
184
  controller.abort();
186
185
  }
187
186
  };
187
+ const refuseAdmission = (now) => {
188
+ const failure = new SandboxBusy({
189
+ capacity: request.policy.capacity,
190
+ waitedMs: Math.max(0, now - queuedAt),
191
+ });
192
+ admission = { kind: "refused", failure };
193
+ return failure;
194
+ };
188
195
  const admit = () => {
189
196
  if (controller.signal.aborted)
190
197
  throw executionFailure(cancellation, request.policy.timeoutMs);
@@ -192,18 +199,28 @@ export function runExecution(request) {
192
199
  const detachCaller = forwardAbort(request.callerSignal, () => {
193
200
  cancel("cancelled");
194
201
  });
202
+ const deadlineAt = startedAt + request.policy.timeoutMs;
195
203
  const invocation = {
196
204
  executionId,
197
- deadlineAt: startedAt + request.policy.timeoutMs,
205
+ deadlineAt,
198
206
  signal: controller.signal,
199
207
  images: request.images,
200
208
  };
201
209
  if (request.toolCallId !== undefined)
202
210
  Object.assign(invocation, { toolCallId: request.toolCallId });
211
+ if (request.output !== undefined)
212
+ Object.assign(invocation, { output: request.output });
203
213
  const context = Object.freeze(invocation);
204
- const sandboxExecution = Effect.suspend(() => {
214
+ const sandboxExecution = Effect.suspend(() => Clock.currentTimeMillis.pipe(Effect.flatMap((now) => {
215
+ if (admission.kind === "refused")
216
+ return Effect.fail(admission.failure);
217
+ if (now >= deadlineAt)
218
+ cancel("deadline_exceeded");
205
219
  if (controller.signal.aborted)
206
220
  return elapsedSince(startedAt).pipe(Effect.flatMap((elapsedMs) => Effect.fail(executionFailure(cancellation, request.policy.timeoutMs, elapsedMs))));
221
+ if (now >= queuedAt + request.policy.waitMs)
222
+ return Effect.fail(refuseAdmission(now));
223
+ admission = { kind: "admitted" };
207
224
  return request.sandbox
208
225
  .execute(Object.freeze({
209
226
  executionId,
@@ -221,7 +238,7 @@ export function runExecution(request) {
221
238
  .pipe(Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause)
222
239
  ? Effect.failCause(cause)
223
240
  : Effect.fail(sandboxFailure(Cause.squash(cause)))));
224
- });
241
+ })));
225
242
  const interruptBacking = (child) => Fiber.interrupt(child).pipe(Effect.timeoutOrElse({
226
243
  duration: request.policy.abortGraceMs,
227
244
  orElse: () => Effect.fail(cancellation === "deadline_exceeded"
@@ -280,8 +297,39 @@ export function runExecution(request) {
280
297
  const elapsedMs = yield* elapsedSince(startedAt);
281
298
  return yield* Effect.fail(executionFailure(cancellation, request.policy.timeoutMs, elapsedMs));
282
299
  }
300
+ queuedAt = yield* Clock.currentTimeMillis;
283
301
  child = yield* request.backingWork.start(sandboxExecution);
284
- const first = yield* Effect.raceFirst(Fiber.await(child).pipe(Effect.map((exit) => Object.freeze({ kind: "settled", exit }))), Effect.raceFirst(Effect.sleep(request.policy.timeoutMs).pipe(Effect.as(Object.freeze({ kind: "deadline" }))), waitForAbort(controller.signal).pipe(Effect.as(Object.freeze({ kind: "cancelled" })))));
302
+ const now = yield* Clock.currentTimeMillis;
303
+ const capacityWait = Effect.sleep(Math.max(0, queuedAt + request.policy.waitMs - now)).pipe(Effect.andThen(Clock.currentTimeMillis), Effect.flatMap((at) => {
304
+ if (admission.kind === "admitted")
305
+ return Effect.never;
306
+ if (admission.kind === "refused")
307
+ return Effect.succeed({
308
+ kind: "busy",
309
+ failure: admission.failure,
310
+ });
311
+ if (controller.signal.aborted)
312
+ return Effect.succeed({ kind: "cancelled" });
313
+ if (at >= deadlineAt) {
314
+ cancel("deadline_exceeded");
315
+ return Effect.succeed({ kind: "deadline" });
316
+ }
317
+ return Effect.succeed({
318
+ kind: "busy",
319
+ failure: refuseAdmission(at),
320
+ });
321
+ }));
322
+ const first = yield* Effect.raceFirst(Fiber.await(child).pipe(Effect.map((exit) => Object.freeze({ kind: "settled", exit }))), Effect.raceFirst(Effect.sleep(Math.max(0, deadlineAt - now)).pipe(Effect.andThen(Effect.sync(() => {
323
+ cancel("deadline_exceeded");
324
+ return Object.freeze({ kind: "deadline" });
325
+ }))), Effect.raceFirst(waitForAbort(controller.signal).pipe(Effect.as(Object.freeze({ kind: "cancelled" }))), capacityWait)));
326
+ const refusal = admission.kind === "refused"
327
+ ? admission.failure
328
+ : first.kind === "busy"
329
+ ? first.failure
330
+ : undefined;
331
+ if (refusal !== undefined)
332
+ return yield* interruptBacking(child).pipe(Effect.andThen(Effect.fail(refusal)));
285
333
  if (first.kind === "settled") {
286
334
  return yield* Effect.matchCauseEffectEager(first.exit, {
287
335
  onFailure: (cause) =>
@@ -0,0 +1,2 @@
1
+ /** Workers and browser hosts have no portable core observation. */
2
+ export declare function availableHostCores(): number | undefined;
@@ -0,0 +1,4 @@
1
+ /** Workers and browser hosts have no portable core observation. */
2
+ export function availableHostCores() {
3
+ return undefined;
4
+ }
@@ -0,0 +1,2 @@
1
+ /** Node's maintained, affinity-aware observation; captured by runtime policy. */
2
+ export declare function availableHostCores(): number;