@sema-agent/core 5.63.0 → 5.65.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +80 -0
- package/dist/agents/cascade.d.ts +5 -1
- package/dist/agents/cascade.js +6 -1
- package/dist/agents/subagent.js +1 -0
- package/dist/agents/verify.d.ts +5 -1
- package/dist/agents/verify.js +5 -2
- package/dist/core/checkpoint-store.d.ts +100 -5
- package/dist/core/fs-write-gate-policy.d.ts +21 -0
- package/dist/core/fs-write-gate-policy.js +14 -3
- package/dist/core/hooks.d.ts +9 -1
- package/dist/core/hooks.js +1 -1
- package/dist/core/memory-engine/dual-root.js +3 -0
- package/dist/core/memory-engine/engine.d.ts +1 -0
- package/dist/core/memory-engine/engine.js +9 -5
- package/dist/core/memory-engine/types.d.ts +16 -0
- package/dist/core/remote-env.d.ts +34 -2
- package/dist/core/runner/prepare-hands-readface.d.ts +9 -0
- package/dist/core/runner/prepare-hands-readface.js +4 -1
- package/dist/core/runner/prepare-memory.js +1 -1
- package/dist/core/runner/prepare-task.d.ts +31 -0
- package/dist/core/runner/prepare-task.js +53 -18
- package/dist/core/runner/prepare-workspace-restore.js +13 -0
- package/dist/core/runner/runtask.d.ts +18 -0
- package/dist/core/runner/runtask.js +173 -20
- package/dist/core/stub-env.d.ts +4 -0
- package/dist/core/stub-env.js +1 -0
- package/dist/core/task-registry-shared.js +20 -2
- package/dist/core/types.d.ts +104 -11
- package/dist/core/usage-window-store.d.ts +44 -12
- package/dist/core/usage-window-store.js +11 -3
- package/dist/core/workflow-run-store-contract.js +30 -0
- package/dist/core/workflow-run-store.d.ts +39 -1
- package/dist/core/workflow-run-store.js +2 -0
- package/dist/engine/harness/agent-harness.d.ts +8 -3
- package/dist/engine/harness/agent-harness.js +9 -4
- package/dist/engine/harness/types.d.ts +107 -3
- package/dist/engine/loop/agent-loop.js +39 -15
- package/dist/engine/loop/types.d.ts +43 -22
- package/dist/index.d.ts +2 -2
- package/dist/internal/harness-types.d.ts +1 -1
- package/dist/orchestration/run-workflow-tool.d.ts +6 -0
- package/dist/orchestration/run-workflow-tool.js +1 -0
- package/dist/orchestration/workflow-types.d.ts +35 -0
- package/dist/orchestration/workflow-types.js +2 -2
- package/dist/orchestration/workflow.d.ts +6 -0
- package/dist/orchestration/workflow.js +21 -1
- package/dist/prompts/default.d.ts +8 -0
- package/dist/prompts/default.js +3 -0
- package/dist/tools/fs/bash-readonly-classifier.d.ts +44 -1
- package/dist/tools/fs/bash-readonly-classifier.js +132 -5
- package/dist/tools/fs/fs-bash.js +9 -2
- package/dist/tools/fs/fs-write.js +19 -8
- package/dist/tools/fs/index.d.ts +1 -0
- package/dist/tools/fs/index.js +1 -0
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +9 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,85 @@
|
|
|
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
|
+
|
|
27
|
+
## 5.64.0 — 2026-08-27
|
|
28
|
+
|
|
29
|
+
### BREAKING
|
|
30
|
+
- **design/380 batch 1 — device-lane placement identity**: `ExecutionEnvFactoryContext` grows a
|
|
31
|
+
REQUIRED `placementRootSessionId` (the run TREE's fixed point, minted once as
|
|
32
|
+
`placementRoot ?? rootSessionId ?? sessionId`; an empty resolved value is refused loudly as
|
|
33
|
+
`config.placement_root_invalid` before the factory is ever called). Deployment code that
|
|
34
|
+
CONSTRUCTS its own factory context gains a compile-time break — factory *implementors* are purely
|
|
35
|
+
additive. The fixed point threads through cascade rungs, verification legs and every spawn family
|
|
36
|
+
(subagent, workflow), so a descendant's fresh session id can no longer read as a new placement;
|
|
37
|
+
durable-resume carriage is the next batch's seat (`O1③`) and its current fallback is pinned in the
|
|
38
|
+
tree so landing it must flip the pin consciously.
|
|
39
|
+
|
|
40
|
+
### Added
|
|
41
|
+
- `WorkspaceHandle.deviceId` — explicit, minter-stated identity of the target a workspace lives on
|
|
42
|
+
(rides the checkpoint's all-string handle whitelist unchanged).
|
|
43
|
+
- `outcome_unknown` joins `ExecutionErrorCode`, `FileErrorCode` and `RemoteExecutionErrorCode`, and
|
|
44
|
+
`target_unavailable` joins `ExecutionErrorCode`: an op COMMITTED to a remote target whose outcome
|
|
45
|
+
is unknowable must never be auto-retried (it is deliberately NOT in
|
|
46
|
+
`RETRYABLE_REMOTE_ERROR_CODES`), while a never-started commit refusal is retry-safe by contract.
|
|
47
|
+
`precondition_failed` joins `FileErrorCode` for the guarded write below. **Downstream exhaustive
|
|
48
|
+
switches over these unions will red — re-pin per the new members.**
|
|
49
|
+
- `FileSystem.writeFileGuarded?` (+ `WriteExpectation` / `WriteReceipt`, both type-only exports):
|
|
50
|
+
optional atomic verify-and-write with a receipt of the real on-disk object, carrying
|
|
51
|
+
`writeFileExclusive`'s degradation law verbatim (a backend without the primitive leaves it
|
|
52
|
+
`undefined` rather than emulating it). New deployment knob `requireGuardedWrite` (default off,
|
|
53
|
+
byte-identical): armed, a covered write on an env lacking the capability is a typed refusal
|
|
54
|
+
instead of a silent fallback.
|
|
55
|
+
- `UsageWindow.maxTokens` becomes optional (#480): a window may govern by `maxCostUsd` alone. A
|
|
56
|
+
window declaring NEITHER ceiling refuses loudly, the exhaustion arithmetic reads declared axes
|
|
57
|
+
only, and a `maxCostUsd` whose micro-USD conversion overflows is refused at the door instead of
|
|
58
|
+
being admitted and never binding (**named narrowing**: a dual window declaring such a ceiling
|
|
59
|
+
previously ran with its money arm silently dead).
|
|
60
|
+
|
|
61
|
+
### Fixed
|
|
62
|
+
- **#477 — the `before_agent_start` replacement prompt is run-scoped**: the between-turn rebuild
|
|
63
|
+
now carries it, so a run's prompt is byte-stable across its three legal change points (run start /
|
|
64
|
+
compaction-boundary epoch adoption / clear) instead of silently reverting to the base prompt from
|
|
65
|
+
turn 2 (which also broke prefix-cache stability). Deployments with no handler — or whose handler
|
|
66
|
+
returns no `systemPrompt` — are byte-identical.
|
|
67
|
+
- **#482 / #486 — read-only command judgment, both directions**: the allowlist grows to CC's safe
|
|
68
|
+
command set (with per-verb availability guards), a classify-only superset admits `find`/`sed`/`cd`
|
|
69
|
+
behind CC's own guard grammars, and compound judgment now THREADS the base a `cd` actually moves —
|
|
70
|
+
every segment's containment *and* deny resolve against the base bash will have, and a poll-loop
|
|
71
|
+
body containing a `cd` is judged over its worst-case iterated shift. Two escapes are closed (an
|
|
72
|
+
iterated `cd ..` loop climbing out of the read roots; a `cd` eating the first segment of a
|
|
73
|
+
multi-segment deny pattern). **This is a judgment fix, not a narrowing**: legitimately-configured
|
|
74
|
+
full-read deployments keep zero-ask on the same commands, pinned by positive controls carrying the
|
|
75
|
+
same weight as the escape pins.
|
|
76
|
+
- **#473** the resume verb snapshots its config once at the door (a mutated bag can no longer make
|
|
77
|
+
the identity gate and the resumed leg disagree); **#474** the prompt-too-long recovery lane adopts
|
|
78
|
+
the rebuilt prompt and honours the turn-scoped abort; **#470/#475** a workflow run that overran
|
|
79
|
+
its token ceiling discloses it on the list projection as well as the full record; **#468**
|
|
80
|
+
`SubagentSteerHandle.steer` gains the `inputId` pass-through its workflow twin already had.
|
|
81
|
+
|
|
82
|
+
|
|
3
83
|
## 5.63.0 — 2026-08-27
|
|
4
84
|
|
|
5
85
|
### BREAKING
|
package/dist/agents/cascade.d.ts
CHANGED
|
@@ -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>;
|
package/dist/agents/cascade.js
CHANGED
|
@@ -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 } : {}) },
|
|
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;
|
package/dist/agents/subagent.js
CHANGED
|
@@ -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 } : {}),
|
package/dist/agents/verify.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/agents/verify.js
CHANGED
|
@@ -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
|
-
},
|
|
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
|
-
},
|
|
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;
|
|
@@ -1156,6 +1160,43 @@ export interface CheckpointState {
|
|
|
1156
1160
|
* delegated child (never `false` — presence-coded like the other monotonic bits here).
|
|
1157
1161
|
*/
|
|
1158
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;
|
|
1159
1200
|
}
|
|
1160
1201
|
/**
|
|
1161
1202
|
* {@link CheckpointState} with **every** field made required-PRESENT, while each value keeps its original
|
|
@@ -1865,6 +1906,30 @@ export declare class CheckpointError extends Error {
|
|
|
1865
1906
|
* refusal exists to close. Rejected PRE-CAS (the checkpoint stays `pending`); re-resume with the
|
|
1866
1907
|
* original principal, or omit the field to inherit the recorded one. */
|
|
1867
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"
|
|
1868
1933
|
/** F-012 L3: the resume carried an approver EDIT (`updatedInput`) and a FROZEN inherited ancestor
|
|
1869
1934
|
* constraint (the persisted projection chain) — or the deployment's resume-edit policy — DENIES the
|
|
1870
1935
|
* edited args. Refused PRE-CAS with fresh-redecision semantics: the checkpoint stays `pending` and
|
|
@@ -1892,8 +1957,11 @@ export declare class CheckpointError extends Error {
|
|
|
1892
1957
|
* discriminant. */
|
|
1893
1958
|
readonly detail?: {
|
|
1894
1959
|
field?: "boundCallId" | "boundInputHash" | "answer" | "settledBy" | "approver";
|
|
1895
|
-
/** #449 G1 — on `resume.usage_window_exhausted
|
|
1896
|
-
* (the same wait a suspended row carries as `gate.resumeAfterMs`).
|
|
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. */
|
|
1897
1965
|
retryAfterMs?: number;
|
|
1898
1966
|
/** WHICH pre-CAS refusal arm fired, where one `code` covers several (requested 2026-08-10: a
|
|
1899
1967
|
* deployment retry policy needs to tell "a newer worker can redeem this row" from "this row is
|
|
@@ -2020,6 +2088,30 @@ export declare class CheckpointError extends Error {
|
|
|
2020
2088
|
* refusal exists to close. Rejected PRE-CAS (the checkpoint stays `pending`); re-resume with the
|
|
2021
2089
|
* original principal, or omit the field to inherit the recorded one. */
|
|
2022
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"
|
|
2023
2115
|
/** F-012 L3: the resume carried an approver EDIT (`updatedInput`) and a FROZEN inherited ancestor
|
|
2024
2116
|
* constraint (the persisted projection chain) — or the deployment's resume-edit policy — DENIES the
|
|
2025
2117
|
* edited args. Refused PRE-CAS with fresh-redecision semantics: the checkpoint stays `pending` and
|
|
@@ -2047,8 +2139,11 @@ export declare class CheckpointError extends Error {
|
|
|
2047
2139
|
* discriminant. */
|
|
2048
2140
|
detail?: {
|
|
2049
2141
|
field?: "boundCallId" | "boundInputHash" | "answer" | "settledBy" | "approver";
|
|
2050
|
-
/** #449 G1 — on `resume.usage_window_exhausted
|
|
2051
|
-
* (the same wait a suspended row carries as `gate.resumeAfterMs`).
|
|
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. */
|
|
2052
2147
|
retryAfterMs?: number;
|
|
2053
2148
|
/** WHICH pre-CAS refusal arm fired, where one `code` covers several (requested 2026-08-10: a
|
|
2054
2149
|
* deployment retry policy needs to tell "a newer worker can redeem this row" from "this row is
|
|
@@ -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
|
}
|
package/dist/core/hooks.d.ts
CHANGED
|
@@ -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. */
|
package/dist/core/hooks.js
CHANGED
|
@@ -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
|
|
@@ -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`),
|
|
224
|
-
*
|
|
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; 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
|
+
* 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
|
/**
|