@sema-agent/core 2.11.0 → 2.13.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 (37) hide show
  1. package/dist/agents/agent-transcript-tool.d.ts +1 -0
  2. package/dist/agents/agent-transcript-tool.js +1 -1
  3. package/dist/brain/stream-engine.js +4 -1
  4. package/dist/core/auto-promote.js +2 -1
  5. package/dist/core/checkpoint-store.d.ts +1 -0
  6. package/dist/core/checkpoint-store.js +3 -0
  7. package/dist/core/git-worktree-env.js +5 -0
  8. package/dist/core/mcp.d.ts +3 -0
  9. package/dist/core/mcp.js +91 -7
  10. package/dist/core/remote-env.d.ts +3 -0
  11. package/dist/core/remote-env.js +19 -1
  12. package/dist/core/runner/active-skill-scope.js +34 -6
  13. package/dist/core/runner/assemble-result.d.ts +3 -0
  14. package/dist/core/runner/assemble-result.js +4 -1
  15. package/dist/core/runner/prepare-task.d.ts +4 -0
  16. package/dist/core/runner/prepare-task.js +120 -25
  17. package/dist/core/runner/runtask.js +49 -10
  18. package/dist/core/runner/tool-disclosure.d.ts +1 -0
  19. package/dist/core/runner/tool-disclosure.js +26 -7
  20. package/dist/core/session-store.js +15 -4
  21. package/dist/core/skill-tool-specifier.d.ts +8 -0
  22. package/dist/core/skill-tool-specifier.js +58 -0
  23. package/dist/core/skills-directory.d.ts +1 -1
  24. package/dist/core/skills-directory.js +16 -4
  25. package/dist/core/types.d.ts +11 -5
  26. package/dist/core/with-retry.js +0 -1
  27. package/dist/engine/harness/types.d.ts +1 -1
  28. package/dist/index.d.ts +3 -3
  29. package/dist/index.js +1 -1
  30. package/dist/internal/llm.d.ts +1 -1
  31. package/dist/orchestration/workflow.js +8 -4
  32. package/dist/tools/fs/bash-readonly-classifier.js +38 -6
  33. package/dist/tools/fs/fs-bash.js +14 -0
  34. package/dist/tools/scheduler-tools.js +38 -18
  35. package/dist/tools/web.d.ts +8 -2
  36. package/dist/tools/web.js +46 -17
  37. package/package.json +1 -1
@@ -49,7 +49,8 @@ import { capAggregateToolResults } from "../tool-result-budget.js";
49
49
  import { capAggregateMediaBytes, AGGREGATE_MEDIA_BUDGET_BYTES } from "../media-byte-cap.js";
50
50
  import { dropOrphanToolResults, guardBudget, insertTrimNotice, trimToBudget } from "../context-guard.js";
51
51
  import { StubExecutionEnv } from "../stub-env.js";
52
- import { hasDestroy, isIsolated, isRemoteExecutionEnv, isSuspendable } from "../remote-env.js";
52
+ import { hasDestroy, isIsolated, isRemoteExecutionEnv, isRetryableRemoteErrorCode, isSuspendable, missingRestoreSurface, RETRYABLE_REMOTE_ERROR_CODES } from "../remote-env.js";
53
+ import { withRetry } from "../with-retry.js";
53
54
  import { hasBackgroundShell, sweepBackgroundShells } from "../background-shell.js";
54
55
  import { createTaskOutputTool, createTaskStopTool, defaultTaskRegistry } from "../task-registry.js";
55
56
  import { createMonitorTool } from "../../tools/monitor.js";
@@ -173,6 +174,27 @@ export function rebaseWorkspacePath(p, fromRaw, toRaw) {
173
174
  const suffix = p.slice(fromPrefix.length).replace(/^\/+/, "");
174
175
  return to === "/" ? `${to}${suffix}` : `${to}/${suffix}`;
175
176
  }
177
+ function remoteEnvFailureNote(op, error, attempts) {
178
+ return { op, code: error.code, retryable: isRetryableRemoteErrorCode(error.code), attempts, message: error.message };
179
+ }
180
+ const REMOTE_RESTORE_MAX_ATTEMPTS = 2;
181
+ const REMOTE_RESTORE_BACKOFF_MS = 200;
182
+ async function restoreWorkspaceWithRetry(env, snapshotId, options) {
183
+ let attempts = 0;
184
+ const outcome = await withRetry(async (attempt) => {
185
+ attempts = attempt;
186
+ return env.resumeVM(snapshotId, options);
187
+ }, { retryableCodes: RETRYABLE_REMOTE_ERROR_CODES, maxAttempts: REMOTE_RESTORE_MAX_ATTEMPTS, backoffMs: () => REMOTE_RESTORE_BACKOFF_MS }, { ...(options.abortSignal !== undefined ? { signal: options.abortSignal } : {}) });
188
+ return { outcome, attempts };
189
+ }
190
+ export function rebaseWorkspacePathAcross(p, froms, to) {
191
+ for (const from of froms) {
192
+ const out = rebaseWorkspacePath(p, from, to);
193
+ if (out !== p)
194
+ return out;
195
+ }
196
+ return p;
197
+ }
176
198
  export async function prepareTask(spec, deps, sessions, resume, internals, runnerSelf) {
177
199
  const toolFaceSnapshot = {
178
200
  exclude: spec.excludeTools ? Object.freeze([...spec.excludeTools]) : undefined,
@@ -410,6 +432,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
410
432
  }
411
433
  }
412
434
  const taskScope = internals?.registryScope ?? spec.principal ?? "default";
435
+ const offloadScope = spec.principal ?? "default";
413
436
  defaultTaskRegistry.maybeGc();
414
437
  const forgetOnThrow = () => forgetQuietly(sessions, sessionId);
