@sema-agent/core 7.39.1 → 7.39.2

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,3 +1,35 @@
1
+ ## 7.39.2 — 2026-10-03
2
+
3
+ **Hard-breaking changes:** none.
4
+
5
+ **Closed sets:** none. **Package exports:** +0 / −0.
6
+
7
+ ### A refused `resumeAt` request leaves the session where it was (#1298; behavior (refusal path: the branch is written back; one refusal judged earlier); +0 exports) (@server @cli @test)
8
+
9
+ - **Before (v7.39.1).** A `resumeAt` request branched the session at the acquire — a durable `leaf` entry — and prepare could still refuse afterwards. Every such refusal kept the branch, while a caller reads a refusal as "nothing happened": its next turn continued on the moved leaf, i.e. the conversation was rolled back with nobody asking. The ticket's shape (server S-991): two turns with `fileHistory: "disabled"`, then `resumeAt` = the first user message + `restoreFiles: true` ⇒ `rewind_snapshot.unresolvable` with the leaf at the anchor (memory and file stores, `runTask` and `runTaskStream`); `resumeAtMode: "before"` the same, the leaf at the anchor's parent. The same held for every other refusal after the branch, measured on v7.39.1: a lineage child's `rewind.child_scope_unsupported`, an execution-env factory that throws (an env that cannot be provisioned), a caller tool of the reserved offload-reader name (`config.reserved_tool_name`), worktree isolation that cannot be honored (`isolation.unavailable`), and `model.extraBody` carrying `max_completion_tokens` (`config.extra_body_output_cap`; that one also left the session view cached).
10
+ - **Now.** One rule: the session leg of prepare's throw cleanup writes a `resumeAt` branch back before it drops the session view — the acquire phase's own failure path while the branch is still inside it, the driver's `forgetOnThrow` once it was handed over (every refusal path after the acquire already runs it). The leaf returns to where the request found it; refusal codes, messages and the success path (its writes, its frames) are unchanged. The write-back only takes back what this request wrote: the leaf must sit on the branch leaf or on entries this prepare appended above it (the storage watch records their ids, so the prepare's own entries after the branch — a reminder mark, an epoch pin — do not stop it), and the write is conditional on the leaf it verified. A leaf another writer moved in the meantime — onto an entry of its own, even one the prepare later appended on top of, or anywhere off the branch — keeps that write: nothing is orphaned, and `onError` (phase `config`) says so in one sentence naming both leaves — `resumeAt: prepare refused this request but could not put session "<id>" back — its leaf stays at "<leaf>" (the request found it at "<leaf>"), so the next turn continues from there: <cause>`. The write-back is a bounded cleanup leg like the others, and so are the acquire phase's own two `forget` calls (a store that never answers is reported as a teardown timeout and does not hold the refusal; a write-back still pending past the bound does not start its write when it wakes — one whose storage write is already in flight cannot be called back, the storage contract carries no cancellation). `config.extra_body_output_cap` is judged before the acquire with the other refusals the request alone decides: a request refused by it never touches the session (no branch, no write-back, the log byte for byte as it was).
11
+ - **User-visible fix.** A rewind that is refused (no file history at that point, a sandbox that does not come up, a configuration error) no longer silently rewinds the conversation: the next turn continues where the user left off.
12
+ - **Who is hurt.** Nobody who reads a refusal as "nothing happened". A tool counting session entries after a refused request sees two `leaf` entries (the branch and its write-back) where it saw one — the leaf is restored, the log bytes are not (refusals judged before the acquire stay byte-identical). A partial restore that is refused (`rewind.restore_failed`, or a partial outcome without `acceptPartialRestore`) leaves the files it already converged converged while the leaf now goes back; the error's per-file ledger lists them, and re-running the same request converges both (before: the leaf stayed at the anchor). A request carrying `max_completion_tokens` together with another fault the acquire judges (`resume.session_not_found`, a `resume_at.*` refusal) now reports `config.extra_body_output_cap` first. A deployment without `onError` does not hear a write-back that could not land (the same channel every other failure of prepare's failure path uses).
13
+ - **Compensation.** None needed for a caller that treats a refusal as a refusal. A tool that counted entries should read the leaf (`getLeafId()`), not the entry count.
14
+ - **Consumers.** server — S-991's two cells read `after.leaf` `toBe(before.leaf)`; no server code depends on the old reading. cli — a refused rewind no longer moves the conversation the shell shows on its next turn. test — the shapes above.
15
+ - **Not changed, stated.** A durable resume's rewind to its checkpoint's leaf is not a branch this writes back: it is the point every resume of that checkpoint starts from, and what a refused resume leaves of the checkpoint is the resume path's own contract, unchanged here. A refusal that comes from a throwing deployment sink or an adapter contract violation in the stretch between the acquire and the session cleanup's first leg (an `onError` that throws at the offload-namespace or reserved-`extraBody`-key note, an `executionEnv.canonicalPath` that throws on a delegated child's explicit `cwd`) still leaves the branch and the cached view — those paths have no session cleanup at all.
16
+ - Pins: `test/backlog1298-resume-at-leaf-untouched-on-refusal.test.ts` — ①⑧ the ticket's shape, memory and file stores, `runTask` and `runTaskStream`; ② `before` mode; ⑤ the next plain turn still sees turn 2; ⑥ the lineage child scope; ⑨–⑪ the env factory, the reserved tool name, isolation; ⑫ the output cap before the acquire (byte-identical log); ⑬ the acquire phase's own failure path after the branch; ⑮ a prepare-own write after the branch, then a refusal; ⑯ a foreign append inside the window with the prepare's own append on top — kept, and named; ⑰ a write-back that never settles — the refusal still returns, a teardown timeout is reported, no write starts later; ⑲ a store `forget` that never settles on the acquire phase's failure path — the refusal still returns, the branch already written back; ⑱ a write racing the write-back's check is not overwritten; ⑭ a foreign leaf move inside the window — kept, and named on `onError`; positive controls ③ (a recorded anchor restores and branches) and ④ (`resumeAt` without `restoreFiles` branches, `conversation_only`); ⑦ the two bundled stores' `canRestore` and `restore`'s `not_found` answer the same question. Every refusal cell read the leaf moved on v7.39.1. `test/backlog1206-new-session-lock.test.ts` ⑤ / ⑦ take the reserved tool name as their "refused after the acquire" trigger (the output cap no longer is one).
17
+
18
+ **Hard-breaking changes:** none.
19
+
20
+ **Closed sets:** none. **Package exports:** +0 / −0.
21
+
22
+ ### A damaged session-rules file refuses the read and the write instead of reading as "no rules" (#1299; behavior (tighten); +0 exports) (@server @cli @test)
23
+
24
+ - **Before (v7.39.1).** `FileSessionPolicyStore` answered `null` — "no rules" — to a keyed read (`getRules`, and the read `putRules` makes first) of a session-rules file that was unparseable, not a JSON object, or the wrong shape (a missing or non-numeric `rev`, a non-string-array rule field), and told only the `onCorruptRead` sink. Session rules only ever tighten, so the row IS the restriction: a torn file dropped it. On a real Runner a session with `toolDeny: ["probe"]` and a torn file ran `probe` on 3 of 3 turns and finished `completed`. `putRules` over such a file overwrote the damaged bytes into a new lineage. A file that could not be READ at all (`EACCES`, `EISDIR`) already threw the raw errno error, and the server's SQL twin of this store throws on a bad row (as the server reports it).
25
+ - **Now.** A rules file that is present but cannot be served as rules is refused: `getRules` and `putRules` reject with `SessionPolicyError`, code `corrupt` (an existing member — no closed-set change), whose message names the session, the grade (`unreadable` / `unparseable` / `malformed`), the reason, and the two ways out: fix the file, or delete it (deleting is an explicit "no rules" and gives up the rules the file held). **The message names no on-disk location** — it reaches end users through a host's error body; the file's absolute path goes to `onCorruptRead` only (the same sink as before), which is where an operator reads it. One refusal for all three grades; the raw errno error of an unreadable file is replaced by it. `onCorruptRead` still fires — now alongside the refusal. A file that is ABSENT (`ENOENT`) is still "no rules": `getRules` answers `null` with no disclosure and the first `putRules` stamps rev 1. `putRules` refuses before it writes anything and leaves the damaged file and the key's high-water mark byte-identical, for an operator write too; the old overwrite-into-a-new-lineage path is gone. On a Runner the task ends `failed` with terminal `code: "corrupt"` at prepare, before the first turn (`stats.turns` 0), and no tool runs; a parked approval resumed over a damaged file fails the same way, its checkpoint stays `pending`, and it resumes once the file is repaired; a leg whose file tears MID-RUN keeps enforcing the row it already enforces (unchanged).
26
+ - **User-visible fix.** A local deployment whose session-rules file is torn or hand-edited into a bad shape no longer runs the session unrestricted: the task stops, and the message names the file and how to recover.
27
+ - **Who is hurt.** A local (file-backed) deployment holding a damaged session-rules file: that session's tasks now fail until the file is fixed or deleted — they used to run without the restriction. A host that treated `getRules` returning `null` from this store as "no rules" for a damaged file now meets a throw (the SQL twin already threw). A host that relied on `putRules` writing over a torn file as the repair: delete the file first. A host that branched on the raw `EACCES` / `EISDIR` error from `getRules` now gets `SessionPolicyError` (`code: "corrupt"`, `message` carries the errno word).
28
+ - **Compensation.** Fix the file (put a valid row back) or delete it; both are the operator's and both are named in the refusal. After a delete the key's next write continues past the high-water mark and the same lineage, so a leg that is already enforcing the old row takes that write as a continuation (KL-1299-b). The `onCorruptRead` coordinates (`path`, `reason`, `sessionId`, `principal`) are unchanged.
29
+ - **Consumers.** server — the local lane's `getRules` now throws on a damaged file, matching `session-policy-store-sql.ts` (as the server reports it); the server's `corrupt-read-f-seats-s990` seat flips from "answers null" to "rejects `corrupt`". cli / client-core — a task whose session-rules file is damaged ends with the existing terminal `{ kind: "failed", code: "corrupt", message }`; no new field.
30
+ - **Not changed, stated.** `listBySession` still skips a damaged row and discloses it (one bad row of any session must not fail every session's enumeration; KL-1299-a). The high-water mark's refusals (`SessionPolicyError` `corrupt` on an unreadable mark) and the erase's torn-row handling are as before. The in-memory store has no file to damage. Two neighbouring faces still turn a damaged row into "no rules" and are recorded, not fixed here: the data-root rebind leaves an unparseable row under the old key (KL-1299-d), and another session's `deleteBySession` sweeps a torn row of a different session (KL-1299-e).
31
+ - Pins: `test/backlog1299-session-policy-corrupt-read-refuses.test.ts` — ①② nine damaged forms refuse `getRules` with code, grade, both exits, no on-disk path in the message, disclosure kept (the path on the sink) (red on v7.39.1: all read `null`); ③ an unreadable file (a directory in its place; `chmod 000`, env-only — skipped under a root uid) refuses with the same code (red on v7.39.1: raw errno); ④ absence is still `null` / rev 1 (green on both); ⑤ a good row still reads (green on both); ⑥ `putRules` refuses on a damaged file, plain and operator, bytes of row and mark unchanged (red on v7.39.1: it overwrote); ⑥b both ways out work; ⑥c the enumeration keeps its tolerance; ⑦ the real Runner lane — `failed` / `corrupt` before any turn, `probe` never runs (red on v7.39.1: `completed`, 3 of 3), the absent-file and intact-row controls, the mid-run tear keeps the enforced row, and the parked-approval resume stays pending and completes after repair. Four older pins that encoded the fail-open flip: `test/file-session-policy-store.test.ts` (4), `test/backlog1003-1000-session-policy-row-shape.test.ts` (P1 ×5, P5c), `test/backlog7250-review-fixes.test.ts` (R9, R10).
32
+
1
33
  ## 7.39.1 — 2026-10-03
2
34
 
3
35
  **Hard-breaking changes:** none.
@@ -0,0 +1,38 @@
1
+ import type { SessionStorage } from "../session.js";
2
+ /** The entries this prepare appended through the watched storage — recorded once a branch is armed (`ids` set). */
3
+ export interface OwnAppends {
4
+ ids: Set<string> | undefined;
5
+ }
6
+ /** The branch a `resumeAt` acquire wrote: the watched storage, the leaf the request found, the leaf it set, and the own appends since. */
7
+ export interface ResumeAtBranch {
8
+ storage: SessionStorage;
9
+ leafBefore: string | null;
10
+ leafSet: string | null;
11
+ own: OwnAppends;
12
+ }
13
+ /**
14
+ * Wrap a storage so a `SessionError("conflict")` from any append/leaf write flips `ref.hit`, and every append
15
+ * that lands is recorded on `own` while a branch is armed. The harness flattens a mid-run storage failure into
16
+ * a generic error (and, since a lost CAS makes every subsequent append fail, even its failure-reporting append
17
+ * fails) — so the conflict code is gone by the time the task result is assembled. Catching it here, at the
18
+ * storage boundary, keeps it recoverable as `TaskResult.errorCode = "conflict"`. Per-task and non-mutating (a
19
+ * Proxy over the shared storage), so it never stacks across tasks on a cached session.
20
+ */
21
+ export declare function detectConflicts(storage: SessionStorage, ref: {
22
+ hit: boolean;
23
+ }, own: OwnAppends): SessionStorage;
24
+ /**
25
+ * Put a refused prepare's `resumeAt` branch back: the leaf returns to `leafBefore` when it still sits on the
26
+ * branch leaf or on this prepare's own appends above it, and the write is conditional on the leaf just
27
+ * verified. A no-op when the leaf is already there. Throws a sentence naming both leaves when the leaf is
28
+ * not this request's to take back or the write cannot land. `signal` aborted before the write starts ⇒ no
29
+ * write (the caller's bound expired and the view is being dropped); a storage write already in flight cannot
30
+ * be called back — the storage contract carries no cancellation.
31
+ */
32
+ export declare function writeBranchBack(branch: ResumeAtBranch, sessionId: string, signal?: AbortSignal): Promise<void>;
33
+ /**
34
+ * The session cleanup's write-back leg: {@link writeBranchBack} bounded like every other cleanup leg (a throw or
35
+ * a timeout goes to `report`, never to the caller). A write-back still pending when the bound expires does not
36
+ * START its write afterwards — the view it would write through is being dropped.
37
+ */
38
+ export declare function settleBranchWriteBack(branch: ResumeAtBranch, sessionId: string, label: string, report: (err: Error) => void): Promise<void>;
@@ -0,0 +1,58 @@
1
+ import { isSessionConflict } from "../session.js";
2
+ import { thrownText } from "../thrown-value.js";
3
+ import { settleTeardownLeg } from "./teardown-bounded.js";
4
+ export function detectConflicts(storage, ref, own) {
5
+ const guard = (fn, landed) => async (...args) => {
6
+ try {
7
+ const out = await fn(...args);
8
+ landed(args);
9
+ return out;
10
+ }
11
+ catch (err) {
12
+ if (isSessionConflict(err)) {
13
+ ref.hit = true;
14
+ }
15
+ throw err;
16
+ }
17
+ };
18
+ return new Proxy(storage, {
19
+ get(target, prop, receiver) {
20
+ const value = Reflect.get(target, prop, receiver);
21
+ if (typeof value !== "function") {
22
+ return value;
23
+ }
24
+ const bound = value.bind(target);
25
+ if (prop === "appendEntry")
26
+ return guard(bound, (args) => own.ids?.add(args[0].id));
27
+ if (prop === "setLeafId")
28
+ return guard(bound, () => undefined);
29
+ return bound;
30
+ },
31
+ });
32
+ }
33
+ export async function writeBranchBack(branch, sessionId, signal) {
34
+ let now;
35
+ try {
36
+ now = await branch.storage.getLeafId();
37
+ if (now === branch.leafBefore)
38
+ return;
39
+ const ids = branch.own.ids ?? new Set();
40
+ for (let at = now, steps = 0; at !== branch.leafSet; steps++) {
41
+ if (at === null || steps > ids.size || !ids.has(at))
42
+ throw new Error("another writer moved the leaf past the entries this request wrote");
43
+ at = (await branch.storage.getEntry(at))?.parentId ?? null;
44
+ }
45
+ if (signal?.aborted === true)
46
+ return;
47
+ await branch.storage.setLeafId(branch.leafBefore, { expectedLeafId: now });
48
+ }
49
+ catch (err) {
50
+ const shown = (id) => (id === undefined ? "(unreadable)" : id === null ? "(empty)" : `"${id}"`);
51
+ throw new Error(`resumeAt: prepare refused this request but could not put session "${sessionId}" back — its leaf stays at ${shown(now)} (the request found it at ${shown(branch.leafBefore)}), so the next turn continues from there: ${thrownText(err)}`);
52
+ }
53
+ }
54
+ export async function settleBranchWriteBack(branch, sessionId, label, report) {
55
+ const ac = new AbortController();
56
+ await settleTeardownLeg(() => writeBranchBack(branch, sessionId, ac.signal), label, report);
57
+ ac.abort();
58
+ }
@@ -16,13 +16,15 @@
16
16
  *
17
17
  * Throws pass through unchanged: `resume.session_not_found` (requireExisting fail-loud mapping),
18
18
  * the `resume_at.*` target-validation refusals, and — after the bounded re-wake budget — the
19
- * session conflict itself.
19
+ * session conflict itself. A throw after a `resumeAt` branch landed first writes that branch back
20
+ * (`settleBranchWriteBack`); on success the branch is handed over with the session.
20
21
  */
21
22
  import { StoredSession } from "../session.js";
22
23
  import type { AcquiredSession, SessionStore } from "../session.js";
23
24
  import { type RecoveredOrphan } from "../session-reconcile.js";
24
25
  import type { TaskSpec, ToolEffect } from "../types.js";
25
26
  import type { PrepareResume } from "./contracts.js";
27
+ import { type ResumeAtBranch } from "./branch-writeback.js";
26
28
  export interface PrepareAcquireReconcileInput {
27
29
  /** borrowed-mutable (service port) — the deployment's session store. This phase's verbs on it are
28
30
  * the acquire/forget half of the session resource protocol: `acquire` per attempt, `forget` to
@@ -52,8 +54,10 @@ export interface PrepareAcquireReconcileInput {
52
54
  * absent seat; nothing in the phase's control flow depends on it. Computed by the driver (which
53
55
  * holds `deps`) rather than here, keeping this slice's input the narrow Pick its contract says. */
54
56
  durableParkGap?: string;
57
+ /** borrowed-readonly — the incident channel for this phase's own cleanup legs: a `resumeAt` branch it could not write back, a forget that did not settle. */
58
+ report?: (err: Error, sessionId: string) => void;
55
59
  }
56
- /** The phase's outputs (four-class form). All five are fresh bindings —
60
+ /** The phase's outputs (four-class form). All six are fresh bindings —
57
61
  * the driver destructures them into consts, so a consumer moved ahead of this call is a lexical
58
62
  * error, which is the static guarantee the extraction keeps. */
59
63
  export interface PrepareAcquireReconcileResult {
@@ -75,6 +79,9 @@ export interface PrepareAcquireReconcileResult {
75
79
  * SUCCEEDS (a conflict-losing attempt re-wakes and re-derives the whole set from the fresh
76
80
  * branch), and stays empty for a fresh session / a clean one with nothing to reconcile. */
77
81
  wakeRecovered: RecoveredOrphan[];
82
+ /** owned (handed over with the session) — the `resumeAt` branch this phase wrote, absent when it wrote
83
+ * none; the driver's throw cleanup writes it back on any later prepare refusal. */
84
+ branch: ResumeAtBranch | undefined;
78
85
  }
79
86
  /** The B-3 phase body — the acquire+reconcile slice, verbatim (see the module header for the contract). */
80
87
  export declare function prepareAcquireReconcile(input: PrepareAcquireReconcileInput): Promise<PrepareAcquireReconcileResult>;
@@ -1,37 +1,15 @@
1
1
  import { StoredSession, isSessionConflict } from "../session.js";
2
2
  import { reconcileInterruptedSession } from "../session-reconcile.js";
3
+ import { detectConflicts, settleBranchWriteBack } from "./branch-writeback.js";
4
+ import { settleTeardownLeg } from "./teardown-bounded.js";
3
5
  const RECONCILE_MAX_RETRIES = 3;
4
- function detectConflicts(storage, ref) {
5
- const guard = (fn) => async (...args) => {
6
- try {
7
- return await fn(...args);
8
- }
9
- catch (err) {
10
- if (isSessionConflict(err)) {
11
- ref.hit = true;
12
- }
13
- throw err;
14
- }
15
- };
16
- return new Proxy(storage, {
17
- get(target, prop, receiver) {
18
- const value = Reflect.get(target, prop, receiver);
19
- if (typeof value !== "function") {
20
- return value;
21
- }
22
- const bound = value.bind(target);
23
- return prop === "appendEntry" || prop === "setLeafId"
24
- ? guard(bound)
25
- : bound;
26
- },
27
- });
28
- }
29
6
  export async function prepareAcquireReconcile(input) {
30
7
  const { sessions, spec, resume, toolEffects } = input;
31
8
  let acquired;
32
9
  let session;
33
10
  let conflictRef;
34
11
  let wakeRecovered = [];
12
+ let branch;
35
13
  for (let attempt = 0;; attempt++) {
36
14
  try {
37
15
  acquired = await sessions.acquire(spec.sessionId, spec.requireExistingSession ? { requireExisting: true } : input.placement !== undefined ? { placement: input.placement } : undefined);
@@ -46,7 +24,8 @@ export async function prepareAcquireReconcile(input) {
46
24
  throw err;
47
25
  }
48
26
  conflictRef = { hit: false };
49
- session = new StoredSession(detectConflicts(acquired.session.getStorage(), conflictRef));
27
+ const own = { ids: undefined };
28
+ session = new StoredSession(detectConflicts(acquired.session.getStorage(), conflictRef, own));
50
29
  if (!spec.sessionId) {
51
30
  break;
52
31
  }
@@ -105,7 +84,10 @@ export async function prepareAcquireReconcile(input) {
105
84
  throw e;
106
85
  }
107
86
  }
108
- await session.getStorage().setLeafId(spec.resumeAtMode === "before" ? entry.parentId : spec.resumeAt);
87
+ const storage = session.getStorage(), leafBefore = await storage.getLeafId(), leafSet = spec.resumeAtMode === "before" ? entry.parentId : spec.resumeAt;
88
+ await storage.setLeafId(leafSet);
89
+ own.ids = new Set();
90
+ branch = { storage, leafBefore, leafSet, own };
109
91
  }
110
92
  wakeRecovered = (await reconcileInterruptedSession(session, toolEffects, resume?.suspendedBatch)).recovered;
111
93
  break;
@@ -113,13 +95,17 @@ export async function prepareAcquireReconcile(input) {
113
95
  catch (err) {
114
96
  if (isSessionConflict(err) && attempt < RECONCILE_MAX_RETRIES) {
115
97
  if (sessions.forget) {
116
- await Promise.resolve(sessions.forget(spec.sessionId)).catch(() => undefined);
98
+ const forget = sessions.forget.bind(sessions), id = spec.sessionId;
99
+ await settleTeardownLeg(() => Promise.resolve(forget(id)).catch(() => undefined), "forget (acquire-reconcile re-wake leg)", (e) => input.report?.(e, id));
117
100
  }
101
+ branch = undefined;
118
102
  continue;
119
103
  }
120
- await Promise.resolve(sessions.forget ? sessions.forget(acquired.sessionId) : undefined).catch(() => undefined);
104
+ if (branch !== undefined)
105
+ await settleBranchWriteBack(branch, acquired.sessionId, "resumeAt branch write-back (acquire-reconcile failure leg)", (e) => input.report?.(e, acquired.sessionId));
106
+ await settleTeardownLeg(() => Promise.resolve(sessions.forget ? sessions.forget(acquired.sessionId) : undefined).catch(() => undefined), "forget (acquire-reconcile failure leg)", (e) => input.report?.(e, acquired.sessionId));
121
107
  throw err;
122
108
  }
123
109
  }
124
- return { acquired, session, conflictRef, wakeRecovered };
110
+ return { acquired, session, conflictRef, wakeRecovered, branch };
125
111
  }
@@ -8,8 +8,8 @@
8
8
  * boundary seat runtask drives.
9
9
  *
10
10
  * Runs INSIDE the driver's prepare try-block, after the workspace root is FINAL (`taskRootFinal`) and the
11
- * env is resolved: every refusal here throws through to the driver's prepare-throw catch exactly as
12
- * before the move (a minted ownedEnv is destroyed there, the session view forgotten). The phase acquires
11
+ * env is resolved: every refusal here throws through to the driver's prepare-throw catch (a minted
12
+ * ownedEnv is destroyed there, a resumeAt branch written back, the session view forgotten). The phase acquires
13
13
  * no resource of its own — the two seats it returns are closures over the store the deployment owns.
14
14
  *
15
15
  * The request door (the bad-value doors and every refusal the request decides alone) lives in
@@ -38,6 +38,7 @@ import { preparePromptAssembly } from "./prepare-prompt-assembly.js";
38
38
  import { limitConfigError, prepareConfigDoors } from "./prepare-config-doors.js";
39
39
  import { prepareSafetyScan } from "./prepare-safety-scan.js";
40
40
  import { prepareAcquireReconcile } from "./prepare-acquire-reconcile.js";
41
+ import { settleBranchWriteBack } from "./branch-writeback.js";
41
42
  import { prepareHandsMount, resolveHandsLessReadFace } from "./prepare-hands-readface.js";
42
43
  import { prepareWorkspaceRestore } from "./prepare-workspace-restore.js";
43
44
  import { parkContaminationMarker } from "./park-commit.js";
@@ -291,8 +292,15 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
291
292
  let shellGatedTools = new Set();
292
293
  refuseRequireExistingWithoutSession(spec);
293
294
  refuseRegistryScopeMismatch(spec, internals);
295
+ if (model.extraBody !== undefined && Object.prototype.hasOwnProperty.call(model.extraBody, "max_completion_tokens")) {
296
+ const e = new Error(`model.extraBody supplies "max_completion_tokens" — this spelling is engine-owned (the overflow retry ` +
297
+ `adjusts the per-request output cap, and a constant extraBody value would override that adjustment on ` +
298
+ `every retry). Set the cap via limits.maxOutputTokens (per request) or model.maxTokens instead.`);
299
+ e.code = "config.extra_body_output_cap";
300
+ throw e;
301
+ }
294
302
  const fileHistoryRequest = resolveFileHistoryRequest(spec, deps);
295
- const { acquired, session, conflictRef, wakeRecovered } = await prepareAcquireReconcile({ sessions, spec, resume, toolEffects, ...(internals?.sessionPlacement !== undefined ? { placement: internals.sessionPlacement } : {}), ...(() => { const g = durableParkGapFor(deps, spec); return g !== undefined ? { durableParkGap: g } : {}; })() });
303
+ const { acquired, session, conflictRef, wakeRecovered, branch } = await prepareAcquireReconcile({ sessions, spec, resume, toolEffects, report: (e, id) => deps.onError?.(e, { phase: "config", sessionId: id }), ...(internals?.sessionPlacement !== undefined ? { placement: internals.sessionPlacement } : {}), ...(() => { const g = durableParkGapFor(deps, spec); return g !== undefined ? { durableParkGap: g } : {}; })() });
296
304
  const sessionId = acquired.sessionId;
297
305
  await holdSessionLock?.(sessionId);
298
306
  const onceLedger = await loadAnnounceOnceLedger(session, resume?.seed.announcedListings, deps.onError);
@@ -320,18 +328,13 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
320
328
  deps.onError?.(new Error(`offload/tool-result namespace note: this run mounts in registry domain "${internals.registryScope}" but its offloaded tool results live under the TRUST identity "${offloadScope}" (principal${spec.principal === undefined ? " absent → default" : ""}). Before 2.13.0 the registry domain named this namespace too; if a deployment used registryScope as its ref-tenancy boundary, that boundary is now the principal — pass one, or read refs under "${offloadScope}".`), { phase: "config", sessionId });
321
329
  }
322
330
  defaultTaskRegistry.maybeGc();
323
- const forgetOnThrow = () => forgetQuietly(sessions, sessionId);
331
+ const forgetOnThrow = () => branch === undefined
332
+ ? forgetQuietly(sessions, sessionId)
333
+ : settleBranchWriteBack(branch, sessionId, "resumeAt branch write-back (prepare refusal)", (e) => deps.onError?.(e, { phase: "config", sessionId })).then(() => forgetQuietly(sessions, sessionId));
324
334
  const extraBodyCollisions = reservedCollisions(model.extraBody, reservedFor(model.api));
325
335
  if (extraBodyCollisions.length > 0) {
326
336
  deps.onError?.(new Error(`model.extraBody contains reserved key(s) [${extraBodyCollisions.join(", ")}] that core owns and sets itself — they are ignored. Remove them from extraBody.`), { phase: "config", sessionId });
327
337
  }
328
- if (model.extraBody !== undefined && Object.prototype.hasOwnProperty.call(model.extraBody, "max_completion_tokens")) {
329
- const e = new Error(`model.extraBody supplies "max_completion_tokens" — this spelling is engine-owned (the overflow retry ` +
330
- `adjusts the per-request output cap, and a constant extraBody value would override that adjustment on ` +
331
- `every retry). Set the cap via limits.maxOutputTokens (per request) or model.maxTokens instead.`);
332
- e.code = "config.extra_body_output_cap";
333
- throw e;
334
- }
335
338
  const { nestedStats, reportUsage, offloadStore, offloadReachableToolsRef, maybeOffload, firstPartyOffload, remoteToolOffload } = prepareOffloadWrappers({ spec, deps, resume, offloadScope, sessionId });
336
339
  if (offloadStore && (spec.tools ?? []).some((t) => t.name === OFFLOAD_TOOL_NAME)) {
337
340
  const e = new Error(`Tool name "${OFFLOAD_TOOL_NAME}" is reserved when large-result offload is enabled.`);
@@ -90,9 +90,10 @@ export interface FileStorageBackendOptions {
90
90
  * per-store constructor parameter was unreachable from here, so the disclosure was wired exactly
91
91
  * nowhere for the deployments that need it most.
92
92
  *
93
- * Reach TODAY: {@link FileStorageBackend.sessionPolicyStore} (rules read as ABSENT ⇒ an unconstrained
94
- * task) and {@link FileStorageBackend.sessionStore}'s repo (a session listing that silently omits what
95
- * it could not read). The aggregated roster and mailbox stores receive the same sink. A store that
93
+ * Reach TODAY: {@link FileStorageBackend.sessionPolicyStore} (an absent file ⇒ no rules; a present but
94
+ * unreadable / unparseable / malformed file ⇒ refused, the disclosure delivered alongside the refusal)
95
+ * and {@link FileStorageBackend.sessionStore}'s repo (a session listing that silently omits what it
96
+ * could not read). The aggregated roster and mailbox stores receive the same sink. A store that
96
97
  * grows the seat later is wired here in the same constructor.
97
98
  *
98
99
  * The payload is the UNION shape ({@link FileStorageCorruptReadInfo}): `path` + `reason` always,
@@ -13,8 +13,9 @@ import { type PutRulesOptions, type SessionPermissionRules, type SessionPolicySt
13
13
  * atomic write), so the read-modify-write is atomic in the one event loop — exactly the InMemory store's premise.
14
14
  * Cross-process CORRECT concurrency is the Pg/TiDB backend's job (a CAS WHERE clause), by design.
15
15
  */
16
- /** Coordinates of one corrupt-treated-as-absent policy read (the {@link FileSessionPolicyStoreOptions.onCorruptRead}
17
- * payload). Named rather than inline so the backend option that forwards it names the SAME shape. */
16
+ /** Coordinates of one damaged policy file the store observed (the {@link FileSessionPolicyStoreOptions.onCorruptRead}
17
+ * payload): the keyed read REFUSES it, the enumeration skips it. Named rather than inline so the backend option
18
+ * that forwards it names the SAME shape. */
18
19
  export interface SessionPolicyCorruptReadInfo {
19
20
  /** Always a whole-file disclosure (the {@link import("./corrupt-read-sink.js").CorruptReadInfo} level). */
20
21
  kind: "file";
@@ -25,12 +26,13 @@ export interface SessionPolicyCorruptReadInfo {
25
26
  principal?: string;
26
27
  /** Absolute path of the offending file. */
27
28
  path: string;
28
- /** Which corruption grade was observed (unparseable / bad shape / unreadable). */
29
+ /** What was observed — the same words the refusal's message carries after its grade (unparseable / malformed / unreadable). */
29
30
  reason: string;
30
31
  }
31
32
  export interface FileSessionPolicyStoreOptions {
32
- /** Disclosure sink for the corrupt-read fail-open (see {@link FileSessionPolicyStore.discloseCorrupt}).
33
- * Never fires on plain ENOENT. Swallow-guarded: a throwing sink cannot re-introduce the fault. */
33
+ /** Disclosure sink for a damaged rules file (see {@link FileSessionPolicyStore.refuseDamaged}): it fires WITH the
34
+ * refusal on the keyed read and on its own where the enumeration skips the row. Never fires on plain ENOENT.
35
+ * Swallow-guarded: a throwing sink cannot change the refusal. */
34
36
  onCorruptRead?: (info: SessionPolicyCorruptReadInfo) => void;
35
37
  }
36
38
  /**
@@ -104,12 +106,16 @@ export declare class FileSessionPolicyStore implements SessionPolicyStore {
104
106
  constructor(root: string, opts?: FileSessionPolicyStoreOptions);
105
107
  /** The one delivery point for {@link onCorruptRead}. */
106
108
  private disclose;
107
- /** RB-482 top item (baggage class-4 ruling): corrupt-read fail-open is a DOCUMENTED compromise (a throwing
108
- * getRules fails the whole task; a throwing putRules self-locks the repair write) — but it must not
109
- * be a SILENT one: a deployment whose policy file was corrupted was getting an apparently normal
110
- * UNCONSTRAINED task with zero signal. Every corrupt-treated-as-absent branch discloses here
111
- * (swallow-guarded; never on a plain ENOENT, which really is absence). */
112
- private discloseCorrupt;
109
+ /** The ONE refusal for a rules file that is PRESENT but cannot be served as rules — every damage grade lands
110
+ * here (`unreadable` bytes, `unparseable` JSON, a `malformed` row), so the keyed read and the write that reads
111
+ * before it answer with one voice. Session rules only TIGHTEN: a row is the restriction an operator put on the
112
+ * session, so reading a damaged one as "no rules" would drop the restriction itself — the wrong direction for a
113
+ * store whose every other face refuses a loosening. ABSENCE (ENOENT) is the only "no rules"; it never reaches
114
+ * here. The disclosure sink fires with the refusal (swallow-guarded: a throwing sink cannot turn it back into an
115
+ * answer) and is the ONLY carrier of the file's path: the thrown message reaches end users through a host's error
116
+ * body, so it names no on-disk location. Two ways out, both the operator's: fix the file, or delete it — deleting is an explicit "no rules" and
117
+ * gives up the rules the file held. */
118
+ private refuseDamaged;
113
119
  /** `(sessionId, principal)` → a safe, INJECTIVE filename (sanitizeScope appends the full sha256 of the raw
114
120
  * composite key, so distinct keys never collide on disk). On a local-owner-adopted root the
115
121
  * anonymous lane resolves to the adopted principal's key (#132 — see {@link anonymousAlias}). */
@@ -121,6 +127,8 @@ export declare class FileSessionPolicyStore implements SessionPolicyStore {
121
127
  private writeMarkFile;
122
128
  /** The mark as given — the erase's lineage transition rewrites a mark keeping whatever attribution it had. */
123
129
  private writeMark;
130
+ /** The keyed read: `null` is ABSENCE only (no file); a file that is there is served as rules or refused
131
+ * ({@link refuseDamaged}) — it is never read as "no rules". */
124
132
  private read;
125
133
  getRules(sessionId: string, principal?: string): Promise<StoredSessionRules | null>;
126
134
  putRules(sessionId: string, principal: string | undefined, rules: SessionPermissionRules, opts?: PutRulesOptions): Promise<StoredSessionRules>;
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync, readdirSync, unlinkSync } from "node:fs";
1
+ import { readFileSync, readdirSync, unlinkSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { loosenReasons, normalizeRules, stripRev, SessionPolicyError, } from "../../core/session-policy-store.js";
4
4
  import { atomicWriteFile, ensureDir, sanitizeScope } from "./fs-atomic.js";
@@ -79,8 +79,10 @@ export class FileSessionPolicyStore {
79
79
  disclose(info) {
80
80
  this.onCorruptRead?.({ kind: "file", ...info });
81
81
  }
82
- discloseCorrupt(sessionId, principal, reason) {
83
- this.disclose({ sessionId, ...(principal !== undefined ? { principal } : {}), path: this.pathFor(sessionId, principal), reason });
82
+ refuseDamaged(sessionId, principal, grade, reason) {
83
+ const path = this.pathFor(sessionId, principal);
84
+ this.disclose({ sessionId, ...(principal !== undefined ? { principal } : {}), path, reason });
85
+ throw new SessionPolicyError("corrupt", `session rules file for ${JSON.stringify(sessionId)} is ${grade} (${reason}) — refusing rather than read the rules it held as none (session rules only tighten). Repair it: fix the file, or delete it (deleting it = explicitly no rules, and gives up the rules it held). The file's location is reported to the store's corrupt-read disclosure (onCorruptRead), not in this message.`);
84
86
  }
85
87
  pathFor(sessionId, principal) {
86
88
  return join(this.dir, `${this.basenameFor(sessionId, principal)}.json`);
@@ -107,18 +109,14 @@ export class FileSessionPolicyStore {
107
109
  catch (err) {
108
110
  if (err.code === "ENOENT")
109
111
  return null;
110
- throw err;
112
+ return this.refuseDamaged(sessionId, principal, "unreadable", `read failed (${err.code ?? err.name})`);
111
113
  }
112
114
  const judged = judgeSessionPolicyRowObject(raw);
113
- if ("corrupt" in judged) {
114
- this.discloseCorrupt(sessionId, principal, judged.corrupt.reason);
115
- return null;
116
- }
115
+ if ("corrupt" in judged)
116
+ return this.refuseDamaged(sessionId, principal, judged.corrupt.grade, judged.corrupt.reason);
117
117
  const shaped = judgeSessionPolicyRowShape(judged.row);
118
- if ("corrupt" in shaped) {
119
- this.discloseCorrupt(sessionId, principal, shaped.corrupt.reason);
120
- return null;
121
- }
118
+ if ("corrupt" in shaped)
119
+ return this.refuseDamaged(sessionId, principal, shaped.corrupt.grade, shaped.corrupt.reason);
122
120
  return shaped.rules;
123
121
  }
124
122
  async getRules(sessionId, principal) {
@@ -137,12 +135,11 @@ export class FileSessionPolicyStore {
137
135
  }
138
136
  }
139
137
  const mark = readSessionPolicyMark(this.markPathFor(sessionId, principal));
140
- const rowFilePresent = prior === null && existsSync(this.pathFor(sessionId, principal));
141
- if (mark?.__lineage === "lost" || (rowFilePresent && mark === null)) {
142
- throw new SessionPolicyError("corrupt", `session rules for ${JSON.stringify(sessionId)} have no lineage evidence (${mark?.__lineage === "lost" ? "the erase left a lost-lineage mark" : "an unreadable row and no high-water mark"}) — refusing to restart the key's lineage by guessing; delete the mark${rowFilePresent ? " and the row file" : ""} to restart it`);
138
+ if (mark?.__lineage === "lost") {
139
+ throw new SessionPolicyError("corrupt", `session rules for ${JSON.stringify(sessionId)} have no lineage evidence (the erase left a lost-lineage mark) — refusing to restart the key's lineage by guessing; delete the mark to restart it`);
143
140
  }
144
141
  const floor = Math.max(prior?.rev ?? 0, mark?.__hwm ?? 0);
145
- const gen = prior !== null ? (prior.gen ?? 0) : rowFilePresent ? (mark?.__gen ?? 0) + 1 : (mark?.__gen ?? 0);
142
+ const gen = prior !== null ? (prior.gen ?? 0) : (mark?.__gen ?? 0);
146
143
  const next = { ...clean, rev: floor + 1, gen };
147
144
  const attributedPrincipal = (principal ?? this.anonymousAlias) ?? null;
148
145
  this.writeMarkFile(this.markPathFor(sessionId, principal), next.rev, sessionId, attributedPrincipal, gen);
@@ -9,11 +9,11 @@ import { type UsageWindow, type UsageWindowReading, type UsageWindowStore } from
9
9
  * atomic replace), so the update is atomic within the one event loop — the in-memory store's premise.
10
10
  * Cross-process governance belongs to a database backend with a real transaction.
11
11
  *
12
- * **Corrupt records FAIL CLOSED** — deliberately the OPPOSITE of `FileSessionPolicyStore`'s fail-open. A
13
- * policy file is a DENY list, where absence is the safe direction; a usage window is a CEILING, where
14
- * absence is the unsafe direction: treating an unreadable ledger as "no usage yet" would hand the key an
15
- * unlimited, silent allowance — precisely what the operator wired this to prevent. A corrupt record
16
- * therefore refuses with `usage_window.store_corrupt` until it is repaired or removed.
12
+ * **Corrupt records FAIL CLOSED** — in the same direction as `FileSessionPolicyStore`'s keyed read. A
13
+ * policy file is a DENY list and a usage window is a CEILING; for both, reading a damaged record as "nothing
14
+ * there" would drop the very limit the operator wired — here an unlimited, silent allowance, precisely what
15
+ * the operator wired this to prevent. A corrupt record therefore refuses with `usage_window.store_corrupt`
16
+ * until it is repaired or removed.
17
17
  */
18
18
  export declare class FileUsageWindowStore implements UsageWindowStore {
19
19
  private readonly dir;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/core",
3
- "version": "7.39.1",
3
+ "version": "7.39.2",
4
4
  "description": "Stateless, task-oriented AI agent core",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",
@@ -2,7 +2,7 @@
2
2
  "repo": "core",
3
3
  "version": "7.33.6",
4
4
  "from": "2341b7826a837ce1786280ed3a9b1e1d656b1d41",
5
- "to": "v7.33.6",
5
+ "to": "af66de6b6e52c72e094d79a3308788bad6e08824",
6
6
  "added": [],
7
7
  "removed": [],
8
8
  "changed": [],
@@ -12,6 +12,50 @@
12
12
  "typeKeys": [],
13
13
  "typeKeysUnreadableArms": [],
14
14
  "typeKeysFlattenedCount": 217,
15
+ "signatures": [
16
+ {
17
+ "name": "isSecretEnvKey",
18
+ "at": [
19
+ "src/core/secret-env.ts:891"
20
+ ],
21
+ "from": [
22
+ "(key: string, value?: string, env?: NodeJS.ProcessEnv): boolean"
23
+ ],
24
+ "to": [
25
+ "(key: string, value?: string, env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): boolean"
26
+ ],
27
+ "changes": [
28
+ {
29
+ "aspect": "param",
30
+ "detail": "+optional param `platform?: NodeJS.Platform`",
31
+ "breaking": false
32
+ }
33
+ ],
34
+ "breaking": false,
35
+ "criteriaCovered": false
36
+ },
37
+ {
38
+ "name": "scrubSecretEnv",
39
+ "at": [
40
+ "src/core/secret-env.ts:907"
41
+ ],
42
+ "from": [
43
+ "(env: NodeJS.ProcessEnv, findings?: SecretEnvFinding[]): NodeJS.ProcessEnv"
44
+ ],
45
+ "to": [
46
+ "(env: NodeJS.ProcessEnv, findings?: SecretEnvFinding[], platform?: NodeJS.Platform): NodeJS.ProcessEnv"
47
+ ],
48
+ "changes": [
49
+ {
50
+ "aspect": "param",
51
+ "detail": "+optional param `platform?: NodeJS.Platform`",
52
+ "breaking": false
53
+ }
54
+ ],
55
+ "breaking": false,
56
+ "criteriaCovered": false
57
+ }
58
+ ],
15
59
  "declarationMoves": [],
16
60
  "mintMoves": [],
17
61
  "criteriaObserves": [
@@ -52,12 +96,17 @@
52
96
  }
53
97
  ],
54
98
  "consumersHint": {
55
- "cli": [],
56
- "server": [],
99
+ "cli": [
100
+ "isSecretEnvKey",
101
+ "scrubSecretEnv"
102
+ ],
103
+ "server": [
104
+ "isSecretEnvKey",
105
+ "scrubSecretEnv"
106
+ ],
57
107
  "sdk": [],
58
- "note": "scanned cli@4e593722 server@46b7fe60 sdk@10cf14c — a name listed is MENTIONED there today, which is evidence of consumption, not proof of it"
108
+ "note": "scanned cli@a7a00bd8 server@79de35f8 sdk@2756723 — a name listed is MENTIONED there today, which is evidence of consumption, not proof of it"
59
109
  },
60
110
  "breakingCount": 0,
61
- "breakingItems": [],
62
- "toResolvedAt": "2f7a1e0a72b2b8c6af18f9fb9de3620ec80c0cf6"
111
+ "breakingItems": []
63
112
  }
@@ -0,0 +1,102 @@
1
+ {
2
+ "repo": "core",
3
+ "version": "7.39.2",
4
+ "from": "335797ec9feceb08277d52fd882d20f00374808f",
5
+ "to": "fe41b09d0494634b4adfb12e9024f67719247eec",
6
+ "added": [],
7
+ "removed": [],
8
+ "changed": [],
9
+ "narrowed": [],
10
+ "closedSetMembers": [],
11
+ "closedSetsUnreadable": [],
12
+ "typeKeys": [],
13
+ "typeKeysUnreadableArms": [],
14
+ "typeKeysFlattenedCount": 220,
15
+ "signatures": [],
16
+ "declarationMoves": [],
17
+ "mintMoves": [],
18
+ "criteriaObserves": [
19
+ {
20
+ "kind": "typeMember",
21
+ "name": "Model.extraBody",
22
+ "assertions": [
23
+ "G5.a"
24
+ ]
25
+ },
26
+ {
27
+ "kind": "typeMember",
28
+ "name": "RunnerDeps.executionEnvFactory",
29
+ "assertions": [
30
+ "G2.a"
31
+ ]
32
+ },
33
+ {
34
+ "kind": "typeMember",
35
+ "name": "RunnerDeps.onError",
36
+ "assertions": [
37
+ "G3.b"
38
+ ]
39
+ },
40
+ {
41
+ "kind": "typeMember",
42
+ "name": "SessionPolicyStore.getRules",
43
+ "assertions": [
44
+ "G6.a"
45
+ ]
46
+ },
47
+ {
48
+ "kind": "typeMember",
49
+ "name": "SessionPolicyStore.putRules",
50
+ "assertions": [
51
+ "G6.b"
52
+ ]
53
+ },
54
+ {
55
+ "kind": "typeMember",
56
+ "name": "TaskResult.rewindNotes",
57
+ "assertions": [
58
+ "G4.a"
59
+ ]
60
+ },
61
+ {
62
+ "kind": "typeMember",
63
+ "name": "TaskResult.terminal",
64
+ "assertions": [
65
+ "G1.b"
66
+ ]
67
+ },
68
+ {
69
+ "kind": "typeMember",
70
+ "name": "TaskResult.terminal.code",
71
+ "assertions": [
72
+ "G1.a",
73
+ "G2.a",
74
+ "G3.a",
75
+ "G5.a",
76
+ "G6.c"
77
+ ]
78
+ },
79
+ {
80
+ "kind": "typeMember",
81
+ "name": "TaskResult.terminal.message",
82
+ "assertions": [
83
+ "G3.c"
84
+ ]
85
+ },
86
+ {
87
+ "kind": "typeMember",
88
+ "name": "TaskSpec.resumeAt",
89
+ "assertions": [
90
+ "G1.a"
91
+ ]
92
+ }
93
+ ],
94
+ "consumersHint": {
95
+ "cli": [],
96
+ "server": [],
97
+ "sdk": [],
98
+ "note": "scanned cli@1c57a121 server@79de35f8 sdk@8ce1faa — a name listed is MENTIONED there today, which is evidence of consumption, not proof of it"
99
+ },
100
+ "breakingCount": 0,
101
+ "breakingItems": []
102
+ }