@sema-agent/core 5.64.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.
@@ -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();
@@ -4314,6 +4337,7 @@ export class Runner {
4314
4337
  throw new CheckpointError("checkpoint.invalid_outcome", `resume outcome must be an object naming its gate (got ${outcome === null ? "null" : typeof outcome})`);
4315
4338
  }
4316
4339
  const config = typeof taskConfig === "object" && taskConfig !== null ? { ...taskConfig } : taskConfig;
4340
+ internals = internals !== undefined ? { ...internals } : undefined;
4317
4341
  const store = resolveCheckpointStore(config, this.deps);
4318
4342
  if (!store) {
4319
4343
  throw new CheckpointError("checkpoint.not_found", "no CheckpointStore wired — cannot resume (set RunnerDeps.checkpointStore or taskConfig.checkpointStore)");
@@ -4694,6 +4718,15 @@ export class Runner {
4694
4718
  "running the resumed leg under a different identity would move its usage-ledger bucket, scope derivation and attribution; " +
4695
4719
  "refused pre-CAS (the checkpoint stays pending): re-resume with the original principal, or omit the field to inherit the recorded one");
4696
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
+ }
4697
4730
  const owesDelivery = outcomeGate !== "resource_limit" ||
4698
4731
  readPendingSteerQueue(cp.state).length > 0 ||
4699
4732
  (cp.state.runningBackgroundTasks?.length ?? 0) > 0;
