pi-supernova 0.8.2 → 0.9.0

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 (66) hide show
  1. package/README.md +188 -51
  2. package/docs/CHANGELOG.md +86 -1
  3. package/docs/TOKEN_COSTS.md +38 -0
  4. package/index.js +10 -175
  5. package/package.json +2 -1
  6. package/src/adapters/bash.js +14 -30
  7. package/src/adapters/errors.js +1 -9
  8. package/src/adapters/read-focus.js +98 -0
  9. package/src/adapters/read-image.js +51 -0
  10. package/src/adapters/read-json.js +42 -0
  11. package/src/adapters/read-text.js +71 -0
  12. package/src/adapters/read.js +66 -635
  13. package/src/bridge/catalog.js +3 -2
  14. package/src/bridge/host-bridge.js +35 -167
  15. package/src/bridge/tool-registry.js +104 -0
  16. package/src/bridge/trace.js +41 -0
  17. package/src/context/evidence-graph.js +249 -0
  18. package/src/context/evidence-rank.js +153 -0
  19. package/src/context/evidence.js +10 -424
  20. package/src/context/query.js +71 -0
  21. package/src/context/repo-index.js +8 -162
  22. package/src/context/search-files.js +19 -0
  23. package/src/context/search.js +2 -24
  24. package/src/context/snap-search.js +202 -0
  25. package/src/context/snap.js +5 -266
  26. package/src/context/source-entry.js +112 -0
  27. package/src/contract/bash.js +6 -1
  28. package/src/contract/program.js +36 -0
  29. package/src/contract/read.js +8 -53
  30. package/src/fs/check.js +1 -1
  31. package/src/fs/commit.js +161 -0
  32. package/src/fs/diff.js +11 -15
  33. package/src/fs/directory.js +79 -0
  34. package/src/fs/file-io.js +100 -0
  35. package/src/fs/glob.js +54 -0
  36. package/src/fs/json-size.js +54 -0
  37. package/src/fs/lines.js +117 -0
  38. package/src/fs/read-window.js +74 -0
  39. package/src/fs/session-resource.js +50 -0
  40. package/src/fs/text-ops.js +7 -227
  41. package/src/fs/vfs.js +5 -239
  42. package/src/fs/workspace.js +2 -1
  43. package/src/output/bottleneck.js +13 -67
  44. package/src/output/final.js +114 -0
  45. package/src/output/format.js +94 -5
  46. package/src/output/outcome.js +91 -0
  47. package/src/runtime/batch-input.js +68 -0
  48. package/src/runtime/guest-api.js +281 -0
  49. package/src/runtime/guest-worker.js +62 -333
  50. package/src/runtime/parallel.js +41 -39
  51. package/src/runtime/program-batch.js +21 -75
  52. package/src/runtime/program-file.js +3 -11
  53. package/src/runtime/program.js +141 -0
  54. package/src/runtime/reference.js +6 -5
  55. package/src/runtime/runtime.js +77 -253
  56. package/src/runtime/worker-pool.js +91 -0
  57. package/src/shared/decode.js +22 -8
  58. package/src/shared/image-worker.js +30 -0
  59. package/src/shared/image.js +78 -0
  60. package/src/shared/png.js +57 -0
  61. package/src/shared/result.js +77 -0
  62. package/src/shared/syntax-context.js +61 -3
  63. package/src/ui/host-render.js +104 -0
  64. package/src/ui/progress.js +51 -0
  65. package/src/ui/render.js +21 -421
  66. package/src/ui/trace.js +277 -0
@@ -1,14 +1,16 @@
1
+ import {buildGuestApi} from "./guest-api.js";
2
+ import {packageFinalReturn} from "../output/final.js";
1
3
  import { parentPort } from "node:worker_threads";
2
- import { AsyncLocalStorage } from "node:async_hooks";
4
+
3
5
  import * as nodeModule from "node:module";
4
- import { isString, isObject, isFunction, toPlain, looksLikePath } from "../shared/decode.js";
5
- import { truncateChars } from "../output/format.js";
6
- import { gatherReadArgs, normalizeRead, decodeReadValue, assertReadPaths } from "../contract/read.js";
7
- import { classifyEdit } from "../contract/edit.js";
8
- import { normalizeBash } from "../contract/bash.js";
6
+ import { errorMessage, isString, isObject, isFunction, toPlain } from "../shared/decode.js";
7
+ import { truncateChars,formatReturn,displayExceeds,formatBoundedValue } from "../output/format.js";
8
+
9
9
  import { guestImportMessage, isDeniedGuestImport } from "./guest-deny-imports.js";
