@sema-agent/core 5.63.0 → 5.64.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 (39) hide show
  1. package/CHANGELOG.md +56 -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 +5 -1
  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/remote-env.d.ts +34 -2
  11. package/dist/core/runner/prepare-task.d.ts +20 -0
  12. package/dist/core/runner/prepare-task.js +12 -3
  13. package/dist/core/runner/prepare-workspace-restore.js +13 -0
  14. package/dist/core/runner/runtask.js +23 -17
  15. package/dist/core/types.d.ts +13 -3
  16. package/dist/core/usage-window-store.d.ts +44 -12
  17. package/dist/core/usage-window-store.js +11 -3
  18. package/dist/core/workflow-run-store-contract.js +17 -0
  19. package/dist/core/workflow-run-store.d.ts +22 -1
  20. package/dist/core/workflow-run-store.js +1 -0
  21. package/dist/engine/harness/agent-harness.d.ts +8 -3
  22. package/dist/engine/harness/agent-harness.js +9 -4
  23. package/dist/engine/harness/types.d.ts +89 -3
  24. package/dist/engine/loop/agent-loop.js +39 -15
  25. package/dist/engine/loop/types.d.ts +43 -22
  26. package/dist/index.d.ts +1 -1
  27. package/dist/internal/harness-types.d.ts +1 -1
  28. package/dist/orchestration/run-workflow-tool.d.ts +6 -0
  29. package/dist/orchestration/run-workflow-tool.js +1 -0
  30. package/dist/orchestration/workflow.d.ts +6 -0
  31. package/dist/orchestration/workflow.js +1 -0
  32. package/dist/tools/fs/bash-readonly-classifier.d.ts +44 -1
  33. package/dist/tools/fs/bash-readonly-classifier.js +132 -5
  34. package/dist/tools/fs/fs-bash.js +9 -2
  35. package/dist/tools/fs/fs-write.js +19 -8
  36. package/dist/tools/fs/index.d.ts +1 -0
  37. package/dist/tools/fs/index.js +1 -0
  38. package/package.json +1 -1
  39. package/test/export-surface.snapshot.json +5 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,61 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.64.0 — 2026-08-27
4
+
5
+ ### BREAKING
6
+ - **design/380 batch 1 — device-lane placement identity**: `ExecutionEnvFactoryContext` grows a
7
+ REQUIRED `placementRootSessionId` (the run TREE's fixed point, minted once as
8
+ `placementRoot ?? rootSessionId ?? sessionId`; an empty resolved value is refused loudly as
9
+ `config.placement_root_invalid` before the factory is ever called). Deployment code that
10
+ CONSTRUCTS its own factory context gains a compile-time break — factory *implementors* are purely
11
+ additive. The fixed point threads through cascade rungs, verification legs and every spawn family
12
+ (subagent, workflow), so a descendant's fresh session id can no longer read as a new placement;
13
+ durable-resume carriage is the next batch's seat (`O1③`) and its current fallback is pinned in the
14
+ tree so landing it must flip the pin consciously.
15
+
16
+ ### Added
17
+ - `WorkspaceHandle.deviceId` — explicit, minter-stated identity of the target a workspace lives on
18
+ (rides the checkpoint's all-string handle whitelist unchanged).
19
+ - `outcome_unknown` joins `ExecutionErrorCode`, `FileErrorCode` and `RemoteExecutionErrorCode`, and
20
+ `target_unavailable` joins `ExecutionErrorCode`: an op COMMITTED to a remote target whose outcome
21
+ is unknowable must never be auto-retried (it is deliberately NOT in
22
+ `RETRYABLE_REMOTE_ERROR_CODES`), while a never-started commit refusal is retry-safe by contract.
23
+ `precondition_failed` joins `FileErrorCode` for the guarded write below. **Downstream exhaustive
24
+ switches over these unions will red — re-pin per the new members.**
25
+ - `FileSystem.writeFileGuarded?` (+ `WriteExpectation` / `WriteReceipt`, both type-only exports):
26
+ optional atomic verify-and-write with a receipt of the real on-disk object, carrying
27
+ `writeFileExclusive`'s degradation law verbatim (a backend without the primitive leaves it
28
+ `undefined` rather than emulating it). New deployment knob `requireGuardedWrite` (default off,
29
+ byte-identical): armed, a covered write on an env lacking the capability is a typed refusal
30
+ instead of a silent fallback.
31
+ - `UsageWindow.maxTokens` becomes optional (#480): a window may govern by `maxCostUsd` alone. A
32
+ window declaring NEITHER ceiling refuses loudly, the exhaustion arithmetic reads declared axes
33
+ only, and a `maxCostUsd` whose micro-USD conversion overflows is refused at the door instead of
34
+ being admitted and never binding (**named narrowing**: a dual window declaring such a ceiling
35
+ previously ran with its money arm silently dead).
36
+
37
+ ### Fixed
38
+ - **#477 — the `before_agent_start` replacement prompt is run-scoped**: the between-turn rebuild
39
+ now carries it, so a run's prompt is byte-stable across its three legal change points (run start /
40
+ compaction-boundary epoch adoption / clear) instead of silently reverting to the base prompt from
41
+ turn 2 (which also broke prefix-cache stability). Deployments with no handler — or whose handler
42
+ returns no `systemPrompt` — are byte-identical.
43
+ - **#482 / #486 — read-only command judgment, both directions**: the allowlist grows to CC's safe
44
+ command set (with per-verb availability guards), a classify-only superset admits `find`/`sed`/`cd`
45
+ behind CC's own guard grammars, and compound judgment now THREADS the base a `cd` actually moves —
46
+ every segment's containment *and* deny resolve against the base bash will have, and a poll-loop
47
+ body containing a `cd` is judged over its worst-case iterated shift. Two escapes are closed (an
48
+ iterated `cd ..` loop climbing out of the read roots; a `cd` eating the first segment of a
49
+ multi-segment deny pattern). **This is a judgment fix, not a narrowing**: legitimately-configured
50
+ full-read deployments keep zero-ask on the same commands, pinned by positive controls carrying the
51
+ same weight as the escape pins.
52
+ - **#473** the resume verb snapshots its config once at the door (a mutated bag can no longer make
53
+ the identity gate and the resumed leg disagree); **#474** the prompt-too-long recovery lane adopts
54
+ the rebuilt prompt and honours the turn-scoped abort; **#470/#475** a workflow run that overran
55
+ its token ceiling discloses it on the list projection as well as the full record; **#468**
56
+ `SubagentSteerHandle.steer` gains the `inputId` pass-through its workflow twin already had.
57
+
58
+
3
59
  ## 5.63.0 — 2026-08-27
