@sema-agent/core 5.62.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 (55) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/dist/agents/cascade.d.ts +5 -1
  3. package/dist/agents/cascade.js +6 -1
  4. package/dist/agents/subagent.d.ts +12 -2
  5. package/dist/agents/subagent.js +4 -2
  6. package/dist/agents/verify.d.ts +5 -1
  7. package/dist/agents/verify.js +5 -2
  8. package/dist/core/auto-compaction.d.ts +6 -4
  9. package/dist/core/auto-compaction.js +3 -0
  10. package/dist/core/checkpoint-store.d.ts +5 -1
  11. package/dist/core/context-edit.d.ts +36 -29
  12. package/dist/core/context-edit.js +3 -3
  13. package/dist/core/fs-write-gate-policy.d.ts +21 -0
  14. package/dist/core/fs-write-gate-policy.js +14 -3
  15. package/dist/core/hooks.d.ts +8 -5
  16. package/dist/core/memory-engine/engine.d.ts +11 -0
  17. package/dist/core/memory-engine/engine.js +29 -3
  18. package/dist/core/memory-engine/index.d.ts +1 -1
  19. package/dist/core/memory-engine/origin-clearance.d.ts +28 -0
  20. package/dist/core/remote-env.d.ts +34 -2
  21. package/dist/core/runner/prepare-config-doors.d.ts +2 -2
  22. package/dist/core/runner/prepare-config-doors.js +11 -8
  23. package/dist/core/runner/prepare-task.d.ts +87 -5
  24. package/dist/core/runner/prepare-task.js +174 -92
  25. package/dist/core/runner/prepare-workspace-restore.js +13 -0
  26. package/dist/core/runner/runtask.js +203 -152
  27. package/dist/core/trace.d.ts +5 -4
  28. package/dist/core/types.d.ts +38 -20
  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 +17 -0
  32. package/dist/core/workflow-run-store.d.ts +22 -1
  33. package/dist/core/workflow-run-store.js +1 -0
  34. package/dist/engine/harness/agent-harness.d.ts +8 -3
  35. package/dist/engine/harness/agent-harness.js +29 -9
  36. package/dist/engine/harness/types.d.ts +127 -3
  37. package/dist/engine/loop/agent-loop.js +47 -4
  38. package/dist/engine/loop/types.d.ts +67 -6
  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 +48 -1
  44. package/dist/orchestration/workflow-types.js +12 -4
  45. package/dist/orchestration/workflow.d.ts +18 -1
  46. package/dist/orchestration/workflow.js +45 -19
  47. package/dist/prompts/default.js +1 -1
  48. package/dist/tools/fs/bash-readonly-classifier.d.ts +44 -1
  49. package/dist/tools/fs/bash-readonly-classifier.js +132 -5
  50. package/dist/tools/fs/fs-bash.js +9 -2
  51. package/dist/tools/fs/fs-write.js +19 -8
  52. package/dist/tools/fs/index.d.ts +1 -0
  53. package/dist/tools/fs/index.js +1 -0
  54. package/package.json +1 -1
  55. package/test/export-surface.snapshot.json +7 -1
@@ -40,6 +40,34 @@ export interface OriginClearanceRow {
40
40
  tombstonedAt?: number;
41
41
  events: OriginClearanceEvent[];
42
42
  }
