@sema-agent/core 5.63.0 → 5.65.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 (56) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/dist/agents/cascade.d.ts +5 -1
  3. package/dist/agents/cascade.js +6 -1
  4. package/dist/agents/subagent.js +1 -0
  5. package/dist/agents/verify.d.ts +5 -1
  6. package/dist/agents/verify.js +5 -2
  7. package/dist/core/checkpoint-store.d.ts +100 -5
  8. package/dist/core/fs-write-gate-policy.d.ts +21 -0
  9. package/dist/core/fs-write-gate-policy.js +14 -3
  10. package/dist/core/hooks.d.ts +9 -1
  11. package/dist/core/hooks.js +1 -1
  12. package/dist/core/memory-engine/dual-root.js +3 -0
  13. package/dist/core/memory-engine/engine.d.ts +1 -0
  14. package/dist/core/memory-engine/engine.js +9 -5
  15. package/dist/core/memory-engine/types.d.ts +16 -0
  16. package/dist/core/remote-env.d.ts +34 -2
  17. package/dist/core/runner/prepare-hands-readface.d.ts +9 -0
  18. package/dist/core/runner/prepare-hands-readface.js +4 -1
  19. package/dist/core/runner/prepare-memory.js +1 -1
  20. package/dist/core/runner/prepare-task.d.ts +31 -0
  21. package/dist/core/runner/prepare-task.js +53 -18
  22. package/dist/core/runner/prepare-workspace-restore.js +13 -0
  23. package/dist/core/runner/runtask.d.ts +18 -0
  24. package/dist/core/runner/runtask.js +173 -20
  25. package/dist/core/stub-env.d.ts +4 -0
  26. package/dist/core/stub-env.js +1 -0
  27. package/dist/core/task-registry-shared.js +20 -2
  28. package/dist/core/types.d.ts +104 -11
  29. package/dist/core/usage-window-store.d.ts +44 -12
  30. package/dist/core/usage-window-store.js +11 -3
  31. package/dist/core/workflow-run-store-contract.js +30 -0
  32. package/dist/core/workflow-run-store.d.ts +39 -1
  33. package/dist/core/workflow-run-store.js +2 -0
  34. package/dist/engine/harness/agent-harness.d.ts +8 -3
  35. package/dist/engine/harness/agent-harness.js +9 -4
  36. package/dist/engine/harness/types.d.ts +107 -3
  37. package/dist/engine/loop/agent-loop.js +39 -15
  38. package/dist/engine/loop/types.d.ts +43 -22
  39. package/dist/index.d.ts +2 -2
  40. package/dist/internal/harness-types.d.ts +1 -1
  41. package/dist/orchestration/run-workflow-tool.d.ts +6 -0
  42. package/dist/orchestration/run-workflow-tool.js +1 -0
  43. package/dist/orchestration/workflow-types.d.ts +35 -0
  44. package/dist/orchestration/workflow-types.js +2 -2
  45. package/dist/orchestration/workflow.d.ts +6 -0
  46. package/dist/orchestration/workflow.js +21 -1
  47. package/dist/prompts/default.d.ts +8 -0
  48. package/dist/prompts/default.js +3 -0
  49. package/dist/tools/fs/bash-readonly-classifier.d.ts +44 -1
  50. package/dist/tools/fs/bash-readonly-classifier.js +132 -5
  51. package/dist/tools/fs/fs-bash.js +9 -2
  52. package/dist/tools/fs/fs-write.js +19 -8
  53. package/dist/tools/fs/index.d.ts +1 -0
  54. package/dist/tools/fs/index.js +1 -0
  55. package/package.json +1 -1
  56. package/test/export-surface.snapshot.json +9 -1
@@ -36,13 +36,13 @@ import { cacheFamilyOf, usageCostMicroUsd } from "./usage-accounting.js";
36
36
  import { assembleResult, errorCodeOf } from "./assemble-result.js";
37
37
  import { ATTACHMENT_BYTE_CAP, CHANGED_FILES_MAX, AGENT_LISTING_REMOVED_HEADER, SKILLS_LISTING_DELTA_HEADER, SKILLS_LISTING_REMOVED_HEADER, advanceCadenceClock, agentListingDeltaHeader, attachmentEnvelopeTags, agentListingInitialHeader, replayAnnouncedListing, replayAnnouncedModels, clipToBytes, collectDateChange, collectDueAttachments, collectInstructionsChange, commitAgentListing, commitInstructionsChange, commitSkillsListing, createAttachmentState, rebaseCadenceWindows, reduceToolEnd, renderAgentListingDelta, renderMcpDroppedTools, renderMcpInstructionsDelta, renderOrphanedBackgroundTasks, selectMcpDroppedBatch, renderSkillsListingDelta, renderToolsDelta, stampWriteAnchor } from "./turn-attachments.js";
38
38
  import { buildWorkingFileAttachments, centerAdoptionOption, emitInputTruncated, forkContextOption } from "./compaction-call-options.js";
39
- import { effectiveDelegationFacts, gatedCallIdOf, prepareTask, resolveCheckpointStore } from "./prepare-task.js";
39
+ import { effectiveDelegationFacts, gatedCallIdOf, placementValueOrAbsent, prepareTask, resolveCheckpointStore } from "./prepare-task.js";
40
40
  import { settleTeardownLeg } from "./teardown-bounded.js";
41
41
  import { TOOL_SEARCH_NAME } from "./tool-disclosure.js";
42
42
  import { hasVerifiableStructureSignal } from "./grounding-signal.js";
43
43
  import { hasDestroy, isIsolated } from "../remote-env.js";
44
44
  import { hasBackgroundShell, sweepBackgroundShells } from "../background-shell.js";
45
- import { cloneObserverInput, formatHookFeedback, hookSeatExpiredError, mintHookInvocationIdentity, resolveHookTimeoutMs, runHookSeat } from "../hooks.js";
45
+ import { cloneObserverInput, formatHookFeedback, hookSeatExpiredError, MAX_HOOK_TIMEOUT_MS, mintHookInvocationIdentity, resolveHookTimeoutMs, runHookSeat } from "../hooks.js";
46
46
  import { buildHumanInputEvent, frameMidTurnUserInput, projectHumanInput } from "../human-input-projection.js";