4
60
 
5
61
  ### BREAKING
@@ -199,6 +199,10 @@ export declare function runCascade(runner: Runner, spec: TaskSpec, config: Casca
199
199
  * what teardown does to the workspace is the factory's business.
200
200
  *
201
201
  * Absent ⇒ every rung is dispatched exactly as before (the call site passes `undefined`, which is
202
- * what an omitted optional argument already was).
202
+ * what an omitted optional argument already was) — with ONE design/380 O1② placement delta either
203
+ * way: when neither `placementRoot` nor `rootSessionId` is present, rungs after the first are
204
+ * dispatched with a fresh shallow copy carrying `placementRoot` = the first rung's resolved
205
+ * sessionId (the ladder's placement fixed point); every other field still rides by the same
206
+ * reference, and an internals object that already carries a fixed point is forwarded verbatim.
203
207
  */
204
208
  internals?: RunInternals): Promise<CascadeRunResult>;
@@ -57,6 +57,7 @@ export async function runCascade(runner, spec, config, internals) {
57
57
  });
58
58
  let lastResult;
59
59
  let passedRung = -1;
60
+ let ladderPlacementRoot;
60
61
  const { sessionId: _drop, ...specBase } = spec;
61
62
  for (let i = 0; i < maxRungs; i++) {
62
63
  if (deadlineAt !== undefined && Date.now() >= deadlineAt)
@@ -81,14 +82,18 @@ export async function runCascade(runner, spec, config, internals) {
81
82
  ? AbortSignal.any([specBase.signal, rungAbort.signal])
82
83
  : rungAbort.signal;
83
84
  let result;
85
+ const rungInternals = ladderPlacementRoot === undefined || internals?.placementRoot !== undefined || internals?.rootSessionId !== undefined
86
+ ? internals
87
+ : { ...internals, placementRoot: ladderPlacementRoot };
84
88
  try {
85
- result = await runner.runTask({ ...specBase, model: rung.model, ...(rung.overrides ?? {}), ...(rungSignal ? { signal: rungSignal } : {}) }, internals);
89
+ result = await runner.runTask({ ...specBase, model: rung.model, ...(rung.overrides ?? {}), ...(rungSignal ? { signal: rungSignal } : {}) }, rungInternals);
86
90
  }
87
91
  finally {
88
92
  if (deadlineTimer !== undefined)
89
93
  clearTimeout(deadlineTimer);
90
94
  }
91
95
  lastResult = result;
96
+ ladderPlacementRoot ??= internals?.placementRoot ?? internals?.rootSessionId ?? result.sessionId;
92
97
  const s = result.stats;
93
98
  const rungCostKnown = s.costMicroUsd !== undefined && (s.nested === undefined || s.nested.costMicroUsd !== undefined);
94
99
  const rungCost = rungCostKnown ? (s.costMicroUsd ?? 0) + (s.nested?.costMicroUsd ?? 0) : undefined;
@@ -1967,6 +1967,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1967
1967
  ...(ctx.taskId !== undefined ? { parentTaskId: ctx.taskId } : {}),
1968
1968
  ...(ctx.sessionId !== undefined ? { parentSessionId: ctx.sessionId } : {}),
1969
1969
  ...((ctx.rootSessionId ?? ctx.sessionId) !== undefined ? { rootSessionId: ctx.rootSessionId ?? ctx.sessionId } : {}),
1970
+ ...(ctx.placementRoot !== undefined ? { placementRoot: ctx.placementRoot } : {}),
1970
1971
  }),
1971
1972
  ...(ctx.centerArtifactDigest !== undefined ? { parentCenterArtifactDigest: ctx.centerArtifactDigest } : {}),
1972
1973
  ...(ctx.centerSourceRevision !== undefined ? { parentCenterSourceRevision: ctx.centerSourceRevision } : {}),