43
+ /**
44
+ * #468② — the ACCOUNT SHADOW a `memory.origin_clear_not_marked` refusal carries when the engine already
45
+ * held the answer: the entry's most recent COMPLETED clearance, as who asked for it and when it settled.
46
+ *
47
+ * It exists to discriminate the refusal's two very different causes without a second full read of the
48
+ * account: an entry that was NEVER marked (no shadow — nothing to clear, and nothing ever cleared) versus a
49
+ * RE-SEND of a clear that already succeeded (shadow present — the caller's own earlier call, or another
50
+ * host's, already did this). Absence is honest absence: the engine never opens an extra read to fill it, so
51
+ * a refusal raised on a path that did not need the ledger simply omits it.
52
+ *
53
+ * `requestId` is the OPENER's attribution (the `clearEntryOrigin` caller's own key — what a re-sending
54
+ * caller compares against), while `settledAt` is the row's terminal `done` event, which on a crash-resumed
55
+ * row is a LATER moment than the opening. The full row (reason, origin, custody, every event) stays one
56
+ * {@link OriginClearanceRow} lookup away — this shadow is a discriminator, never a replacement for it.
57
+ *
58
+ * AS-OF SEMANTICS (codex r3): the account is read ONCE per `clearEntryOrigin` call, by the probe that looks
59
+ * for a resumable pending row, and the committed entry is read after it. A clearance opened AND settled by
60
+ * ANOTHER caller inside that window is therefore not in the snapshot this shadow is derived from, so its
61
+ * absence means "no settled clearance as of this call's own read of the account", never "none exists" — and
62
+ * a fresh call answers with the newer account. The caller's OWN earlier clear (the re-send case this exists
63
+ * for) settled long before and is always in the snapshot. Deliberately not closed by a second read: the
64
+ * shadow is a convenience over the authoritative `listOriginClearances`, and buying strictness with an extra
65
+ * ledger read on a refusal path would cost every caller what only a concurrent third party could observe.
66
+ */
67
+ export interface OriginClearanceShadow {
68
+ requestId: string;
69
+ settledAt: number;
70
+ }
43
71
  /** Lock-free strict read of the whole account (host audit face; journal-aware, corrupt = throw). */
44
72
  export declare function readOriginClearances(controlDir: string): OriginClearanceRow[];
45
73
  /**
@@ -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
  /**
@@ -158,7 +158,7 @@ export interface PrepareConfigDoorsResult {
158
158
  * for the door + placement reasoning). The per-run state builder consumes exactly these values —
159
159
  * the deps bag is never re-read after this door. */
160
160
  microCompactKnob: {
161
- machine: ContextEditMachine;
161
+ machine: "off" | ContextEditMachine;
162
162
  clearOnRejection: boolean;
163
163
  };
164
164
  /** owned — the resolved role; its `systemPrompt` seat is still read at prompt-input time. */
@@ -216,7 +216,7 @@ export interface PrepareConfigDoorsResult {
216
216
  * it must refuse exactly like a malformed field, never fold to the defaults. The RESOLVED values
217
217
  * returned here are immutable and are the state builder's ONLY source — nothing re-reads the bag. */
218
218
  export declare function resolveMicroCompactKnob(bag: RunnerDeps["microCompact"]): {
219
- machine: ContextEditMachine;
219
+ machine: "off" | ContextEditMachine;
220
220
  clearOnRejection: boolean;
221
221
  };
222
222
  export declare function prepareConfigDoors(input: PrepareConfigDoorsInput): PrepareConfigDoorsResult;
@@ -123,8 +123,8 @@ function microCompactConfigError(field, value, legal) {
123
123
  }
124
124
  const seat = field === undefined ? "microCompact" : `microCompact.${field}`;
125
125
  const e = new Error(`${seat} ${shown} is not ${legal} — an unevaluable declaration is refused loudly, ` +
126
- `never folded to the default: a silently-ignored opt-in would run the pre-374 machine while the ` +
127
- `deployment believes it opted in.`);
126
+ `never folded to the default: a silently-ignored declaration would run the DEFAULT machine ` +
127
+ `while the deployment believes its opt-in or opt-out took effect.`);
128
128
  e.code = "config.microcompact_invalid";
129
129
  return e;
130
130
  }
@@ -133,14 +133,17 @@ export function resolveMicroCompactKnob(bag) {
133
133
  throw microCompactConfigError(undefined, bag, "an object carrying optional machine/clearOnRejection keys");
134
134
  }
135
135
  const declaredMachine = bag?.machine;