415
438
  const extraBodyCollisions = reservedCollisions(model.extraBody, reservedFor(model.api));
@@ -434,7 +457,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
434
457
  const offloadEnabled = Number.isFinite(offloadThreshold) && offloadThreshold > 0;
435
458
  const rawOffloadStore = offloadEnabled ? (deps.toolResultStore ?? new InMemoryToolResultStore()) : undefined;
436
459
  const offloadStore = rawOffloadStore instanceof RunnerSharedToolResultStore
437
- ? new ScopedToolResultStore(rawOffloadStore, taskScope)
460
+ ? new ScopedToolResultStore(rawOffloadStore, offloadScope)
438
461
  : rawOffloadStore;
439
462
  const offloadReachableToolsRef = {};
440
463
  const maybeOffload = (tool, perTool) => {
@@ -558,11 +581,13 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
558
581
  }
559
582
  }
560
583
  let restoredRootRebase;
561
- const rebaseRestoredPath = (p) => restoredRootRebase === undefined ? p : rebaseWorkspacePath(p, restoredRootRebase.from, restoredRootRebase.to);
584
+ const rebaseRestoredPath = (p) => restoredRootRebase === undefined ? p : rebaseWorkspacePathAcross(p, restoredRootRebase.from, restoredRootRebase.to);
562
585
  if (resume?.workspaceHandle !== undefined) {
563
- const failResume = (message, cause) => {
586
+ const failResume = (message, cause, note) => {
564
587
  const e = new Error(message, cause ? { cause } : undefined);
565
588
  e.code = "resume.env_failed";
589
+ if (note !== undefined)
590
+ e.remoteEnvFailure = note;
566
591
  throw e;
567
592
  };
568
593
  const handle = resume.workspaceHandle;
@@ -570,7 +595,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
570
595
  failResume("resume needs a RemoteExecutionEnv from executionEnvFactory to restore the workspace snapshot");
571
596
  }
572
597
  else if (handle.snapshotId === undefined) {
573
- if (handle.restoreMode !== "park_only" && isSuspendable(ownedEnv)) {
598
+ if (handle.restoreMode !== "park_only" && ownedEnv.capabilities.suspendable) {
574
599
  failResume("checkpoint workspaceHandle has no snapshotId and is not a park_only handle, but the resumed env is suspendable — refusing to resume on a possibly-unrestored workspace (corrupt checkpoint?)");
575
600
  }
576
601
  if (handle.mountPath && handle.mountPath !== taskRootPath) {
@@ -581,12 +606,37 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
581
606
  const restoreSignal = spec.signal
582
607
  ? AbortSignal.any([abortController.signal, spec.signal])
583
608
  : abortController.signal;
584
- const restored = await ownedEnv.resumeVM(handle.snapshotId, { abortSignal: restoreSignal, priorHandle: handle });
609
+ const missingHere = missingRestoreSurface(ownedEnv);
610
+ if (missingHere.length > 0) {
611
+ failResume(`the resumed execution env cannot restore a workspace snapshot: its adapter does not implement ${missingHere.join(" or ")}. The checkpoint holds snapshot "${handle.snapshotId}" — wire an adapter that implements the full RemoteExecutionEnv restore surface and re-resume.`);
612
+ }
613
+ const { outcome: restored, attempts: restoreAttempts } = await restoreWorkspaceWithRetry(ownedEnv, handle.snapshotId, {
614
+ abortSignal: restoreSignal,
615
+ priorHandle: handle,
616
+ });
585
617
  if (!restored.ok) {
586
- failResume(`resumeVM failed (${restored.error.code}): ${restored.error.message}`, restored.error);
618
+ failResume(`resumeVM failed after ${restoreAttempts} attempt(s) (${restored.error.code}): ${restored.error.message}`, restored.error, remoteEnvFailureNote("resumeVM", restored.error, restoreAttempts));
587
619
  }
588
620
  else {
621
+ const restoredEnv = ownedEnv;
622
+ const canonicalInEnv = async (p) => {
623
+ try {
624
+ const r = await restoredEnv.canonicalPath(p, restoreSignal);
625
+ return r.ok ? r.value : undefined;
626
+ }
627
+ catch {
628
+ return undefined;
629
+ }
630
+ };
631
+ let checkpointedCanonical;
632
+ let sameRootUnderAlias = false;
589
633
  if (restored.value.mountPath !== handle.mountPath) {
634
+ checkpointedCanonical = await canonicalInEnv(handle.mountPath);
635
+ const restoredCanonical = await canonicalInEnv(restored.value.mountPath);
636
+ sameRootUnderAlias =
637
+ checkpointedCanonical !== undefined && restoredCanonical !== undefined && checkpointedCanonical === restoredCanonical;
638
+ }
639
+ if (restored.value.mountPath !== handle.mountPath && !sameRootUnderAlias) {
590
640
  if (resume.executesApprovedAction === true) {
591
641
  failResume(`resumeVM workspace-root divergence with a pending approved action: checkpointed mountPath "${handle.mountPath}" but the restored handle reports "${restored.value.mountPath}" — the approved args are bound to the checkpointed root; refusing to execute them against a moved workspace (adapter should honor priorHandle)`);
592
642
  }
@@ -599,13 +649,23 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
599
649
  if (restored.value.mountPath && restored.value.mountPath !== taskRootPath) {
600
650
  taskRootPath = restored.value.mountPath;
601
651
  }
602
- if (restored.value.mountPath && restored.value.mountPath !== handle.mountPath) {
603
- restoredRootRebase = { from: handle.mountPath, to: restored.value.mountPath };
652
+ if (restored.value.mountPath && restored.value.mountPath !== handle.mountPath && !sameRootUnderAlias) {
653
+ const from = checkpointedCanonical !== undefined && checkpointedCanonical !== handle.mountPath
654
+ ? [handle.mountPath, checkpointedCanonical]
655
+ : [handle.mountPath];
656
+ restoredRootRebase = { from, to: restored.value.mountPath };
657
+ try {
658
+ deps.onError?.(new Error(from.length > 1
659
+ ? `resumeVM workspace-root rebase accepts both spellings of the checkpointed root (${from.map((f) => `"${f}"`).join(" and ")}) when migrating persisted paths to "${restored.value.mountPath}"`
660
+ : `resumeVM workspace-root rebase is spelling-exact: it matches the checkpointed root "${handle.mountPath}" only, so persisted paths recorded under an equivalent alias of it are NOT migrated to "${restored.value.mountPath}" and stay as written`), { phase: "config", sessionId });
661
+ }
662
+ catch {
663
+ }
604
664
  }
605
665
  }
606
666
  const init = await ownedEnv.postResumeInit();
607
667
  if (!init.ok) {
608
- failResume(`postResumeInit failed (${init.error.code}): ${init.error.message}`, init.error);
668
+ failResume(`postResumeInit failed (${init.error.code}): ${init.error.message}`, init.error, remoteEnvFailureNote("postResumeInit", init.error, 1));
609
669
  }
610
670
  }
611
671
  }
@@ -948,6 +1008,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
948
1008
  }
949
1009
  const suspendRef = {};
950
1010
  const reviewRef = {};
1011
+ const remoteEnvFailures = [];
951
1012
  const suspendLoopRef = { hit: false };
952
1013
  const compactionReuseRef = { consecutive: 0 };
953
1014
  const trimPressureRef = { droppedMessages: false };
@@ -1324,6 +1385,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1324
1385
  owner: hostTaskId,
1325
1386
  scope: taskScope,
1326
1387
  ...(sessionId !== undefined ? { sessionId } : {}),
1388
+ enrichCtx: enrichSpecToolCtx,
1327
1389
  })));