10
10
 
11
11
  const { register, registerHooks } = nodeModule;
12
+ // heapUsed/external belong to this worker; rss includes the entire Pi/OMP host.
13
+ const memoryUsage = process.memoryUsage.bind(process);
12
14
 
13
15
  if (isFunction(registerHooks)) {
14
16
  registerHooks({
@@ -41,21 +43,17 @@ const PARAMS = ["console", "read", "edit", "write", "bash"];
41
43
 
42
44
  const BODY_LINE_OFFSET = 2;
43
45
 
44
- const QUERY_URI = /^(agent|artifact):\/\/.*\?/i;
45
-
46
- const PER_PATH_HINT = "; use Promise.allSettled(paths.map(path => read(path))) for per-path outcomes";
47
-
48
- /** Best-effort guest line:col from an error stack (V8 "<anonymous>:L:C", JSC "eval code").*/
46
+ /** Only match the guest source, never an anonymous callback in the host bridge. */
49
47
  function guestLocation(err) {
50
48
  const stack = String(err?.stack);
51
- const m = /(?:<anonymous>:|(?:eval code|anonymous)(?:@|:))(\d+):(\d+)/.exec(stack);
49
+ const m = /^\s+at (async )?[^\n]*\(supernova-guest\.js:(\d+):(\d+)\)$/m.exec(stack);
52
50
 
53
51
  if (!m) return null;
54
- const line = Number(m[1]) - BODY_LINE_OFFSET;
52
+ const line = Number(m[2]) - BODY_LINE_OFFSET;
55
53
 
56
54
  if (line < 1) return null;
57
55
 
58
- return { line, col: Number(m[2]) };
56
+ return { line, col: Number(m[3]), awaited: Boolean(m[1]) };
59
57
  }
60
58
 
61
59
  let activeRunId = 0;
@@ -70,15 +68,23 @@ function post(msg) {
70
68
  parentPort.postMessage(msg);
71
69
  }
72
70
 
73
- function callRpc(runId, method, args) {
71
+ function reportMemory(runId) {
72
+ if (!runActive || runId !== activeRunId) return;
73
+ const { heapUsed, external } = memoryUsage();
74
+ // arrayBuffers is already included in external; do not double-charge it.
75
+ post({ op: "memory", runId, heapUsed, external });
76
+ }
77
+
78
+ function callRpc(runId, method, args, onItem) {
74
79
  if (!runActive || runId !== activeRunId) return Promise.reject(new Error("program is already complete"));
75
80
 
76
81
  return new Promise((resolve, reject) => {
77
82
  const id = ++rpcSeq;
78
- pendingRpc.set(id, { resolve, reject });
83
+ pendingRpc.set(id, { resolve, reject, onItem });
79
84
 
80
85
  try {
81
- post({ op: "rpc", id, runId, method, args });
86
+ reportMemory(runId);
87
+ post({ op: "rpc", id, runId, method, args, streamRead: Boolean(onItem) });
82
88
  } catch (err) {
83
89
  pendingRpc.delete(id);
84
90
  reject(new Error("nova." + method + " arguments are not transferable: " + err?.message));
@@ -86,202 +92,25 @@ function callRpc(runId, method, args) {
86
92
  });
87
93
  }
88
94
 
89
- function unwrapValue(res) {
90
- if (res?.ok === false) throw new Error(String(res.value ?? res.error ?? "host tool failed"));
91
-
92
- if ("value" in Object(res)) return res.value;
93
-
94
- return res;
95
- }
96
-
97
- function unwrapRead(res, args) {
98
- const value = unwrapValue(res);
99
-
100
- if ((args.complete === true || args.json !== undefined) && res?.truncated) throw new Error("incomplete read: complete:true or json refuses truncated host output");
101
-
102
- return value;
103
- }
104
-
105
- /** Keep details/truncated reachable but out of the returned literal unless they carry signal. */
106
- function leanEnvelope(res) {
107
- if (!isObject(res)) return res;
108
-
109
- if ("details" in res) Object.defineProperty(res, "details", { value: res.details, enumerable: false, writable: true });
110
-
111
- for (const key of Object.keys(res)) if (res[key] === undefined) delete res[key];
112
-
113
- if (res.truncated === false) delete res.truncated;
114
-
115
- return res;
116
- }
117
-
118
- function swallow(promise) {
119
- promise.catch(() => {});
120
-
121
- return promise;
122
- }
123
-
124
- function throwReadPathError(target, detail) {
125
- throw new Error(`read failed for ${target}: ${detail}${PER_PATH_HINT}`);
126
- }
127
-
128
- function isQueryUri(item) {
129
- return QUERY_URI.test(item);
130
- }
131
-
132
- function firstItemErrorIndex(res) {
133
- return res?.itemErrors?.findIndex(error => error != null) ?? -1;
134
- }
135
-
136
- function missingResolvedIndex(values, paths) {
137
- return values.findIndex((value, index) => value?.status === "not_found" && looksLikePath(paths[index]));
138
- }
139
-
140
- async function rpcReadWave(rpc, wave) {
141
- if (wave.length === 1) {
142
- const res = leanEnvelope(await rpc("call", ["read", wave[0].args]));
143
-
144
- return { values: [unwrapRead(res, wave[0].args)], errors: [] };
145
- }
146
- const args = { ...wave[0].args, path: wave.map(job => job.args.path), _independent: true };
147
- const res = leanEnvelope(await rpc("call", ["read", args]));
148
- unwrapRead(res, args);
149
-
150
- return { values: res.items, errors: res.itemErrors ?? [] };
151
- }
152
-
153
- function settleReadWave(wave, values, errors) {
154
- if (!Array.isArray(values) || values.length !== wave.length) throw new Error("invalid batch read response");
155
-
156
- for (let i = 0; i < wave.length; i++) {
157
- if (errors[i]) wave[i].reject(new Error(errors[i]));
158
- else wave[i].resolve(values[i]);
95
+ function encodeConsoleArg(a, limit) {
96
+ if (isString(a)) {
97
+ const raw = truncateChars(a,limit,"log");
98
+ const encoded = truncateChars(formatReturn(raw.text),limit,"log");
99
+ encoded.truncated ||= raw.truncated;
100
+ return encoded;
159
101
  }
160
- }
161
-
162
- function rejectReadWave(wave, error) {
163
- for (const job of wave) job.reject(error);
164
- }
165
-
166
- function dispatchReadWaves(pending, pendingReadWaves, enqueueHost, rpc) {
167
- for (let start = 0; start < pending.length; start += 64) {
168
- const wave = pending.slice(start, start + 64);
169
- const delivery = enqueueHost(() => rpcReadWave(rpc, wave))
170
- .then(({ values, errors }) => settleReadWave(wave, values, errors))
171
- .catch(error => rejectReadWave(wave, error));
172
-
173
- pendingReadWaves.add(delivery);
174
- void delivery.finally(() => pendingReadWaves.delete(delivery));
175
- }
176
- }
177
-
178
- function enqueueCompatibleRead(readState, flushReads, args, decode) {
179
- const key = JSON.stringify({ ...args, path: undefined });
180
-
181
- if (readState.queued.length && readState.queued[0].key !== key) flushReads();
182
-
183
- const promise = new Promise((resolve, reject) => {
184
- readState.queued.push({ args, key, resolve, reject });
185
-
186
- if (readState.queued.length === 1) queueMicrotask(flushReads);
187
- }).then(decode);
188
-
189
- return swallow(promise);
190
- }
191
-
192
- async function readManyPaths(readOne, invoke, batchRead, args, paths, decode) {
193
- assertReadPaths(paths);
194
- const readEach = async () => {
195
- const values = await Promise.all(paths.map(item => readOne({ ...args, path: item })));
196
- const missing = args.resolve ? missingResolvedIndex(values, paths) : -1;
197
-
198
- if (missing >= 0) throwReadPathError(paths[missing], "not_found");
199
-
200
- return values;
201
- };
202
-
203
- const guardedReadEach = () => swallow(readEach());
204
-
205
- if (!batchRead || args.resolve || paths.some(isQueryUri)) return guardedReadEach();
206
- const res = await invoke("read", args);
207
- const failed = firstItemErrorIndex(res);
208
-
209
- if (failed >= 0) throwReadPathError(paths[failed], res.itemErrors[failed]);
210
- unwrapRead(res, args);
211
-
212
- if (Array.isArray(res?.items)) return res.items.map(decode);
213
-
214
- return guardedReadEach();
215
- }
216
-
217
- function attachCallManyMeta(wave) {
218
- const results = Array.isArray(wave?.results) ? wave.results : Array.isArray(wave) ? wave : [];
219
- Object.defineProperties(results, {
220
- mode: { value: wave?.mode, enumerable: false },
221
- reason: { value: wave?.reason, enumerable: false },
222
- results: { value: results, enumerable: false },
223
- });
224
-
225
- return results;
226
- }
227
-
228
- async function runSpeculation(fn, token, checkpointScope, drainReads, enqueueHost, rpc) {
229
- let began = false;
230
-
231
- try {
232
- await drainReads();
233
- await enqueueHost(() => rpc("speculateBegin", []));
234
- began = true;
235
- const value = await checkpointScope.run(token, fn);
236
- await drainReads();
237
- await enqueueHost(() => rpc("speculateCommit", []));
238
-
239
- return { ok: true, committed: true, value };
240
- } catch (err) {
241
- await drainReads();
242
-
243
- if (began) await enqueueHost(() => rpc("speculateRollback", []));
244
-
245
- throw err;
246
- }
247
- }
248
-
249
- function formatBashFailure(command, res) {
250
- let exitCode;
251
-
252
- try {
253
- exitCode = JSON.parse(res.details).exitCode;
254
- } catch {}
255
-
256
- const output = String(res.value).trimEnd();
257
- const suffix = Number.isInteger(exitCode) ? " (exit " + exitCode + ")" : "";
258
-
259
- return "command failed" + suffix + ": " + truncateChars(command, 240, "command").text + (output ? "\n" + output : "");
260
- }
261
-
262
- function markTruncatedOutput(res, text) {
263
- if (res?.truncated && isString(text) && !text.includes("truncated")) return text + "\n…[output truncated]…";
264
-
265
- return text;
266
- }
267
-
268
- function encodeConsoleArg(a) {
269
- if (isString(a)) return a;
270
-
271
102
  try {
272
103
  const plain = toPlain(a);
104
+ if (displayExceeds(plain,limit)) return {text:formatBoundedValue(plain,limit,"log"),truncated:true};
273
105
  const encoded = JSON.stringify(plain);
274
-
275
- return encoded === undefined ? String(plain) : encoded;
276
- } catch {
277
- return String(a);
278
- }
106
+ return truncateChars(encoded === undefined ? String(plain) : encoded,limit,"log");
107
+ } catch { return truncateChars(formatReturn(String(a)),limit,"log"); }
279
108
  }
280
109
 
281
110
  function compileGuest(prepared, data) {
282
111
  const bindings = data === undefined ? PARAMS : [...PARAMS, "data"];
283
112
 
284
- return { fn: new AsyncFunction(...bindings, prepared.body), hasReturn: prepared.hasReturn };
113
+ return { fn: new AsyncFunction(...bindings, prepared.body + "\n//# sourceURL=supernova-guest.js"), hasReturn: prepared.hasReturn };
285
114
  }
286
115
 
287
116
  function plainGuestValue(value) {
@@ -299,129 +128,14 @@ function handleRpcResult(msg) {
299
128
  else pending.reject(new Error(msg.error));
300
129
  }
301
130
 
302
- function buildGuestApi(available, batchRead, runId, _nativeArgv) {
303
- const rpc = (method, args) => callRpc(runId, method, args);
304
- const checkpointScope = new AsyncLocalStorage();
305
- let checkpoint = null;
306
-
307
- const assertScope = () => {
308
- const token = checkpointScope.getStore();
309
-
310
- if (token ? token !== checkpoint : checkpoint !== null) throw new Error("await the active edit checkpoint before issuing other commands; completed checkpoints cannot issue commands");
311
- };
312
-
313
- let operationTail = Promise.resolve();
314
-
315
- const enqueueHost = operation => {
316
- const next = operationTail.then(operation);
317
- operationTail = next.then(() => {}, () => {});
318
-
319
- return next;
320
- };
321
-
322
- const nova = {
323
- call(name, args) {
324
- assertScope(); flushReads();
325
-
326
- return swallow(enqueueHost(() => rpc("call", [name, args]).then(leanEnvelope)));
327
- },
328
- callMany(calls) {
329
- assertScope(); flushReads();
330
-
331
- return swallow(enqueueHost(async () => attachCallManyMeta(await rpc("callMany", [calls]))));
332
- },
333
- async speculate(fn) {
334
- if (checkpoint) throw new Error("edit checkpoints cannot overlap or nest; await the current checkpoint");
335
- const token = {};
336
- checkpoint = token;
337
-
338
- try { return await runSpeculation(fn, token, checkpointScope, drainReads, enqueueHost, rpc); }
339
- finally { checkpoint = null; }
340
- },
341
- };
342
-
343
- // Coalesce already-started compatible reads without rewriting JS control flow.
344
- const readState = { queued: [], waves: new Set() };
345
-
346
- function flushReads() {
347
- const pending = readState.queued;
348
- readState.queued = [];
349
- dispatchReadWaves(pending, readState.waves, enqueueHost, rpc);
350
- }
351
-
352
- async function drainReads() {
353
- for (;;) {
354
- flushReads();
355
- const waves = [...readState.waves];
356
-
357
- if (!waves.length) return;
358
- await Promise.allSettled(waves);
359
- }
360
- }
361
-
362
- const invoke = (name, args) => {
363
- assertScope(); flushReads();
364
-
365
- return swallow(nova.call(name, args));
366
- };
367
-
368
- const read = async (p, a, b) => {
369
- assertScope();
370
- const args = normalizeRead(gatherReadArgs(p, a, b));
371
- const decode = value => decodeReadValue(args, value);
372
- p = args.path;
373
-
374
- if (Array.isArray(p)) {
375
- const values = await readManyPaths(read, invoke, batchRead, args, p, decode);
376
- for (const item of p) if (isString(item)) noteReadPath({ path: item }, values);
377
-
378
- return values;
379
- }
380
-
381
- const readValue = !batchRead
382
- ? decode(unwrapRead(await invoke("read", args), args))
383
- : await enqueueCompatibleRead(readState, flushReads, args, decode);
384
- noteReadPath(args, readValue);
385
-
386
- return readValue;
387
- };
388
-
389
- const readFiles = new Set();
390
-
391
- function noteReadPath(args, value) {
392
- if (isString(args.path) && looksLikePath(args.path)) readFiles.add(args.path);
393
- if (isObject(value) && isString(value.path) && looksLikePath(value.path)) readFiles.add(value.path);
394
- }
395
-
396
- const write = async (p, content) => {
397
- const args = isObject(p) ? p : { path: p, content };
398
-
399
- if (args.append !== true && args.replace !== true && isString(args.path) && readFiles.has(args.path)) {
400
- throw new Error("file was already read this program; use edit(oldText, newText) or edit(view, ...). write({path,content,replace:true}) replaces anyway");
401
- }
402
-
403
- return unwrapValue(await invoke("write", args));
404
- };
405
-
406
- const edit = async (p, oldText, newText) => {
407
- const classified = classifyEdit(p, oldText, newText);
408
-
409
- if (classified.kind === "checkpoint") return nova.speculate(classified.fn);
410
-
411
- return unwrapValue(await invoke(classified.command, classified.args));
412
- };
413
-
414
- const bash = async (command, opts) => {
415
- const args = normalizeBash(command, opts);
416
- command = args.command;
417
- const res = await invoke("bash", args);
418
-
419
- if (res?.ok === false) throw new Error(formatBashFailure(command, res));
420
-
421
- return markTruncatedOutput(res, unwrapValue(res));
422
- };
423
-
424
- return { read, write, edit, bash, nova };
131
+ function handleReadItem(msg) {
132
+ const pending = pendingRpc.get(msg.id);
133
+ if (!pending?.onItem || !runActive || msg.runId !== activeRunId) return;
134
+ let error;
135
+ try { pending.onItem(msg.index,msg.value); }
136
+ catch (err) { error = errorMessage(err); pending.reject(err); pendingRpc.delete(msg.id); }
137
+ reportMemory(activeRunId);
138
+ post({op:"rpc:ack",id:msg.id,index:msg.index,runId:activeRunId,error});
425
139
  }
426
140
 
427
141
  function makeConsole(runId, limits) {
@@ -439,7 +153,15 @@ function makeConsole(runId, limits) {
439
153
  return; }
440
154
 
441
155
  count++;
442
- const line = args.map(encodeConsoleArg).join(" ");
156
+ let line = "", first = true;
157
+ for (const arg of args) {
158
+ const room = limits.maxLogLineChars-line.length-(first ? 0 : 1);
159
+ if (room <= 0) { markTruncated(); break; }
160
+ const encoded = encodeConsoleArg(arg,room);
161
+ if (encoded.truncated) markTruncated();
162
+ line += (first ? "" : " ")+encoded.text;
163
+ first = false;
164
+ }
443
165
  const clipped = truncateChars(line, limits.maxLogLineChars, "log");
444
166
 
445
167
  if (clipped.truncated) markTruncated();
@@ -451,7 +173,7 @@ function makeConsole(runId, limits) {
451
173
 
452
174
  function postFailure(runId, err, location) {
453
175
  runActive = false;
454
- const message = err instanceof Error ? err.message : String(err);
176
+ const message = errorMessage(err);
455
177
  post({ op: "error", runId, message, location });
456
178
  }
457
179
 
@@ -495,7 +217,7 @@ function sealGuestRealm() {
495
217
  }
496
218
 
497
219
  async function handleRun(msg) {
498
- const { runId, prepared, limits, available, batchRead = true } = msg;
220
+ const { runId, prepared, limits, batchRead = true } = msg;
499
221
  activeRunId = runId;
500
222
  runActive = true;
501
223
  let compiled;
@@ -510,8 +232,9 @@ async function handleRun(msg) {
510
232
  return;
511
233
  }
512
234
 
513
- const api = buildGuestApi(available, batchRead, runId, msg.nativeArgv === true);
235
+ const api = buildGuestApi((method, args, onItem) => callRpc(runId, method, args, onItem), batchRead);
514
236
  const scopedConsole = makeConsole(runId, limits);
237
+ const memoryTimer = setInterval(() => reportMemory(runId), 50);
515
238
 
516
239
  try {
517
240
  const value = await compiled.fn(
@@ -520,17 +243,23 @@ async function handleRun(msg) {
520
243
 
521
244
  if (runId !== activeRunId) return;
522
245
  const plain = plainGuestValue(value);
246
+ // Reduce model output while it is still covered by worker memory/deadlines.
247
+ // Only a bounded display and retained images cross back to the Pi/OMP host.
248
+ const result = msg.formatResult ? {output:packageFinalReturn(plain,[],limits)} : {value:plain};
249
+ reportMemory(runId);
523
250
  runActive = false;
524
- post({ op: "done", runId, value: plain, undefinedReturn: value === undefined && !compiled.hasReturn, hasReturn: compiled.hasReturn });
251
+ post({ op:"done",runId,...result,undefinedReturn:value === undefined && !compiled.hasReturn,hasReturn:compiled.hasReturn });
525
252
  } catch (err) {
526
253
  if (runId !== activeRunId) return;
527
254
  postFailure(runId, err, guestLocation(err));
528
- }
255
+ } finally { clearInterval(memoryTimer); }
529
256
  }
530
257
 
531
258
  parentPort.on("message", (msg) => {
532
259
  if (!isObject(msg)) return;
533
260
 
261
+ if (msg.op === "rpc:item") { handleReadItem(msg); return; }
262
+
534
263
  if (msg.op === "rpc:result") {
535
264
  handleRpcResult(msg);
536
265
 
@@ -1,4 +1,4 @@
1
- import { isFunction, isObject } from "../shared/decode.js";
1
+ import { isFunction, isObject, isString } from "../shared/decode.js";
2
2
 
3
3
  const READ_ONLY_TOOLS = new Set(["read", "grep", "glob", "find", "ls", "snap", "evidence", "surface", "asgrep_search", "asgrep_status", "ast_grep", "web_search"]);
4
4
 
@@ -47,18 +47,18 @@ export function isMutatingTool(name, config = {}, args = {}, definition) {
47
47
  return !READ_ONLY_TOOLS.has(name);
48
48
  }
49
49
 
50
- function takeScheduledWave(queue, first, maxParallelReads) {
51
- const wave = [first];
52
-
53
- if (first.name !== "read") return wave;
54
-
55
- while (wave.length < maxParallelReads && queue[0]?.name === "read") {
56
- const next = queue.shift();
57
-
58
- if (!next.cancelled) wave.push(next);
59
- }
50
+ function compatibleScheduledJob(job, active) {
51
+ return [...active].every(running =>
52
+ (job.name === "read" && running.name === "read") ||
53
+ (isString(job.key) && isString(running.key) && job.key !== running.key));
54
+ }
60
55
 
61
- return wave;
56
+ async function prepareScheduledJob(job) {
57
+ if (!job.resolveKey) return;
58
+ // Key lookup is advisory. Invalid paths still run in isolation so the real
59
+ // adapter reports the error through its normal trace/permission checks.
60
+ try { job.key = await job.resolveKey(); } catch {}
61
+ job.resolveKey = undefined;
62
62
  }
63
63
 
64
64
  async function runScheduledJob(job) {
@@ -74,12 +74,6 @@ async function runScheduledJob(job) {
74
74
  } catch (error) { job.reject(error); }
75
75
  }
76
76
 
77
- function runScheduledWave(wave) {
78
- // Settle each promise separately: one failed read must not discard its
79
- // siblings or release a write barrier while other reads are still active.
80
- return Promise.all(wave.map(runScheduledJob));
81
- }
82
-
83
77
  function attachJobAbort(job, signal, reject) {
84
78
  job.abort = () => {
85
79
  if (job.started) return;
@@ -91,34 +85,47 @@ function attachJobAbort(job, signal, reject) {
91
85
  signal?.addEventListener("abort", job.abort, { once: true });
92
86
  }
93
87
 
94
- /** FIFO read waves with mutation barriers; callers keep independent promises. */
88
+ /** FIFO admission: concurrent reads or disjoint native files, otherwise a barrier. */
95
89
  export function createNativeScheduler({ maxParallelReads = 8 } = {}) {
96
90
  if (!Number.isInteger(maxParallelReads) || maxParallelReads < 1) throw new Error("maxParallelReads must be a positive integer");
97
91
  const queue = [];
92
+ const active = new Set();
98
93
  const stats = { calls: 0, readWaves: 0, peakParallelReads: 0 };
99
94
  let draining = false;
100
95
 
96
+ function start(job) {
97
+ if (job.name === "read") {
98
+ if (!active.size) stats.readWaves++;
99
+ stats.peakParallelReads = Math.max(stats.peakParallelReads, active.size + 1);
100
+ }
101
+ active.add(job);
102
+ // Each promise settles independently. Barriers still wait for every active
103
+ // sibling, including when Promise.all in the guest has already rejected.
104
+ void runScheduledJob(job).finally(() => { active.delete(job); void drain(); });
105
+ }
106
+
101
107
  async function drain() {
108
+ if (draining) return;
109
+ draining = true;
102
110
  try {
103
- while (queue.length) {
104
- const first = queue.shift();
105
-
106
- if (first.cancelled) continue;
107
- const wave = takeScheduledWave(queue, first, maxParallelReads);
108
-
109
- if (first.name === "read") {
110
- stats.readWaves++;
111
- stats.peakParallelReads = Math.max(stats.peakParallelReads, wave.length);
112
- }
113
-
114
- await runScheduledWave(wave);
111
+ while (queue.length && active.size < maxParallelReads) {
112
+ const job = queue[0];
113
+ if (job.cancelled) { queue.shift(); continue; }
114
+ // Resolve identities after shell/override barriers, which may change
115
+ // symlinks. Check synchronously: a finishing job must not lose its wakeup.
116
+ if (job.resolveKey && [...active].some(running => !isString(running.key))) break;
117
+ await prepareScheduledJob(job);
118
+ if (job.cancelled) continue;
119
+ if (!compatibleScheduledJob(job, active)) break;
120
+ queue.shift();
121
+ start(job);
115
122
  }
116
123
  } finally { draining = false; }
117
124
  }
118
125
 
119
126
  return {
120
127
  stats,
121
- schedule(name, run, signal) {
128
+ schedule(name, run, signal, resolveKey) {
122
129
  if (!NATIVE_TOOLS.includes(name) || !isFunction(run)) {
123
130
  return Promise.reject(new Error("scheduler requires a native tool and an executor"));
124
131
  }
@@ -127,16 +134,11 @@ export function createNativeScheduler({ maxParallelReads = 8 } = {}) {
127
134
  stats.calls++;
128
135
 
129
136
  return new Promise((resolve, reject) => {
130
- const job = { name, run, signal, resolve, reject, started: false, cancelled: false };
137
+ const job = { name, run, signal, resolve, reject, resolveKey, started: false, cancelled: false };
131
138
  attachJobAbort(job, signal, reject);
132
139
  queue.push(job);
133
140
 
134
- if (!draining) {
135
- draining = true;
136
- // Pi submits sibling tools in the same turn without model-side code.
137
- // Collect those submissions before selecting the first read wave.
138
- queueMicrotask(() => { void drain(); });
139
- }
141
+ void drain();
140
142
  });
141
143
  },
142
144
  };