136
- if (declaredMachine !== undefined && declaredMachine !== "legacy" && declaredMachine !== "cc") {
137
- throw microCompactConfigError("machine", declaredMachine, `"legacy" | "cc"`);
136
+ if (declaredMachine !== undefined && declaredMachine !== "off" && declaredMachine !== "legacy" && declaredMachine !== "cc") {
137
+ throw microCompactConfigError("machine", declaredMachine, `"off" | "legacy" | "cc"`);
138
138
  }
139
- const declaredClearOnRejection = bag?.clearOnRejection;
140
- if (declaredClearOnRejection !== undefined && typeof declaredClearOnRejection !== "boolean") {
141
- throw microCompactConfigError("clearOnRejection", declaredClearOnRejection, "a boolean");
139
+ return { machine: declaredMachine ?? "cc", clearOnRejection: resolveClearOnRejection(bag) };
140
+ }
141
+ function resolveClearOnRejection(bag) {
142
+ const declared = bag?.clearOnRejection;
143
+ if (declared !== undefined && typeof declared !== "boolean") {
144
+ throw microCompactConfigError("clearOnRejection", declared, "a boolean");
142
145
  }
143
- return { machine: declaredMachine ?? "legacy", clearOnRejection: declaredClearOnRejection === true };
146
+ return declared ?? true;
144
147
  }
145
148
  export function prepareConfigDoors(input) {
146
149
  const { deps, sessions, resume, internals } = input;
@@ -145,6 +145,51 @@ export declare function checkpointScopeOf(spec: {
145
145
  export { resolveCheckpointStore } from "../checkpoint-store.js";
146
146
  export { isFableFamilyModelId, resolveAttachmentsConfig, resolveModelPromptTraits, resolveTaskLimits } from "./prepare-config-doors.js";
147
147
  export { rebaseWorkspacePath, rebaseWorkspacePathAcross } from "./prepare-workspace-restore.js";
148
+ /** See {@link runGuardChain}. */
149
+ export interface GuardChainArgs {
150
+ /** The post-frontier view the chain starts from. */
151
+ edited: AgentMessage[];
152
+ /** The pre-cap counterpart view (arm A's clearSource — RB-212 durable composition seat). */
153
+ replayed: AgentMessage[];
154
+ index: OccurrenceIndex;
155
+ microCompact: PreparedMicroCompact;
156
+ guardAt: number;
157
+ charsPerToken: number;
158
+ offloadStore: import("../tool-result-store.js").ToolResultStore | undefined;
159
+ sessionId: string;
160
+ taskId: string;
161
+ deps: RunnerDeps;
162
+ trimPressureRef: {
163
+ droppedMessages: boolean;
164
+ };
165
+ recheck: boolean | undefined;
166
+ signal: AbortSignal | undefined;
167
+ /** The r7 per-request observation buffer — the chain DROPS it on its early returns (a discarded
168
+ * provisional projection must not narrate itself). */
169
+ pendingProjectionObservations: Array<() => void>;
170
+ }
171
+ export type GuardChainOutcome = {
172
+ earlyReturn: {
173
+ messages: AgentMessage[];
174
+ adoptSessionRebuild?: boolean;
175
+ };
176
+ } | {
177
+ working: AgentMessage[];
178
+ trimmed: AgentMessage[];
179
+ trimDroppedMessages: boolean;
180
+ };
181
+ /**
182
+ * design/374 slice 3 — the GUARD CHAIN over one request build (D-7 case-ii transfer table),
183
+ * module-level per the design/238 body-span bank. design/123 D3: the chain reads the anchored
184
+ * coordinate re-estimated on the EDITED array (pre-anchor clears don't lower it — deliberately;
185
+ * trimToBudget doc). The opt-out machine = the pre-374 backstop order byte-identical
186
+ * (trim as the ordinary second line); otherwise arm A (blocking machine run, only when the
187
+ * frontier pass is off) → arm B (in-turn forced compaction behind the adopt seam) → arm C (trim,
188
+ * the disaster-only last resort and this chain's sole `context.trim` emitter). Returns either the
189
+ * post-chain view for the pipeline tail, or an EARLY hook result the context handler must return
190
+ * verbatim (arm-B adoption / the r8 dead-turn stand-down).
191
+ */
192
+ export declare function runGuardChain(args: GuardChainArgs): Promise<GuardChainOutcome>;
148
193
  export interface Prepared {
149
194
  harness: AgentHarness;
150
195
  /** The CONCRETE built-in session (engine-internal: prepare constructs/acquires `StoredSession` itself,
@@ -886,21 +931,38 @@ export interface Prepared {
886
931
  trimPressureRef: {
887
932
  droppedMessages: boolean;
888
933
  };
889
- /** design/374 slices 1b/2 — the microCompact machine state this run: the selected clearing
934
+ /** design/374 slices 1b/2/3 — the microCompact machine state this run: the selected clearing
890
935
  * machine, the cleared-projection ledger (request-view application, durable decisions — see
891
936
  * `context-edit.ts`'s ledger note; per-run in-memory, so durable resume / `resumeAt` rebuilds
892
937
  * start EMPTY by construction), the last request's projection seat (what the provider actually
893
938
  * saw — the MC-R rejection arm computes its candidates and savings on THIS view, never on the
894
- * raw session rebuild), and the MC-R knob. Default machine "legacy" + MC-R off the ledger
895
- * never gains an entry and every replay is a same-reference no-op (default bytes unchanged). */
939
+ * raw session rebuild), the MC-R knob, and the slice-3 arm-B seat. The explicit opt-out
940
+ * (`machine: "legacy"` + MC-R off) ⇒ the ledger never gains an entry and every replay is a
941
+ * same-reference no-op (opt-out bytes unchanged). */
896
942
  microCompact: PreparedMicroCompact;
897
943
  }
898
944
  /** See {@link Prepared.microCompact}. */
899
945
  export interface PreparedMicroCompact {
900
- machine: ContextEditMachine;
946
+ /** The frontier-machine selection — `"off"` = no proactive frontier clearing (the unified
947
+ * machine instead gets its one blocking-point shot, slice-3 arm A). */
948
+ machine: "off" | ContextEditMachine;
901
949
  /** MC-R (design/374 §3.2): one-shot clear-and-retry on a provider input-too-long rejection.
902
- * Default false (X2: lands with the machinery, flips with slice 3). */
950
+ * Default true since the slice-3 flip. */
903
951
  clearOnRejection: boolean;
952
+ /** design/374 slice 3 (arm B) — the in-turn forced-compaction seat: runtask wires a closure
953
+ * that runs the SAME forced-compaction pass the prompt-too-long recovery uses (gates included)
954
+ * and answers whether a compaction landed in the session. The context hook calls it when the
955
+ * pre-send estimate breaks the guard budget and then returns `adoptSessionRebuild` so the
956
+ * harness adopts the reduced transcript. `signal` is the TURN-scoped abort of the request
957
+ * build (r3): a turn interrupt must be able to cut the summary call short instead of waiting
958
+ * it out. `anchoredEstimate` is the chain's own trigger coordinate — the seat consults the
959
+ * §25.2 anti-thrash floor against it (an ineffective landing must not be repeated per request
960
+ * build; the chain's arm C owns the bounded fallback). Unwired (pure-prepare callers) ⇒ arm B
961
+ * declines and the chain falls to the trim last resort — same posture as a compaction-disabled
962
+ * run. */
963
+ inTurnCompactionRef: {
964
+ current?: (signal?: AbortSignal, anchoredEstimate?: number) => Promise<boolean>;
965
+ };
904
966
  ledger: ClearedProjectionLedger;
905
967
  projectionRef: {
906
968
  current?: {
@@ -1436,6 +1498,26 @@ export interface RunInternals {
1436
1498
  * after a restart those intermediate sessions are dead ends, and a recovery face enumerating
1437
1499
  * "everything under this host session" needs the root anchor, not an alias walk. */
1438
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;
1439
1521
  /**
1440
1522
  * RB-429 — the REGISTRY SCOPE this run's own background row lives in: the domain its registry-facing
1441
1523
  * tools (TaskOutput / TaskStop / SendMessage / AgentTranscript / Monitor, and the announce listing)
@@ -345,6 +345,7 @@ function buildMicroCompactState(deps, model, sessionId, offloadStore, knob) {
345
345
  clearOnRejection: knob.clearOnRejection,
346
346
  ledger: createClearedProjectionLedger(),
347
347
  projectionRef: {},
348
+ inTurnCompactionRef: {},
348
349
  ...(offloadStore ? { offloadPersist: createOffloadPersist(offloadStore, sessionId, deps.onNotice) } : {}),
349
350
  };
350
351
  }
@@ -363,10 +364,75 @@ function recordFrontierClears(args) {
363
364
  taskId: args.taskId,
364
365
  clearedCount: args.pass.clears.length,
365
366
  tokensSavedEstimate: args.pass.tokensSavedEstimate,
366
- trigger: "frontier",
367
+ trigger: args.trigger,
367
368
  ts: Date.now(),
368
369
  }));
369
370
  }
371
+ function flushProjectionObservations(pending) {
372
+ for (const fire of pending.splice(0)) {
373
+ try {
374
+ fire();
375
+ }
376
+ catch {
377
+ }
378
+ }
379
+ }
380
+ export async function runGuardChain(args) {
381
+ const { edited, microCompact, guardAt, charsPerToken, deps, taskId } = args;
382
+ const applyGuardTrim = (view, anchoredTotal) => {
383
+ let trimmed = trimToBudget(view, guardAt, anchoredTotal, charsPerToken);
384
+ const dropped = trimmed.length < view.length;
385
+ if (dropped) {
386
+ args.trimPressureRef.droppedMessages = true;
387
+ const droppedCount = view.length - trimmed.length;
388
+ trimmed = insertTrimNotice(trimmed);
389
+ emitTrace(deps.tracer, () => ({
390
+ kind: "context.trim",
391
+ version: 1,
392
+ taskId,
393
+ dropped: droppedCount,
394
+ ts: Date.now(),
395
+ }));
396
+ }
397
+ return { trimmed, dropped };
398
+ };
399
+ let working = edited;
400
+ let anchoredWorking = estimateContextTokens(working, charsPerToken).tokens;
401
+ if (microCompact.machine === "legacy") {
402
+ const t = applyGuardTrim(working, anchoredWorking);
403
+ return { working, trimmed: t.trimmed, trimDroppedMessages: t.dropped };
404
+ }
405
+ if (microCompact.machine === "off" && anchoredWorking > guardAt) {
406
+ let blockingPass;
407
+ const blockingEdited = clearStaleToolResults(working, {
408
+ budgetTokens: guardAt,
409
+ machine: "cc",
410
+ clearSource: args.replayed,
411
+ onCleared: (pass) => { blockingPass = pass; },
412
+ anchoredTotalTokens: anchoredWorking,
413
+ charsPerToken,
414
+ ...(args.offloadStore ? { offload: { persist: createOffloadPersist(args.offloadStore, args.sessionId, deps.onNotice) } } : {}),
415
+ });
416
+ if (blockingPass !== undefined) {
417
+ recordFrontierClears({ pass: blockingPass, index: args.index, edited: blockingEdited, ledger: microCompact.ledger, tracer: deps.tracer, taskId, trigger: "blocking" });
418
+ working = blockingEdited;
419
+ anchoredWorking = estimateContextTokens(working, charsPerToken).tokens;
420
+ }
421
+ }
422
+ if (anchoredWorking > guardAt && args.recheck !== true) {
423
+ const compacted = (await microCompact.inTurnCompactionRef.current?.(args.signal, anchoredWorking)) === true;
424
+ if (compacted) {
425
+ args.pendingProjectionObservations.length = 0;
426
+ return { earlyReturn: { messages: working, adoptSessionRebuild: true } };
427
+ }
428
+ if (args.signal?.aborted === true) {
429
+ args.pendingProjectionObservations.length = 0;
430
+ return { working, trimmed: working, trimDroppedMessages: false };
431
+ }
432
+ }
433
+ const t = applyGuardTrim(working, anchoredWorking);
434
+ return { working, trimmed: t.trimmed, trimDroppedMessages: t.dropped };
435
+ }
370
436
  export function gatedCallIdOf(p) {
371
437
  if (p.suspendRef.token !== undefined)
372
438
  return p.suspendRef.gatedCallId;
@@ -615,6 +681,15 @@ async function derivedRouteFallsBack(args) {
615
681
  return false;
616
682
  }
617
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
+ }
618
693
  export async function prepareTask(spec, deps, sessions, resume, internals, runnerSelf) {
619
694
  const doors = prepareConfigDoors({ spec, deps, sessions, resume, internals });
620
695
  spec = doors.spec;
@@ -723,7 +798,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
723
798
  try {
724
799
  ownedEnv = deps.executionEnvFactory
725
800
  ? await deps.executionEnvFactory({
726
- sessionId,
801
+ sessionId, placementRootSessionId: mintPlacementRootSessionId(internals, sessionId),
727
802
  taskId: spec.taskId,
728
803
  ...(internals?.isolation ? { isolation: internals.isolation } : {}),
729
804
  ...(internals?.parentCwd ? { parentCwd: internals.parentCwd } : {}),
@@ -1107,7 +1182,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1107
1182
  taskId: hostTaskId,
1108
1183
  ...(internals?.parentTaskId !== undefined ? { parentTaskId: internals.parentTaskId } : {}),
1109
1184
  ...(internals?.parentSessionId !== undefined ? { parentSessionId: internals.parentSessionId } : {}),
1110
- ...(internals?.rootSessionId !== undefined ? { rootSessionId: internals.rootSessionId } : {}),
1185
+ ...(internals?.rootSessionId !== undefined ? { rootSessionId: internals.rootSessionId } : {}), ...(internals?.placementRoot !== undefined ? { placementRoot: internals.placementRoot } : {}),
1111
1186
  ...(internals?.explicitAgentName !== undefined ? { spawnedAgentName: internals.explicitAgentName } : {}),
1112
1187
  ...(internals?.peerSelfRef !== undefined ? { peerSelfRef: internals.peerSelfRef } : {}),
1113
1188
  ...(internals?.peerInboundChainRef !== undefined ? { peerInboundChainRef: internals.peerInboundChainRef } : {}),
@@ -1282,7 +1357,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1282
1357
  scope: taskScope,
1283
1358
  notifier: deps.workflowCompletionNotifier,
1284
1359
  originatingSessionId: sessionId,
1285
- rootSessionId: internals?.rootSessionId ?? sessionId,
1360
+ rootSessionId: internals?.rootSessionId ?? sessionId, ...(internals?.placementRoot !== undefined ? { placementRoot: internals.placementRoot } : {}),
1286
1361
  taskRegistry: defaultTaskRegistry,
1287
1362
  taskNotification: internals?.onTaskNotification,
1288
1363
  taskOwner: hostTaskId,
@@ -4621,101 +4696,108 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
4621
4696
  const guardAt = guardBudget(model);
4622
4697
  const microCompact = buildMicroCompactState(deps, model, sessionId, offloadStore, microCompactKnob);
4623
4698
  const charsPerToken = model.charsPerToken ?? DEFAULT_CHARS_PER_TOKEN;
4624
- harness.on("context", async ({ messages }) => {
4699
+ harness.on("context", async ({ messages, recheck, signal }) => {
4625
4700
  batchHaltRef.current = undefined;
4626
- const healed = dropEmptyFailureAssistants(messages);
4627
- const replay = replayClearedProjection(healed, microCompact.ledger);
4628
- const replayed = replay.messages;
4629
- const capped = await capAggregateToolResults(replayed, {
4630
- store: offloadStore,
4631
- sessionId,
4632
- onCapped: (info) => emitTrace(deps.tracer, () => ({
4633
- kind: "tool_result.capped",
4634
- version: 1,
4635
- taskId: spec.taskId ?? sessionId,
4636
- ...(info.tool !== undefined ? { tool: info.tool } : {}),
4637
- sizeChars: info.sizeChars,
4638
- storeFallback: info.storeFallback,
4639
- ts: Date.now(),
4640
- })),
4641
- });
4642
- const mediaCapped = capAggregateMediaBytes(capped, {
4643
- limitBytes: deps.mediaByteCapBytes ?? AGGREGATE_MEDIA_BUDGET_BYTES,
4644
- onStripped: deps.onMediaStripped,
4645
- });
4646
- let ccPass;
4647
- const edited = clearStaleToolResults(mediaCapped, {
4648
- budgetTokens: editAt,
4649
- ...(microCompact.machine === "cc" || microCompact.clearOnRejection ? { recognizeCcMarkers: true } : {}),
4650
- ...(microCompact.machine === "cc"
4651
- ? {
4652
- machine: "cc",
4653
- clearSource: replayed,
4654
- onCleared: (pass) => { ccPass = pass; },
4655
- }
4656
- : {}),
4657
- anchoredTotalTokens: estimateContextTokens(mediaCapped, charsPerToken).tokens,
4658
- charsPerToken,
4659
- ...(offloadStore
4660
- ? {
4661
- offload: {
4662
- persist: createOffloadPersist(offloadStore, sessionId, deps.onNotice),
4663
- },
4664
- }
4665
- : {}),
4666
- });
4667
- if (ccPass !== undefined) {
4668
- recordFrontierClears({ pass: ccPass, index: replay.index, edited, ledger: microCompact.ledger, tracer: deps.tracer, taskId: spec.taskId ?? sessionId });
4669
- }
4670
- let trimmed = trimToBudget(edited, guardAt, estimateContextTokens(edited, charsPerToken).tokens, charsPerToken);
4671
- const trimDroppedMessages = trimmed.length < edited.length;
4672
- if (trimDroppedMessages) {
4673
- trimPressureRef.droppedMessages = true;
4674
- const droppedCount = edited.length - trimmed.length;
4675
- trimmed = insertTrimNotice(trimmed);
4676
- emitTrace(deps.tracer, () => ({
4677
- kind: "context.trim",
4678
- version: 1,
4679
- taskId: spec.taskId ?? sessionId,
4680
- dropped: droppedCount,
4681
- ts: Date.now(),
4682
- }));
4683
- }
4684
- trimmed = applyGitFrameGuard({
4685
- before: edited,
4686
- trimmed,
4687
- budgetTokens: guardAt,
4688
- ref: gitStatusRef,
4689
- charsPerToken,
4690
- onDegrade: (message) => {
4701
+ const pendingProjectionObservations = [];
4702
+ try {
4703
+ const healed = dropEmptyFailureAssistants(messages);
4704
+ const replay = replayClearedProjection(healed, microCompact.ledger);
4705
+ const replayed = replay.messages;
4706
+ const capped = await capAggregateToolResults(replayed, {
4707
+ store: offloadStore,
4708
+ sessionId,
4709
+ onCapped: (info) => pendingProjectionObservations.push(() => emitTrace(deps.tracer, () => ({
4710
+ kind: "tool_result.capped",
4711
+ version: 1,
4712
+ taskId: spec.taskId ?? sessionId,
4713
+ ...(info.tool !== undefined ? { tool: info.tool } : {}),
4714
+ sizeChars: info.sizeChars,
4715
+ storeFallback: info.storeFallback,
4716
+ ts: Date.now(),
4717
+ }))),
4718
+ });
4719
+ const mediaCapped = capAggregateMediaBytes(capped, {
4720
+ limitBytes: deps.mediaByteCapBytes ?? AGGREGATE_MEDIA_BUDGET_BYTES,
4721
+ onStripped: (info) => {
4722
+ const onMediaStripped = deps.onMediaStripped;
4723
+ if (onMediaStripped !== undefined)
4724
+ pendingProjectionObservations.push(() => onMediaStripped(info));
4725
+ },
4726
+ });
4727
+ let ccPass;
4728
+ const edited = microCompact.machine === "off" ? mediaCapped : clearStaleToolResults(mediaCapped, {
4729
+ budgetTokens: editAt,
4730
+ machine: microCompact.machine,
4731
+ ...(microCompact.machine === "cc" || microCompact.clearOnRejection ? { recognizeCcMarkers: true } : {}),
4732
+ ...(microCompact.machine === "cc"
4733
+ ? {
4734
+ clearSource: replayed,
4735
+ onCleared: (pass) => { ccPass = pass; },
4736
+ }
4737
+ : {}),
4738
+ anchoredTotalTokens: estimateContextTokens(mediaCapped, charsPerToken).tokens,
4739
+ charsPerToken,
4740
+ ...(offloadStore
4741
+ ? {
4742
+ offload: {
4743
+ persist: createOffloadPersist(offloadStore, sessionId, deps.onNotice),
4744
+ },
4745
+ }
4746
+ : {}),
4747
+ });
4748
+ if (ccPass !== undefined) {
4749
+ recordFrontierClears({ pass: ccPass, index: replay.index, edited, ledger: microCompact.ledger, tracer: deps.tracer, taskId: spec.taskId ?? sessionId, trigger: "frontier" });
4750
+ }
4751
+ const chain = await runGuardChain({
4752
+ edited, replayed, index: replay.index, microCompact, guardAt, charsPerToken, offloadStore,
4753
+ sessionId, taskId: spec.taskId ?? sessionId, deps, trimPressureRef, recheck, signal,
4754
+ pendingProjectionObservations,
4755
+ });
4756
+ if ("earlyReturn" in chain) {
4757
+ return chain.earlyReturn;
4758
+ }
4759
+ const { working, trimDroppedMessages } = chain;
4760
+ let trimmed = chain.trimmed;
4761
+ trimmed = applyGitFrameGuard({
4762
+ before: working,
4763
+ trimmed,
4764
+ budgetTokens: guardAt,
4765
+ ref: gitStatusRef,
4766
+ charsPerToken,
4767
+ onDegrade: (message) => {
4768
+ try {
4769
+ deps.onError?.(new Error(message), { phase: "degraded", sessionId, classification: "env-git-snapshot" });
4770
+ }
4771
+ catch {
4772
+ }
4773
+ },
4774
+ });
4775
+ const swept = dropOrphanToolResults(trimmed);
4776
+ if (swept.dropped.length > 0) {
4691
4777
  try {
4692
- deps.onError?.(new Error(message), { phase: "degraded", sessionId, classification: "env-git-snapshot" });
4778
+ deps.onError?.(new Error(`context belt: dropped ${swept.dropped.length} orphan toolResult(s) whose tool-call assistant was not in the request view — ` +
4779
+ swept.dropped
4780
+ .map((d) => `toolCallId=${d.toolCallId} at index ${d.index} (prev=${d.prevRole}, next=${d.nextRole})`)
4781
+ .join("; ")), { phase: "hook", sessionId });
4693
4782
  }
4694
4783
  catch {
4695
4784
  }
4696
- },
4697
- });
4698
- const swept = dropOrphanToolResults(trimmed);
4699
- if (swept.dropped.length > 0) {
4700
- try {
4701
- deps.onError?.(new Error(`context belt: dropped ${swept.dropped.length} orphan toolResult(s) whose tool-call assistant was not in the request view — ` +
4702
- swept.dropped
4703
- .map((d) => `toolCallId=${d.toolCallId} at index ${d.index} (prev=${d.prevRole}, next=${d.nextRole})`)
4704
- .join("; ")), { phase: "hook", sessionId });
4705
- }
4706
- catch {
4707
4785
  }
4786
+ requestLossyRef.current =
4787
+ replayed !== healed ||
4788
+ capped !== replayed ||
4789
+ mediaCapped !== capped ||
4790
+ edited !== mediaCapped ||
4791
+ working !== edited ||
4792
+ trimDroppedMessages ||
4793
+ swept.dropped.length > 0 ||
4794
+ gitStatusRef.overBudgetShrunk === true;
4795
+ microCompact.projectionRef.current = { messages: swept.messages, keyOf: replay.index.keyOf };
4796
+ return { messages: swept.messages };
4797
+ }
4798
+ finally {
4799
+ flushProjectionObservations(pendingProjectionObservations);
4708
4800
  }
4709
- requestLossyRef.current =
4710
- replayed !== healed ||
4711
- capped !== replayed ||
4712
- mediaCapped !== capped ||
4713
- edited !== mediaCapped ||
4714
- trimDroppedMessages ||
4715
- swept.dropped.length > 0 ||
4716
- gitStatusRef.overBudgetShrunk === true;
4717
- microCompact.projectionRef.current = { messages: swept.messages, keyOf: replay.index.keyOf };
4718
- return { messages: swept.messages };
4719
4801
  });
4720
4802
  const cacheBreakDetector = deps.cacheBreakDetection === false ? undefined : new CacheBreakDetector();
4721
4803
  const cacheFingerprint = cacheBreakDetector
@@ -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 {