1328
1390
  }
1329
1391
  }
@@ -1855,6 +1917,12 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1855
1917
  throw e;
1856
1918
  }
1857
1919
  const registry = buildDeferredRegistry(deferred, tools);
1920
+ let activationChain = Promise.resolve();
1921
+ const serializeActivation = (section) => {
1922
+ const p = activationChain.then(section);
1923
+ activationChain = p.then(() => undefined, () => undefined);
1924
+ return p;
1925
+ };
1858
1926
  const directCallFor = (name) => {
1859
1927
  if (spec.deferSelfResolve === false)
1860
1928
  return undefined;
@@ -1870,7 +1938,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1870
1938
  };
1871
1939
  },
1872
1940
  ...(executionMode !== undefined ? { executionMode } : {}),
1873
- activate: async () => {
1941
+ activate: async () => serializeActivation(async () => {
1874
1942
  if (activeTools.has(name))
1875
1943
  return undefined;
1876
1944
  activeTools.add(name);
@@ -1882,7 +1950,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1882
1950
  throw e;
1883
1951
  }
1884
1952
  return listingRideRef.current?.([name]);
1885
- },
1953
+ }),
1886
1954
  };
1887
1955
  };
1888
1956
  const placeholders = new Map([...registry.values()].map((i) => [i.name, createPlaceholderTool(i, directCallFor(i.name))]));
@@ -1956,6 +2024,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1956
2024
  listingRide: (newly) => listingRideRef.current?.(newly),
1957
2025
  mountedNames: callableToolNames,
1958
2026
  directCallEnabled: spec.deferSelfResolve !== false,
2027
+ serializeActivation,
1959
2028
  });
1960
2029
  harnessTools = buildToolList(activeTools);
1961
2030
  }
@@ -2541,16 +2610,21 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
2541
2610
  const preToolContexts = new Map();
2542
2611
  const blockedToolCalls = new Set();
2543
2612
  const blockedTracked = Boolean(hooks?.postToolUse || hooks?.preToolUse || hooks?.postToolUseFailure || hooks?.postToolBatch);
2613
+ const restoreSurfaceGap = ownedEnv !== undefined && isRemoteExecutionEnv(ownedEnv) && ownedEnv.capabilities.suspendable ? missingRestoreSurface(ownedEnv) : [];
2614
+ const incompleteSuspendAdapter = restoreSurfaceGap.length > 0 ? restoreSurfaceGap : undefined;
2544
2615
  const resourceSuspendEligible = spec.resourceSuspend !== undefined &&
2545
2616
  (spec.checkpointStore ?? deps.checkpointStore) !== undefined &&
2546
2617
  !(offloadStore !== undefined && isVolatileOffloadStore(offloadStore)) &&
