@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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.65.0 — 2026-08-28
4
+
5
+ ### Added
6
+ - design/380 batch 2 — the device-lane durable half:
7
+ - `CheckpointState.placementRootSessionId` (additive): stamped at every suspend mint, restored into the internals binding on resume, chain-stable across arbitrary park depth. A resume whose EXPLICIT `internals.placementRoot` contradicts the recorded root refuses pre-CAS with the NEW code `resume.placement_mismatch` (closed-set addition to `CheckpointError`), positioned in the identity block above the usage-window rung. A supplied `rootSessionId` is delegation identity, never a placement claim — the engine's own parked-agent revive lands at the recorded root. Legacy rows keep the historic fold byte-identical.
8
+ - `RunnerDeps.resumePreflight` + `resumePreflightTimeoutMs`, NEW public exports `ResumePreflightInfo` / `ResumePreflightVerdict`: a deployment callback screening every redeem attempt as the last pre-CAS rung, reading facts off the persisted row only. Non-answers (throw/timeout/unreadable verdict — including a terminal verdict without a readable message) fail closed as the NEW code `resume.preflight_rejected` on the retry-later arm; only an explicit terminal disposition settles the row, honoring the fence-winner duties (same-process paused env destroyed exactly once; cross-Runner losers retire their reap records). The deadline knob validates its full admissible range loudly and folds to its own 10s default.
9
+ - `ExecutionEnv.externalContentTarget` (adapter-declared) + `CheckpointState.externalContentTarget` (monotonic bit): the run's env-MOUNTED tool band (fs/bash, Monitor, worktree, LSP — the roster is minted at the mount sites) classifies as external for memory governance, ORs into the `execIsExternalContent` channel, persists across park/resume, and derives one neutral environment-facts prompt line. Orchestration tools never enter the roster. Undeclared deployments are byte-identical.
10
+ - `WorkflowRun.timeoutInterruption` + `WorkflowRunSummary.timeoutInterruption` (#485 core half): a run abandoned by `totalTimeoutMs` carries `{timeoutMs, agentsCompleted, agentsFailed, agentsInFlight}` stamped once at the failure terminal (before the abandonment fold — the seat describes the world the deadline found); record face, list projection (cross-backend contract case), and the poll `note` read the same seat. The status enum is unchanged.
11
+ - `HarvestReport.reportId` (#479): engine-minted uuidv7 per-occurrence identity on all five report-derived memory notices as flat `detail.reportId`; the dedup key is `(code, detail.reportId)`.
12
+ - The workflow poll receipt disclosed a terminal budget overshoot (#478): one additive sentence off the summary seat; seatless runs byte-identical.
13
+
14
+ ### Changed
15
+ - Workflow budget narration labels its unit honestly (#487, case A): four sema-authored faces now say "total tokens"; the CC-verbatim `WorkflowBudgetExceededError` message is deliberately frozen (anchor-inherited label, pinned byte-exact).
16
+ - The read-boundary positive-control pins converted to a differential invariant (test-only).
17
+
18
+ ### Fixed
19
+ - Merged-code rescan batch (A-081, 7 confirmed pre-ship): the placement rung no longer bricks parked durable agents revived under an explicit spawner placement root (P1); the O9a roster is minted at the mount sites instead of a static table, closing the Monitor/worktree/LSP under-marking hole AND the TaskOutput false-positive (P1); the terminal preflight expire honors the design/51 fence-winner duties (paused env reclaimed, P2); the preflight deadline knob's silent double-validation band closed (P2); empty-string placement anchors read uniformly as absence; a terminal verdict without a readable message never terminalizes a row; rung order fixed so a deterministic dead claim is never masked by a retryable code.
20
+
21
+ ### BREAKING/adaptation notes
22
+ - `CheckpointError` closed set +2 (`resume.placement_mismatch`, `resume.preflight_rejected`); `detail.retryAfterMs` now rides two codes.
23
+ - `SerializedCheckpointState` gains two REQUIRED keys (`placementRootSessionId`, `externalContentTarget`) — out-of-tree builders/`satisfies` sites gain compile errors (the mapped type's documented non-homomorphic contract).
24
+ - Export surface +2 (`ResumePreflightInfo`, `ResumePreflightVerdict`).
25
+ - A durable resume whose explicit placement anchor contradicts the recorded root now refuses loudly (named narrowing; re-pin old-behavior probes).
26
+
3
27
  ## 5.64.0 — 2026-08-27
4
28
 
5
29
  ### BREAKING
@@ -1160,6 +1160,43 @@ export interface CheckpointState {
1160
1160
  * delegated child (never `false` — presence-coded like the other monotonic bits here).
1161
1161
  */
1162
1162
  isDelegatedChild?: true;
1163
+ /**
1164
+ * design/380 O1③ — the run TREE's placement fixed point at suspend: the SAME three-segment fold the
1165
+ * env-factory ctx mint uses (explicit `RunInternals.placementRoot` > the delegation lanes'
1166
+ * `rootSessionId` > the leg's own resolved session), stamped from TRUSTED internals so a durable
1167
+ * resume can restore it instead of falling back to the parked leg's own session (a target-bound
1168
+ * factory keys its placement lookup on the fixed point — the self fallback read a resumed leg as a
1169
+ * NEW placement). Restore is the `isDelegatedChild` posture replayed on the placement axis: an
1170
+ * EXPLICIT live `internals.placementRoot` wins when re-supplied; otherwise THIS key restores into
1171
+ * `internals.placementRoot` at prepare so descendants and the NEXT re-suspend mint inherit it too
1172
+ * — outranking a supplied `rootSessionId`, which is DELEGATION IDENTITY (the peer/registry axis a
1173
+ * revive leg re-supplies from its row), never a placement claim (rescan C1: reading it as one
1174
+ * permanently stuck every parked agent spawned under an explicit placement root). The resume entry
1175
+ * ALSO reads this key as an identity record: a resume whose supplied `internals.placementRoot`
1176
+ * CONTRADICTS it is refused pre-CAS (`resume.placement_mismatch`); either side absent skips the
1177
+ * check (an older row / a claim-less resume — the honest degradation arm), and `""` reads as
1178
+ * absence (the identity fields' discipline). Schema ADDITION (additive, old readers ignore it — the Q5 posture;
1179
+ * deliberately NOT version-gated: same rolling-deployment account as {@link Checkpoint.principal},
1180
+ * whose doc records version-gating as a design candidate — any new stamp is a lattice-wide window
1181
+ * rewrite, owned by that ticket, not ad-hoc here). Absent ⇒ minted before the field, or the fold
1182
+ * resolved empty on a factory-less run (never persisted as garbage).
1183
+ */
1184
+ placementRootSessionId?: string;
1185
+ /**
1186
+ * design/380 O9a — MONOTONIC presence bit: this run's hand-tool content fold was EXTERNAL-TARGET
1187
+ * armed (the env declared `ExecutionEnv.externalContentTarget`, or an earlier leg of this suspend
1188
+ * chain did). Stamped at the suspend mint from the run's RESOLVED fold state — never from first
1189
+ * tool use, so a park landing BEFORE the first hand call is covered too. Presence-coded like
1190
+ * {@link isDelegatedChild} (absent = never armed; never `false`). The resume side folds
1191
+ * **persisted OR live declaration** (a monotonic join, never a downgrade): a run minted under a
1192
+ * declaring adapter keeps its memory fold and its prompt disclosure when resumed on an
1193
+ * undeclared/older adapter — the downgrade is closed in the derivation itself, deliberately
1194
+ * WITHOUT a new refusal arm (one fewer failure path, same safety). Feeds the memory face and the
1195
+ * prompt disclosure only — no gate/policy/roster behavior reads it (the design/378 red line).
1196
+ * Schema ADDITION (additive, Q5 posture; the version-gate residual is shared with
1197
+ * {@link placementRootSessionId} above and owned by the same version-lattice ticket).
1198
+ */
1199
+ externalContentTarget?: true;
1163
1200
  }
1164
1201
  /**
1165
1202
  * {@link CheckpointState} with **every** field made required-PRESENT, while each value keeps its original
@@ -1869,6 +1906,30 @@ export declare class CheckpointError extends Error {
1869
1906
  * refusal exists to close. Rejected PRE-CAS (the checkpoint stays `pending`); re-resume with the
1870
1907
  * original principal, or omit the field to inherit the recorded one. */
1871
1908
  | "resume.principal_mismatch"
1909
+ /** design/380 O1③ (placement identity continuity — the `resume.principal_mismatch` rung's twin
1910
+ * on the placement axis): the resume supplied trusted internals whose EXPLICIT
1911
+ * `placementRoot` differs from the {@link CheckpointState.placementRootSessionId} recorded at
1912
+ * the suspend mint. A target-bound env factory keys its placement lookup on that fixed point,
1913
+ * so silently resuming under a different one would re-place the leg — and every descendant
1914
+ * the child-internals chain hands it to — on another target than the parked workspace lives
1915
+ * on. A supplied `rootSessionId` deliberately does NOT take this refusal (rescan C1): it is
1916
+ * delegation identity, re-supplied by every revive leg, and legitimately differs from the
1917
+ * recorded root whenever the spawner carried an explicit placement — the recorded root simply
1918
+ * outranks it in prepare's restore fold. Rejected PRE-CAS (the checkpoint stays `pending`);
1919
+ * re-resume with the recorded root, or omit `internals.placementRoot` to inherit it. Either
1920
+ * side absent (an older row / a claim-less resume) skips the check, and `""` reads as absence
1921
+ * — the honest degradation arm. */
1922
+ | "resume.placement_mismatch"
1923
+ /** design/380 O2: the deployment's `RunnerDeps.resumePreflight` refused this resume — an
1924
+ * explicit refusal verdict, a throw, a timeout past its bounded deadline, or an unreadable
1925
+ * answer (all fail-closed). The LAST rung before the CAS, so nothing was consumed. Two arms,
1926
+ * discriminated by the message: the default `retry_later` arm leaves the checkpoint `pending`
1927
+ * and the SAME token redeemable once the obstacle clears (`detail.retryAfterMs` carries the
1928
+ * deployment's wait hint when it gave one — the #449 G1 carrier); the explicit `terminal` arm
1929
+ * has ALREADY settled the row out of `pending` via the single-shot `expire` CAS (reaper
1930
+ * parity) before this throw — the token is NOT redeemable, by the deployment's own permanent
1931
+ * verdict (a dead binding must not be redialed forever). */
1932
+ | "resume.preflight_rejected"
1872
1933
  /** F-012 L3: the resume carried an approver EDIT (`updatedInput`) and a FROZEN inherited ancestor
1873
1934
  * constraint (the persisted projection chain) — or the deployment's resume-edit policy — DENIES the
1874
1935
  * edited args. Refused PRE-CAS with fresh-redecision semantics: the checkpoint stays `pending` and
@@ -1896,8 +1957,11 @@ export declare class CheckpointError extends Error {
1896
1957
  * discriminant. */
1897
1958
  readonly detail?: {
1898
1959
  field?: "boundCallId" | "boundInputHash" | "answer" | "settledBy" | "approver";
1899
- /** #449 G1 — on `resume.usage_window_exhausted` ONLY: how long until the binding window frees
1900
- * (the same wait a suspended row carries as `gate.resumeAfterMs`). Absent on every other code. */
1960
+ /** #449 G1 — on `resume.usage_window_exhausted`: how long until the binding window frees
1961
+ * (the same wait a suspended row carries as `gate.resumeAfterMs`). design/380 O2 on a
1962
+ * retry-later `resume.preflight_rejected`: the deployment preflight's own wait hint, when it
1963
+ * gave one (same carrier, same semantics: retry the SAME token after the wait). Absent on
1964
+ * every other code. */
1901
1965
  retryAfterMs?: number;
1902
1966
  /** WHICH pre-CAS refusal arm fired, where one `code` covers several (requested 2026-08-10: a
1903
1967
  * deployment retry policy needs to tell "a newer worker can redeem this row" from "this row is
@@ -2024,6 +2088,30 @@ export declare class CheckpointError extends Error {
2024
2088
  * refusal exists to close. Rejected PRE-CAS (the checkpoint stays `pending`); re-resume with the
2025
2089
  * original principal, or omit the field to inherit the recorded one. */
2026
2090
  | "resume.principal_mismatch"
2091
+ /** design/380 O1③ (placement identity continuity — the `resume.principal_mismatch` rung's twin
2092
+ * on the placement axis): the resume supplied trusted internals whose EXPLICIT
2093
+ * `placementRoot` differs from the {@link CheckpointState.placementRootSessionId} recorded at
2094
+ * the suspend mint. A target-bound env factory keys its placement lookup on that fixed point,
2095
+ * so silently resuming under a different one would re-place the leg — and every descendant
2096
+ * the child-internals chain hands it to — on another target than the parked workspace lives
2097
+ * on. A supplied `rootSessionId` deliberately does NOT take this refusal (rescan C1): it is
2098
+ * delegation identity, re-supplied by every revive leg, and legitimately differs from the
2099
+ * recorded root whenever the spawner carried an explicit placement — the recorded root simply
2100
+ * outranks it in prepare's restore fold. Rejected PRE-CAS (the checkpoint stays `pending`);
2101
+ * re-resume with the recorded root, or omit `internals.placementRoot` to inherit it. Either
2102
+ * side absent (an older row / a claim-less resume) skips the check, and `""` reads as absence
2103
+ * — the honest degradation arm. */
2104
+ | "resume.placement_mismatch"
2105
+ /** design/380 O2: the deployment's `RunnerDeps.resumePreflight` refused this resume — an
2106
+ * explicit refusal verdict, a throw, a timeout past its bounded deadline, or an unreadable
2107
+ * answer (all fail-closed). The LAST rung before the CAS, so nothing was consumed. Two arms,
2108
+ * discriminated by the message: the default `retry_later` arm leaves the checkpoint `pending`
2109
+ * and the SAME token redeemable once the obstacle clears (`detail.retryAfterMs` carries the
2110
+ * deployment's wait hint when it gave one — the #449 G1 carrier); the explicit `terminal` arm
2111
+ * has ALREADY settled the row out of `pending` via the single-shot `expire` CAS (reaper
2112
+ * parity) before this throw — the token is NOT redeemable, by the deployment's own permanent
2113
+ * verdict (a dead binding must not be redialed forever). */
2114
+ | "resume.preflight_rejected"
2027
2115
  /** F-012 L3: the resume carried an approver EDIT (`updatedInput`) and a FROZEN inherited ancestor
2028
2116
  * constraint (the persisted projection chain) — or the deployment's resume-edit policy — DENIES the
2029
2117
  * edited args. Refused PRE-CAS with fresh-redecision semantics: the checkpoint stays `pending` and
@@ -2051,8 +2139,11 @@ export declare class CheckpointError extends Error {
2051
2139
  * discriminant. */
2052
2140
  detail?: {
2053
2141
  field?: "boundCallId" | "boundInputHash" | "answer" | "settledBy" | "approver";
2054
- /** #449 G1 — on `resume.usage_window_exhausted` ONLY: how long until the binding window frees
2055
- * (the same wait a suspended row carries as `gate.resumeAfterMs`). Absent on every other code. */
2142
+ /** #449 G1 — on `resume.usage_window_exhausted`: how long until the binding window frees
2143
+ * (the same wait a suspended row carries as `gate.resumeAfterMs`). design/380 O2 on a
2144
+ * retry-later `resume.preflight_rejected`: the deployment preflight's own wait hint, when it
2145
+ * gave one (same carrier, same semantics: retry the SAME token after the wait). Absent on
2146
+ * every other code. */
2056
2147
  retryAfterMs?: number;
2057
2148
  /** WHICH pre-CAS refusal arm fired, where one `code` covers several (requested 2026-08-10: a
2058
2149
  * deployment retry policy needs to tell "a newer worker can redeem this row" from "this row is
@@ -278,6 +278,10 @@ export interface Hooks {
278
278
  * answer in a second says so with `timeoutMs`.
279
279
  */
280
280
  export declare const DEFAULT_HOOK_TIMEOUT_MS = 600000;
281
+ /** `setTimeout`'s own ceiling: above it the platform silently clamps to ~1ms, which would turn a
282
+ * deployment's "very long bound" into an instant expiry it never asked for. Refused loudly instead
283
+ * (same ceiling, same posture as the gate's `approvalTimeoutMs`). */
284
+ export declare const MAX_HOOK_TIMEOUT_MS = 2147483647;
281
285
  /**
282
286
  * How long an observation seat still gets when the task is cancelled `elapsed` ms into its wait — the
283
287
  * grace, capped by what is LEFT of the declared deadline.
@@ -303,7 +307,11 @@ export declare function postAbortGraceMs(remainingMs: number): number;
303
307
  */
304
308
  export declare function resolveHookTimeoutMs(supplied: number | undefined, report?: (err: unknown) => void, owner?: object): number;
305
309
  /** The ten {@link Hooks} callback seats, named so a bound wait can say WHICH one did not answer. */
306
- export type HookSeatName = "preToolUse" | "postToolUse" | "postToolUseFailure" | "postToolBatch" | "userPromptSubmit" | "stop" | "stopFailure" | "preCompact" | "postCompact" | "permissionDenied";
310
+ export type HookSeatName = "preToolUse" | "postToolUse" | "postToolUseFailure" | "postToolBatch" | "userPromptSubmit" | "stop" | "stopFailure" | "preCompact" | "postCompact" | "permissionDenied"
311
+ /** design/380 O2 — not a {@link Hooks} record seat: the `RunnerDeps.resumePreflight` deployment
312
+ * callback, run through the same bounded-wait home so the pre-CAS resume ladder cannot grow its
313
+ * own dialect of "a deployment callback gets a deadline". */
314
+ | "resumePreflight";
307
315
  /** What one bounded seat invocation produced: the seat's own value, or the fact that the wait ended
308
316
  * first. A seat that THREW is neither — the throw propagates out of {@link runHookSeat} unchanged, so
309
317
  * each site's existing crash arm keeps owning it. */
@@ -7,7 +7,7 @@ import { ASK_USER_QUESTION_TOOL_NAME } from "./ask-question.js";
7
7
  import { createSafeNotifier } from "./safe-notify.js";
8
8
  import { ORG_ADJUDICATION_TIMEOUT_MS, ORG_RULE_DECISION_REASON, ORG_UNAVAILABLE_DECISION_REASON, settleOrgVerdictWithin } from "./permission-rule-org.js";
9
9
  export const DEFAULT_HOOK_TIMEOUT_MS = 600_000;
10
- const MAX_HOOK_TIMEOUT_MS = 2_147_483_647;
10
+ export const MAX_HOOK_TIMEOUT_MS = 2_147_483_647;
11
11
  const POST_ABORT_GRACE_MS = 1_000;
12
12
  export function postAbortGraceMs(remainingMs) {
13
13
  return Math.max(0, Math.min(POST_ABORT_GRACE_MS, remainingMs));
@@ -63,6 +63,9 @@ export function mergeHarvestReports(a, b) {
63
63
  restored: [...a.restored, ...b.restored],
64
64
  warnings: [...a.warnings, ...b.warnings],
65
65
  };
66
+ const reportId = a.reportId ?? b.reportId;
67
+ if (reportId !== undefined)
68
+ merged.reportId = reportId;
66
69
  const incident = a.incident ?? b.incident;
67
70
  if (incident !== undefined)
68
71
  merged.incident = incident;
@@ -443,6 +443,7 @@ export declare function memoryHarvestQuarantinedNotice(input: {
443
443
  sessionId?: string;
444
444
  provenance?: "off" | "carry";
445
445
  indexRolledBack?: boolean;
446
+ reportId?: string;
446
447
  }): EngineNotice;
447
448
  /**
448
449
  * design/336 §6.3 — the hold family's operator notices, derived from ONE harvest report's
@@ -111,12 +111,13 @@ export function pollutionContainmentCounts(report) {
111
111
  }
112
112
  export function memoryHarvestQuarantinedNotice(input) {
113
113
  const reason = input.reason !== undefined ? inlineUntrusted(input.reason, 200) : undefined;
114
+ const reportId = input.reportId !== undefined ? { reportId: input.reportId } : {};
114
115
  if (input.count === 0 && input.indexRolledBack === true) {
115
116
  return {
116
117
  code: "memory.harvest_quarantined",
117
118
  message: `Memory harvest rolled the derived index (MEMORY.md) back to its materialize-time baseline${reason !== undefined ? ` (${reason})` : ""}: ` +
118
119
  `this session's index prose additions were captured to control-plane quarantine and not retained. No entry files were withheld.`,
119
- detail: { count: 0, moved: 0, escalated: 0, indexRolledBack: true, ...(reason !== undefined ? { reason } : {}), ...(input.sessionId !== undefined ? { sessionId: input.sessionId } : {}) },
120
+ detail: { count: 0, moved: 0, escalated: 0, indexRolledBack: true, ...reportId, ...(reason !== undefined ? { reason } : {}), ...(input.sessionId !== undefined ? { sessionId: input.sessionId } : {}) },
120
121
  };
121
122
  }
122
123
  const lead = input.provenance === "carry"
@@ -135,6 +136,7 @@ export function memoryHarvestQuarantinedNotice(input) {
135
136
  count: input.count,
136
137
  moved: input.moved,
137
138
  escalated: input.escalated,
139
+ ...reportId,
138
140
  ...(reason !== undefined ? { reason } : {}),
139
141
  ...(input.sessionId !== undefined ? { sessionId: input.sessionId } : {}),
140
142
  },
@@ -145,6 +147,7 @@ export function memoryHoldNotices(report, sessionId) {
145
147
  if (c === undefined)
146
148
  return [];
147
149
  const caps = (paths) => paths.slice(0, 20).map((p) => inlineUntrusted(p, 160));
150
+ const reportId = report.reportId !== undefined ? { reportId: report.reportId } : {};
148
151
  const notices = [];
149
152
  if (c.heldInstruction.length > 0) {
150
153
  notices.push({
@@ -152,14 +155,14 @@ export function memoryHoldNotices(report, sessionId) {
152
155
  message: `Memory harvest HELD ${c.heldInstruction.length} instruction-form entry file(s): this session's delegation outcome ` +
153
156
  `is still pending, so instruction entries wait off the model-visible plane and commit automatically once every ` +
154
157
  `delegation settles clean (a dirty or expired settlement disposes them to quarantine, announced; resolveHold is the host valve).`,
155
- detail: { count: c.heldInstruction.length, paths: caps(c.heldInstruction), ...(sessionId !== undefined ? { sessionId } : {}) },
158
+ detail: { count: c.heldInstruction.length, paths: caps(c.heldInstruction), ...reportId, ...(sessionId !== undefined ? { sessionId } : {}) },
156
159
  });
157
160
  }
158
161
  if (c.releasedHolds.length > 0) {
159
162
  notices.push({
160
163
  code: "memory.hold_released",
161
164
  message: `Memory harvest RELEASED ${c.releasedHolds.length} held instruction entry file(s) — their writer sessions settled clean (or a host valve released them) and the entries re-walked the full gate set and committed.`,
162
- detail: { count: c.releasedHolds.length, paths: caps(c.releasedHolds), ...(sessionId !== undefined ? { sessionId } : {}) },
165
+ detail: { count: c.releasedHolds.length, paths: caps(c.releasedHolds), ...reportId, ...(sessionId !== undefined ? { sessionId } : {}) },
163
166
  });
164
167
  }
165
168
  if (c.disposedHolds.length > 0) {
@@ -168,7 +171,7 @@ export function memoryHoldNotices(report, sessionId) {
168
171
  message: `Memory harvest DISPOSED ${c.disposedHolds.length} held instruction entry file(s) to control-plane quarantine ` +
169
172
  `(terminals: ${[...new Set(c.disposedHolds.map((d) => d.terminal))].join(", ")}). An "expired" terminal is a TIMEOUT, not a ` +
170
173
  `conviction — resolveHold(holdId, "release") commits such an entry with a "static"-cause marker after host review; the bytes are never silently dropped.`,
171
- detail: { count: c.disposedHolds.length, disposed: c.disposedHolds.slice(0, 20).map((d) => ({ path: inlineUntrusted(d.relPath, 160), terminal: d.terminal })), ...(sessionId !== undefined ? { sessionId } : {}) },
174
+ detail: { count: c.disposedHolds.length, disposed: c.disposedHolds.slice(0, 20).map((d) => ({ path: inlineUntrusted(d.relPath, 160), terminal: d.terminal })), ...reportId, ...(sessionId !== undefined ? { sessionId } : {}) },
172
175
  });
173
176
  }
174
177
  return notices;
@@ -180,7 +183,7 @@ export function memoryConsolidationRecommendedNotice(report, sessionId) {
180
183
  return {
181
184
  code: "memory.consolidation_recommended",
182
185
  message: `Memory consolidation is recommended for scope ${inlineUntrusted(rec.scope, 80)}: ${rec.sessionsSince} distinct session(s) harvested since the last run and the time gate is open. The host decides — nothing runs automatically.`,
183
- detail: { scope: inlineUntrusted(rec.scope, 80), sessionsSince: rec.sessionsSince, ...(sessionId !== undefined ? { sessionId } : {}) },
186
+ detail: { scope: inlineUntrusted(rec.scope, 80), sessionsSince: rec.sessionsSince, ...(report.reportId !== undefined ? { reportId: report.reportId } : {}), ...(sessionId !== undefined ? { sessionId } : {}) },
184
187
  };
185
188
  }
186
189
  export function memoryConsolidationCommittedNotice(input) {
@@ -1279,6 +1282,7 @@ export class MemoryEngine {
1279
1282
  arr.push(rel);
1280
1283
  };
1281
1284
  const report = {
1285
+ reportId: uuidv7(),
1282
1286
  ok: true,
1283
1287
  patches: { add: 0, update: 0, delete: 0 },
1284
1288
  conflicts: [],
@@ -497,6 +497,22 @@ export interface MemoryAnnouncement {
497
497
  * structure; the runner's boundary-announcement wiring (attachments member) lands in S2 (TODO(S2)).
498
498
  */
499
499
  export interface HarvestReport {
500
+ /** #479 — this report's own per-occurrence identity: a uuidv7 minted ONCE where the engine
501
+ * creates the report (`harvestCore`'s single literal — every exit path, including the refused
502
+ * and zero-admission arms, returns that one object), and read wherever a derived face needs to
503
+ * name this harvest: the `memory.harvest_quarantined` + `memory.hold_*` notice family carries
504
+ * it as `detail.reportId`. Occurrence semantics, both directions: two harvests whose counts and
505
+ * paths happen to coincide are two facts and get two ids (the un-dedupable five-key repeat this
506
+ * member exists to break), while every notice derived from ONE report shares the one id — a
507
+ * consumer's dedup key is therefore `(code, reportId)`, never `reportId` alone. The report is
508
+ * transient (delivered via `RunnerDeps.onMemoryHarvestReport` and the notice lane; the engine
509
+ * persists no report row), so replay-stability comes from the id riding whatever record the
510
+ * HOST persists: a durable replay re-reads the recorded value, a genuinely new harvest mints a
511
+ * new one — per-delivery minting (the fail-open shape) is exactly what this seat avoids.
512
+ * Optional in the TYPE only for hand-built reports (external constructors,
513
+ * `mergeHarvestReports` inputs); every engine-minted report carries it, and the notice
514
+ * factories OMIT the detail key rather than invent one when it is absent. */
515
+ reportId?: string;
500
516
  /** False ⇔ the whole harvest was refused (deletion fuse / missing dir / corrupt sidecar incident). */
501
517
  ok: boolean;
502
518
  /** Set when a fuse tripped (§2.4 删除防线 / B3 fail-closed): the harvest is refused wholesale, NO
@@ -428,9 +428,9 @@ export interface ExecutionEnvFactoryContext {
428
428
  * design/380 O1① — the run TREE's placement root (fixed point), minted by prepare AFTER session
429
429
  * resolution as `internals.placementRoot ?? internals.rootSessionId ?? sessionId` — a top-level
430
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 landsresidual pinned). A target-bound
431
+ * workflow spawn chains re-thread an explicit `placementRoot` — C12; a bare DURABLE RESUME crosses
432
+ * too since design/380 O1③: the suspend mint stamps the fixed point on the checkpoint and the
433
+ * resume restores it into trusted internals formerly the pinned residual). A target-bound
434
434
  * factory (device lane) keys its placement lookup on THIS, never on `sessionId` (a child's fresh
435
435
  * session id must not read as a new placement). Always present and non-empty — an empty value is
436
436
  * an assembly error the engine refuses loudly at the mint (`config.placement_root_invalid`) and a
@@ -181,6 +181,15 @@ export interface PrepareHandsReadFaceResult {
181
181
  shellGatedBash: boolean;
182
182
  /** write-back — the Monitor twin of the bit above. */
183
183
  shellGatedMonitor: boolean;
184
+ /** owned (design/380 rescan C4) — the ENV-HAND tool names, minted AT THE MOUNT: every tool this
185
+ * phase mounted over the ExecutionEnv (the fs/bash band), later grown by the driver's own env
186
+ * mounts (Monitor, the worktree pair — the SAME Set identity, mutated at those mount sites).
187
+ * Consumed by the O9a external-content-target fold: these are the tools whose RESULTS are the
188
+ * target's bytes, so membership must be what actually mounted on the env — never a static table
189
+ * (the wake/resume reconciliation table lists TaskOutput/TaskStop, which are orchestration, and
190
+ * misses Monitor/worktree, which are hands — both directions wrong for this consumer). Empty on
191
+ * a hands-less leg (nothing mounts on the env; the driver's env mounts are hands-gated too). */
192
+ envHandToolNames: Set<string>;
184
193
  }
185
194
  export declare function resolveHandsLessReadFace(input: PrepareHandsReadFaceInput): PrepareHandsReadFaceResult;
186
195
  /** The hands-MOUNT branch — the async half of the phase (see the module header for the contract).
@@ -71,11 +71,13 @@ export function resolveHandsLessReadFace(input) {
71
71
  additionalReadRootsCanonical: [],
72
72
  attachmentRootCanonical: undefined,
73
73
  readDenyMatcher: undefined,
74
+ envHandToolNames: new Set(),
74
75
  shellGatedBash: false,
75
76
  shellGatedMonitor: false,
76
77
  };
77
78
  }
78
79
  export async function prepareHandsMount(input) {
80
+ const envHandToolNames = new Set();
79
81
  const { handsEnabled, executionEnv, taskRootFinal, rebaseRestoredPath, resume, session, sessionId, hostTaskId, taskScope, fullShellReachable, effectiveShellGate, toolFaceSnapshot, model, spec, deps, internals, memoryWriteGateRef, firstPartyOffload, tools, egressTools, irreversibilityTier, irreversibleTools, reversibilityProbes } = input;
80
82
  assertStringArraySeat(spec.additionalDirectories, "TaskSpec.additionalDirectories", "strings");
81
83
  assertStringArraySeat(spec.additionalReadDirectories, "TaskSpec.additionalReadDirectories", "strings");
@@ -257,7 +259,7 @@ export async function prepareHandsMount(input) {
257
259
  deps.onError?.(new Error(`spec.tools ${shadowed.length > 1 ? "entries" : "entry"} ${names} collide${shadowed.length > 1 ? "" : "s"} with the built-in hands tool band — the built-in wins (registered later; the harness tool Map is last-write-wins) and the caller tool never dispatches this task. Rename the caller tool, or don't inject executionEnv if you intend to replace the band.`), { phase: "config", sessionId });
258
260
  }
259
261
  }
260
- tools.push(...band.map((t) => firstPartyOffload(t)));
262
+ tools.push(...band.map((t) => (envHandToolNames.add(t.name), firstPartyOffload(t))));
261
263
  const shellGate = effectiveShellGate;
262
264
  if (shellGate === "off" && !(executionEnv instanceof StubExecutionEnv) && spec.handsReadOnly !== true) {
263
265
  deps.onError?.(new Error(`shell gate doctrine is "off" while a real writable shell (Bash) is mounted — no shell safety-axis ` +
@@ -304,6 +306,7 @@ export async function prepareHandsMount(input) {
304
306
  additionalReadRootsCanonical,
305
307
  attachmentRootCanonical,
306
308
  readDenyMatcher,
309
+ envHandToolNames,
307
310
  shellGatedBash,
308
311
  shellGatedMonitor,
309
312
  };
@@ -314,7 +314,7 @@ export async function prepareMemory(input) {
314
314
  }
315
315
  catch {
316
316
  }
317
- deliverEngineNotice(deps.onNotice, memoryHarvestQuarantinedNotice({ count, moved, escalated, ...(reason !== undefined ? { reason } : {}), sessionId, provenance: input.memoryProvenance, ...(indexOnly ? { indexRolledBack: true } : {}) }));
317
+ deliverEngineNotice(deps.onNotice, memoryHarvestQuarantinedNotice({ count, moved, escalated, ...(report.reportId !== undefined ? { reportId: report.reportId } : {}), ...(reason !== undefined ? { reason } : {}), sessionId, provenance: input.memoryProvenance, ...(indexOnly ? { indexRolledBack: true } : {}) }));
318
318
  }
319
319
  for (const notice of memoryHoldNotices(report, sessionId))
320
320
  deliverEngineNotice(deps.onNotice, notice);
@@ -1512,10 +1512,11 @@ export interface RunInternals {
1512
1512
  * workflow deps → shared internals base), so every descendant of a placed leg keeps the fixed
1513
1513
  * point; prepare's mint reads it first (`placementRoot ?? rootSessionId ?? sessionId`). Absent
1514
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).
1515
+ * root through the middle segment, unchanged. DURABLE since design/380 O1③ (the former C12
1516
+ * residual, fulfilled): the suspend mint stamps the resolved fixed point as
1517
+ * `CheckpointState.placementRootSessionId`, and a bare durable resume restores it into this
1518
+ * member (live internals win when re-supplied; a CONTRADICTING re-supply refuses pre-CAS,
1519
+ * `resume.placement_mismatch`). TRUSTED run-scoped channel (never a {@link TaskSpec} field).
1519
1520
  */
1520
1521
  placementRoot?: string;
1521
1522
  /**
@@ -1759,6 +1760,16 @@ export declare function batchContextAt(messages: AgentMessage[], currentId: stri
1759
1760
  batchToolCallIds: string[];
1760
1761
  completedCallIds: string[];
1761
1762
  };
1763
+ /**
1764
+ * rescan C5 — the ONE spelling of the placement fields' empty-string discipline: `""` is absence
1765
+ * wearing clothes (the resume entry's principal-rung posture), and every placement read that must
1766
+ * treat it so — the resume rung's two sides (runtask), the restore fold's seed and the suspend
1767
+ * stamp — goes through THIS helper, so three sites cannot drift into three readings. Deliberately
1768
+ * NOT applied to the live `internals.placementRoot`/`rootSessionId` reads of the RESOLUTION fold:
1769
+ * an empty supplied claim there is an assembly error {@link mintPlacementRootSessionId} refuses
1770
+ * loudly on the factory path, and normalizing it away would silently repair what should be loud.
1771
+ */
1772
+ export declare function placementValueOrAbsent(value: string | undefined): string | undefined;
1762
1773
  /**
1763
1774
  * Build everything a task run needs (council design/34 ②: a free function with EXPLICIT deps, not a
1764
1775
  * Runner method — testable and decoupled). Resolves the model/role/thinking, acquires + reconciles the
@@ -681,14 +681,34 @@ 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 === "") {
684
+ function mintPlacementRootSessionId(placementRootResolved) {
685
+ if (placementRootResolved === "") {
687
686
  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
687
  e.code = "config.placement_root_invalid";
689
688
  throw e;
690
689
  }
691
- return placementRootSessionId;
690
+ return placementRootResolved;
691
+ }
692
+ function restorePlacementRoot(internals, seedPlacementRoot) {
693
+ if (internals?.placementRoot !== undefined)
694
+ return internals;
695
+ const seed = placementValueOrAbsent(seedPlacementRoot);
696
+ if (seed === undefined)
697
+ return internals;
698
+ return { ...(internals ?? {}), placementRoot: seed };
699
+ }
700
+ export function placementValueOrAbsent(value) {
701
+ return value || undefined;
702
+ }
703
+ function stampPlacementRootSessionId(placementRootResolved) {
704
+ return placementValueOrAbsent(placementRootResolved);
705
+ }
706
+ function refuseRequireExistingWithoutSession(spec) {
707
+ if (spec.requireExistingSession && !spec.sessionId) {
708
+ const e = new Error(`requireExistingSession requires a sessionId — cannot require an existing session without one (design/114 Phase3)`);
709
+ e.code = "resume.session_not_found";
710
+ throw e;
711
+ }
692
712
  }
693
713
  export async function prepareTask(spec, deps, sessions, resume, internals, runnerSelf) {
694
714
  const doors = prepareConfigDoors({ spec, deps, sessions, resume, internals });
@@ -698,15 +718,12 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
698
718
  const { toolEffects, egressTools, irreversibleTools, irreversibilityTier, axisExplicitNegatives, reversibilityProbes, ownToolNames } = prepareSafetyScan({ spec, deps });
699
719
  let shellGatedBash = false;
700
720
  let shellGatedMonitor = false;
701
- if (spec.requireExistingSession && !spec.sessionId) {
702
- const e = new Error(`requireExistingSession requires a sessionId — cannot require an existing session without one (design/114 Phase3)`);
703
- e.code = "resume.session_not_found";
704
- throw e;
705
- }
721
+ refuseRequireExistingWithoutSession(spec);
706
722
  const { acquired, session, conflictRef, wakeRecovered, resumeAtBeforeParentId } = await prepareAcquireReconcile({ sessions, spec, resume, toolEffects, ...(internals?.sessionPlacement !== undefined ? { placement: internals.sessionPlacement } : {}), ...(() => { const g = durableParkGapFor(deps, spec); return g !== undefined ? { durableParkGap: g } : {}; })() });
707
723
  const sessionId = acquired.sessionId;
708
724
  const hostTaskId = spec.taskId ?? sessionId;
709
725
  const delegation = effectiveDelegationFacts(internals, resume?.seed.isDelegatedChild);
726
+ const placementRootResolved = (internals = restorePlacementRoot(internals, resume?.seed.placementRootSessionId))?.placementRoot ?? internals?.rootSessionId ?? sessionId;
710
727
  let effectiveCompModel = compModel;
711
728
  if (spec.compactionModel === undefined &&
712
729
  compModel !== undefined &&
@@ -798,7 +815,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
798
815
  try {
799
816
  ownedEnv = deps.executionEnvFactory
800
817
  ? await deps.executionEnvFactory({
801
- sessionId, placementRootSessionId: mintPlacementRootSessionId(internals, sessionId),
818
+ sessionId, placementRootSessionId: mintPlacementRootSessionId(placementRootResolved),
802
819
  taskId: spec.taskId,
803
820
  ...(internals?.isolation ? { isolation: internals.isolation } : {}),
804
821
  ...(internals?.parentCwd ? { parentCwd: internals.parentCwd } : {}),
@@ -812,6 +829,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
812
829
  throw factoryErr;
813
830
  }
814
831
  const executionEnv = ownedEnv ?? deps.executionEnv ?? new StubExecutionEnv();
832
+ const externalContentTargetActive = executionEnv.externalContentTarget === true || resume?.seed.externalContentTarget === true;
815
833
  if (internals?.requestedCwd !== undefined) {
816
834
  const canon = async (p) => {
817
835
  const r = await executionEnv.canonicalPath(p);
@@ -1605,7 +1623,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1605
1623
  const memoryWriteGateRef = {};
1606
1624
  const handsReadFaceInput = { handsEnabled, executionEnv, taskRootFinal, rebaseRestoredPath, resume, session, sessionId, hostTaskId, taskScope, fullShellReachable, effectiveShellGate, toolFaceSnapshot, model, spec, deps, internals, memoryWriteGateRef, firstPartyOffload, tools, egressTools, irreversibilityTier, irreversibleTools, reversibilityProbes, reminderMark, reminderDisclosureCounts };
1607
1625
  const handsReadFace = handsEnabled ? await prepareHandsMount(handsReadFaceInput) : resolveHandsLessReadFace(handsReadFaceInput);
1608
- const { readFileStateForCheckpoint, seedContextFiles, handsCwdRef, workspaceStateSettle, wsSnapshot, rebaseWsPath, backgroundTaskToolsActive, additionalRootsCanonical, additionalReadRootsCanonical, attachmentRootCanonical, readDenyMatcher } = handsReadFace;
1626
+ const { readFileStateForCheckpoint, seedContextFiles, handsCwdRef, workspaceStateSettle, wsSnapshot, rebaseWsPath, backgroundTaskToolsActive, additionalRootsCanonical, additionalReadRootsCanonical, attachmentRootCanonical, readDenyMatcher, envHandToolNames } = handsReadFace;
1609
1627
  resolvedReadFace = handsReadFace.resolvedReadFace;
1610
1628
  readDenyAdditionsNormalized = handsReadFace.readDenyAdditionsNormalized;
1611
1629
  handsLessResolvedFace = handsReadFace.handsLessResolvedFace;
@@ -1689,6 +1707,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1689
1707
  }
1690
1708
  if (backgroundTaskToolsActive) {
1691
1709
  toolEffects.set("Monitor", "write");
1710
+ envHandToolNames.add("Monitor");
1692
1711
  tools.push(firstPartyOffload(createMonitorTool(executionEnv, {
1693
1712
  registry: defaultTaskRegistry,
1694
1713
  owner: hostTaskId,
@@ -1724,7 +1743,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1724
1743
  };
1725
1744
  if (workspaceStateSettle !== undefined)
1726
1745
  workspaceStateSettle.restoredWorktreeDir = worktreeSessionRef.current?.worktreeDir;
1727
- tools.push(...createWorktreeTools(executionEnv, { repoRoot: taskRootFinal, cwdRef: handsCwdRef, session: worktreeSessionRef }).map((t) => firstPartyOffload(t)));
1746
+ tools.push(...createWorktreeTools(executionEnv, { repoRoot: taskRootFinal, cwdRef: handsCwdRef, session: worktreeSessionRef }).map((t) => (envHandToolNames.add(t.name), firstPartyOffload(t))));
1728
1747
  }
1729
1748
  if (offloadStore)
1730
1749
  tools.push(createReadToolResultTool(offloadStore));
@@ -1809,7 +1828,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1809
1828
  ...(spec.principal !== undefined ? { principal: spec.principal } : {}),
1810
1829
  ...(sessionId !== undefined ? { sessionId } : {}),
1811
1830
  requestStopAfterTurn,
1812
- }));
1831
+ }).map((t) => (envHandToolNames.add(t.name), t)));
1813
1832
  }
1814
1833
  if (internals?.insideFork !== true &&
1815
1834
  runnerSelf &&
@@ -1823,6 +1842,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1823
1842
  if (lspManager) {
1824
1843
  const lspRoot = taskRootFinal;
1825
1844
  tools.push(createLspTool(lspManager, { isPathIgnored: gitCheckIgnoreFilter(executionEnv, lspRoot), env: executionEnv }));
1845
+ envHandToolNames.add("LSP");
1826
1846
  }
1827
1847
  const lspDiagnostics = spec.lspDiagnostics !== false && lspManager?.diagnostics !== undefined && handsEnabled && spec.handsReadOnly !== true
1828
1848
  ? lspManager.diagnostics
@@ -2098,6 +2118,8 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
2098
2118
  envFacts.sandboxEgress = spec.envFacts.egress;
2099
2119
  if (spec.envFacts?.scratchpadDir)
2100
2120
  envFacts.scratchpadDir = spec.envFacts.scratchpadDir;
2121
+ if (externalContentTargetActive)
2122
+ envFacts.externalContentTarget = true;
2101
2123
  if (resume !== undefined && spec.envFacts?.resumeFacts) {
2102
2124
  const rf = spec.envFacts.resumeFacts;
2103
2125
  const copy = {};
@@ -2278,7 +2300,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
2278
2300
  let staticMarkWaiverAnnounced = false;
2279
2301
  const effectiveSafety = narrowContentSafety(delegationProvenanceChannel?.contentSafety, memoryEngineSession?.contentSafety);
2280
2302
  const trustedTools = effectiveSafety.trustedTools;
2281
- const execIsExternalContent = effectiveSafety.execIsExternalContent;
2303
+ const execIsExternalContent = effectiveSafety.execIsExternalContent || externalContentTargetActive;
2282
2304
  provenanceForChildrenRef.current = { trustedTools: [...trustedTools], execIsExternalContent };
2283
2305
  announceDeclaredMcpContentClasses({
2284
2306
  entries: lockedPreflight.mcp,
@@ -2300,12 +2322,14 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
2300
2322
  };
2301
2323
  const callerToolNames = new Set((spec.tools ?? []).map((t) => t.name));
2302
2324
  const contentOriginWrapped = new WeakSet();
2303
- const classOf = (t) => classifyToolContentOrigin({
2304
- declared: t.contentOrigin,
2305
- isProtocolTool: protocolOf(t.name) !== undefined,
2306
- isCallerTool: callerToolNames.has(t.name),
2307
- trusted: trustedTools.has(t.name),
2308
- });
2325
+ const classOf = (t) => externalContentTargetActive && envHandToolNames.has(t.name)
2326
+ ? "external"
2327
+ : classifyToolContentOrigin({
2328
+ declared: t.contentOrigin,
2329
+ isProtocolTool: protocolOf(t.name) !== undefined,
2330
+ isCallerTool: callerToolNames.has(t.name),
2331
+ trusted: trustedTools.has(t.name),
2332
+ });
2309
2333
  const delegationByName = new Map();
2310
2334
  for (const t of spec.tools ?? []) {
2311
2335
  const withFaces = t;
@@ -3897,6 +3921,8 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3897
3921
  gitAnnouncement: gitStatusRef.announced !== undefined ? { ...gitStatusRef.announced } : undefined,
3898
3922
  delegationProvenance: internals?.delegationProvenance !== undefined ? { ...internals.delegationProvenance.ref.current } : undefined,
3899
3923
  isDelegatedChild: hookIdentity.isDelegatedChild ? true : undefined,
3924
+ placementRootSessionId: stampPlacementRootSessionId(placementRootResolved),
3925
+ externalContentTarget: externalContentTargetActive ? true : undefined,
3900
3926
  });
3901
3927
  const commitSuspendSaga = async (token, cp, remoteEnv, remoteHandle) => {
3902
3928
  if (!checkpointStore)
@@ -179,6 +179,24 @@ export declare class Runner {
179
179
  * observed `already_resolved` and by the FIFO cap — an evicted entry only ever degrades to the explicit
180
180
  * re-supply requirement (fail-loud), never fail-open. */
181
181
  private readonly parentConstraintRegistry;
182
+ /** rescan C2 — same-process suspended-env reap handles by token: the design/51 fence contract is
183
+ * "only the expire WINNER destroys the paused env", and the terminal resume-preflight arm
184
+ * (resumeStream) can now BE that winner — without this registry the losing stream-side
185
+ * reapSuspended returned early and the paused env this process holds leaked forever. Registered
186
+ * when a suspend publishes its reap handle; retired when the stream's own reap runs, when a
187
+ * resume wins the resolve CAS (the restore path owns the env from there — a later reopen keeps
188
+ * the ORIGINAL stream's closure as the reaping owner, deliberately), and when the terminal arm
189
+ * wins. Bounded by live suspended streams (the closures hold the same handles). */
190
+ private readonly suspendedEnvReaps;
191
+ /** rescan C2 review r3 — SAME-RUNNER claim marks for {@link suspendedEnvReaps}: a token appears
192
+ * here from the moment an in-process actor ATTEMPTS its settling CAS (the terminal preflight's
193
+ * expire; a resume's resolve) until that actor's duty decision is done. The losing stream-side
194
+ * reap preserves a record ONLY while a local claim is in flight — a CROSS-Runner settlement
195
+ * leaves no local claim, so the loser retires the record (drops the strong ref; physical cleanup
196
+ * belongs to the winning owner, unprovable from here) instead of retaining env handles without
197
+ * bound in a long-lived Runner. A stale claim's failure direction is retention (the status quo),
198
+ * never a wrong destroy. */
199
+ private readonly locallyClaimedTokens;
182
200
  private static readonly PARENT_CONSTRAINT_REGISTRY_CAP;
183
201
  constructor(deps: RunnerDeps);
184
202
  /** design/73 §1 v1 — fire `RunnerDeps.onTaskOutcome` through the single swallow-guarded chokepoint