@sema-agent/core 2.10.0 → 2.12.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.
@@ -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,
@@ -409,7 +431,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
409
431
  }));
410
432
  }
411
433
  }
412
- const taskScope = spec.principal ?? "default";
434
+ const taskScope = internals?.registryScope ?? spec.principal ?? "default";
413
435
  defaultTaskRegistry.maybeGc();
414
436
  const forgetOnThrow = () => forgetQuietly(sessions, sessionId);
415
437
  const extraBodyCollisions = reservedCollisions(model.extraBody, reservedFor(model.api));
@@ -558,11 +580,13 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
558
580
  }
559
581
  }
560
582
  let restoredRootRebase;
561
- const rebaseRestoredPath = (p) => restoredRootRebase === undefined ? p : rebaseWorkspacePath(p, restoredRootRebase.from, restoredRootRebase.to);
583
+ const rebaseRestoredPath = (p) => restoredRootRebase === undefined ? p : rebaseWorkspacePathAcross(p, restoredRootRebase.from, restoredRootRebase.to);
562
584
  if (resume?.workspaceHandle !== undefined) {
563
- const failResume = (message, cause) => {
585
+ const failResume = (message, cause, note) => {
564
586
  const e = new Error(message, cause ? { cause } : undefined);
565
587
  e.code = "resume.env_failed";
588
+ if (note !== undefined)
589
+ e.remoteEnvFailure = note;
566
590
  throw e;
567
591
  };
568
592
  const handle = resume.workspaceHandle;
@@ -570,7 +594,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
570
594
  failResume("resume needs a RemoteExecutionEnv from executionEnvFactory to restore the workspace snapshot");
571
595
  }
572
596
  else if (handle.snapshotId === undefined) {
573
- if (handle.restoreMode !== "park_only" && isSuspendable(ownedEnv)) {
597
+ if (handle.restoreMode !== "park_only" && ownedEnv.capabilities.suspendable) {
574
598
  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
599
  }
576
600
  if (handle.mountPath && handle.mountPath !== taskRootPath) {
@@ -581,12 +605,37 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
581
605
  const restoreSignal = spec.signal
582
606
  ? AbortSignal.any([abortController.signal, spec.signal])
583
607
  : abortController.signal;
584
- const restored = await ownedEnv.resumeVM(handle.snapshotId, { abortSignal: restoreSignal, priorHandle: handle });
608
+ const missingHere = missingRestoreSurface(ownedEnv);
609
+ if (missingHere.length > 0) {
610
+ 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.`);
611
+ }
612
+ const { outcome: restored, attempts: restoreAttempts } = await restoreWorkspaceWithRetry(ownedEnv, handle.snapshotId, {
613
+ abortSignal: restoreSignal,
614
+ priorHandle: handle,
615
+ });
585
616
  if (!restored.ok) {
586
- failResume(`resumeVM failed (${restored.error.code}): ${restored.error.message}`, restored.error);
617
+ failResume(`resumeVM failed after ${restoreAttempts} attempt(s) (${restored.error.code}): ${restored.error.message}`, restored.error, remoteEnvFailureNote("resumeVM", restored.error, restoreAttempts));
587
618
  }
588
619
  else {
620
+ const restoredEnv = ownedEnv;
621
+ const canonicalInEnv = async (p) => {
622
+ try {
623
+ const r = await restoredEnv.canonicalPath(p, restoreSignal);
624
+ return r.ok ? r.value : undefined;
625
+ }
626
+ catch {
627
+ return undefined;
628
+ }
629
+ };
630
+ let checkpointedCanonical;
631
+ let sameRootUnderAlias = false;
589
632
  if (restored.value.mountPath !== handle.mountPath) {
633
+ checkpointedCanonical = await canonicalInEnv(handle.mountPath);
634
+ const restoredCanonical = await canonicalInEnv(restored.value.mountPath);
635
+ sameRootUnderAlias =
636
+ checkpointedCanonical !== undefined && restoredCanonical !== undefined && checkpointedCanonical === restoredCanonical;
637
+ }
638
+ if (restored.value.mountPath !== handle.mountPath && !sameRootUnderAlias) {
590
639
  if (resume.executesApprovedAction === true) {
591
640
  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
641
  }
@@ -599,13 +648,23 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
599
648
  if (restored.value.mountPath && restored.value.mountPath !== taskRootPath) {
600
649
  taskRootPath = restored.value.mountPath;
601
650
  }
602
- if (restored.value.mountPath && restored.value.mountPath !== handle.mountPath) {
603
- restoredRootRebase = { from: handle.mountPath, to: restored.value.mountPath };
651
+ if (restored.value.mountPath && restored.value.mountPath !== handle.mountPath && !sameRootUnderAlias) {
652
+ const from = checkpointedCanonical !== undefined && checkpointedCanonical !== handle.mountPath
653
+ ? [handle.mountPath, checkpointedCanonical]
654
+ : [handle.mountPath];
655
+ restoredRootRebase = { from, to: restored.value.mountPath };
656
+ try {
657
+ deps.onError?.(new Error(from.length > 1
658
+ ? `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}"`
659
+ : `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 });
660
+ }
661
+ catch {
662
+ }
604
663
  }
605
664
  }
606
665
  const init = await ownedEnv.postResumeInit();
607
666
  if (!init.ok) {
608
- failResume(`postResumeInit failed (${init.error.code}): ${init.error.message}`, init.error);
667
+ failResume(`postResumeInit failed (${init.error.code}): ${init.error.message}`, init.error, remoteEnvFailureNote("postResumeInit", init.error, 1));
609
668
  }
610
669
  }
611
670
  }
@@ -618,6 +677,11 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
618
677
  }
619
678
  const rewindNotes = [];
620
679
  const rewindCaptureRequested = spec.rewindFiles === true;
680
+ if (spec.resumeAt !== undefined && spec.rewindFilesTo !== undefined) {
681
+ const e = new Error(`rewind-files: resumeAt ("${spec.resumeAt}") and rewindFilesTo ("${spec.rewindFilesTo}") were both set — resumeAt already anchors the file restore when rewindFiles is true, so a separate rewindFilesTo target is a conflicting request. Drop one of the two.`);
682
+ e.code = "rewind.conflicting_targets";
683
+ throw e;
684
+ }
621
685
  let rewindTarget = spec.resumeAt !== undefined ? (rewindCaptureRequested ? spec.resumeAt : undefined) : spec.rewindFilesTo;
622
686
  const rewindBefore = rewindTarget !== undefined && spec.resumeAt !== undefined && spec.resumeAtMode === "before";
623
687
  if (spec.resumeAt !== undefined && !rewindCaptureRequested) {
@@ -943,6 +1007,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
943
1007
  }
944
1008
  const suspendRef = {};
945
1009
  const reviewRef = {};
1010
+ const remoteEnvFailures = [];
946
1011
  const suspendLoopRef = { hit: false };
947
1012
  const compactionReuseRef = { consecutive: 0 };
948
1013
  const trimPressureRef = { droppedMessages: false };
@@ -1833,15 +1898,6 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1833
1898
  }
1834
1899
  const activeTools = new Set();
1835
1900
  const fpRef = {};
1836
- if (rebuildHarnessToolsRef.current === undefined) {
1837
- rebuildHarnessToolsRef.current = async () => {
1838
- const list = [...tools];
1839
- await harnessRef.current.setTools(list, list.map((t) => t.name));
1840
- if (fpRef.current)
1841
- fpRef.current.tools = toolsToFingerprintInputs(list);
1842
- turnSnapshotRef.current?.refreshTools(toolsToFingerprintInputs(list));
1843
- };
1844
- }
1845
1901
  const turnSnapshotRef = {};
1846
1902
  let harnessTools = tools;
1847
1903
  const failedMcpServers = mcp.statuses
@@ -1908,7 +1964,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1908
1964
  if (deferred.has(n))
1909
1965
  activeTools.add(n);
1910
1966
  }
1911
- offloadReachableToolsRef.current = () => {
1967
+ const callableToolNames = () => {
1912
1968
  const s = new Set(tools.map((t) => t.name));
1913
1969
  for (const n of deferred)
1914
1970
  if (!activeTools.has(n))
@@ -1916,6 +1972,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1916
1972
  s.add(TOOL_SEARCH_NAME);
1917
1973
  return s;
1918
1974
  };
1975
+ offloadReachableToolsRef.current = callableToolNames;
1919
1976
  let toolSearch;
1920
1977
  const buildToolList = (active) => {
1921
1978
  const list = tools.map((t) => (deferred.has(t.name) && !active.has(t.name) ? placeholders.get(t.name) : t));
@@ -1957,11 +2014,20 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1957
2014
  active: activeTools,
1958
2015
  rematerialize,
1959
2016
  listingRide: (newly) => listingRideRef.current?.(newly),
1960
- mountedNames: () => new Set(buildToolList(activeTools).map((t) => t.name).filter((n) => !registry.has(n) || activeTools.has(n))),
2017
+ mountedNames: callableToolNames,
1961
2018
  directCallEnabled: spec.deferSelfResolve !== false,
1962
2019
  });
1963
2020
  harnessTools = buildToolList(activeTools);
1964
2021
  }
2022
+ else {
2023
+ rebuildHarnessToolsRef.current = async () => {
2024
+ const list = [...tools];
2025
+ await harnessRef.current.setTools(list, list.map((t) => t.name));
2026
+ if (fpRef.current)
2027
+ fpRef.current.tools = toolsToFingerprintInputs(list);
2028
+ turnSnapshotRef.current?.refreshTools(toolsToFingerprintInputs(list));
2029
+ };
2030
+ }
1965
2031
  if (spec.agents !== undefined && spec.agents.length > 0) {
1966
2032
  const known = new Set(tools.flatMap((t) => [canonicalToolName(t.name), ...(t.aliases ?? []).map((a) => canonicalToolName(a))]));
1967
2033
  for (const def of spec.agents) {
@@ -2535,16 +2601,21 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
2535
2601
  const preToolContexts = new Map();
2536
2602
  const blockedToolCalls = new Set();
2537
2603
  const blockedTracked = Boolean(hooks?.postToolUse || hooks?.preToolUse || hooks?.postToolUseFailure || hooks?.postToolBatch);
2604
+ const restoreSurfaceGap = ownedEnv !== undefined && isRemoteExecutionEnv(ownedEnv) && ownedEnv.capabilities.suspendable ? missingRestoreSurface(ownedEnv) : [];
2605
+ const incompleteSuspendAdapter = restoreSurfaceGap.length > 0 ? restoreSurfaceGap : undefined;
2538
2606
  const resourceSuspendEligible = spec.resourceSuspend !== undefined &&
2539
2607
  (spec.checkpointStore ?? deps.checkpointStore) !== undefined &&
2540
2608
  !(offloadStore !== undefined && isVolatileOffloadStore(offloadStore)) &&
2541
- (ownedEnv === undefined || isRemoteExecutionEnv(ownedEnv));
2609
+ (ownedEnv === undefined || isRemoteExecutionEnv(ownedEnv)) &&
2610
+ incompleteSuspendAdapter === undefined;
2542
2611
  if (spec.resourceSuspend !== undefined && !resourceSuspendEligible) {
2543
2612
  const why = (spec.checkpointStore ?? deps.checkpointStore) === undefined
2544
2613
  ? "no CheckpointStore is wired"
2545
2614
  : offloadStore !== undefined && isVolatileOffloadStore(offloadStore)
2546
2615
  ? "tool-result offload uses the in-memory store (a resume needs durable results)"
2547
- : "the per-task execution env is not a RemoteExecutionEnv (it would be destroyed on suspend)";
2616
+ : incompleteSuspendAdapter !== undefined
2617
+ ? `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)`
2618
+ : "the per-task execution env is not a RemoteExecutionEnv (it would be destroyed on suspend)";
2548
2619
  deps.onError?.(new Error(`resourceSuspend is set but INACTIVE: ${why}; resource limits will hard-fail, not suspend`), {
2549
2620
  phase: "config",
2550
2621
  sessionId,
@@ -2720,14 +2791,18 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
2720
2791
  catch (putErr) {
2721
2792
  deps.onError?.(putErr, { phase: "config", sessionId });
2722
2793
  if (remoteEnv !== undefined && remoteHandle?.snapshotId !== undefined) {
2723
- const back = await remoteEnv.resumeVM(remoteHandle.snapshotId, { abortSignal: abortController.signal });
2794
+ const { outcome: back, attempts: backAttempts } = await restoreWorkspaceWithRetry(remoteEnv, remoteHandle.snapshotId, {
2795
+ abortSignal: abortController.signal,
2796
+ });
2724
2797
  if (back.ok) {
2725
2798
  const init = await remoteEnv.postResumeInit();
2726
2799
  if (init.ok)
2727
2800
  return false;
2801
+ remoteEnvFailures.push(remoteEnvFailureNote("postResumeInit", init.error, 1));
2728
2802
  deps.onError?.(init.error, { phase: "config", sessionId });
2729
2803
  }
2730
2804
  else {
2805
+ remoteEnvFailures.push(remoteEnvFailureNote("resumeVM", back.error, backAttempts));
2731
2806
  deps.onError?.(back.error, { phase: "config", sessionId });
2732
2807
  }
2733
2808
  abortController.abort();
@@ -2737,10 +2812,12 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
2737
2812
  return false;
2738
2813
  }
2739
2814
  };
2740
- const publishCommittedSuspend = (token, gate, scope) => {
2815
+ const publishCommittedSuspend = (token, gate, scope, remoteHandle) => {
2741
2816
  const ref = gate.kind === "needs_review" || gate.kind === "plan_review" ? reviewRef : suspendRef;
2742
2817
  ref.token = token;
2743
2818
  ref.gate = gate;
2819
+ if (remoteHandle !== undefined)
2820
+ ref.restoreMode = remoteHandle.restoreMode === "park_only" ? "park_only" : "snapshot";
2744
2821
  ref.scope = scope;
2745
2822
  };
2746
2823
  const suspendLoopCapHit = (count, cap, detail) => {
@@ -2757,7 +2834,9 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
2757
2834
  return true;
2758
2835
  };
2759
2836
  const suspendableEnv = ownedEnv !== undefined && isSuspendable(ownedEnv) ? ownedEnv : undefined;
2760
- const parkOnlyRemoteEnv = suspendableEnv === undefined && ownedEnv !== undefined && isRemoteExecutionEnv(ownedEnv) ? ownedEnv : undefined;
2837
+ const parkOnlyRemoteEnv = suspendableEnv === undefined && ownedEnv !== undefined && isRemoteExecutionEnv(ownedEnv) && incompleteSuspendAdapter === undefined
2838
+ ? ownedEnv
2839
+ : undefined;
2761
2840
  const parkOnlyHandle = (env) => {
2762
2841
  const { snapshotId: _lineage, ...identity } = env.workspaceHandle();
2763
2842
  return { ...identity, restoreMode: "park_only" };
@@ -2782,6 +2861,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
2782
2861
  await sweepBackgroundShells(suspendableEnv, defaultTaskRegistry);
2783
2862
  const snap = await suspendableEnv.suspendVM({ abortSignal: abortController.signal });
2784
2863
  if (!snap.ok) {
2864
+ remoteEnvFailures.push(remoteEnvFailureNote("suspendVM", snap.error, 1));
2785
2865
  deps.onError?.(new Error(`resource suspendVM failed (${snap.error.code}): ${snap.error.message}`, { cause: snap.error }), { phase: "config", sessionId });
2786
2866
  return false;
2787
2867
  }
@@ -2816,7 +2896,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
2816
2896
  };
2817
2897
  if (!(await commitSuspendSaga(token, cp, suspendableEnv, remoteHandle)))
2818
2898
  return false;
2819
- publishCommittedSuspend(token, gate, rs.scope);
2899
+ publishCommittedSuspend(token, gate, rs.scope, remoteHandle);
2820
2900
  try {
2821
2901
  await sessions.pin?.(sessionId);
2822
2902
  }
@@ -2833,6 +2913,14 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
2833
2913
  return false;
2834
2914
  if (abortController.signal.aborted)
2835
2915
  return false;
2916
+ if (incompleteSuspendAdapter !== undefined) {
2917
+ try {
2918
+ 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 });
2919
+ }
2920
+ catch {
2921
+ }
2922
+ return false;
2923
+ }
2836
2924
  if (suspendLoopCapHit(priorSuspendCount, maxSuspends, " for a plan_review (likely a resume/restart loop)."))
2837
2925
  return false;
2838
2926
  const leafId = await session.getLeafId();
@@ -2852,6 +2940,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
2852
2940
  await sweepBackgroundShells(suspendableEnv, defaultTaskRegistry);
2853
2941
  const snap = await suspendableEnv.suspendVM({ abortSignal: abortController.signal });
2854
2942
  if (!snap.ok) {
2943
+ remoteEnvFailures.push(remoteEnvFailureNote("suspendVM", snap.error, 1));
2855
2944
  deps.onError?.(new Error(`plan_review suspendVM failed (${snap.error.code}): ${snap.error.message}`, { cause: snap.error }), { phase: "config", sessionId });
2856
2945
  return false;
2857
2946
  }
@@ -2890,7 +2979,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
2890
2979
  };
2891
2980
  if (!(await commitSuspendSaga(token, cp, suspendableEnv, remoteHandle)))
2892
2981
  return false;
2893
- publishCommittedSuspend(token, gate, scope);
2982
+ publishCommittedSuspend(token, gate, scope, remoteHandle);
2894
2983
  try {
2895
2984
  await sessions.pin?.(sessionId);
2896
2985
  }
@@ -2924,10 +3013,12 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
2924
3013
  "RunnerDeps.toolResultStore or disable offload.");
2925
3014
  }
2926
3015
  if (ownedEnv !== undefined && remoteEnv === undefined && parkOnlyRemoteEnv === undefined) {
2927
- throw new Error("durable suspend is not supported with a non-remote per-task executionEnvFactory env: the " +
2928
- "minted env is destroyed on suspend, so a resumed file/shell tool would act on a fresh " +
2929
- "(empty) env. Use a RemoteExecutionEnv factory (it is paused, not destroyed) or a static, " +
2930
- "caller-owned RunnerDeps.executionEnv.");
3016
+ throw new Error(incompleteSuspendAdapter !== undefined
3017
+ ? `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).`
3018
+ : "durable suspend is not supported with a non-remote per-task executionEnvFactory env: the " +
3019
+ "minted env is destroyed on suspend, so a resumed file/shell tool would act on a fresh " +
3020
+ "(empty) env. Use a RemoteExecutionEnv factory (it is paused, not destroyed) or a static, " +
3021
+ "caller-owned RunnerDeps.executionEnv.");
2931
3022
  }
2932
3023
  const leafId = await session.getLeafId();
2933
3024
  if (!leafId) {
@@ -2945,6 +3036,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
2945
3036
  await sweepBackgroundShells(remoteEnv, defaultTaskRegistry);
2946
3037
  const snap = await remoteEnv.suspendVM({ abortSignal: abortController.signal });
2947
3038
  if (!snap.ok) {
3039
+ remoteEnvFailures.push(remoteEnvFailureNote("suspendVM", snap.error, 1));
2948
3040
  throw new Error(`suspendVM failed (${snap.error.code}): ${snap.error.message}`, { cause: snap.error });
2949
3041
  }
2950
3042
  remoteHandle = { ...remoteEnv.workspaceHandle(), snapshotId: snap.value };
@@ -3033,7 +3125,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3033
3125
  if (!(await commitSuspendSaga(token, cp, remoteEnv, remoteHandle))) {
3034
3126
  return undefined;
3035
3127
  }
3036
- publishCommittedSuspend(token, gate, cp.scope);
3128
+ publishCommittedSuspend(token, gate, cp.scope, remoteHandle);
3037
3129
  try {
3038
3130
  await sessions.pin?.(sessionId);
3039
3131
  }
@@ -3382,7 +3474,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3382
3474
  : undefined;
3383
3475
  overheadState.promptChars = systemPrompt.length;
3384
3476
  const preparedHolder = {};
3385
- 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 } : {}) });
3477
+ 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 } : {}) });
3386
3478
  const prepared = buildPrepared();
3387
3479
  preparedHolder.current = prepared;
3388
3480
  return prepared;
@@ -1345,6 +1345,16 @@ export class Runner {
1345
1345
  queue.close();
1346
1346
  return;
1347
1347
  }
1348
+ const remoteEnvFailure = (() => {
1349
+ let cur = err;
1350
+ for (let depth = 0; cur && typeof cur === "object" && depth < 8; depth++) {
1351
+ const note = cur.remoteEnvFailure;
1352
+ if (note !== undefined)
1353
+ return [note];
1354
+ cur = cur.cause;
1355
+ }
1356
+ return undefined;
1357
+ })();
1348
1358
  resultValue = {
1349
1359
  taskId: taskIdRef.current ?? "unknown",
1350
1360
  sessionId: taskIdRef.sessionId ?? "unknown",
@@ -1352,6 +1362,7 @@ export class Runner {
1352
1362
  result: "",
1353
1363
  errorMessage: err instanceof Error ? err.message : String(err),
1354
1364
  errorCode: code,
1365
+ ...(remoteEnvFailure !== undefined ? { remoteEnvFailures: remoteEnvFailure } : {}),
1355
1366
  stats: { turns: 0, tokens: 0, toolCalls: 0, cachedTokens: 0, costMicroUsd: 0 },
1356
1367
  };
1357
1368
  emitTrace(spec.tracer ?? this.deps.tracer, () => ({
@@ -2765,6 +2776,7 @@ export class Runner {
2765
2776
  model: prepared.model.id,
2766
2777
  unpricedSpend: rs.telemetry.unpricedSpend,
2767
2778
  rewindNotes: prepared.rewindNotes,
2779
+ remoteEnvFailures: prepared.remoteEnvFailures,
2768
2780
  abortedForTimeout: timeout.fired,
2769
2781
  abortedForTurns: rs.limits.turnsExceeded,
2770
2782
  abortedLive,
@@ -2774,10 +2786,18 @@ export class Runner {
2774
2786
  outputInvalid: rs.degrade.outputInvalid,
2775
2787
  suspendLoop: prepared.suspendLoopRef.hit,
2776
2788
  suspendRef: prepared.suspendRef.token !== undefined && prepared.suspendRef.gate !== undefined
2777
- ? { token: prepared.suspendRef.token, gate: prepared.suspendRef.gate }
2789
+ ? {
2790
+ token: prepared.suspendRef.token,
2791
+ gate: prepared.suspendRef.gate,
2792
+ ...(prepared.suspendRef.restoreMode !== undefined ? { restoreMode: prepared.suspendRef.restoreMode } : {}),
2793
+ }
2778
2794
  : undefined,
2779
2795
  reviewRef: prepared.reviewRef.token !== undefined && prepared.reviewRef.gate !== undefined
2780
- ? { token: prepared.reviewRef.token, gate: prepared.reviewRef.gate }
2796
+ ? {
2797
+ token: prepared.reviewRef.token,
2798
+ gate: prepared.reviewRef.gate,
2799
+ ...(prepared.reviewRef.restoreMode !== undefined ? { restoreMode: prepared.reviewRef.restoreMode } : {}),
2800
+ }
2781
2801
  : undefined,
2782
2802
  });
2783
2803
  if (resume !== undefined &&
@@ -12,6 +12,7 @@ export interface TtlSessionStoreOptions {
12
12
  export declare class TtlSessionStore implements SessionStore {
13
13
  private repo;
14
14
  private entries;
15
+ private owners;
15
16
  private pending;
16
17
  private pinned;
17
18
  private defaultTtlMs;
@@ -31,6 +32,8 @@ export declare class TtlSessionStore implements SessionStore {
31
32
  forget(sessionId: string): void;
32
33
  pin(sessionId: string): void;
33
34
  unpin(sessionId: string): void;
35
+ ownerOf(sessionId: string): Promise<string | null | undefined>;
36
+ register(sessionId: string, owner: string | null): Promise<void>;
34
37
  sweep(now?: number): void;
35
38
  get size(): number;
36
39
  dispose(): void;
@@ -6,6 +6,7 @@ export { SESSION_DEFAULT_TTL_DAYS };
6
6
  export class TtlSessionStore {
7
7
  repo;
8
8
  entries = new Map();
9
+ owners = new Map();
9
10
  pending = new Map();
10
11
  pinned = new Set();
11
12
  defaultTtlMs;
@@ -132,6 +133,7 @@ export class TtlSessionStore {
132
133
  this.entries.delete(sessionId);
133
134
  if (this.evictPolicy === "delete") {
134
135
  await this.repo.delete(await e.session.getMetadata());
136
+ this.owners.delete(sessionId);
135
137
  }
136
138
  }
137
139
  forget(sessionId) {
@@ -143,6 +145,13 @@ export class TtlSessionStore {
143
145
  unpin(sessionId) {
144
146
  this.pinned.delete(sessionId);
145
147
  }
148
+ async ownerOf(sessionId) {
149
+ return this.owners.has(sessionId) ? this.owners.get(sessionId) : undefined;
150
+ }
151
+ async register(sessionId, owner) {
152
+ if (!this.owners.has(sessionId))
153
+ this.owners.set(sessionId, owner);
154
+ }
146
155
  sweep(now = Date.now()) {
147
156
  for (const [id, e] of this.entries) {
148
157
  if (this.pinned.has(id)) {
@@ -152,6 +161,7 @@ export class TtlSessionStore {
152
161
  this.entries.delete(id);
153
162
  if (this.evictPolicy === "delete") {
154
163
  void this.repo.delete({ id, createdAt: "" });
164
+ this.owners.delete(id);
155
165
  }
156
166
  }
157
167
  }
@@ -31,6 +31,8 @@ export interface SessionStore {
31
31
  noteTaskRun?(sessionId: string, taskId: string): void | Promise<void>;
32
32
  list?(): Promise<SessionStoreSummary[]>;
33
33
  fork?(sourceId: string, owner?: string | null): Promise<string | null>;
34
+ ownerOf?(sessionId: string): Promise<string | null | undefined>;
35
+ register?(sessionId: string, owner: string | null): Promise<void>;
34
36
  readonly size: number;
35
37
  dispose(): void | Promise<void>;
36
38
  }
@@ -21,6 +21,8 @@ function isOffloadedPreview(content) {
21
21
  return first?.type === "text" && typeof first.text === "string" && first.text.startsWith(PERSISTED_OUTPUT_PREFIX);
22
22
  }
23
23
  function replaceTextBlock(m, text) {
24
+ if (!isToolResult(m))
25
+ return m;
24
26
  const images = m.content.filter((b) => b.type !== "text");
25
27
  return { ...m, content: [{ type: "text", text }, ...images] };
26
28
  }
@@ -370,6 +370,13 @@ export interface TaskSpec {
370
370
  signal?: AbortSignal;
371
371
  }
372
372
  export type TaskStatus = "completed" | "blocked" | "failed" | "timeout" | "suspended" | "needs_review";
373
+ export interface RemoteEnvFailureNote {
374
+ op: "suspendVM" | "resumeVM" | "postResumeInit";
375
+ code: import("./remote-env.js").RemoteExecutionErrorCode;
376
+ retryable: boolean;
377
+ attempts: number;
378
+ message: string;
379
+ }
373
380
  export interface TaskResult {
374
381
  taskId: string;
375
382
  sessionId: string;
@@ -380,6 +387,8 @@ export interface TaskResult {
380
387
  blockedReason?: string;
381
388
  checkpointToken?: import("./checkpoint-store.js").CheckpointToken;
382
389
  checkpointGate?: import("./checkpoint-store.js").CheckpointGate;
390
+ workspaceRestoreMode?: "snapshot" | "park_only";
391
+ remoteEnvFailures?: RemoteEnvFailureNote[];
383
392
  errorMessage?: string;
384
393
  errorCode?: string;
385
394
  degraded?: {
@@ -47,7 +47,7 @@ export declare class FileError extends Error {
47
47
  path?: string;
48
48
  constructor(code: FileErrorCode, message: string, path?: string, cause?: Error);
49
49
  }
50
- export type ExecutionErrorCode = "aborted" | "timeout" | "shell_unavailable" | "spawn_error" | "callback_error" | "transport_lost" | "unknown";
50
+ export type ExecutionErrorCode = "aborted" | "timeout" | "shell_unavailable" | "spawn_error" | "callback_error" | "transport_lost" | "suspended" | "auth_failed" | "unknown";
51
51
  export declare class ExecutionError extends Error {
52
52
  code: ExecutionErrorCode;
53
53
  partialStdout?: string;
@@ -34,8 +34,9 @@ export function buildSessionContext(pathEntries, opts) {
34
34
  }
35
35
  }
36
36
  const messages = [];
37
+ const isAssistantMessage = (m) => m.role === "assistant";
37
38
  const stripAssistantUsage = (message) => {
38
- if (message.role !== "assistant") {
39
+ if (!isAssistantMessage(message)) {
39
40
  return message;
40
41
  }
41
42
  return {
package/dist/index.d.ts CHANGED
@@ -56,7 +56,7 @@ export { getShellConfig, isWslBashLauncher } from "./engine/execution-env/node-e
56
56
  export { isSecretEnvKey, scrubSecretEnv } from "./core/secret-env.js";
57
57
  export { MAX_EXEC_OUTPUT_BYTES, RollingTailBuffer, markTruncated } from "./core/exec-output-tail.js";
58
58
  export type { ExecutionEnv, FileInfo, Result, FileErrorCode, ExecutionErrorCode } from "./internal/harness.js";
59
- export { RemoteExecutionError, hasDestroy, isRemoteExecutionEnv, isSuspendable, isIsolated, } from "./core/remote-env.js";
59
+ export { RemoteExecutionError, hasDestroy, isRemoteExecutionEnv, isSuspendable, isIsolated, missingRestoreSurface, isRetryableRemoteErrorCode, RETRYABLE_REMOTE_ERROR_CODES, } from "./core/remote-env.js";
60
60
  export { withRetry } from "./core/with-retry.js";
61
61
  export type { RetryPolicy, RetryResult } from "./core/with-retry.js";
62
62
  export type { RemoteExecutionEnv, WorkspaceHandle, SnapshotId, SessionToken, SandboxTier, OutputChunk, ExecStreamOptions, RemoteConnectConfig, VmLifecycleOptions, SecretRef, RemoteExecutionErrorCode, ExecutionEnvFactory, ExecutionEnvFactoryContext, } from "./core/remote-env.js";
@@ -189,7 +189,7 @@ export { retryBackoffMs, parseRetryAfter } from "./brain/retry.js";
189
189
  export { type BrainTimeoutConfig } from "./brain/timeout.js";
190
190
  export { createAssistantMessageEventStream } from "./internal/llm.js";
191
191
  export type { AssistantMessage, AssistantMessageEvent, Context, DocumentContent, ImageContent, Message, StopReason, StreamFn, TextContent, ThinkingContent, ToolCall, ToolResultMessage, Usage, UserMessage, } from "./internal/llm.js";
192
- export type { AgentDefinition, BeforeWriteHook, BeforeWriteRequest, BeforeWriteResult, HandsBandOptions, Brain, BrainStatus, BrainStatusPhase, ImageInput, McpElicitRequest, McpElicitResponse, McpServerSpec, OnElicit, Model, ModelRef, ProjectMemoryLoad, RunnerDeps, RuntimeCaps, BackgroundChildEvent, SkillManifest, SkillSpec, TaskEvent, TaskEventIdentity, ToolActivity, TaskResult, TaskSpec, TaskStatus, TaskStream, CompactOutcome, ThinkingLevel, ToolExecuteContext, ToolReturn, ToolSpec, ToolEffect, WorkflowGovernanceBaseline, } from "./core/types.js";
192
+ export type { AgentDefinition, BeforeWriteHook, BeforeWriteRequest, BeforeWriteResult, HandsBandOptions, Brain, BrainStatus, BrainStatusPhase, ImageInput, McpElicitRequest, McpElicitResponse, McpServerSpec, OnElicit, Model, ModelRef, ProjectMemoryLoad, RunnerDeps, RuntimeCaps, BackgroundChildEvent, SkillManifest, SkillSpec, TaskEvent, TaskEventIdentity, ToolActivity, TaskResult, RemoteEnvFailureNote, TaskSpec, TaskStatus, TaskStream, CompactOutcome, ThinkingLevel, ToolExecuteContext, ToolReturn, ToolSpec, ToolEffect, WorkflowGovernanceBaseline, } from "./core/types.js";
193
193
  export { Type } from "typebox";
194
194
  export type { TSchema, Static } from "typebox";
195
195
  export { explainPromptAssembly, describeDefaultPack, type DefaultPackDescription, type ExplainInput } from "./prompt-assembly/explain.js";
package/dist/index.js CHANGED
@@ -51,7 +51,7 @@ export { killProcessTree, signalProcessTree } from "./engine/execution-env/kill-
51
51
  export { getShellConfig, isWslBashLauncher } from "./engine/execution-env/node-execution-env.js";
52
52
  export { isSecretEnvKey, scrubSecretEnv } from "./core/secret-env.js";
53
53
  export { MAX_EXEC_OUTPUT_BYTES, RollingTailBuffer, markTruncated } from "./core/exec-output-tail.js";
54
- export { RemoteExecutionError, hasDestroy, isRemoteExecutionEnv, isSuspendable, isIsolated, } from "./core/remote-env.js";
54
+ export { RemoteExecutionError, hasDestroy, isRemoteExecutionEnv, isSuspendable, isIsolated, missingRestoreSurface, isRetryableRemoteErrorCode, RETRYABLE_REMOTE_ERROR_CODES, } from "./core/remote-env.js";
55
55
  export { withRetry } from "./core/with-retry.js";
56
56
  export { addWorktree, pruneWorktrees, WORKTREE_PARENT } from "./core/git-worktree-env.js";
57
57
  export { runExecGate } from "./core/exec-gate.js";
@@ -229,6 +229,20 @@ async function runShell(env, cwd, toolName, command, timeoutSec, caps, signal, c
229
229
  isError: true,
230
230
  };
231
231
  }
232
+ if (res.error.code === "suspended") {
233
+ return {
234
+ content: `Error (${toolName}): the execution environment is suspended (its workspace VM is paused), so the command did NOT run and no command can run in this leg. Do not retry it here — the task must be resumed first; report that the workspace is suspended. (${res.error.message})`,
235
+ details: { type: "bash", envSuspended: true },
236
+ isError: true,
237
+ };
238
+ }
239
+ if (res.error.code === "auth_failed") {
240
+ return {
241
+ content: `Error (${toolName}): authentication to the execution environment was rejected, so the command did NOT run. Do NOT retry — this is a permanent credential failure that an operator must fix; report it instead of trying other commands. (${res.error.message})`,
242
+ details: { type: "bash", authFailed: true },
243
+ isError: true,
244
+ };
245
+ }
232
246
  if (res.error.code === "timeout" || res.error.code === "aborted" || res.error.code === "callback_error") {
233
247
  const rawStdout = res.error.partialStdout ?? "";
234
248
  const rawStderr = res.error.partialStderr ?? "";
@@ -152,6 +152,7 @@ export function createEditFileTool(env, state, rootCanonical, cwdRef, additional
152
152
  return errorResult(`Error (Edit): "${path}" has a truncated UTF-16 body; repair/convert it with bash first.`);
153
153
  const original = decoded.text;
154
154
  const entry = state.get(r.key);
155
+ const readWasTruncated = entry?.truncated === true;
155
156
  const stale = checkStale(entry, sha256(original));
156
157
  if (stale)
157
158
  return errorResult(violationText("Edit", stale));
@@ -180,7 +181,7 @@ export function createEditFileTool(env, state, rootCanonical, cwdRef, additional
180
181
  oldS = resolvedEsc;
181
182
  }
182
183
  }
183
- const match = checkEditMatch(working, oldS, e.replace_all === true, entry.truncated);
184
+ const match = checkEditMatch(working, oldS, e.replace_all === true, readWasTruncated);
184
185
  if (match) {
185
186
  const escNote = match.code === "ambiguous_edit" && escapeMatchWasAttempted(e.old_string) && !working.includes(oldS) ? ESCAPE_MATCH_MISS_NOTE : "";
186
187
  return errorResult(batch ? `Error (Edit): ${where}${match.message}${escNote} (no changes written — the batch is atomic).` : `${violationText("Edit", match)}${escNote}`);
@@ -58,7 +58,7 @@ export declare const OVERSIZE_READ_ESCAPE_HINT = "(This file is over the Read to
58
58
  export declare const PARTIAL_VIEW_READ_ESCAPE_HINT = "(Your last Read of this file returned only a PARTIAL view \u2014 the output token cap paginated it, so a default Read will keep returning the same page. Re-read it with explicit offset/limit (start from the page marker's next-page hint) until you have seen the part you are about to change; an explicit slice that fits satisfies the read-first rule. Or inspect/transform it with bash (e.g. `sed -n`, `grep`) instead.)";
59
59
  export declare const WRITE_ENCODING_DEADLOCK_ESCAPE_HINT = "(If the Read tool refuses this file (binary/unknown encoding), overwrite or convert it with bash instead \u2014 e.g. `rm` + rewrite, or `iconv`.)";
60
60
  export declare function checkNoChange(oldString: string, newString: string): FsViolation | undefined;
61
- export declare function checkStale(entry: ReadEntry, currentHash: string): FsViolation | undefined;
61
+ export declare function checkStale(entry: ReadEntry | undefined, currentHash: string): FsViolation | undefined;
62
62
  export declare function countOccurrences(haystack: string, needle: string): number;
63
63
  export declare function similarNameSuggestion(siblingNames: readonly string[], missingName: string): string | undefined;
64
64
  export declare function normalizeQuotes(s: string): string;