47
47
  import { delimitUntrusted, inlineUntrusted, REVIEWER_NOTE_MAX_BODY, sanitizeUntrustedText, SHELLED_BODY_ENVELOPE_TAGS } from "../untrusted-text.js";
48
48
  import { appendInterruptionMarker, reconcileInterruptedSession } from "../session-reconcile.js";
@@ -84,6 +84,7 @@ function sameAcceptedSteerInput(a, b) {
84
84
  a.actor?.issuer === b.actor?.issuer);
85
85
  }
86
86
  const MAX_CONSECUTIVE_COMPACTION_FAILURES = 3;
87
+ const RESUME_PREFLIGHT_DEFAULT_TIMEOUT_MS = 10_000;
87
88
  const STOP_HOOK_BLOCK_CAP = 8;
88
89
  const COMPACTION_REGROWTH_FACTOR = 1.5;
89
90
  const COMPACTION_FREED_EPSILON = 256;
@@ -1455,6 +1456,8 @@ export class Runner {
1455
1456
  snapshotTooLargeRoots = new Map();
1456
1457
  pendingSessionNotifications = new PendingSessionNotifications();
1457
1458
  parentConstraintRegistry = new Map();
1459
+ suspendedEnvReaps = new Map();
1460
+ locallyClaimedTokens = new Set();
1458
1461
  static PARENT_CONSTRAINT_REGISTRY_CAP = 1024;
1459
1462
  constructor(deps) {
1460
1463
  this.deps = deps;
@@ -1663,8 +1666,11 @@ export class Runner {
1663
1666
  await this.runLocked(spec, queue, (r) => {
1664
1667
  resultValue = r;
1665
1668
  const pcs = internals?.inheritedGate?.parentConstraints;
1669
+ if (resume !== undefined)
1670
+ this.locallyClaimedTokens.delete(resume.cp.token);
1666
1671
  if (resume !== undefined && r.checkpointToken !== resume.cp.token) {
1667
1672
  this.parentConstraintRegistry.delete(resume.cp.token);
1673
+ this.suspendedEnvReaps.delete(resume.cp.token);
1668
1674
  }
1669
1675
  if (r.status === "suspended" && r.checkpointToken !== undefined && pcs !== undefined && pcs.length > 0) {
1670
1676
  if (this.parentConstraintRegistry.size >= Runner.PARENT_CONSTRAINT_REGISTRY_CAP) {
@@ -1681,6 +1687,7 @@ export class Runner {
1681
1687
  publishReady(h);
1682
1688
  }, (s) => {
1683
1689
  reapHandle = s;
1690
+ this.suspendedEnvReaps.set(s.token, s);
1684
1691
  }, manualCompactRef, taskIdRef, resume, { ...(internals ?? {}), detachHub }, notifyRef, entryActor);
1685
1692
  }
1686
1693
  finally {
@@ -1698,6 +1705,7 @@ export class Runner {
1698
1705
  const reopenReason = code === "resume.tool_unavailable" ? "tool_unavailable" : "env_failed";
1699
1706
  const reopenable = resume !== undefined && resume.pendingActionStarted !== true && reopenReason !== undefined && !(resumeDecisionWasNegative(resume) && resume.decisionDelivered === true);
1700
1707
  let reopenCommitted = false;
1708
+ let reopenStateUnknown = false;
1701
1709
  if (reopenable && resume?.onEnvRestoreFailed) {
1702
1710
  try {
1703
1711
  await resume.onEnvRestoreFailed(reopenReason);
@@ -1708,15 +1716,26 @@ export class Runner {
1708
1716
  const original = err instanceof Error ? err : new Error(String(err));
1709
1717
  const reopenMsg = reopenErr instanceof Error ? reopenErr.message : String(reopenErr);
1710
1718
  const definitive = errorCodeOf(reopenErr) === "checkpoint.reopen_failed";
1719
+ reopenStateUnknown = !definitive;
1711
1720
  err = Object.assign(new Error(definitive
1712
1721
  ? `${reopenMsg} (original resume failure: ${original.message})`
1713
1722
  : `checkpoint reopen threw mid-flight — state UNKNOWN, confirm via the checkpoint store before retrying: ${reopenMsg} (original resume failure: ${original.message})`, { cause: original }), { code: "checkpoint.reopen_failed" });
1714
1723
  code = "checkpoint.reopen_failed";
1715
1724
  }
1716
1725
  }
1726
+ if (resume)
1727
+ this.locallyClaimedTokens.delete(resume.cp.token);
1717
1728
  if (resume && !reopenCommitted) {
1718
1729
  this.parentConstraintRegistry.delete(resume.cp.token);
1719
1730
  await settleTeardownLeg(() => this.sessions.unpin?.(resume.cp.sessionId), "sessions.unpin (resume-failure leg)", (e) => this.deps.onError?.(e, { phase: "config", sessionId: resume.cp.sessionId }));
1731
+ if (!reopenStateUnknown) {
1732
+ const failedReap = this.suspendedEnvReaps.get(resume.cp.token);
1733
+ this.suspendedEnvReaps.delete(resume.cp.token);
1734
+ if (failedReap?.env !== undefined && hasDestroy(failedReap.env)) {
1735
+ const failedEnv = failedReap.env;
1736
+ await settleTeardownLeg(() => failedEnv.destroy(), "terminal resume-failure env destroy", (e) => this.deps.onError?.(e, { phase: "config", sessionId: resume.cp.sessionId }));
1737
+ }
1738
+ }
1720
1739
  }
1721
1740
  if (resultValue !== undefined) {
1722
1741
  try {
@@ -1802,9 +1821,13 @@ export class Runner {
1802
1821
  reportErr(err);
1803
1822
  return;
1804
1823
  }
1805
- if (!won)
1824
+ if (!won) {
1825
+ if (!this.locallyClaimedTokens.has(rh.token))
1826
+ this.suspendedEnvReaps.delete(rh.token);
1806
1827
  return;
1828
+ }
1807
1829
  this.parentConstraintRegistry.delete(rh.token);
1830
+ this.suspendedEnvReaps.delete(rh.token);
1808
1831
  if (rh.env && hasDestroy(rh.env)) {
1809
1832
  try {
1810
1833
  await rh.env.destroy();
@@ -3119,7 +3142,10 @@ export class Runner {
3119
3142
  }
3120
3143
  catch (err) {
3121
3144
  if (turnSignal?.aborted === true && !prepared.abortController.signal.aborted) {
3122
- queue.push({ type: "compaction_outcome", outcome: "failed", trigger: "forced", reason: "guard-chain forced compaction cut short by a turn interrupt", ...ident() });
3145
+ const interruptReason = lane === "rejection"
3146
+ ? "prompt-too-long recovery pass cut short by a turn interrupt"
3147
+ : "guard-chain forced compaction cut short by a turn interrupt";
3148
+ queue.push({ type: "compaction_outcome", outcome: "failed", trigger: "forced", reason: interruptReason, ...ident() });
3123
3149
  return false;
3124
3150
  }
3125
3151
  compactionBreaker.failures += 1;
@@ -3135,9 +3161,11 @@ export class Runner {
3135
3161
  thinkingOnly: {},
3136
3162
  degenerateOutput: { detect: (m) => isDegenerateCutMessage(m) },
3137
3163
  promptTooLong: {
3138
- recover: async (attempt) => {
3164
+ recover: async (attempt, turnSignal) => {
3139
3165
  if (prepared.abortController.signal.aborted || prepared.suspendRef.token !== undefined)
3140
3166
  return false;
3167
+ if (turnSignal?.aborted === true)
3168
+ return false;
3141
3169
  if (prepared.microCompact.clearOnRejection && attempt === 1) {
3142
3170
  const proj = prepared.microCompact.projectionRef.current;
3143
3171
  const compactionAvailable = (spec.compaction?.enabled ?? true) && compactionBreaker.failures < MAX_CONSECUTIVE_COMPACTION_FAILURES;
@@ -3174,7 +3202,7 @@ export class Runner {
3174
3202
  return true;
3175
3203
  }
3176
3204
  }
3177
- return runForcedCompactionPass("rejection");
3205
+ return runForcedCompactionPass("rejection", turnSignal);
3178
3206
  },
3179
3207
  },
3180
3208
  });
@@ -4308,7 +4336,9 @@ export class Runner {
4308
4336
  if (outcome === null || typeof outcome !== "object") {
4309
4337
  throw new CheckpointError("checkpoint.invalid_outcome", `resume outcome must be an object naming its gate (got ${outcome === null ? "null" : typeof outcome})`);
4310
4338
  }
4311
- const store = resolveCheckpointStore(taskConfig, this.deps);
4339
+ const config = typeof taskConfig === "object" && taskConfig !== null ? { ...taskConfig } : taskConfig;
4340
+ internals = internals !== undefined ? { ...internals } : undefined;
4341
+ const store = resolveCheckpointStore(config, this.deps);
4312
4342
  if (!store) {
4313
4343
  throw new CheckpointError("checkpoint.not_found", "no CheckpointStore wired — cannot resume (set RunnerDeps.checkpointStore or taskConfig.checkpointStore)");
4314
4344
  }
@@ -4335,7 +4365,7 @@ export class Runner {
4335
4365
  let wakeMessage;
4336
4366
  let wakeHookContext;
4337
4367
  const outcomeGate = outcome.gate;
4338
- const resumeSignal = taskConfig.signal;
4368
+ const resumeSignal = config.signal;
4339
4369
  let suppliedMessage;
4340
4370
  if (outcomeGate === "wake") {
4341
4371
  suppliedMessage = outcome.message;
@@ -4564,7 +4594,7 @@ export class Runner {
4564
4594
  : "resume carried a content-ask `answer` on a `deny` — a denial injects a refusal, never an answer; refusing rather than dropping it silently", { field: "answer" });
4565
4595
  }
4566
4596
  if (contentAskCallId !== undefined && plainPolicyOutcome.decision === "allow" && suppliedAnswer === undefined) {
4567
- if (!isLiveQuestionFace(taskConfig.onQuestion ?? this.deps.onQuestion)) {
4597
+ if (!isLiveQuestionFace(config.onQuestion ?? this.deps.onQuestion)) {
4568
4598
  throw new CheckpointError("checkpoint.invalid_outcome", "resume approved a content-ask (the reserved question tool) without an `answer`, and this resume has no live answering face — " +
4569
4599
  'executing the question against nothing would hand the model a fabricated "no human is available" default while consuming the approval; ' +
4570
4600
  "re-resume with the operator's answer on the outcome (or deny it), the checkpoint stays pending", { field: "answer" });
@@ -4675,19 +4705,28 @@ export class Runner {
4675
4705
  },
4676
4706
  }
4677
4707
  : internals;
4678
- if (this.deps.lockedConfig?.keys?.includes("toolPolicy") === true && taskConfig.basePolicyForResumeEdit !== undefined) {
4708
+ if (this.deps.lockedConfig?.keys?.includes("toolPolicy") === true && config.basePolicyForResumeEdit !== undefined) {
4679
4709
  throw new CheckpointError("checkpoint.invalid_outcome", "TaskSpec.basePolicyForResumeEdit is administratively locked by this deployment (locked key \"toolPolicy\") — a task-supplied " +
4680
4710
  "resume-edit policy is refused pre-CAS (the checkpoint stays pending); remove the field or change the deployment's lock configuration");
4681
4711
  }
4682
4712
  let recheckGovernanceWindow;
4683
4713
  {
4684
4714
  const rowPrincipal = cp.principal || undefined;
4685
- const suppliedPrincipal = taskConfig.principal || undefined;
4715
+ const suppliedPrincipal = config.principal || undefined;
4686
4716
  if (rowPrincipal !== undefined && suppliedPrincipal !== undefined && suppliedPrincipal !== rowPrincipal) {
4687
4717
  throw new CheckpointError("resume.principal_mismatch", `the resume config carries principal ${JSON.stringify(suppliedPrincipal)} but this checkpoint was suspended under principal ${JSON.stringify(rowPrincipal)} — ` +
4688
4718
  "running the resumed leg under a different identity would move its usage-ledger bucket, scope derivation and attribution; " +
4689
4719
  "refused pre-CAS (the checkpoint stays pending): re-resume with the original principal, or omit the field to inherit the recorded one");
4690
4720
  }
4721
+ {
4722
+ const recordedPlacementRoot = placementValueOrAbsent(cp.state.placementRootSessionId);
4723
+ const suppliedPlacementRoot = placementValueOrAbsent(internals?.placementRoot);
4724
+ if (recordedPlacementRoot !== undefined && suppliedPlacementRoot !== undefined && suppliedPlacementRoot !== recordedPlacementRoot) {
4725
+ throw new CheckpointError("resume.placement_mismatch", `the resume supplied explicit placement root ${JSON.stringify(suppliedPlacementRoot)} but this checkpoint recorded placement root ${JSON.stringify(recordedPlacementRoot)} at suspend — ` +
4726
+ "resuming under a different placement fixed point would re-place the leg (and its descendants) on another target; " +
4727
+ "refused pre-CAS (the checkpoint stays pending): re-resume with the recorded root, or omit internals.placementRoot to inherit it");
4728
+ }
4729
+ }
4691
4730
  const owesDelivery = outcomeGate !== "resource_limit" ||
4692
4731
  readPendingSteerQueue(cp.state).length > 0 ||
4693
4732
  (cp.state.runningBackgroundTasks?.length ?? 0) > 0;
@@ -4709,13 +4748,13 @@ export class Runner {
4709
4748
  }
4710
4749
  }
4711
4750
  if (outcomeGate === "wake" && wakeMessage !== undefined) {
4712
- const resumeScreenHooks = taskConfig.hooks ?? this.deps.hooks;
4751
+ const resumeScreenHooks = config.hooks ?? this.deps.hooks;
4713
4752
  const screen = resumeScreenHooks?.userPromptSubmit;
4714
4753
  if (screen !== undefined && resumeSignal?.aborted !== true) {
4715
4754
  const screenedMessage = wakeMessage;
4716
4755
  const identity = mintHookInvocationIdentity({
4717
4756
  sessionId: cp.sessionId,
4718
- taskId: taskConfig.taskId ?? cp.sessionId,
4757
+ taskId: config.taskId ?? cp.sessionId,
4719
4758
  legKind: "resume",
4720
4759
  isDelegatedChild: effectiveDelegationFacts(internals, cp.state.isDelegatedChild).isDelegatedChild,
4721
4760
  ...(internals?.insideFork === true ? { insideFork: true } : {}),
@@ -4784,7 +4823,7 @@ export class Runner {
4784
4823
  budget: editBudget,
4785
4824
  ...(cp.state.handsCwd !== undefined ? { cwd: cp.state.handsCwd } : {}),
4786
4825
  };
4787
- const editSignal = taskConfig.signal;
4826
+ const editSignal = config.signal;
4788
4827
  const raceEditAbort = async (p) => {
4789
4828
  if (editSignal === undefined)
4790
4829
  return p;
@@ -4823,7 +4862,7 @@ export class Runner {
4823
4862
  const toolPolicyLocked = this.deps.lockedConfig?.keys?.includes("toolPolicy") === true;
4824
4863
  const editBasePolicy = toolPolicyLocked
4825
4864
  ? (this.deps.basePolicyForResumeEdit ?? this.deps.toolPolicy)
4826
- : (taskConfig.basePolicyForResumeEdit ?? this.deps.basePolicyForResumeEdit ?? taskConfig.toolPolicy ?? this.deps.toolPolicy);
4865
+ : (config.basePolicyForResumeEdit ?? this.deps.basePolicyForResumeEdit ?? config.toolPolicy ?? this.deps.toolPolicy);
4827
4866
  if (editBasePolicy !== undefined) {
4828
4867
  let rechecked;
4829
4868
  try {
@@ -4843,7 +4882,7 @@ export class Runner {
4843
4882
  `refused pre-CAS (the checkpoint stays pending); re-submit the edited action so the rewrite is adjudicated and approved synchronously`);
4844
4883
  }
4845
4884
  }
4846
- const resumeHooks = taskConfig.hooks ?? this.deps.hooks;
4885
+ const resumeHooks = config.hooks ?? this.deps.hooks;
4847
4886
  if (resumeHooks?.preToolUse !== undefined && resumeHooks.preToolUseObservational === true) {
4848
4887
  try {
4849
4888
  await raceEditAbort(Promise.resolve(resumeHooks.preToolUse(cp.pendingAction.toolName, cloneObserverInput(editedArgs), {
@@ -4863,7 +4902,121 @@ export class Runner {
4863
4902
  if (outcomeForStore === undefined) {
4864
4903
  throw new CheckpointError("checkpoint.invalid_outcome", `resume outcome (gate "${describeSuppliedValue(outcomeGate)}") matched the checkpoint gate but no lane captured it — refusing pre-CAS rather than passing the caller's live object to the store and the resumed run`);
4865
4904
  }
4905
+ if (this.deps.resumePreflight !== undefined) {
4906
+ const preflight = this.deps.resumePreflight;
4907
+ const rawDeadline = this.deps.resumePreflightTimeoutMs;
4908
+ let preflightTimeoutMs = RESUME_PREFLIGHT_DEFAULT_TIMEOUT_MS;
4909
+ if (rawDeadline !== undefined) {
4910
+ if (typeof rawDeadline === "number" && Number.isFinite(rawDeadline) && rawDeadline > 0 && rawDeadline <= MAX_HOOK_TIMEOUT_MS) {
4911
+ preflightTimeoutMs = rawDeadline;
4912
+ }
4913
+ else {
4914
+ try {
4915
+ this.deps.onError?.(new Error(`RunnerDeps.resumePreflightTimeoutMs is not a positive finite number within the seat ceiling of ${MAX_HOOK_TIMEOUT_MS}ms (got ${describeSuppliedValue(rawDeadline)}) — the resume preflight falls back to its own ${RESUME_PREFLIGHT_DEFAULT_TIMEOUT_MS}ms default`), { phase: "config", sessionId: cp.sessionId });
4916
+ }
4917
+ catch {
4918
+ }
4919
+ }
4920
+ }
4921
+ const info = {
4922
+ token,
4923
+ sessionId: cp.sessionId,
4924
+ ...(cp.state.placementRootSessionId !== undefined ? { placementRootSessionId: cp.state.placementRootSessionId } : {}),
4925
+ ...(cp.principal ? { principal: cp.principal } : {}),
4926
+ ...(cp.state.workspaceHandle !== undefined ? { workspaceHandle: structuredClone(cp.state.workspaceHandle) } : {}),
4927
+ gateKind: cp.gate.kind,
4928
+ };
4929
+ const refusalTail = "; the checkpoint stays pending and the same token is redeemable once the obstacle clears";
4930
+ let verdict;
4931
+ try {
4932
+ const seat = await runHookSeat("resumePreflight", {
4933
+ timeoutMs: preflightTimeoutMs,
4934
+ ...(resumeSignal !== undefined ? { signal: resumeSignal } : {}),
4935
+ abortEnds: true,
4936
+ onBadTimeout: (badErr) => {
4937
+ try {
4938
+ this.deps.onError?.(badErr instanceof Error ? badErr : new Error(String(badErr)), { phase: "hook", sessionId: cp.sessionId });
4939
+ }
4940
+ catch {
4941
+ }
4942
+ },
4943
+ owner: this.deps,
4944
+ }, (sig) => preflight(info, sig));
4945
+ if (seat.expired) {
4946
+ throw new CheckpointError("resume.preflight_rejected", seat.cause === "timeout"
4947
+ ? `the deployment's resumePreflight did not answer within its ${preflightTimeoutMs}ms deadline while screening this resume — refused fail-closed (retry-later arm)${refusalTail}`
4948
+ : `the resume was cancelled while the deployment's resumePreflight was still screening it — refused (retry-later arm)${refusalTail}`);
4949
+ }
4950
+ verdict = seat.value;
4951
+ }
4952
+ catch (preflightErr) {
4953
+ if (preflightErr instanceof CheckpointError)
4954
+ throw preflightErr;
4955
+ const err = preflightErr instanceof Error ? preflightErr : new Error(String(preflightErr));
4956
+ try {
4957
+ this.deps.onError?.(err, { phase: "hook", sessionId: cp.sessionId });
4958
+ }
4959
+ catch {
4960
+ }
4961
+ throw new CheckpointError("resume.preflight_rejected", `the deployment's resumePreflight crashed while screening this resume (${inlineUntrusted(err.message)}) — refused fail-closed (retry-later arm)${refusalTail}`);
4962
+ }
4963
+ const verdictOk = verdict !== null && typeof verdict === "object" ? verdict.ok : undefined;
4964
+ if (verdictOk !== true) {
4965
+ const bag = verdict !== null && typeof verdict === "object" ? verdict : undefined;
4966
+ const disposition = bag?.disposition;
4967
+ const suppliedMessage = bag?.message;
4968
+ const retryAfterMs = bag?.retryAfterMs;
4969
+ const readableMessage = typeof suppliedMessage === "string" && suppliedMessage !== "" ? suppliedMessage : undefined;
4970
+ const said = readableMessage !== undefined ? `: ${inlineUntrusted(readableMessage)}` : " (no readable verdict — fail-closed)";
4971
+ if (disposition === "terminal" && readableMessage !== undefined) {
4972
+ this.locallyClaimedTokens.add(token);
4973
+ let settled;
4974
+ try {
4975
+ settled = await store.expire(token, cp.scope);
4976
+ }
4977
+ catch (expireErr) {
4978
+ this.locallyClaimedTokens.delete(token);
4979
+ throw expireErr;
4980
+ }
4981
+ if (!settled) {
4982
+ this.locallyClaimedTokens.delete(token);
4983
+ const live = await store.get(token);
4984
+ if (live?.status === "pending") {
4985
+ throw new CheckpointError("resume.preflight_rejected", `the deployment's resumePreflight ruled this resume PERMANENTLY blocked${said}, but the row moved under this refusal (a concurrent resolve/reopen cycle) — nothing was settled here; the row is pending again, re-resume against the current state (the preflight screens every attempt)`);
4986
+ }
4987
+ throw new CheckpointError("resume.preflight_rejected", `the deployment's resumePreflight ruled this resume PERMANENTLY blocked${said} — a concurrent actor had already settled the row (${describeSuppliedValue(live?.status ?? "consumed")}); nothing was settled by this refusal`);
4988
+ }
4989
+ this.parentConstraintRegistry.delete(token);
4990
+ const terminalReap = this.suspendedEnvReaps.get(token);
4991
+ this.suspendedEnvReaps.delete(token);
4992
+ this.locallyClaimedTokens.delete(token);
4993
+ if (terminalReap?.env !== undefined && hasDestroy(terminalReap.env)) {
4994
+ const terminalEnv = terminalReap.env;
4995
+ await settleTeardownLeg(() => terminalEnv.destroy(), "terminal-preflight env destroy", (envErr) => {
4996
+ try {
4997
+ this.deps.onError?.(envErr, { phase: "config", sessionId: terminalReap.sessionId });
4998
+ }
4999
+ catch {
5000
+ }
5001
+ });
5002
+ }
5003
+ try {
5004
+ this.deps.onError?.(new Error(`resumePreflight ruled this row PERMANENTLY blocked${said} — the row was settled terminally (expired) by this refusal's single-shot CAS (token no longer redeemable)`), { phase: "hook", sessionId: cp.sessionId });
5005
+ }
5006
+ catch {
5007
+ }
5008
+ throw new CheckpointError("resume.preflight_rejected", `the deployment's resumePreflight ruled this resume PERMANENTLY blocked${said} — the row was settled terminally (expired by this refusal's single-shot CAS); the token is not redeemable`);
5009
+ }
5010
+ const waitHint = typeof retryAfterMs === "number" && Number.isFinite(retryAfterMs) && retryAfterMs >= 0 ? retryAfterMs : undefined;
5011
+ throw new CheckpointError("resume.preflight_rejected", `the deployment's resumePreflight refused this resume${said}${refusalTail}${waitHint !== undefined ? ` (deployment wait hint: ${String(waitHint)}ms)` : ""}`, waitHint !== undefined ? { retryAfterMs: waitHint } : undefined);
5012
+ }
5013
+ if (recheckGovernanceWindow !== undefined)
5014
+ await recheckGovernanceWindow();
5015
+ }
5016
+ this.locallyClaimedTokens.add(token);
4866
5017
  const won = await store.resolve(token, cp.scope, outcomeForStore, { rev: cp.rev ?? 0 });
5018
+ if (!won)
5019
+ this.locallyClaimedTokens.delete(token);
4867
5020
  if (!won) {
4868
5021
  const live = await store.get(token);
4869
5022
  if (live?.status === "pending") {
@@ -4878,15 +5031,15 @@ export class Runner {
4878
5031
  consumeFlipDone = true;
4879
5032
  }
4880
5033
  const answerFace = plainPolicyOutcome !== undefined && redeemedAnswer !== undefined
4881
- ? answerFaceForRedeemedCall(redeemedAnswer, plainPolicyOutcome.boundCallId, taskConfig.onQuestion ?? this.deps.onQuestion, boundInputHashOf(cp.pendingAction.args?.questions))
5034
+ ? answerFaceForRedeemedCall(redeemedAnswer, plainPolicyOutcome.boundCallId, config.onQuestion ?? this.deps.onQuestion, boundInputHashOf(cp.pendingAction.args?.questions))
4882
5035
  : undefined;
4883
5036
  const spec = {
4884
- ...taskConfig,
4885
- ...((taskConfig.principal || undefined) === undefined && (cp.principal || undefined) !== undefined ? { principal: cp.principal } : {}),
5037
+ ...config,
5038
+ ...((config.principal || undefined) === undefined && (cp.principal || undefined) !== undefined ? { principal: cp.principal } : {}),
4886
5039
  objective: "",
4887
5040
  sessionId: cp.sessionId,
4888
5041
  requireExistingSession: true,
4889
- preemptSignal: taskConfig.preemptSignal?.aborted ? undefined : taskConfig.preemptSignal,
5042
+ preemptSignal: config.preemptSignal?.aborted ? undefined : config.preemptSignal,
4890
5043
  ...(answerFace !== undefined ? { onQuestion: answerFace } : {}),
4891
5044
  };
4892
5045
  const reopenFn = store.reopen?.bind(store);
@@ -8,6 +8,10 @@ import { type ExecResult, type ExecutionEnv, ExecutionError, FileError, type Fil
8
8
  */
9
9
  export declare class StubExecutionEnv implements ExecutionEnv {
10
10
  cwd: string;
11
+ /** design/380 O9a — declared on the class so the prepare fold can read the optional interface
12
+ * member off a `ExecutionEnv | StubExecutionEnv` union; the no-I/O stub produces no target
13
+ * content, so it never declares (always `undefined` — the trusted-side default). */
14
+ readonly externalContentTarget?: boolean;
11
15
  constructor(cwd?: string);
12
16
  private fsErr;
13
17
  absolutePath(path: string): Promise<Result<string, FileError>>;
@@ -1,6 +1,7 @@
1
1
  import { ExecutionError, FileError, err, } from "../internal/harness.js";
2
2
  export class StubExecutionEnv {
3
3
  cwd;
4
+ externalContentTarget;
4
5
  constructor(cwd = "/") {
5
6
  this.cwd = cwd;
6
7
  }
@@ -166,6 +166,22 @@ function workflowAgentRow(a, ordinal) {
166
166
  ...(a.replayed === true ? { replayed: true } : {}),
167
167
  };
168
168
  }
169
+ function budgetOvershootNote(overshoot) {
170
+ if (overshoot === undefined)
171
+ return "";
172
+ const unsettled = overshoot.unsettledTokens ?? 0;
173
+ const total = overshoot.spentTokens + unsettled;
174
+ return (` token budget OVERSHOT: this run spent ${total.toLocaleString()} total tokens against a ${overshoot.budgetTokens.toLocaleString()} ceiling ` +
175
+ `(over by ${(total - overshoot.budgetTokens).toLocaleString()}` +
176
+ `${unsettled > 0 ? `, of which ${unsettled.toLocaleString()} was observed on agents still in flight at the terminal and never settled` : ""}).`);
177
+ }
178
+ function timeoutInterruptionNote(interruption) {
179
+ if (interruption === undefined)
180
+ return "";
181
+ return (` INTERRUPTED by the workflow's total timeout (${interruption.timeoutMs.toLocaleString()}ms): when the deadline fired ` +
182
+ `${interruption.agentsCompleted} agent(s) had completed, ${interruption.agentsFailed} had failed, and ` +
183
+ `${interruption.agentsInFlight} were still in flight — the failed run status is the deadline's, not a verdict on those agents.`);
184
+ }
169
185
  export function formatWorkflowRun(run) {
170
186
  const summary = summarizeWorkflowRun(run);
171
187
  const done = run.agents.filter((a) => a.status === "completed" || a.status === "failed").length;
@@ -223,7 +239,7 @@ export function formatWorkflowRun(run) {
223
239
  : {}),
224
240
  }
225
241
  : {}),
226
- note: run.status === "running"
242
+ note: (run.status === "running"
227
243
  ? "still running — poll again shortly."
228
244
  : run.status === "completed"
229
245
  ? run.result !== undefined
@@ -231,7 +247,9 @@ export function formatWorkflowRun(run) {
231
247
  : "completed — this run predates result persistence; its result was delivered on the completion notification."
232
248
  : finishedAgents.length > 0
233
249
  ? `failed — see error. ${finishedAgents.length} agent(s) had already completed before the run ended; their outputs are inlined under \`partial_results\`.`
234
- : "failed — see error.",
250
+ : "failed — see error.") +
251
+ timeoutInterruptionNote(summary.timeoutInterruption) +
252
+ budgetOvershootNote(summary.budgetOvershoot),
235
253
  }),
236
254
  details: {
237
255
  task_id: run.id,
@@ -552,6 +552,13 @@ export interface ToolExecuteContext {
552
552
  * fixed point `ctx.rootSessionId ?? ctx.sessionId` at every spawn). Absent for a top-level run
553
553
  * (its OWN sessionId is the root). */
554
554
  rootSessionId?: string;
555
+ /** design/380 O1② (C12) — the spawning run's EXPLICIT placement fixed point
556
+ * (`RunInternals.placementRoot`), present only when its internals carried one (cascade rungs /
557
+ * verification legs after the first, or a caller-declared root). A delegation tool threads it
558
+ * VERBATIM into child RunInternals so every descendant keeps the ladder/gate placement; absent ⇒
559
+ * the `rootSessionId` chain above IS the placement root through prepare's mint middle segment
560
+ * (`placementRoot ?? rootSessionId ?? sessionId`) and nothing extra is threaded. */
561
+ placementRoot?: string;
555
562
  /**
556
563
  * design/151 §7 S3c — TRUSTED tier-3 revival claim (same trusted-internals seat as
557
564
  * {@link parentTaskId}; never a model argument). Filled ONLY by the auto-mounted SendMessage's
@@ -5209,8 +5216,13 @@ export interface EngineNotice {
5209
5216
  * two numbers are read off the report's rows, never subtracted from each other). One notice PER
5210
5217
  * HARVEST that withheld at least one entry file (a checkpoint harvest and the terminal harvest
5211
5218
  * are distinct facts), never minted for a clean session; `detail: { count, moved, escalated,
5212
- * reason?, sessionId? }` (`reason` absent ⇔ the pollution marker could not be re-read at report
5213
- * time — the withheld count stays true either way). The formerly registered index-only gap is
5219
+ * reportId?, reason?, sessionId? }` (`reason` absent ⇔ the pollution marker could not be re-read
5220
+ * at report time — the withheld count stays true either way). `reportId` (#479) is the minting
5221
+ * harvest's own occurrence identity ({@link import("./memory-engine/types.js").HarvestReport}
5222
+ * `.reportId`, uuidv7): two harvests whose counts and paths coincide carry DISTINCT ids, a
5223
+ * durable replay of the same notice carries the SAME id, and the hold family below shares this
5224
+ * harvest's id — dedup on `(code, detail.reportId)`, never on `reportId` alone (absent only for
5225
+ * a hand-built report; every engine-minted report carries it). The formerly registered index-only gap is
5214
5226
  * CLOSED under `memoryProvenance: "carry"` (design/336 §6.3, #331): the mint condition reads
5215
5227
  * `HarvestReport.containment`, so a containment whose only act was the derived-index rollback
5216
5228
  * announces with `count: 0` and `detail.indexRolledBack: true`; under `"off"` the pre-336
@@ -5219,15 +5231,18 @@ export interface EngineNotice {
5219
5231
  * §4/§6.3) — the instruction-hold lifecycle, derived from the same structured
5220
5232
  * `HarvestReport.containment` signal (hold seats are only ever filled under
5221
5233
  * `memoryProvenance: "carry"`): a PENDING session's instruction-form entry files were captured
5222
- * off the model-visible plane (`hold_opened`, `detail: { count, paths, sessionId? }` — paths
5223
- * neutralized/length-bounded); previously held entries re-walked the full gate set and
5234
+ * off the model-visible plane (`hold_opened`, `detail: { count, paths, reportId?, sessionId? }`
5235
+ * — paths neutralized/length-bounded); previously held entries re-walked the full gate set and
5224
5236
  * committed after their writer session settled clean or a host valve released them
5225
- * (`hold_released`); held entries moved to control-plane quarantine (`hold_disposed`,
5226
- * `detail.disposed: [{ path, terminal }]` with the closed terminal set
5237
+ * (`hold_released`, same detail shape); held entries moved to control-plane quarantine
5238
+ * (`hold_disposed`, `detail.disposed: [{ path, terminal }]` with the closed terminal set
5227
5239
  * dirty/expired/conflict/capture_lost/discarded — an `expired` terminal is explicitly a
5228
5240
  * TIMEOUT, not a conviction, and the message names `resolveHold` as the recovery valve). At
5229
5241
  * most one notice per family per harvest report; wording is factual, never threat-flavored
5230
- * (the design/336 §13-2 model-psyche guardrail).
5242
+ * (the design/336 §13-2 model-psyche guardrail). `detail.reportId` (#479) is shared by all
5243
+ * three families AND `"memory.harvest_quarantined"` when they derive from the same harvest
5244
+ * report — that sharing is the contract (one report, several distinct facts): dedup on
5245
+ * `(code, detail.reportId)`; a repeat with the same pair is a replay, a new pair is a new fact.
5231
5246
  * - `"memory.delegation_static_mark_waived"` (design/324, #324 ruling ①) — the deployment set
5232
5247
  * {@link RunnerDeps.memoryDelegationEvidence} to `"attested-only"` and a delegation call whose
5233
5248
  * STATIC tool-face verdict would have marked this session's memory polluted (attestation
@@ -5291,7 +5306,9 @@ export interface EngineNotice {
5291
5306
  * session count crossed the consolidation thresholds (time gate open ∧ enough distinct
5292
5307
  * sessions); minted at most once per crossing (a completed run re-arms the edge), NEVER when
5293
5308
  * the deployment leaves consolidation off. ADVISORY: the host owns the verbs, nothing runs
5294
- * automatically; `detail: { scope, sessionsSince, sessionId? }`.
5309
+ * automatically; `detail: { scope, sessionsSince, reportId?, sessionId? }` — `reportId` (#479)
5310
+ * is the minting harvest report's occurrence identity, shared with the harvest/hold family's
5311
+ * notices of the same report (dedup on `(code, detail.reportId)`).
5295
5312
  * - `"memory.consolidation_committed"` (design/339 §6.2) — a consolidation plan reached
5296
5313
  * `completed`: products landed, superseded targets left the default read face (retained as
5297
5314
  * evidence), intents settled; `detail: { planId, scope, products, superseded, intents }` —
@@ -5369,6 +5386,56 @@ export declare function undrainedUserInputNotices(counts: {
5369
5386
  steer: number;
5370
5387
  followUp: number;
5371
5388
  }, taskId?: string, sessionId?: string): EngineNotice[];
5389
+ /**
5390
+ * design/380 O2 — the facts handed to a deployment's {@link RunnerDeps.resumePreflight}: the row's
5391
+ * own recorded identity + placement record, read off the persisted checkpoint (never off the resume
5392
+ * caller's bag), so the hook judges the SAME row the CAS is about to consume.
5393
+ */
5394
+ export interface ResumePreflightInfo {
5395
+ token: import("./checkpoint-store.js").CheckpointToken;
5396
+ sessionId: string;
5397
+ /** design/380 O1③ — the row's recorded placement root, when stamped. */
5398
+ placementRootSessionId?: string;
5399
+ /** The row's recorded identity ({@link import("./checkpoint-store.js").Checkpoint.principal}), when stamped —
5400
+ * the resume entry's identity-continuity rung already refused a contradicting supplied principal
5401
+ * before this hook runs, so a present value IS the resumed leg's identity. */
5402
+ principal?: string;
5403
+ /** provider / deviceId / mountPath — the placement facts. */
5404
+ workspaceHandle?: import("./remote-env.js").WorkspaceHandle;
5405
+ gateKind: import("./checkpoint-store.js").CheckpointGate["kind"];
5406
+ }
5407
+ /** design/380 O2 — a {@link RunnerDeps.resumePreflight} answer. */
5408
+ export type ResumePreflightVerdict = {
5409
+ ok: true;
5410
+ }
5411
+ /** Transient obstacle (device offline, dependency briefly down): the checkpoint stays `pending`,
5412
+ * the SAME token is redeemable later; retryAfterMs threads the wait hint (the #449 G1 carrier). */
5413
+ | {
5414
+ ok: false;
5415
+ disposition?: "retry_later";
5416
+ message: string;
5417
+ retryAfterMs?: number;
5418
+ }
5419
+ /** PERMANENT obstacle (binding revoked, placement identity gone): the row is settled TERMINALLY —
5420
+ * core CASes it out of `pending` before answering, so a dead binding cannot
5421
+ * be redialed forever (prose-terminal + mechanically-retriable = unbounded redeem). The `expire`
5422
+ * CAS is a pure STATUS flip — the row records no reason (the store seam has no seat for one);
5423
+ * the REASON travels on the typed `resume.preflight_rejected` refusal (the hook's `message`
5424
+ * inlined), the `onError` disclosure sink, and the deployment hook's own audit plane — it
5425
+ * authored the verdict and owns the durable record of why. The exact
5426
+ * terminal CAS form is the single-shot `expire` CAS (reaper parity — exactly one settler wins; a
5427
+ * LOST race is re-read and reported as concurrent movement, never claimed as this refusal's
5428
+ * settle); the CONTRACT is: terminal verdict ⇒ atomic single-shot settle, never a silent
5429
+ * pending-forever. PHYSICAL reclamation of a suspended workspace stays with the DEPLOYMENT that
5430
+ * ruled the binding dead — core holds no env pre-restore and a factory cannot reconnect to a
5431
+ * revoked target; the session pin follows the store contract's documented reap posture. Trust
5432
+ * grant is acceptable: the hook lives on RunnerDeps, the same deployment plane that owns the
5433
+ * checkpoint store itself. */
5434
+ | {
5435
+ ok: false;
5436
+ disposition: "terminal";
5437
+ message: string;
5438
+ };
5372
5439
  /** Runtime dependencies shared across tasks. */
5373
5440
  export interface RunnerDeps {
5374
5441
  brain: Brain;
@@ -5632,6 +5699,29 @@ export interface RunnerDeps {
5632
5699
  * `checkpointStore` overrides this. Omitted → no durable suspension (policy `ask` stays synchronous).
5633
5700
  */
5634
5701
  checkpointStore?: import("./checkpoint-store.js").CheckpointStore;
5702
+ /**
5703
+ * design/380 O2 — deployment-supplied resume preflight, called INSIDE the pre-CAS ladder (after the
5704
+ * row-integrity rungs, immediately before the CAS) with a bounded deadline
5705
+ * ({@link resumePreflightTimeoutMs}). Refusal / throw / timeout ⇒ typed `resume.preflight_rejected`
5706
+ * (CheckpointError closed-set addition); the DEFAULT/absent disposition is `retry_later` (the safe
5707
+ * arm — fail-closed hooks that just throw can never accidentally terminalize a row) — the
5708
+ * entrance-screen posture of the wake-message hook (design/373 D2), generalized from "screen the
5709
+ * wake message" to "screen the resume". A deployment binding runs to targets (a device lane) checks
5710
+ * its placement/binding tables here: a transient obstacle answers `retry_later` (+`retryAfterMs`,
5711
+ * the #449 G1 wait-hint carrier — the row stays `pending`, the SAME token redeems later); a
5712
+ * PERMANENT one answers `terminal` (the row is settled by the single-shot `expire` CAS — never a
5713
+ * silent pending-forever; bounded waiting for a target to come back also belongs HERE, inside the
5714
+ * deadline, never in the env factory). Absent ⇒ no preflight, byte-identical resume behavior.
5715
+ */
5716
+ resumePreflight?: (info: ResumePreflightInfo, signal: AbortSignal) => Promise<ResumePreflightVerdict>;
5717
+ /**
5718
+ * design/380 O2 — the {@link resumePreflight} deadline in ms. Default 10s (its own bound,
5719
+ * deliberately NOT the hooks-record default: a resume preflight sits on every redeem attempt of a
5720
+ * parked row and must answer promptly or get out of the way). Bad values (non-finite, ≤ 0,
5721
+ * non-number) are DISCLOSED loudly through `onError` and fall back to the default (the bad-value
5722
+ * loudness default: announce, never silently absorb).
5723
+ */
5724
+ resumePreflightTimeoutMs?: number;
5635
5725
  /**
5636
5726
  * design/101 §E19 — working-tree snapshot backend for {@link TaskSpec.rewindFiles}. A task with
5637
5727
  * `rewindFiles` captures a snapshot per completed turn (keyed by the leaf `SessionTreeEntry.id`) and, when it
@@ -5932,9 +6022,12 @@ export interface RunnerDeps {
5932
6022
  * `TaskSpec.limits`, which is the allowance ONE task asked for — an operator granting "N tokens per 5
5933
6023
  * hours" cannot express it as a task limit, because nothing stops the next task from asking again.
5934
6024
  *
5935
- * A window carries a TOKEN ceiling and, optionally, a MONEY ceiling (`UsageWindow.maxCostUsd`, absolute
5936
- * USD — the `TaskLimits.maxCostUsd` quantity one governance level up). The two are independent and
5937
- * either one binds. A $ ceiling requires a PRICED run: a task whose model has neither a {@link pricing}
6025
+ * A window carries a TOKEN ceiling (`UsageWindow.maxTokens`), a MONEY ceiling (`UsageWindow.maxCostUsd`,
6026
+ * absolute USD — the `TaskLimits.maxCostUsd` quantity one governance level up), or both: each is
6027
+ * optional on its own and at least one is required, so a deployment governed by spend alone declares
6028
+ * only `maxCostUsd` (a window with neither ceiling is refused, `config.usage_window_invalid`). The two
6029
+ * are independent and every declared one binds. A $ ceiling requires a PRICED run: a task whose model
6030
+ * has neither a {@link pricing}
5938
6031
  * entry nor a `Model.cost` declaration is refused at the door (`config.usage_window_unpriced`) rather
5939
6032
  * than charged the fabricated 0 an unpriced run would otherwise file into an operator's ceiling.
5940
6033
  *