@@ -287,7 +287,11 @@ export declare function verifyCompleted(runner: Runner, result: TaskResult, spec
287
287
  * refusal does and does not undo.
288
288
  *
289
289
  * Absent ⇒ every leg is dispatched exactly as before (each call site passes `undefined`, which is
290
- * what an omitted optional argument already was).
290
+ * what an omitted optional argument already was) — with ONE design/380 O1② placement delta either
291
+ * way: when neither `placementRoot` nor `rootSessionId` is present, the verifier and fix legs are
292
+ * dispatched with a fresh shallow copy carrying `placementRoot` = the entry implementation leg's
293
+ * sessionId (the gate's placement fixed point); every other field still rides by the same
294
+ * reference, and an internals object that already carries a fixed point is forwarded verbatim.
291
295
  */
292
296
  internals?: RunInternals): Promise<VerificationResult>;
293
297
  /**
@@ -129,6 +129,9 @@ export async function verifyCompleted(runner, result, specBase, objective, confi
129
129
  };
130
130
  const evidenceMode = config.evidence != null && config.evidence.trim() !== "";
131
131
  const verifierPrompt = config.verifierPrompt ?? (evidenceMode ? STATIC_VERIFICATION_PROMPT : VERIFICATION_PROMPT);
132
+ const legInternals = () => internals?.placementRoot !== undefined || internals?.rootSessionId !== undefined
133
+ ? internals
134
+ : { ...internals, placementRoot: result.sessionId };
132
135
  const runVerifier = async (impl, round) => {
133
136
  refuseUnhonorableInternals(internals, "before_leg");
134
137
  const roundEvidence = round === 1 ? config.evidence : undefined;
@@ -162,7 +165,7 @@ export async function verifyCompleted(runner, result, specBase, objective, confi
162
165
  ...(specBase.clientContext !== undefined ? { clientContext: { ...specBase.clientContext } } : {}),
163
166
  ...(specBase.promptProfile !== undefined ? { promptProfile: specBase.promptProfile } : {}),
164
167
  signal: specBase.signal,
165
- }, internals);
168
+ }, legInternals());
166
169
  try {
167
170
  const cost = (v.stats.costMicroUsd ?? 0) + (v.stats.nested?.costMicroUsd ?? 0);
168
171
  return { verdict: v.structuredOutput, cost, ...(isDurablePause(v.status) ? { paused: v } : {}) };
@@ -242,7 +245,7 @@ export async function verifyCompleted(runner, result, specBase, objective, confi
242
245
  objective: fixObjective(outcome.findings),
243
246
  ...(foldedRootsFace ? { readFace: "roots" } : {}),
244
247
  ...(foldedReadDeny.length > 0 ? { readDenyPatterns: [...foldedReadDeny] } : {}),
245
- }, internals);
248
+ }, legInternals());
246
249
  spend += (current.stats.costMicroUsd ?? 0) + (current.stats.nested?.costMicroUsd ?? 0);
247
250
  implAccount.add(current.stats);
248
251
  if (isDurablePause(current.status)) {
@@ -349,7 +349,11 @@ export interface PendingSteerEntry {
349
349
  seq: number;
350
350
  /** Caller-supplied correlation/idempotency id (a service passes the message id it already minted);
351
351
  * a uuidv7 is minted when absent. Re-appending the SAME `inputId` is a no-op, which is what keeps
352
- * `setPendingSteer` retry-safe now that it appends instead of overwriting. */
352
+ * `setPendingSteer` retry-safe now that it appends instead of overwriting. Because the id lives on
353
+ * the PERSISTED entry and every replayed drain of this row carries it verbatim, `(notice code,
354
+ * sessionId, detail.inputId)` is the parked family's cross-replay OCCURRENCE key — consumers dedup
355
+ * on it directly; no per-delivery key exists or is needed. (Write-path guarantee: a row written by
356
+ * a non-conforming store may lack it, and read points do not re-validate.) */
353
357
  inputId: string;
354
358
  /** Carried VERBATIM for the serving layer; it does NOT reorder the drain (drain is `seq` order). */
355
359
  priority?: SystemInjectionPriority;
@@ -38,6 +38,27 @@ export interface FsWriteGatePolicyOptions {
38
38
  * asks for an UNRESOLVABLE target fire before any canonical key exists, so they are never exempted.
39
39
  */
40
40
  isExempt?: (toolName: string, canonicalPath: string) => boolean | Promise<boolean>;
41
+ /**
42
+ * design/380 O13 — the deployment HARD-CLOSE knob for guarded writes. Armed (`true`), a covered
43
+ * write whose env does NOT provide `writeFileGuarded` is REFUSED (deny, typed message) — never the
44
+ * plain-writeFile fallback: for a deployment that exists to close the adjudicate→write TOCTOU
45
+ * window, a silent advisory downgrade would open the hole right beside its patch. The version-skew
46
+ * arm is the same refusal (an older adapter without the method IS an env without the capability).
47
+ * Default off = every verdict ACTION exactly as before (the capability's ABSENCE is still disclosed
48
+ * on the ask face either way — see the advisory sentence at the ask sites). The knob lives on the
49
+ * deployment's gate options, not in core-global state: core stays lane-agnostic (no device concept
50
+ * here); the deployment that runs guarded-capable envs arms it.
51
+ *
52
+ * WHAT ARMED DOES AND DOES NOT CLOSE (honesty boundary, codex r1-1): armed guarantees every covered
53
+ * write lands through an ATOMIC precondition-verified backend step — the tool's own
54
+ * canonicalize→write window is closed. It does NOT yet bind THIS GATE's adjudicated canonical key
55
+ * across an approval wait: the tool re-canonicalizes at execute time, so a symlink swapped between
56
+ * this gate's verdict and the tool's execution is re-resolved (and then atomically verified against
57
+ * the NEW resolution). Closing that remaining window needs the adjudicated key carried through a
58
+ * trusted approval receipt into tool execution — a separate seat, deliberately not smuggled into
59
+ * this knob.
60
+ */
61
+ requireGuardedWrite?: boolean;
41
62
  }