@@ -4869,7 +4902,121 @@ export class Runner {
4869
4902
  if (outcomeForStore === undefined) {
4870
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`);
4871
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);
4872
5017
  const won = await store.resolve(token, cp.scope, outcomeForStore, { rev: cp.rev ?? 0 });
5018
+ if (!won)
5019
+ this.locallyClaimedTokens.delete(token);
4873
5020
  if (!won) {
4874
5021
  const live = await store.get(token);
4875
5022
  if (live?.status === "pending") {
@@ -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,
@@ -5216,8 +5216,13 @@ export interface EngineNotice {
5216
5216
  * two numbers are read off the report's rows, never subtracted from each other). One notice PER
5217
5217
  * HARVEST that withheld at least one entry file (a checkpoint harvest and the terminal harvest
5218
5218
  * are distinct facts), never minted for a clean session; `detail: { count, moved, escalated,
5219
- * reason?, sessionId? }` (`reason` absent ⇔ the pollution marker could not be re-read at report
5220
- * 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
5221
5226
  * CLOSED under `memoryProvenance: "carry"` (design/336 §6.3, #331): the mint condition reads
5222
5227
  * `HarvestReport.containment`, so a containment whose only act was the derived-index rollback
5223
5228
  * announces with `count: 0` and `detail.indexRolledBack: true`; under `"off"` the pre-336
@@ -5226,15 +5231,18 @@ export interface EngineNotice {
5226
5231
  * §4/§6.3) — the instruction-hold lifecycle, derived from the same structured
5227
5232
  * `HarvestReport.containment` signal (hold seats are only ever filled under
5228
5233
  * `memoryProvenance: "carry"`): a PENDING session's instruction-form entry files were captured
5229
- * off the model-visible plane (`hold_opened`, `detail: { count, paths, sessionId? }` — paths
5230
- * 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
5231
5236
  * committed after their writer session settled clean or a host valve released them
5232
- * (`hold_released`); held entries moved to control-plane quarantine (`hold_disposed`,
5233
- * `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
5234
5239
  * dirty/expired/conflict/capture_lost/discarded — an `expired` terminal is explicitly a
5235
5240
  * TIMEOUT, not a conviction, and the message names `resolveHold` as the recovery valve). At
5236
5241
  * most one notice per family per harvest report; wording is factual, never threat-flavored
5237
- * (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.
5238
5246
  * - `"memory.delegation_static_mark_waived"` (design/324, #324 ruling ①) — the deployment set
5239
5247
  * {@link RunnerDeps.memoryDelegationEvidence} to `"attested-only"` and a delegation call whose
5240
5248
  * STATIC tool-face verdict would have marked this session's memory polluted (attestation
@@ -5298,7 +5306,9 @@ export interface EngineNotice {
5298
5306
  * session count crossed the consolidation thresholds (time gate open ∧ enough distinct
5299
5307
  * sessions); minted at most once per crossing (a completed run re-arms the edge), NEVER when
5300
5308
  * the deployment leaves consolidation off. ADVISORY: the host owns the verbs, nothing runs
5301
- * 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)`).
5302
5312
  * - `"memory.consolidation_committed"` (design/339 §6.2) — a consolidation plan reached
5303
5313
  * `completed`: products landed, superseded targets left the default read face (retained as
5304
5314
  * evidence), intents settled; `detail: { planId, scope, products, superseded, intents }` —
@@ -5376,6 +5386,56 @@ export declare function undrainedUserInputNotices(counts: {
5376
5386
  steer: number;
5377
5387
  followUp: number;
5378
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
+ };
5379
5439
  /** Runtime dependencies shared across tasks. */
5380
5440
  export interface RunnerDeps {
5381
5441
  brain: Brain;
@@ -5639,6 +5699,29 @@ export interface RunnerDeps {
5639
5699
  * `checkpointStore` overrides this. Omitted → no durable suspension (policy `ask` stays synchronous).
5640
5700
  */
5641
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;
5642
5725
  /**
5643
5726
  * design/101 §E19 — working-tree snapshot backend for {@link TaskSpec.rewindFiles}. A task with
5644
5727
  * `rewindFiles` captures a snapshot per completed turn (keyed by the leaf `SessionTreeEntry.id`) and, when it
@@ -118,6 +118,19 @@ export async function workflowRunStoreContract(make, runAssertion = defaultSeque
118
118
  byId.get("d-over").budgetOvershoot.spentTokens = 7;
119
119
  assert.equal((await store.get("d-over")).budgetOvershoot.spentTokens, 1_200);
120
120
  });
121
+ run("the TOTAL-TIMEOUT disclosure reaches the LIST row: timeoutInterruption projects (de-aliased), and a run without it keeps the key ABSENT", async () => {
122
+ const store = make();
123
+ const seat = { timeoutMs: 600_000, agentsCompleted: 3, agentsFailed: 1, agentsInFlight: 2 };
124
+ const cut = createWorkflowRun({ id: "d-timeout", createdAt: 8_000, status: "failed", error: "workflow total timeout", timeoutInterruption: seat });
125
+ const threw = createWorkflowRun({ id: "d-threw", createdAt: 8_100, status: "failed", error: "script blew up" });
126
+ for (const r of [cut, threw])
127
+ await store.put(r.id, r);
128
+ const byId = new Map((await store.listByScope("tenant-a")).map((s) => [s.id, s]));
129
+ assert.deepEqual(byId.get("d-timeout").timeoutInterruption, seat);
130
+ assert.equal("timeoutInterruption" in byId.get("d-threw"), false, "a run its own error ended ⇒ the key is OMITTED, not null/zeroed");
131
+ byId.get("d-timeout").timeoutInterruption.agentsCompleted = 7;
132
+ assert.equal((await store.get("d-timeout")).timeoutInterruption.agentsCompleted, 3);
133
+ });
121
134
  run("reap: only terminal runs, NEVER running; maxAgeMs + keep retention; no policy → no-op", async () => {
122
135
  const store = make();
123
136
  const running = createWorkflowRun({ id: "k-run", status: "running", createdAt: 100, endedAt: undefined });
@@ -67,6 +67,23 @@ export interface WorkflowRunSummary {
67
67
  spentTokens: number;
68
68
  unsettledTokens?: number;
69
69
  };
70
+ /** The run's TERMINAL total-timeout interruption ({@link WorkflowRun.timeoutInterruption}) — present ONLY
71
+ * when the `totalTimeoutMs` deadline had fired by the time the run reached its terminal. The third member
72
+ * of the same family as {@link agentFailures} and {@link budgetOvershoot}: it lets a list row separate
73
+ * "this run was CUT SHORT, and here is what it had achieved" from "this run failed", without an N+1 `get`
74
+ * of the full run. Nothing else on this projection can answer that — {@link status} is `failed` either
75
+ * way, and {@link agentCount} counts records regardless of how they ended.
76
+ *
77
+ * Projected as a de-aliased shallow COPY (same anti-aliasing posture as {@link budgetOvershoot}: a list
78
+ * row must not be a mutable window into the stored run). Read the members per
79
+ * {@link WorkflowRun.timeoutInterruption} — in particular `agentsFailed` is a PRE-abandonment count and is
80
+ * NOT {@link agentFailures}. */
81
+ timeoutInterruption?: {
82
+ timeoutMs: number;
83
+ agentsCompleted: number;
84
+ agentsFailed: number;
85
+ agentsInFlight: number;
86
+ };
70
87
  /** Title of the latest phase recorded ({@link WorkflowRun.phases}`.at(-1).title`) — what the run is on RIGHT
71
88
  * NOW for a `running` row (so the `/workflows` list shows the live phase without subscribing to the event
72
89
  * stream). Absent when no phase has started yet. */
@@ -10,6 +10,7 @@ export function summarizeWorkflowRun(run) {
10
10
  status: run.status,
11
11
  ...(run.agentFailures !== undefined ? { agentFailures: run.agentFailures } : {}),
12
12
  ...(run.budgetOvershoot !== undefined ? { budgetOvershoot: { ...run.budgetOvershoot } } : {}),
13
+ ...(run.timeoutInterruption !== undefined ? { timeoutInterruption: { ...run.timeoutInterruption } } : {}),
13
14
  ...(latestPhase !== undefined ? { currentPhase: latestPhase.title } : {}),
14
15
  phaseCount: run.phases.length,
15
16
  agentCount: run.agents.length,
@@ -455,6 +455,24 @@ export interface ExecutionEnv extends FileSystem, Shell {
455
455
  * harmless explicit spelling of the local default.
456
456
  */
457
457
  readonly hostLocalPaths?: boolean;
458
+ /**
459
+ * design/380 O9 — declared by the ADAPTER: content produced by THIS env (every FileSystem/Shell
460
+ * result — stdout, file bytes, listings, names) originates from a target OUTSIDE the deployment's
461
+ * trust boundary (a personal device, an unmanaged host). Omitted ⇒ historic behavior (trusted-side
462
+ * results). `true` feeds three EXISTING channels, no new vocabulary:
463
+ * - memory: the run's hand-tool results are treated as external-class content — the session mark
464
+ * (`memory.session_polluted`) + the design/336 origin carriage at harvest, via the same fold
465
+ * `execIsExternalContent` upgrades through (this flag ORs into that fold for the run);
466
+ * - env facts / prompt renderer: one declarative line disclosing the execution locus + trust posture;
467
+ * - audit: TaskResult/checkpoint carry it implicitly through WorkspaceHandle{provider, deviceId}.
468
+ * Suspend/resume: the run-level fold is PRESENCE-persisted on the checkpoint
469
+ * (`CheckpointState.externalContentTarget`, monotonic) and resume joins persisted OR live
470
+ * declaration — a run minted under a declaring adapter never silently downgrades on an
471
+ * undeclared/older one. A trust-posture declaration ONLY: no gate/policy/roster behavior reads it
472
+ * (the design/378 content-origin red line), and it is ORTHOGONAL to `capabilities.isolation` — an
473
+ * SSH env to an organization-managed build host can be non-isolated yet omit this flag.
474
+ */
475
+ readonly externalContentTarget?: boolean;
458
476
  }
459
477
  /** Base fields shared by append-only session tree entries. */
460
478
  export interface SessionTreeEntryBase {
package/dist/index.d.ts CHANGED
@@ -263,7 +263,7 @@ export { ROUTE_ADJUDICATION_CONFORMANCE_CORPUS, type RouteAdjudicationVector } f
263
263
  export { type BrainTimeoutConfig } from "./brain/timeout.js";
264
264
  export { createAssistantMessageEventStream } from "./internal/llm.js";
265
265
  export type { AssistantMessage, AssistantMessageEvent, CompleteSimpleFn, Context, DocumentContent, ImageContent, Message, StopReason, StreamFn, TextContent, ThinkingContent, ToolCall, ToolResultMessage, Usage, UserMessage, } from "./internal/llm.js";
266
- export type { AgentDefinition, BeforeWriteHook, BeforeWriteRequest, BeforeWriteResult, HandsBandOptions, Brain, BrainStatus, BrainStatusPhase, BrainRetryErrClass, ImageInput, McpElicitRequest, McpElicitResponse, McpServerSpec, A2aServerSpec, OnElicit, Model, ModelRef, ProjectMemoryLoad, RunnerDeps, EngineNotice, RuntimeCaps, BackgroundChildEvent, DelegationLifecycleEvent, SkillManifest, SkillSpec, TaskEvent, TaskEventIdentity, ToolActivity, TaskLimits, StaleToolResultOffloadOptions, TaskResult, EffectiveMemoryScopes, RemoteEnvFailureNote, TaskSpec, TaskStatus, TaskStream, CompactOutcome, ThinkingLevel, ToolExecuteContext, ToolReturn, ToolSpec, ReversibilityVerdict, ToolEffect, ToolContentOrigin, WorkflowGovernanceBaseline, DelegationTaskType, } from "./core/types.js";
266
+ export type { AgentDefinition, BeforeWriteHook, BeforeWriteRequest, BeforeWriteResult, HandsBandOptions, Brain, BrainStatus, BrainStatusPhase, BrainRetryErrClass, ImageInput, McpElicitRequest, McpElicitResponse, McpServerSpec, A2aServerSpec, OnElicit, Model, ModelRef, ProjectMemoryLoad, RunnerDeps, ResumePreflightInfo, ResumePreflightVerdict, EngineNotice, RuntimeCaps, BackgroundChildEvent, DelegationLifecycleEvent, SkillManifest, SkillSpec, TaskEvent, TaskEventIdentity, ToolActivity, TaskLimits, StaleToolResultOffloadOptions, TaskResult, EffectiveMemoryScopes, RemoteEnvFailureNote, TaskSpec, TaskStatus, TaskStream, CompactOutcome, ThinkingLevel, ToolExecuteContext, ToolReturn, ToolSpec, ReversibilityVerdict, ToolEffect, ToolContentOrigin, WorkflowGovernanceBaseline, DelegationTaskType, } from "./core/types.js";
267
267
  export { Type } from "typebox";
268
268
  export type { TSchema, Static } from "typebox";
269
269
  export { explainPromptAssembly, describeDefaultPack, type DefaultPackDescription, type ExplainInput } from "./prompt-assembly/explain.js";
@@ -192,6 +192,41 @@ export interface WorkflowRun {
192
192
  spentTokens: number;
193
193
  unsettledTokens?: number;
194
194
  };
195
+ /** The run's TERMINAL total-timeout interruption — present ONLY when the `totalTimeoutMs` deadline had
196
+ * FIRED by the time this run reached its (failed) terminal, absent on every other run: one that finished
197
+ * inside its deadline, one that failed for its own reason before the deadline, and one that armed no
198
+ * deadline at all. Same ADDITIVE-observation contract as {@link agentFailures} and
199
+ * {@link budgetOvershoot} (never a gate input, never re-read by the engine, status enums untouched).
200
+ *
201
+ * WHY IT EXISTS: the deadline abandons the script body, so the run finalizes `failed` carrying the
202
+ * deadline's own message — and that is ALL any downstream face had to go on. A run whose agents had in
203
+ * fact answered (their outputs already on the record, inlined as `partial_results` on a poll) read as a
204
+ * total loss. This seat is the missing distinction: it states that the deadline is what ended the run,
205
+ * and what the run had ACHIEVED at that moment. Extending {@link WorkflowRunStatus} with a separate
206
+ * terminal word would say the same thing at the cost of a wire contract every consumer switches on, so
207
+ * the fact rides an optional member instead.
208
+ *
209
+ * `timeoutMs` is the deadline that fired (the run's configured `totalTimeoutMs`), so a reader can raise
210
+ * it deliberately rather than guess it.
211
+ *
212
+ * ⚠️ THE COUNTS ARE A POINT-IN-TIME SNAPSHOT, taken BEFORE the terminal fold that closes abandoned
213
+ * agents. That is the whole point (it reports what the deadline INTERRUPTED), and it is also why
214
+ * `agentsFailed` here is NOT {@link agentFailures}: the terminal fold marks every still-running agent
215
+ * `failed`, so the record's tally counts them and this member does not. The two are different questions
216
+ * about the same run — "how many had failed on their own when the clock ran out" versus "how many agent
217
+ * records ended failed" — and a consumer that swaps one for the other reports abandoned work as failed
218
+ * work. `agentsInFlight` counts records still `running` at that moment, which includes calls still
219
+ * QUEUED for a concurrency slot (they had not been refused, they had not finished).
220
+ *
221
+ * The seat is an OBJECT rather than a flat pair of members so a later disclosure about the same terminal
222
+ * (how much of the deadline was spent waiting on something outside the run, say) joins it additively with
223
+ * no shape change on any of the three faces. Nothing beyond the four members below is declared today. */
224
+ timeoutInterruption?: {
225
+ timeoutMs: number;
226
+ agentsCompleted: number;
227
+ agentsFailed: number;
228
+ agentsInFlight: number;
229
+ };
195
230
  phases: WorkflowPhase[];
196
231
  agents: WorkflowAgentRun[];
197
232
  /** design/97 CORE-3: nested `ctx.workflow` sub-groups (the persisted group tree). Empty when the script
@@ -67,11 +67,11 @@ export class WorkflowMaxAgentsError extends Error {
67
67
  `Add a hard iteration cap to the loop, or pass a token budget.`
68
68
  : spentTokens !== undefined && spentTokens > budgetTotal
69
69
  ? `Workflow agent() call cap reached (${max}), and the token budget is ALREADY EXCEEDED ` +
70
- `(${spentTokens.toLocaleString()} spent / ${budgetTotal.toLocaleString()} output tokens): agents already in flight when ` +
70
+ `(${spentTokens.toLocaleString()} spent / ${budgetTotal.toLocaleString()} total tokens): agents already in flight when ` +
71
71
  `the ceiling was reached are not bound by the per-call gate, so their spend landed on top of it. BOTH bounds are ` +
72
72
  `binding — raising maxAgents alone would only buy more overshoot. Fan out over fewer items, or lower concurrency ` +
73
73
  `(it bounds the overshoot) and raise the token budget deliberately.`
74
- : `Workflow agent() call cap reached (${max}). A token budget IS set (${budgetTotal.toLocaleString()} output tokens), ` +
74
+ : `Workflow agent() call cap reached (${max}). A token budget IS set (${budgetTotal.toLocaleString()} total tokens), ` +
75
75
  `so this is the CALL-COUNT cap, not the token ceiling: the script asked for more than ${max} agent() calls. ` +
76
76
  `Fan out over fewer items, or raise maxAgents.`);
77
77
  this.max = max;