2547
- (ownedEnv === undefined || isRemoteExecutionEnv(ownedEnv));
2618
+ (ownedEnv === undefined || isRemoteExecutionEnv(ownedEnv)) &&
2619
+ incompleteSuspendAdapter === undefined;
2548
2620
  if (spec.resourceSuspend !== undefined && !resourceSuspendEligible) {
2549
2621
  const why = (spec.checkpointStore ?? deps.checkpointStore) === undefined
2550
2622
  ? "no CheckpointStore is wired"
2551
2623
  : offloadStore !== undefined && isVolatileOffloadStore(offloadStore)
2552
2624
  ? "tool-result offload uses the in-memory store (a resume needs durable results)"
2553
- : "the per-task execution env is not a RemoteExecutionEnv (it would be destroyed on suspend)";
2625
+ : incompleteSuspendAdapter !== undefined
2626
+ ? `the per-task execution env declares capabilities.suspendable but its adapter does not implement ${incompleteSuspendAdapter.join(" or ")} (a snapshot nothing can restore is worse than no snapshot)`
2627
+ : "the per-task execution env is not a RemoteExecutionEnv (it would be destroyed on suspend)";
2554
2628
  deps.onError?.(new Error(`resourceSuspend is set but INACTIVE: ${why}; resource limits will hard-fail, not suspend`), {
2555
2629
  phase: "config",
2556
2630
  sessionId,
@@ -2726,14 +2800,18 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
2726
2800
  catch (putErr) {
2727
2801
  deps.onError?.(putErr, { phase: "config", sessionId });
2728
2802
  if (remoteEnv !== undefined && remoteHandle?.snapshotId !== undefined) {
2729
- const back = await remoteEnv.resumeVM(remoteHandle.snapshotId, { abortSignal: abortController.signal });
2803
+ const { outcome: back, attempts: backAttempts } = await restoreWorkspaceWithRetry(remoteEnv, remoteHandle.snapshotId, {
2804
+ abortSignal: abortController.signal,
2805
+ });
2730
2806
  if (back.ok) {
2731
2807
  const init = await remoteEnv.postResumeInit();
2732
2808
  if (init.ok)
2733
2809
  return false;
2810
+ remoteEnvFailures.push(remoteEnvFailureNote("postResumeInit", init.error, 1));
2734
2811
  deps.onError?.(init.error, { phase: "config", sessionId });
2735
2812
  }
2736
2813
  else {
2814
+ remoteEnvFailures.push(remoteEnvFailureNote("resumeVM", back.error, backAttempts));
2737
2815
  deps.onError?.(back.error, { phase: "config", sessionId });
2738
2816
  }
2739
2817
  abortController.abort();
@@ -2743,10 +2821,12 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
2743
2821
  return false;
2744
2822
  }
2745
2823
  };
2746
- const publishCommittedSuspend = (token, gate, scope) => {
2824
+ const publishCommittedSuspend = (token, gate, scope, remoteHandle) => {
2747
2825
  const ref = gate.kind === "needs_review" || gate.kind === "plan_review" ? reviewRef : suspendRef;
2748
2826
  ref.token = token;
2749
2827
  ref.gate = gate;
2828
+ if (remoteHandle !== undefined)
2829
+ ref.restoreMode = remoteHandle.restoreMode === "park_only" ? "park_only" : "snapshot";
2750
2830
  ref.scope = scope;
2751
2831
  };
2752
2832
  const suspendLoopCapHit = (count, cap, detail) => {
@@ -2763,7 +2843,9 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
2763
2843
  return true;
2764
2844
  };
2765
2845
  const suspendableEnv = ownedEnv !== undefined && isSuspendable(ownedEnv) ? ownedEnv : undefined;
2766
- const parkOnlyRemoteEnv = suspendableEnv === undefined && ownedEnv !== undefined && isRemoteExecutionEnv(ownedEnv) ? ownedEnv : undefined;
2846
+ const parkOnlyRemoteEnv = suspendableEnv === undefined && ownedEnv !== undefined && isRemoteExecutionEnv(ownedEnv) && incompleteSuspendAdapter === undefined
2847
+ ? ownedEnv
2848
+ : undefined;
2767
2849
  const parkOnlyHandle = (env) => {
2768
2850
  const { snapshotId: _lineage, ...identity } = env.workspaceHandle();
2769
2851
  return { ...identity, restoreMode: "park_only" };
@@ -2788,6 +2870,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
2788
2870
  await sweepBackgroundShells(suspendableEnv, defaultTaskRegistry);
2789
2871
  const snap = await suspendableEnv.suspendVM({ abortSignal: abortController.signal });
2790
2872
  if (!snap.ok) {
2873
+ remoteEnvFailures.push(remoteEnvFailureNote("suspendVM", snap.error, 1));
2791
2874
  deps.onError?.(new Error(`resource suspendVM failed (${snap.error.code}): ${snap.error.message}`, { cause: snap.error }), { phase: "config", sessionId });
2792
2875
  return false;
2793
2876
  }
@@ -2822,7 +2905,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
2822
2905
  };
2823
2906
  if (!(await commitSuspendSaga(token, cp, suspendableEnv, remoteHandle)))
2824
2907
  return false;
2825
- publishCommittedSuspend(token, gate, rs.scope);
2908
+ publishCommittedSuspend(token, gate, rs.scope, remoteHandle);
2826
2909
  try {
2827
2910
  await sessions.pin?.(sessionId);
2828
2911
  }
@@ -2839,6 +2922,14 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
2839
2922
  return false;
2840
2923
  if (abortController.signal.aborted)
2841
2924
  return false;
2925
+ if (incompleteSuspendAdapter !== undefined) {
2926
+ try {
2927
+ deps.onError?.(new Error(`plan_review park skipped: the per-task execution env declares capabilities.suspendable but its adapter does not implement ${incompleteSuspendAdapter.join(" or ")}, so its workspace could not be restored on resume (the plan-review request was dropped; the run continues without pausing)`), { phase: "config", sessionId });
2928
+ }
2929
+ catch {
2930
+ }
2931
+ return false;
2932
+ }
2842
2933
  if (suspendLoopCapHit(priorSuspendCount, maxSuspends, " for a plan_review (likely a resume/restart loop)."))
2843
2934
  return false;
2844
2935
  const leafId = await session.getLeafId();
@@ -2858,6 +2949,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
2858
2949
  await sweepBackgroundShells(suspendableEnv, defaultTaskRegistry);
2859
2950
  const snap = await suspendableEnv.suspendVM({ abortSignal: abortController.signal });
2860
2951
  if (!snap.ok) {
2952
+ remoteEnvFailures.push(remoteEnvFailureNote("suspendVM", snap.error, 1));
2861
2953
  deps.onError?.(new Error(`plan_review suspendVM failed (${snap.error.code}): ${snap.error.message}`, { cause: snap.error }), { phase: "config", sessionId });
2862
2954
  return false;
2863
2955
  }
@@ -2896,7 +2988,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
2896
2988
  };
2897
2989
  if (!(await commitSuspendSaga(token, cp, suspendableEnv, remoteHandle)))
2898
2990
  return false;
2899
- publishCommittedSuspend(token, gate, scope);
2991
+ publishCommittedSuspend(token, gate, scope, remoteHandle);
2900
2992
  try {
2901
2993
  await sessions.pin?.(sessionId);
2902
2994
  }
@@ -2930,10 +3022,12 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
2930
3022
  "RunnerDeps.toolResultStore or disable offload.");
2931
3023
  }
2932
3024
  if (ownedEnv !== undefined && remoteEnv === undefined && parkOnlyRemoteEnv === undefined) {
2933
- throw new Error("durable suspend is not supported with a non-remote per-task executionEnvFactory env: the " +
2934
- "minted env is destroyed on suspend, so a resumed file/shell tool would act on a fresh " +
2935
- "(empty) env. Use a RemoteExecutionEnv factory (it is paused, not destroyed) or a static, " +
2936
- "caller-owned RunnerDeps.executionEnv.");
3025
+ throw new Error(incompleteSuspendAdapter !== undefined
3026
+ ? `durable suspend is not supported with this per-task executionEnvFactory env: it declares capabilities.suspendable but its adapter does not implement ${incompleteSuspendAdapter.join(" or ")}, so a snapshot taken now could never be restored. Implement the full RemoteExecutionEnv restore surface, or declare capabilities.suspendable:false if the workspace is externally durable (the park-only lane).`
3027
+ : "durable suspend is not supported with a non-remote per-task executionEnvFactory env: the " +
3028
+ "minted env is destroyed on suspend, so a resumed file/shell tool would act on a fresh " +
3029
+ "(empty) env. Use a RemoteExecutionEnv factory (it is paused, not destroyed) or a static, " +
3030
+ "caller-owned RunnerDeps.executionEnv.");
2937
3031
  }
2938
3032
  const leafId = await session.getLeafId();
2939
3033
  if (!leafId) {
@@ -2951,6 +3045,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
2951
3045
  await sweepBackgroundShells(remoteEnv, defaultTaskRegistry);
2952
3046
  const snap = await remoteEnv.suspendVM({ abortSignal: abortController.signal });
2953
3047
  if (!snap.ok) {
3048
+ remoteEnvFailures.push(remoteEnvFailureNote("suspendVM", snap.error, 1));
2954
3049
  throw new Error(`suspendVM failed (${snap.error.code}): ${snap.error.message}`, { cause: snap.error });
2955
3050
  }
2956
3051
  remoteHandle = { ...remoteEnv.workspaceHandle(), snapshotId: snap.value };
@@ -3039,7 +3134,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3039
3134
  if (!(await commitSuspendSaga(token, cp, remoteEnv, remoteHandle))) {
3040
3135
  return undefined;
3041
3136
  }
3042
- publishCommittedSuspend(token, gate, cp.scope);
3137
+ publishCommittedSuspend(token, gate, cp.scope, remoteHandle);
3043
3138
  try {
3044
3139
  await sessions.pin?.(sessionId);
3045
3140
  }
@@ -3388,7 +3483,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3388
3483
  : undefined;
3389
3484
  overheadState.promptChars = systemPrompt.length;
3390
3485
  const preparedHolder = {};
3391
- const buildPrepared = () => ({ harness, session, sessionId, taskRootPath, model, thinking, compModel, mcp: mcp, blockedRef, outputRef, abortController, conflictRef, blockedToolCalls, nestedStats, ...(rewindNotes.length > 0 ? { rewindNotes } : {}), cwdRef: handsCwdRef, ...(worktreeSessionRef !== undefined ? { worktreeSessionRef } : {}), ...(workspaceStateSettle !== undefined ? { workspaceStateSettle } : {}), denyNarrowingPolicy, ...(basePolicyForResumeEdit !== undefined ? { basePolicyForResumeEdit } : {}), releaseSignal, cacheBreakDetector, cacheFingerprint, promptManifest, epochDeclaredSections, activeTools, ...(deferred.size > 0 ? { deferredToolNames: deferred } : {}), ownedEnv, suspendRef, reviewRef, reviewRequestRef, suspendLoopRef, suspendForResource, ...(callCapRef ? { callCapRef } : {}), ...(cutKills ? { cutKills } : {}), ...(callCapRef ? { callIssuedAtRef } : {}), suspendForReview, resourceLedger: priorLedger, liveSpendRef, humanReviewRef, now, tools, toolEffects, promptOverheadTokens, readTaskFile, recentlyReadFiles, normalizeAttachmentPath, isDedupStubResult, ...(onCompactionApplied ? { onCompactionApplied } : {}), compactionReuseRef, trimPressureRef, ...(memoryEngineSession ? { memoryEngineSession } : {}), ...(subagentRetain ? { subagentRetain } : {}), ...(lspDiagnostics && nudgeLspOnEdit ? { lspDiagnostics: { registry: lspDiagnostics, nudge: nudgeLspOnEdit } } : {}), planModeRef, ...(dateChange ? { dateChange } : {}), ...(instructionSources ? { instructionSources } : {}), ...(detectExternalChanges ? { detectExternalChanges } : {}), ...(toolsDeltaRef ? { toolsDeltaRef } : {}), ...(agentListing ? { agentListing } : {}), ...(skillsListing ? { skillsListing } : {}), announcedListingsRef, listBackgroundTasks, ...(turnSnapshotRef.current !== undefined ? { turnSnapshot: turnSnapshotRef.current } : {}), ...(centerCompactionCandidate !== undefined ? { centerCompactionCandidate } : {}) });
3486
+ const buildPrepared = () => ({ harness, session, sessionId, taskRootPath, model, thinking, compModel, mcp: mcp, blockedRef, outputRef, abortController, conflictRef, blockedToolCalls, nestedStats, ...(rewindNotes.length > 0 ? { rewindNotes } : {}), cwdRef: handsCwdRef, ...(worktreeSessionRef !== undefined ? { worktreeSessionRef } : {}), ...(workspaceStateSettle !== undefined ? { workspaceStateSettle } : {}), denyNarrowingPolicy, ...(basePolicyForResumeEdit !== undefined ? { basePolicyForResumeEdit } : {}), releaseSignal, cacheBreakDetector, cacheFingerprint, promptManifest, epochDeclaredSections, activeTools, ...(deferred.size > 0 ? { deferredToolNames: deferred } : {}), ownedEnv, suspendRef, reviewRef, remoteEnvFailures, reviewRequestRef, suspendLoopRef, suspendForResource, ...(callCapRef ? { callCapRef } : {}), ...(cutKills ? { cutKills } : {}), ...(callCapRef ? { callIssuedAtRef } : {}), suspendForReview, resourceLedger: priorLedger, liveSpendRef, humanReviewRef, now, tools, toolEffects, promptOverheadTokens, readTaskFile, recentlyReadFiles, normalizeAttachmentPath, isDedupStubResult, ...(onCompactionApplied ? { onCompactionApplied } : {}), compactionReuseRef, trimPressureRef, ...(memoryEngineSession ? { memoryEngineSession } : {}), ...(subagentRetain ? { subagentRetain } : {}), ...(lspDiagnostics && nudgeLspOnEdit ? { lspDiagnostics: { registry: lspDiagnostics, nudge: nudgeLspOnEdit } } : {}), planModeRef, ...(dateChange ? { dateChange } : {}), ...(instructionSources ? { instructionSources } : {}), ...(detectExternalChanges ? { detectExternalChanges } : {}), ...(toolsDeltaRef ? { toolsDeltaRef } : {}), ...(agentListing ? { agentListing } : {}), ...(skillsListing ? { skillsListing } : {}), announcedListingsRef, listBackgroundTasks, ...(turnSnapshotRef.current !== undefined ? { turnSnapshot: turnSnapshotRef.current } : {}), ...(centerCompactionCandidate !== undefined ? { centerCompactionCandidate } : {}) });
3392
3487
  const prepared = buildPrepared();
3393
3488
  preparedHolder.current = prepared;
3394
3489
  return prepared;
@@ -222,6 +222,7 @@ function resumeContinuation(resume) {
222
222
  }
223
223
  function makeTurnBoundary(prepared, stats, rs, deps) {
224
224
  const { spec, queue, manualCompactRef, todoToolMounted, taskToolsMounted, turnToolSpan, walltimeDeadlineMs, walltimeMonotonicDeadline, nudgeSchedule, buildFinalizeText, timeout, ident, postToolBatchHook, batchArgs, compactionBrain, withinTaskCompaction, compactionBreaker, windowSafetyOptions, rapidRefill, drainManualCompact, runnerHooks } = deps;
225
+ let finalVerifySeenAtLastBoundary = 0;
225
226
  const onTurnBoundary = async (event) => {
226
227
  if (prepared.callCapRef && turnToolSpan.startMin !== undefined && turnToolSpan.endMax !== undefined) {
227
228
  recordToolCycleSample(prepared.callCapRef.state, turnToolSpan.endMax - turnToolSpan.startMin);
@@ -499,11 +500,13 @@ function makeTurnBoundary(prepared, stats, rs, deps) {
499
500
  }
500
501
  }
501
502
  let batchContextBlock;
503
+ const finalVerifyInjectedThisTurn = rs.counters.finalVerifyInjections !== finalVerifySeenAtLastBoundary;
504
+ finalVerifySeenAtLastBoundary = rs.counters.finalVerifyInjections;
502
505
  if (postToolBatchHook !== undefined && rs.turn.toolBatch.length > 0) {
503
506
  const batch = rs.turn.toolBatch;
504
507
  rs.turn.toolBatch = [];
505
508
  batchArgs?.clear();
506
- if (!boundarySteered && !rs.counters.finalizeInjected && rs.counters.finalVerifyInjections === 0 && !prepared.abortController.signal.aborted) {
509
+ if (!boundarySteered && !rs.counters.finalizeInjected && !finalVerifyInjectedThisTurn && !prepared.abortController.signal.aborted) {
507
510
  try {
508
511
  const r = await postToolBatchHook(batch);
509
512
  if (r?.additionalContext) {
@@ -1345,6 +1348,16 @@ export class Runner {
1345
1348
  queue.close();
1346
1349
  return;
1347
1350
  }
1351
+ const remoteEnvFailure = (() => {
1352
+ let cur = err;
1353
+ for (let depth = 0; cur && typeof cur === "object" && depth < 8; depth++) {
1354
+ const note = cur.remoteEnvFailure;
1355
+ if (note !== undefined)
1356
+ return [note];
1357
+ cur = cur.cause;
1358
+ }
1359
+ return undefined;
1360
+ })();
1348
1361
  resultValue = {
1349
1362
  taskId: taskIdRef.current ?? "unknown",
1350
1363
  sessionId: taskIdRef.sessionId ?? "unknown",
@@ -1352,6 +1365,7 @@ export class Runner {
1352
1365
  result: "",
1353
1366
  errorMessage: err instanceof Error ? err.message : String(err),
1354
1367
  errorCode: code,
1368
+ ...(remoteEnvFailure !== undefined ? { remoteEnvFailures: remoteEnvFailure } : {}),
1355
1369
  stats: { turns: 0, tokens: 0, toolCalls: 0, cachedTokens: 0, costMicroUsd: 0 },
1356
1370
  };
1357
1371
  emitTrace(spec.tracer ?? this.deps.tracer, () => ({
@@ -1651,6 +1665,9 @@ export class Runner {
1651
1665
  const gateToolName = "toolName" in resume.cp.gate ? resume.cp.gate.toolName : undefined;
1652
1666
  prepared.humanReviewRef.gates.push({ kind: resume.cp.gate.kind, waitMs, ...(decision !== undefined ? { decision } : {}), ...(gateToolName !== undefined ? { toolName: gateToolName } : {}) });
1653
1667
  }
1668
+ if (resume !== undefined && resume.outcome.gate === "plan_review" && resume.outcome.decision === "reject") {
1669
+ prepared.planModeRef.active = true;
1670
+ }
1654
1671
  const rs = createRunState();
1655
1672
  rs.telemetry.cacheFamily = cacheFamilyOf(prepared.model);
1656
1673
  rs.telemetry.pricing = this.deps.pricing?.[prepared.model.id] ?? modelCostToPricing(prepared.model.cost);
@@ -2520,13 +2537,26 @@ export class Runner {
2520
2537
  let promptBlocked = false;
2521
2538
  const userPromptSubmit = (spec.hooks ?? this.deps.hooks)?.userPromptSubmit;
2522
2539
  if (userPromptSubmit) {
2523
- const decision = await userPromptSubmit(spec.objective);
2524
- if (decision?.block) {
2525
- prepared.blockedRef.reason = formatHookFeedback(decision.block);
2526
- promptBlocked = true;
2540
+ try {
2541
+ const decision = await userPromptSubmit(spec.objective);
2542
+ if (decision?.block) {
2543
+ prepared.blockedRef.reason = formatHookFeedback(decision.block);
2544
+ promptBlocked = true;
2545
+ }
2546
+ else if (decision?.additionalContext) {
2547
+ effectiveObjective = `${formatHookFeedback(decision.additionalContext)}\n\n${spec.objective}`;
2548
+ }
2527
2549
  }
2528
- else if (decision?.additionalContext) {
2529
- effectiveObjective = `${formatHookFeedback(decision.additionalContext)}\n\n${spec.objective}`;
2550
+ catch (hookErr) {
2551
+ const err = hookErr instanceof Error ? hookErr : new Error(String(hookErr));
2552
+ try {
2553
+ this.deps.onError?.(err, { phase: "hook", sessionId: prepared.sessionId });
2554
+ }
2555
+ catch {
2556
+ }
2557
+ prepared.blockedRef.reason = formatHookFeedback(`the deployment's userPromptSubmit hook crashed while screening this prompt (${err.message}); ` +
2558
+ `the prompt was NOT submitted (fail-closed)`);
2559
+ promptBlocked = true;
2530
2560
  }
2531
2561
  }
2532
2562
  const firstFrames = [];
@@ -2765,6 +2795,7 @@ export class Runner {
2765
2795
  model: prepared.model.id,
2766
2796
  unpricedSpend: rs.telemetry.unpricedSpend,
2767
2797
  rewindNotes: prepared.rewindNotes,
2798
+ remoteEnvFailures: prepared.remoteEnvFailures,
2768
2799
  abortedForTimeout: timeout.fired,
2769
2800
  abortedForTurns: rs.limits.turnsExceeded,
2770
2801
  abortedLive,
@@ -2774,10 +2805,18 @@ export class Runner {
2774
2805
  outputInvalid: rs.degrade.outputInvalid,
2775
2806
  suspendLoop: prepared.suspendLoopRef.hit,
2776
2807
  suspendRef: prepared.suspendRef.token !== undefined && prepared.suspendRef.gate !== undefined
2777
- ? { token: prepared.suspendRef.token, gate: prepared.suspendRef.gate }
2808
+ ? {
2809
+ token: prepared.suspendRef.token,
2810
+ gate: prepared.suspendRef.gate,
2811
+ ...(prepared.suspendRef.restoreMode !== undefined ? { restoreMode: prepared.suspendRef.restoreMode } : {}),
2812
+ }
2778
2813
  : undefined,
2779
2814
  reviewRef: prepared.reviewRef.token !== undefined && prepared.reviewRef.gate !== undefined
2780
- ? { token: prepared.reviewRef.token, gate: prepared.reviewRef.gate }
2815
+ ? {
2816
+ token: prepared.reviewRef.token,
2817
+ gate: prepared.reviewRef.gate,
2818
+ ...(prepared.reviewRef.restoreMode !== undefined ? { restoreMode: prepared.reviewRef.restoreMode } : {}),
2819
+ }
2781
2820
  : undefined,
2782
2821
  });
2783
2822
  if (resume !== undefined &&
@@ -3082,7 +3121,7 @@ export class Runner {
3082
3121
  if (!r.ok) {
3083
3122
  const tooLarge = r.error.code === "too_large";
3084
3123
  const refusedAt = Date.now();
3085
- if (tooLarge)
3124
+ if (tooLarge && !this.snapshotTooLargeRoots.has(root))
3086
3125
  this.snapshotTooLargeRoots.set(root, { refusedAt, skipAnnounced: false });
3087
3126
  try {
3088
3127
  this.deps.onError?.(new Error(`rewind-files snapshot failed (${r.error.code}): ${r.error.message}` +
@@ -57,4 +57,5 @@ export declare function createToolSearchTool(opts: {
57
57
  listingRide?: (newlyActivated: readonly string[]) => string | undefined;
58
58
  mountedNames?: () => ReadonlySet<string>;
59
59
  directCallEnabled?: boolean;
60
+ serializeActivation?: <T>(section: () => Promise<T>) => Promise<T>;
60
61
  }): AgentTool;
@@ -172,11 +172,22 @@ export function resolveToolSearch(args, registry) {
172
172
  export function extractDiscoveredToolNames(messages, registry) {
173
173
  const names = new Set();
174
174
  const pendingDirect = new Map();
175
+ const pendingSearch = new Map();
175
176
  for (const m of messages) {
176
177
  if (m.role === "toolResult") {
177
178
  const name = pendingDirect.get(m.toolCallId);
178
- if (name !== undefined && m.isError !== true)
179
- names.add(name);
179
+ if (name !== undefined) {
180
+ pendingDirect.delete(m.toolCallId);
181
+ if (m.isError !== true)
182
+ names.add(name);
183
+ }
184
+ const searched = pendingSearch.get(m.toolCallId);
185
+ if (searched !== undefined) {
186
+ pendingSearch.delete(m.toolCallId);
187
+ if (m.isError !== true)
188
+ for (const n of searched)
189
+ names.add(n);
190
+ }
180
191
  continue;
181
192
  }
182
193
  if (m.role !== "assistant")
@@ -185,15 +196,18 @@ export function extractDiscoveredToolNames(messages, registry) {
185
196
  if (part.type !== "toolCall")
186
197
  continue;
187
198
  if (part.name === TOOL_SEARCH_NAME) {
188
- for (const n of resolveToolSearch(part.arguments, registry)) {
189
- names.add(n);
190
- }
199
+ const prior = pendingSearch.get(part.id);
200
+ const resolved = resolveToolSearch(part.arguments, registry);
201
+ pendingSearch.set(part.id, prior === undefined ? resolved : [...prior, ...resolved]);
191
202
  }
192
203
  else if (registry.has(part.name)) {
193
204
  pendingDirect.set(part.id, part.name);
194
205
  }
195
206
  }
196
207
  }
208
+ for (const list of pendingSearch.values())
209
+ for (const n of list)
210
+ names.add(n);
197
211
  return [...names];
198
212
  }
199
213
  export function createToolSearchTool(opts) {
@@ -209,6 +223,12 @@ export function createToolSearchTool(opts) {
209
223
  "first. When any instruction, reminder, or another tool's description names a deferred tool, activate it " +
210
224
  'with query "select:<name>" before calling it. ';
211
225
  let activationChain = Promise.resolve();
226
+ const serializeActivation = opts.serializeActivation ??
227
+ ((section) => {
228
+ const p = activationChain.then(section);
229
+ activationChain = p.then(() => undefined, () => undefined);
230
+ return p;
231
+ });
212
232
  return defineTool({
213
233
  name: TOOL_SEARCH_NAME,
214
234
  contract: { contractId: "core.tool_search@1", implementationRevision: "1" },
@@ -259,7 +279,7 @@ export function createToolSearchTool(opts) {
259
279
  },
260
280
  };
261
281
  }
262
- const section = activationChain.then(async () => {
282
+ const section = serializeActivation(async () => {
263
283
  const newly = matched.filter((n) => !active.has(n));
264
284
  if (newly.length > 0) {
265
285
  for (const n of newly)
@@ -275,7 +295,6 @@ export function createToolSearchTool(opts) {
275
295
  }
276
296
  return { newly, ride: newly.length > 0 ? listingRide?.(newly) : undefined };
277
297
  });
278
- activationChain = section.then(() => undefined, () => undefined);
279
298
  const { newly, ride } = await section;
280
299
  const lines = matched.map((n) => {
281
300
  const info = registry.get(n);
@@ -114,6 +114,11 @@ export class TtlSessionStore {
114
114
  }
115
115
  const forked = await this.repo.fork(await source.getMetadata(), forkParams);
116
116
  const meta = await forked.getMetadata();
117
+ const inherited = this.owners.has(sourceId) ? this.owners.get(sourceId) : undefined;
118
+ const forkOwner = owner ?? inherited;
119
+ if (forkOwner !== undefined && !this.owners.has(meta.id)) {
120
+ this.owners.set(meta.id, forkOwner);
121
+ }
117
122
  this.entries.set(meta.id, {
118
123
  session: forked,
119
124
  lastActiveAt: Date.now(),
@@ -149,8 +154,13 @@ export class TtlSessionStore {
149
154
  return this.owners.has(sessionId) ? this.owners.get(sessionId) : undefined;
150
155
  }
151
156
  async register(sessionId, owner) {
152
- if (!this.owners.has(sessionId))
153
- this.owners.set(sessionId, owner);
157
+ if (this.owners.has(sessionId)) {
158
+ return;
159
+ }
160
+ if (owner !== null && typeof owner !== "string") {
161
+ throw new TypeError(`SessionStore.register: owner must be a string (owned) or null (anonymous), got ${owner === undefined ? "undefined" : typeof owner}`);
162
+ }
163
+ this.owners.set(sessionId, owner);
154
164
  }
155
165
  sweep(now = Date.now()) {
156
166
  for (const [id, e] of this.entries) {
@@ -160,8 +170,9 @@ export class TtlSessionStore {
160
170
  if (now - e.lastActiveAt > this.defaultTtlMs) {
161
171
  this.entries.delete(id);
162
172
  if (this.evictPolicy === "delete") {
163
- void this.repo.delete({ id, createdAt: "" });
164
- this.owners.delete(id);
173
+ void this.repo.delete({ id, createdAt: "" }).then(() => {
174
+ this.owners.delete(id);
175
+ }, () => { });
165
176
  }
166
177
  }
167
178
  }
@@ -0,0 +1,8 @@
1
+ export interface SkillToolEntry {
2
+ readonly raw: string;
3
+ readonly name: string;
4
+ readonly specifier?: string;
5
+ }
6
+ export declare function parseSkillToolEntry(entry: string): SkillToolEntry;
7
+ export declare function isSkillSpecifierEnforced(canonicalName: string): boolean;
8
+ export declare function skillSpecifierRejection(entry: SkillToolEntry, args: unknown): string | undefined;