42
63
  /**
43
64
  * Build the file-write approval gate policy. Mount via `combinePolicies` alongside the deployment's
@@ -6,18 +6,29 @@ export function createFsWriteGatePolicy(opts) {
6
6
  const gated = PATH_CONFINABLE_WRITE_TOOLS;
7
7
  const acceptDirs = opts.acceptDirs && opts.acceptDirs.length > 0 ? opts.acceptDirs : undefined;
8
8
  const exemptDirs = opts.exemptDirs && opts.exemptDirs.length > 0 ? opts.exemptDirs : undefined;
9
+ const guardedCapable = env.writeFileGuarded !== undefined;
10
+ const advisory = guardedCapable
11
+ ? ""
12
+ : " (advisory adjudication: this environment provides no guarded write, so approval is judged before a separate, non-atomic write step)";
9
13
  return {
10
14
  async check(req, signal) {
11
15
  const canonical = req.toolName;
12
16
  if (!gated.has(canonical))
13
17
  return { action: "allow" };
18
+ if (opts.requireGuardedWrite === true && !guardedCapable) {
19
+ return {
20
+ action: "deny",
21
+ message: `write tool "${req.toolName}" refused: this deployment requires guarded (atomic precondition-verified) writes (requireGuardedWrite), but the execution environment provides no writeFileGuarded capability — refusing rather than falling back to a non-atomic write`,
22
+ decisionReason: "rule",
23
+ };
24
+ }
14
25
  const path = writeTargetPath(canonical, req.args);
15
26
  if (path === undefined) {
16
- return ask(`write tool "${req.toolName}" requires approval: the call has no resolvable path target to confine`);
27
+ return ask(`write tool "${req.toolName}" requires approval: the call has no resolvable path target to confine${advisory}`);
17
28
  }
18
29
  const canon = await canonicalizeTarget(env, path, signal, req.cwd ?? rootPath);
19
30
  if (!canon.ok) {
20
- return ask(`write to "${path}" requires approval: its real target could not be resolved (${canon.message})`);
31
+ return ask(`write to "${path}" requires approval: its real target could not be resolved (${canon.message})${advisory}`);
21
32
  }
22
33
  for (const dirs of [exemptDirs, acceptDirs]) {
23
34
  if (!dirs)
@@ -46,7 +57,7 @@ export function createFsWriteGatePolicy(opts) {
46
57
  }
47
58
  if (defaultWrite === "allow")
48
59
  return { action: "allow" };
49
- return ask(`approve write to "${path}"?`);
60
+ return ask(`approve write to "${path}"?${advisory}`);
50
61
  },
51
62
  };
52
63
  }
@@ -58,6 +58,13 @@ export interface WorkspaceHandle {
58
58
  * `snapshotId` as corruption (fail-closed) unless the resumed env is itself non-suspendable
59
59
  * (tolerance for park handles minted by 1.257.1 before this field existed). */
60
60
  restoreMode?: "park_only";
61
+ /**
62
+ * design/380 O1④ — explicit identity of the physical/target device this workspace lives on
63
+ * (device lane; an SSH/ADB adapter MAY also stamp it). MINTER-STATED: consumers read this field,
64
+ * never parse an identity out of `sandboxId`. Plain string → rides CheckpointState.workspaceHandle
65
+ * (all-string whitelist) with zero schema movement.
66
+ */
67
+ deviceId?: string;
61
68
  }
62
69
  /**
63
70
  * A reference to a secret injected at {@link RemoteExecutionEnv.connect}/{@link RemoteExecutionEnv.postResumeInit}
@@ -213,6 +220,16 @@ export type RemoteExecutionErrorCode =
213
220
  * else a retry whitelist misses them.
214
221
  */
215
222
  | "transport_lost"
223
+ /**
224
+ * design/380 O12 — the op was COMMITTED to the remote target and its outcome is UNKNOWABLE (the target
225
+ * went unreachable / restarted / was revoked after dispatch-commit; an execStream cut where the target
226
+ * protocol's own server has ruled the outcome unknowable). NEVER auto-retried and deliberately NOT in
227
+ * {@link RETRYABLE_REMOTE_ERROR_CODES}: the command may have already executed — a retry whitelist that
228
+ * contained it would re-drive committed side effects. Distinct from `transport_lost` (connection story
229
+ * known, idempotency-gated retry after reconnect). Message MUST carry the verify-first sentence ("may
230
+ * have already executed on the target; verify its effect before re-running").
231
+ */
232
+ | "outcome_unknown"
216
233
  /** Unclassified provider/transport failure. */
217
234
  | "unknown";
218
235
  /**
@@ -220,8 +237,10 @@ export type RemoteExecutionErrorCode =
220
237
  * can succeed (`auth_transient`: retry-exactly-once after the device is authorized; `connect_failed`: the
221
238
  * workspace could not be reached/provisioned this attempt; `timeout`: a liveness bound tripped;
222
239
  * `transport_lost`: re-establish the connection). Every OTHER code is permanent for this attempt
223
- * (`auth_failed`/`unsupported`), caller-driven (`aborted`), or unclassifiable (`unknown`) — retrying them
224
- * burns budget or, worse, re-drives a rejected credential.
240
+ * (`auth_failed`/`unsupported`), caller-driven (`aborted`), unclassifiable (`unknown`), or
241
+ * outcome-unknowable (`outcome_unknown` design/380 O12: the op may have ALREADY executed, so a retry
242
+ * re-drives committed side effects) — retrying them burns budget or, worse, re-drives a rejected
243
+ * credential or a committed op.
225
244
  *
226
245
  * ONE list, two consumers, so a caller's retry decision and its DISCLOSURE can never disagree: the engine
227
246
  * retries only IDEMPOTENT ops on these codes (see {@link withRetry}'s red line — a snapshot-taking
@@ -405,6 +424,19 @@ export interface RemoteExecutionEnv extends ExecutionEnv {
405
424
  export interface ExecutionEnvFactoryContext {
406
425
  /** Resolved session id for the task — the stable identity of its per-task workspace. */
407
426
  sessionId: string;
427
+ /**
428
+ * design/380 O1① — the run TREE's placement root (fixed point), minted by prepare AFTER session
429
+ * resolution as `internals.placementRoot ?? internals.rootSessionId ?? sessionId` — a top-level
430
+ * run's own resolved sessionId, every descendant's inherited root, verbatim (delegation and
431
+ * workflow spawn chains re-thread an explicit `placementRoot` — C12; the ONE boundary it does not
432
+ * yet cross is a bare DURABLE RESUME, which falls back to the checkpointed session until
433
+ * design/380 O1③'s checkpoint carriage lands — residual pinned). A target-bound
434
+ * factory (device lane) keys its placement lookup on THIS, never on `sessionId` (a child's fresh
435
+ * session id must not read as a new placement). Always present and non-empty — an empty value is
436
+ * an assembly error the engine refuses loudly at the mint (`config.placement_root_invalid`) and a
437
+ * factory MUST refuse loudly too (loud-bad-value posture), never default around.
438
+ */
439
+ placementRootSessionId: string;
408
440
  /** Caller-supplied task id, when set on the `TaskSpec`. */
409
441
  taskId?: string;
410
442
  /**
@@ -1498,6 +1498,26 @@ export interface RunInternals {
1498
1498
  * after a restart those intermediate sessions are dead ends, and a recovery face enumerating
1499
1499
  * "everything under this host session" needs the root anchor, not an alias walk. */
1500
1500
  rootSessionId?: string;
1501
+ /**
1502
+ * design/380 O1② — the run tree's PLACEMENT root: the fixed point a target-bound env factory keys
1503
+ * its placement lookup on ({@link import("../remote-env.js").ExecutionEnvFactoryContext.placementRootSessionId}).
1504
+ * A SEPARATE axis from {@link rootSessionId} deliberately: that field means "member of this host
1505
+ * session's DELEGATION tree" and is consumed by the registry access/recovery faces — cascade rungs
1506
+ * and verification legs are intentionally NOT members of that tree (independent cold re-runs in
1507
+ * their own sessions), so widening `rootSessionId` to cover them would corrupt the recovery faces'
1508
+ * reading. This member says only "place me where this session was placed". Producers: the
1509
+ * orchestration entries (`runCascade` / `runWithVerification` family) after their first leg's
1510
+ * sessionId receipt, and — C12 — the delegation/workflow spawn chains, which re-thread a parent's
1511
+ * EXPLICIT value verbatim into child internals (ToolExecuteContext.placementRoot → childInternals;
1512
+ * workflow deps → shared internals base), so every descendant of a placed leg keeps the fixed
1513
+ * point; prepare's mint reads it first (`placementRoot ?? rootSessionId ?? sessionId`). Absent
1514
+ * everywhere else — the delegation lanes' `rootSessionId` fixed point then becomes the placement
1515
+ * root through the middle segment, unchanged. NOT yet durable (C12 residual, pinned): a bare
1516
+ * durable resume cannot recover it until design/380 O1③'s checkpoint carriage lands — the
1517
+ * resumed leg falls back to its own session; a resume caller needing continuity re-supplies the
1518
+ * member itself. TRUSTED run-scoped channel (never a {@link TaskSpec} field).
1519
+ */
1520
+ placementRoot?: string;
1501
1521
  /**
1502
1522
  * RB-429 — the REGISTRY SCOPE this run's own background row lives in: the domain its registry-facing
1503
1523
  * tools (TaskOutput / TaskStop / SendMessage / AgentTranscript / Monitor, and the announce listing)
@@ -681,6 +681,15 @@ async function derivedRouteFallsBack(args) {
681
681
  return false;
682
682
  }
683
683
  }
684
+ function mintPlacementRootSessionId(internals, sessionId) {
685
+ const placementRootSessionId = internals?.placementRoot ?? internals?.rootSessionId ?? sessionId;
686
+ if (placementRootSessionId === "") {
687
+ const e = new Error("placement root resolved EMPTY (internals.placementRoot / internals.rootSessionId carries an empty string) — an empty fixed point cannot key a placement lookup; fix the spawning lane instead of defaulting around it.");
688
+ e.code = "config.placement_root_invalid";
689
+ throw e;
690
+ }
691
+ return placementRootSessionId;
692
+ }
684
693
  export async function prepareTask(spec, deps, sessions, resume, internals, runnerSelf) {
685
694
  const doors = prepareConfigDoors({ spec, deps, sessions, resume, internals });
686
695
  spec = doors.spec;
@@ -789,7 +798,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
789
798
  try {
790
799
  ownedEnv = deps.executionEnvFactory
791
800
  ? await deps.executionEnvFactory({
792
- sessionId,
801
+ sessionId, placementRootSessionId: mintPlacementRootSessionId(internals, sessionId),
793
802
  taskId: spec.taskId,
794
803
  ...(internals?.isolation ? { isolation: internals.isolation } : {}),
795
804
  ...(internals?.parentCwd ? { parentCwd: internals.parentCwd } : {}),
@@ -1173,7 +1182,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1173
1182
  taskId: hostTaskId,
1174
1183
  ...(internals?.parentTaskId !== undefined ? { parentTaskId: internals.parentTaskId } : {}),
1175
1184
  ...(internals?.parentSessionId !== undefined ? { parentSessionId: internals.parentSessionId } : {}),
1176
- ...(internals?.rootSessionId !== undefined ? { rootSessionId: internals.rootSessionId } : {}),
1185
+ ...(internals?.rootSessionId !== undefined ? { rootSessionId: internals.rootSessionId } : {}), ...(internals?.placementRoot !== undefined ? { placementRoot: internals.placementRoot } : {}),
1177
1186
  ...(internals?.explicitAgentName !== undefined ? { spawnedAgentName: internals.explicitAgentName } : {}),
1178
1187
  ...(internals?.peerSelfRef !== undefined ? { peerSelfRef: internals.peerSelfRef } : {}),
1179
1188
  ...(internals?.peerInboundChainRef !== undefined ? { peerInboundChainRef: internals.peerInboundChainRef } : {}),
@@ -1348,7 +1357,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1348
1357
  scope: taskScope,
1349
1358
  notifier: deps.workflowCompletionNotifier,
1350
1359
  originatingSessionId: sessionId,
1351
- rootSessionId: internals?.rootSessionId ?? sessionId,
1360
+ rootSessionId: internals?.rootSessionId ?? sessionId, ...(internals?.placementRoot !== undefined ? { placementRoot: internals.placementRoot } : {}),
1352
1361
  taskRegistry: defaultTaskRegistry,
1353
1362
  taskNotification: internals?.onTaskNotification,
1354
1363
  taskOwner: hostTaskId,
@@ -49,6 +49,16 @@ export async function prepareWorkspaceRestore(input) {
49
49
  throw e;
50
50
  };
51
51
  const handle = resume.workspaceHandle;
52
+ const refuseIdentityMismatch = (resumedHandle) => {
53
+ if (resumedHandle.provider !== handle.provider) {
54
+ failResume(`resume workspace identity mismatch: the checkpoint records provider "${handle.provider}" but the resumed env reports "${resumedHandle.provider}" — refusing to continue a workspace on a different backend (wire the factory to rebuild the checkpointed lane and re-resume)`);
55
+ }
56
+ if (handle.deviceId !== undefined && resumedHandle.deviceId !== handle.deviceId) {
57
+ failResume(resumedHandle.deviceId === undefined
58
+ ? `resume workspace identity mismatch: the checkpoint records deviceId "${handle.deviceId}" but the resumed env reports none — a device-bound workspace cannot be verified on a handle that does not state its device (absence is not a match)`
59
+ : `resume workspace identity mismatch: the checkpoint records deviceId "${handle.deviceId}" but the resumed env reports "${resumedHandle.deviceId}" — refusing to continue on a different physical device`);
60
+ }
61
+ };
52
62
  if (ownedEnv === undefined || !isRemoteExecutionEnv(ownedEnv)) {
53
63
  failResume("resume needs a RemoteExecutionEnv from executionEnvFactory to restore the workspace snapshot");
54
64
  }
@@ -56,6 +66,7 @@ export async function prepareWorkspaceRestore(input) {
56
66
  if (handle.restoreMode !== "park_only" && ownedEnv.capabilities.suspendable) {
57
67
  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?)");
58
68
  }
69
+ refuseIdentityMismatch(ownedEnv.workspaceHandle());
59
70
  if (handle.mountPath && handle.mountPath !== taskRootPath) {
60
71
  taskRootPath = handle.mountPath;
61
72
  }
@@ -68,6 +79,7 @@ export async function prepareWorkspaceRestore(input) {
68
79
  if (missingHere.length > 0) {
69
80
  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.`);
70
81
  }
82
+ refuseIdentityMismatch(ownedEnv.workspaceHandle());
71
83
  const { outcome: restored, attempts: restoreAttempts } = await restoreWorkspaceWithRetry(ownedEnv, handle.snapshotId, {
72
84
  abortSignal: restoreSignal,
73
85
  priorHandle: handle,
@@ -76,6 +88,7 @@ export async function prepareWorkspaceRestore(input) {
76
88
  failResume(`resumeVM failed after ${restoreAttempts} attempt(s) (${restored.error.code}): ${restored.error.message}`, restored.error, remoteEnvFailureNote("resumeVM", restored.error, restoreAttempts));
77
89
  }
78
90
  else {
91
+ refuseIdentityMismatch(restored.value);
79
92
  const restoredEnv = ownedEnv;
80
93
  const canonicalInEnv = async (p) => {
81
94
  try {
@@ -3119,7 +3119,10 @@ export class Runner {
3119
3119
  }
3120
3120
  catch (err) {
3121
3121
  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() });
3122
+ const interruptReason = lane === "rejection"
3123
+ ? "prompt-too-long recovery pass cut short by a turn interrupt"
3124
+ : "guard-chain forced compaction cut short by a turn interrupt";
3125
+ queue.push({ type: "compaction_outcome", outcome: "failed", trigger: "forced", reason: interruptReason, ...ident() });
3123
3126
  return false;
3124
3127
  }
3125
3128
  compactionBreaker.failures += 1;
@@ -3135,9 +3138,11 @@ export class Runner {
3135
3138
  thinkingOnly: {},
3136
3139
  degenerateOutput: { detect: (m) => isDegenerateCutMessage(m) },
3137
3140
  promptTooLong: {
3138
- recover: async (attempt) => {
3141
+ recover: async (attempt, turnSignal) => {
3139
3142
  if (prepared.abortController.signal.aborted || prepared.suspendRef.token !== undefined)
3140
3143
  return false;
3144
+ if (turnSignal?.aborted === true)
3145
+ return false;
3141
3146
  if (prepared.microCompact.clearOnRejection && attempt === 1) {
3142
3147
  const proj = prepared.microCompact.projectionRef.current;
3143
3148
  const compactionAvailable = (spec.compaction?.enabled ?? true) && compactionBreaker.failures < MAX_CONSECUTIVE_COMPACTION_FAILURES;
@@ -3174,7 +3179,7 @@ export class Runner {
3174
3179
  return true;
3175
3180
  }
3176
3181
  }
3177
- return runForcedCompactionPass("rejection");
3182
+ return runForcedCompactionPass("rejection", turnSignal);
3178
3183
  },
3179
3184
  },
3180
3185
  });
@@ -4308,7 +4313,8 @@ export class Runner {
4308
4313
  if (outcome === null || typeof outcome !== "object") {
4309
4314
  throw new CheckpointError("checkpoint.invalid_outcome", `resume outcome must be an object naming its gate (got ${outcome === null ? "null" : typeof outcome})`);
4310
4315
  }
4311
- const store = resolveCheckpointStore(taskConfig, this.deps);
4316
+ const config = typeof taskConfig === "object" && taskConfig !== null ? { ...taskConfig } : taskConfig;
4317
+ const store = resolveCheckpointStore(config, this.deps);
4312
4318
  if (!store) {
4313
4319
  throw new CheckpointError("checkpoint.not_found", "no CheckpointStore wired — cannot resume (set RunnerDeps.checkpointStore or taskConfig.checkpointStore)");
4314
4320
  }
@@ -4335,7 +4341,7 @@ export class Runner {
4335
4341
  let wakeMessage;
4336
4342
  let wakeHookContext;
4337
4343
  const outcomeGate = outcome.gate;
4338
- const resumeSignal = taskConfig.signal;
4344
+ const resumeSignal = config.signal;
4339
4345
  let suppliedMessage;
4340
4346
  if (outcomeGate === "wake") {
4341
4347
  suppliedMessage = outcome.message;
@@ -4564,7 +4570,7 @@ export class Runner {
4564
4570
  : "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
4571
  }
4566
4572
  if (contentAskCallId !== undefined && plainPolicyOutcome.decision === "allow" && suppliedAnswer === undefined) {
4567
- if (!isLiveQuestionFace(taskConfig.onQuestion ?? this.deps.onQuestion)) {
4573
+ if (!isLiveQuestionFace(config.onQuestion ?? this.deps.onQuestion)) {
4568
4574
  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
4575
  'executing the question against nothing would hand the model a fabricated "no human is available" default while consuming the approval; ' +
4570
4576
  "re-resume with the operator's answer on the outcome (or deny it), the checkpoint stays pending", { field: "answer" });
@@ -4675,14 +4681,14 @@ export class Runner {
4675
4681
  },
4676
4682
  }
4677
4683
  : internals;
4678
- if (this.deps.lockedConfig?.keys?.includes("toolPolicy") === true && taskConfig.basePolicyForResumeEdit !== undefined) {
4684
+ if (this.deps.lockedConfig?.keys?.includes("toolPolicy") === true && config.basePolicyForResumeEdit !== undefined) {
4679
4685
  throw new CheckpointError("checkpoint.invalid_outcome", "TaskSpec.basePolicyForResumeEdit is administratively locked by this deployment (locked key \"toolPolicy\") — a task-supplied " +
4680
4686
  "resume-edit policy is refused pre-CAS (the checkpoint stays pending); remove the field or change the deployment's lock configuration");
4681
4687
  }
4682
4688
  let recheckGovernanceWindow;
4683
4689
  {
4684
4690
  const rowPrincipal = cp.principal || undefined;
4685
- const suppliedPrincipal = taskConfig.principal || undefined;
4691
+ const suppliedPrincipal = config.principal || undefined;
4686
4692
  if (rowPrincipal !== undefined && suppliedPrincipal !== undefined && suppliedPrincipal !== rowPrincipal) {
4687
4693
  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
4694
  "running the resumed leg under a different identity would move its usage-ledger bucket, scope derivation and attribution; " +
@@ -4709,13 +4715,13 @@ export class Runner {
4709
4715
  }
4710
4716
  }
4711
4717
  if (outcomeGate === "wake" && wakeMessage !== undefined) {
4712
- const resumeScreenHooks = taskConfig.hooks ?? this.deps.hooks;
4718
+ const resumeScreenHooks = config.hooks ?? this.deps.hooks;
4713
4719
  const screen = resumeScreenHooks?.userPromptSubmit;
4714
4720
  if (screen !== undefined && resumeSignal?.aborted !== true) {
4715
4721
  const screenedMessage = wakeMessage;
4716
4722
  const identity = mintHookInvocationIdentity({
4717
4723
  sessionId: cp.sessionId,
4718
- taskId: taskConfig.taskId ?? cp.sessionId,
4724
+ taskId: config.taskId ?? cp.sessionId,
4719
4725
  legKind: "resume",
4720
4726
  isDelegatedChild: effectiveDelegationFacts(internals, cp.state.isDelegatedChild).isDelegatedChild,
4721
4727
  ...(internals?.insideFork === true ? { insideFork: true } : {}),
@@ -4784,7 +4790,7 @@ export class Runner {
4784
4790
  budget: editBudget,
4785
4791
  ...(cp.state.handsCwd !== undefined ? { cwd: cp.state.handsCwd } : {}),
4786
4792
  };
4787
- const editSignal = taskConfig.signal;
4793
+ const editSignal = config.signal;
4788
4794
  const raceEditAbort = async (p) => {
4789
4795
  if (editSignal === undefined)
4790
4796
  return p;
@@ -4823,7 +4829,7 @@ export class Runner {
4823
4829
  const toolPolicyLocked = this.deps.lockedConfig?.keys?.includes("toolPolicy") === true;
4824
4830
  const editBasePolicy = toolPolicyLocked
4825
4831
  ? (this.deps.basePolicyForResumeEdit ?? this.deps.toolPolicy)
4826
- : (taskConfig.basePolicyForResumeEdit ?? this.deps.basePolicyForResumeEdit ?? taskConfig.toolPolicy ?? this.deps.toolPolicy);
4832
+ : (config.basePolicyForResumeEdit ?? this.deps.basePolicyForResumeEdit ?? config.toolPolicy ?? this.deps.toolPolicy);
4827
4833
  if (editBasePolicy !== undefined) {
4828
4834
  let rechecked;
4829
4835
  try {
@@ -4843,7 +4849,7 @@ export class Runner {
4843
4849
  `refused pre-CAS (the checkpoint stays pending); re-submit the edited action so the rewrite is adjudicated and approved synchronously`);
4844
4850
  }
4845
4851
  }
4846
- const resumeHooks = taskConfig.hooks ?? this.deps.hooks;
4852
+ const resumeHooks = config.hooks ?? this.deps.hooks;
4847
4853
  if (resumeHooks?.preToolUse !== undefined && resumeHooks.preToolUseObservational === true) {
4848
4854
  try {
4849
4855
  await raceEditAbort(Promise.resolve(resumeHooks.preToolUse(cp.pendingAction.toolName, cloneObserverInput(editedArgs), {
@@ -4878,15 +4884,15 @@ export class Runner {
4878
4884
  consumeFlipDone = true;
4879
4885
  }
4880
4886
  const answerFace = plainPolicyOutcome !== undefined && redeemedAnswer !== undefined
4881
- ? answerFaceForRedeemedCall(redeemedAnswer, plainPolicyOutcome.boundCallId, taskConfig.onQuestion ?? this.deps.onQuestion, boundInputHashOf(cp.pendingAction.args?.questions))
4887
+ ? answerFaceForRedeemedCall(redeemedAnswer, plainPolicyOutcome.boundCallId, config.onQuestion ?? this.deps.onQuestion, boundInputHashOf(cp.pendingAction.args?.questions))
4882
4888
  : undefined;
4883
4889
  const spec = {
4884
- ...taskConfig,
4885
- ...((taskConfig.principal || undefined) === undefined && (cp.principal || undefined) !== undefined ? { principal: cp.principal } : {}),
4890
+ ...config,
4891
+ ...((config.principal || undefined) === undefined && (cp.principal || undefined) !== undefined ? { principal: cp.principal } : {}),
4886
4892
  objective: "",
4887
4893
  sessionId: cp.sessionId,
4888
4894
  requireExistingSession: true,
4889
- preemptSignal: taskConfig.preemptSignal?.aborted ? undefined : taskConfig.preemptSignal,
4895
+ preemptSignal: config.preemptSignal?.aborted ? undefined : config.preemptSignal,
4890
4896
  ...(answerFace !== undefined ? { onQuestion: answerFace } : {}),
4891
4897
  };
4892
4898
  const reopenFn = store.reopen?.bind(store);
@@ -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
@@ -5932,9 +5939,12 @@ export interface RunnerDeps {
5932
5939
  * `TaskSpec.limits`, which is the allowance ONE task asked for — an operator granting "N tokens per 5
5933
5940
  * hours" cannot express it as a task limit, because nothing stops the next task from asking again.
5934
5941
  *
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}
5942
+ * A window carries a TOKEN ceiling (`UsageWindow.maxTokens`), a MONEY ceiling (`UsageWindow.maxCostUsd`,
5943
+ * absolute USD — the `TaskLimits.maxCostUsd` quantity one governance level up), or both: each is
5944
+ * optional on its own and at least one is required, so a deployment governed by spend alone declares
5945
+ * only `maxCostUsd` (a window with neither ceiling is refused, `config.usage_window_invalid`). The two
5946
+ * are independent and every declared one binds. A $ ceiling requires a PRICED run: a task whose model
5947
+ * has neither a {@link pricing}
5938
5948
  * entry nor a `Model.cost` declaration is refused at the door (`config.usage_window_unpriced`) rather
5939
5949
  * than charged the fabricated 0 an unpriced run would otherwise file into an operator's ceiling.
5940
5950
  *