@sema-agent/core 7.54.0 → 7.54.1

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,17 @@
1
+ ## 7.54.1 — 2026-10-20
2
+
3
+ **Hard-breaking changes:** none. **Closed sets:** none (`invalid_session` is an existing code). **Exports:** +0 / −0 names; no type or signature change. **Behavior:** a session log whose parent chain loops back on itself is refused `invalid_session` by `getPathToRoot` and by the resume-at ancestry scan, where each used to hold the thread until the process aborted; the shared store kit gains one `[tree]` case that says so. Present in every earlier version.
4
+
5
+ **Packages:** `@sema-agent/mods-kernel` is re-cut at 7.54.1 beside core — packages are cut with core at the same version (`scripts/verify-packages.mjs`) — with contents identical to 7.54.0 (no kernel source changed in this patch).
6
+
7
+ ### `getPathToRoot` and the resume-at ancestry scan refuse a parent chain that loops back on itself instead of walking it forever (#2239; fix; a log that could not be read at all; hard-breaking: none; closed sets: none; exports: +0 / −0) (@server)
8
+
9
+ - **Before (v7.54.0; the same in every earlier version).** `BaseSessionStorage.getPathToRoot` followed `parentId` upward with no memory of the entries it had stepped onto. A log in which an entry is its own parent, or two entries are each other's parent, or a leaf walks into such a pair, never reached a root: the walk is synchronous, so it held the whole thread (no timer, timeout or abort could run) while the path it was building grew until the process aborted out of memory (measured on a 256 MB heap: about 13 s, then `SIGABRT`). Every bundled storage (memory, file) and the example adapters inherit this one walk. The resume-at phase had a second walk of the same kind: with `resumeAtMode: "before"` it climbs the target's ancestors, one awaited read at a time, until it finds a conversation message; a log whose ancestry above the target is only non-message entries that loop (for example two labels that parent each other) never ended it, and because each read resolves as a microtask the loop starved timers as well. A store that implements the storage interface directly with a walk of its own had no kit case to say a looping log must not be walked.
10
+ - **Now.** Both walks take one small guard (`ParentChainGuard`, internal; not a package export) that remembers the entries a walk has stepped onto. An entry stepped onto twice ends the walk with `SessionError("invalid_session", "Entry <id> is its own ancestor (parent chain cycles at <id>)")`, naming the entry the chain came back to (for a leaf outside the loop, the first loop entry the walk reached; for the resume-at scan the target itself counts as stepped onto, so a loop that closes through it is refused before any branch is written). Claude Code's own chain builder stops at the repeat and returns the part it had walked (cli296.js:250676:3080); this engine refuses instead, because a loop is a damaged log and a truncated history would read as a shorter conversation (stricter, completeness-first; open to veto). A declared floor still ends the walk at the floor without reading its parent, so a loop that closes through the floor, or lies above it, is not walked and nothing is refused; a floor the walk never meets does not shield the loop. A chain with no loop reads exactly as before (a 1 000-entry chain returns whole, root first). `sessionStoreContract` gains the case `[tree] a parent chain that loops back on itself …`: an entry that is its own parent, two entries that parent each other, a leaf that walks into such a pair, and a ring of three must each read `invalid_session` naming an entry of the loop, within 2 s; a backend that refuses the looping write itself is stricter than the rule and passes.
11
+ - **Who is hurt.** Nobody who reads a sound log. A host that wrote a looping log (a hand-edited or damaged file, or a store with no write-time check) now sees `invalid_session` on the read where it saw a stalled thread and then an aborted process. A store that implements `SessionStore` directly and walks the parent chain itself fails the new kit case until its walk refuses a loop (the server's SQL store is the known consumer: it retires its own guarded walker for this one in its 7.134.0).
12
+ - **Compensation.** None needed. A store that wants the engine's walk extends `BaseSessionStorage`; one that keeps its own walk adds a visited set (or a depth bound) and the refusal above, and runs the kit.
13
+ - **Pins.** `test/backlog2239-session-path-cycle.test.ts` L1–L5 (each walk runs in a child process, because a walk that never returns holds its whole thread and no in-process timeout could fire; red on v7.54.0 as "no answer in 2 s" for L1, L2, L3 and the off-path floor of L5), L6 (the resume-at scan: a loop above the target hangs on v7.54.0; a loop through the target and the two controls), L7 (the guard) and K1–K2 (the kit case on the bundled memory and file lanes; six wrong walks each redden it).
14
+
1
15
  ## 7.54.0 — 2026-10-19
2
16
 
3
17
  ### A table of the faces kept alive for compatibility, a gate that keeps it honest, and one ruler per word (#1336, #2100; release tooling and src comments only; behavior change: none; hard-breaking: none; closed sets: none; exports: none; members: none)
@@ -8,16 +8,15 @@
8
8
  * (resume-at validation + rebranch + reconcile re-run as one unit against the fresh view) — pinned
9
9
  * in `prepare-task-phase-pins`.
10
10
  *
11
- * Ownership: on SUCCESS the acquired session's ownership transfers whole
12
- * to the caller (the driver's forgetOnThrow / the Runner's finish own the cleanup from then on);
11
+ * Ownership: on SUCCESS the acquired session's ownership transfers whole to the caller (the driver's forgetOnThrow / the Runner's finish own the cleanup from then on);
13
12
  * every failure path INSIDE this phase drops its own cached view first (`forget`, never `release` —
14
13
  * audit B-17: release deletes the history on the default store), so a throw here never strands a
15
14
  * cached view and never hands out a half-acquired resource.
16
15
  *
17
16
  * Throws pass through unchanged: `resume.session_not_found` (requireExisting fail-loud mapping),
18
- * the `resume_at.*` target-validation refusals, and — after the bounded re-wake budget — the
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.
17
+ * the `resume_at.*` target-validation refusals, `invalid_session` for a log whose parent links loop back, and — after the
18
+ * bounded re-wake budget — the session conflict itself. A throw after a `resumeAt` branch landed first writes that branch
19
+ * back (`settleBranchWriteBack`); on success the branch is handed over with the session.
21
20
  */
22
21
  import { StoredSession } from "../session.js";
23
22
  import type { AcquiredSession, SessionStore } from "../session.js";
@@ -1,5 +1,5 @@
1
1
  import { StoredSession, isSessionConflict } from "../session.js";
2
- import { SessionError } from "../../internal/harness.js";
2
+ import { ParentChainGuard, SessionError } from "../../internal/harness.js";
3
3
  import { readMember } from "../thrown-value.js";
4
4
  import { userAttachmentOf } from "../../internal/llm.js";
5
5
  import { lastAttachmentFrameOf } from "../user-attachment.js";
@@ -77,12 +77,14 @@ export async function prepareAcquireReconcile(input) {
77
77
  e.code = "resume_at.before_target_not_user";
78
78
  throw e;
79
79
  }
80
- let ancestorId = entry.parentId;
81
- let sawConversationAbove = false;
80
+ let ancestorId = entry.parentId, sawConversationAbove = false;
81
+ const chain = new ParentChainGuard();
82
+ chain.stepOnto(entry.id);
82
83
  while (ancestorId !== null) {
83
84
  const ancestor = await session.getEntry(ancestorId);
84
85
  if (!ancestor)
85
86
  break;
87
+ chain.stepOnto(ancestor.id);
86
88
  if (ancestor.type === "message" || ancestor.type === "custom_message") {
87
89
  sawConversationAbove = true;
88
90
  break;
@@ -62,6 +62,22 @@ async function rejectsWithConflict(fn, what) {
62
62
  const miss = conflictMiss(await thrownBy(fn, what));
63
63
  assert.equal(miss, undefined, `${what}: ${miss}`);
64
64
  }
65
+ const LOOP_WALK_BUDGET_MS = 2000;
66
+ async function refusedAsLoop(storage, leafId, loopMembers, what) {
67
+ let timer;
68
+ const expiry = new Promise((_, reject) => {
69
+ timer = setTimeout(() => reject(new assert.AssertionError({ message: `${what}: no answer within ${LOOP_WALK_BUDGET_MS} ms — the walk does not end on a parent chain that loops back on itself` })), LOOP_WALK_BUDGET_MS);
70
+ });
71
+ const settled = Promise.resolve()
72
+ .then(() => storage.getPathToRoot(leafId))
73
+ .then((path) => ({ path }), (err) => ({ err }));
74
+ const outcome = await Promise.race([settled, expiry]).finally(() => clearTimeout(timer));
75
+ if ("path" in outcome)
76
+ assert.fail(`${what}: a parent chain that loops back on itself answered a path of ${outcome.path.length} entries instead of refusing`);
77
+ assert.equal(readMember(outcome.err, "code"), "invalid_session", `${what}: the refusal must carry code invalid_session`);
78
+ const sentence = String(readMember(outcome.err, "message"));
79
+ assert.ok(loopMembers.some((id) => sentence.includes(id)), `${what}: the refusal must name an entry of the loop (${loopMembers.join(" / ")}), got "${sentence}"`);
80
+ }
65
81
  function rowOf(rows, sessionId) {
66
82
  return rows.find((r) => r.sessionId === sessionId);
67
83
  }
@@ -275,6 +291,34 @@ async function runSessionStoreContract(make, hooks) {
275
291
  const head = (await session.getStorage().getPathToRoot("e1"))[0];
276
292
  assert.ok(head.parentId === null || head.id === session.getStorage().getFloorId?.(), "a path's head is a root or the declared floor");
277
293
  });
294
+ run("[tree] a parent chain that loops back on itself — an entry that is its own parent, two entries that parent each other, a leaf that walks into such a pair — reads invalid_session naming an entry of the loop, and the walk ends (also for a ring of three)", async (store) => {
295
+ const { session } = await store.acquire(freshId());
296
+ await append(session, userEntry("e1", null));
297
+ const accepted = async (...entries) => {
298
+ try {
299
+ for (const e of entries)
300
+ await append(session, e);
301
+ return true;
302
+ }
303
+ catch {
304
+ return false;
305
+ }
306
+ };
307
+ const storage = session.getStorage();
308
+ if (await accepted(userEntry("kit-loop-self", "kit-loop-self"))) {
309
+ await refusedAsLoop(storage, "kit-loop-self", ["kit-loop-self"], "the path from an entry that is its own parent");
310
+ }
311
+ if (await accepted(userEntry("kit-loop-p", "kit-loop-q"), userEntry("kit-loop-q", "kit-loop-p"))) {
312
+ await refusedAsLoop(storage, "kit-loop-q", ["kit-loop-p", "kit-loop-q"], "the path from one of two entries that parent each other");
313
+ if (await accepted(userEntry("kit-loop-t", "kit-loop-p"))) {
314
+ await refusedAsLoop(storage, "kit-loop-t", ["kit-loop-p", "kit-loop-q"], "the path from a leaf outside the loop that walks into it");
315
+ }
316
+ }
317
+ if (await accepted(userEntry("kit-ring-1", "kit-ring-3"), userEntry("kit-ring-2", "kit-ring-1"), userEntry("kit-ring-3", "kit-ring-2"))) {
318
+ await refusedAsLoop(storage, "kit-ring-3", ["kit-ring-1", "kit-ring-2", "kit-ring-3"], "the path from one of three entries that parent each other in a ring");
319
+ }
320
+ assert.deepEqual(await pathIds(session, "e1"), ["e1"], "a path that does not touch the loop still reads");
321
+ });
278
322
  run("[tree] the write CAS: an append or setLeafId whose expectedLeafId is stale is refused with the engine's conflict and writes nothing", async (store) => {
279
323
  const { session } = await store.acquire(freshId());
280
324
  const storage = session.getStorage();
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The memory of one walk up a session log's parent chain, shared by every walk that follows `parentId`. An entry the
3
+ * walk stands on twice means the parent links loop, which no ancestry can contain: the walk is refused
4
+ * `invalid_session` naming that entry instead of being followed again. One guard per walk.
5
+ */
6
+ export declare class ParentChainGuard {
7
+ private readonly stepped;
8
+ /** Records that the walk now stands on `id`; throws `invalid_session` when it already stood there. */
9
+ stepOnto(id: string): void;
10
+ }
@@ -0,0 +1,10 @@
1
+ import { SessionError } from "../harness/types.js";
2
+ export class ParentChainGuard {
3
+ stepped = new Set();
4
+ stepOnto(id) {
5
+ if (this.stepped.has(id)) {
6
+ throw new SessionError("invalid_session", `Entry ${id} is its own ancestor (parent chain cycles at ${id})`);
7
+ }
8
+ this.stepped.add(id);
9
+ }
10
+ }
@@ -1,4 +1,5 @@
1
1
  import { SessionError, } from "../harness/types.js";
2
+ import { ParentChainGuard } from "./parent-chain-guard.js";
2
3
  import { uuidv7 } from "./uuid.js";
3
4
  function updateLabelCache(labelsById, entry) {
4
5
  if (entry.type !== "label") {
@@ -111,11 +112,13 @@ export class BaseSessionStorage {
111
112
  return [];
112
113
  }
113
114
  const path = [];
115
+ const chain = new ParentChainGuard();
114
116
  let current = this.byId.get(leafId);
115
117
  if (!current) {
116
118
  throw new SessionError("not_found", `Entry ${leafId} not found`);
117
119
  }
118
120
  while (current) {
121
+ chain.stepOnto(current.id);
119
122
  path.push(current);
120
123
  if (current.id === this.floorId) {
121
124
  break;
@@ -23,6 +23,7 @@ export { BaseSessionStorage, leafIdAfterEntry } from "../engine/session/storage-
23
23
  export { InMemorySessionStorage } from "../engine/session/memory-storage.js";
24
24
  export { InMemorySessionRepo } from "../engine/session/memory-repo.js";
25
25
  export { uuidv7 } from "../engine/session/uuid.js";
26
+ export { ParentChainGuard } from "../engine/session/parent-chain-guard.js";
26
27
  export { readToolCallArguments } from "../engine/loop/agent-loop.js";
27
28
  export { SessionError } from "../engine/harness/types.js";
28
29
  export type { AnnouncedListingSnapshot, AnnouncedListingSnapshotInput, AnnouncedMcpFailure } from "../engine/harness/types.js";
@@ -11,6 +11,7 @@ export { BaseSessionStorage, leafIdAfterEntry } from "../engine/session/storage-
11
11
  export { InMemorySessionStorage } from "../engine/session/memory-storage.js";
12
12
  export { InMemorySessionRepo } from "../engine/session/memory-repo.js";
13
13
  export { uuidv7 } from "../engine/session/uuid.js";
14
+ export { ParentChainGuard } from "../engine/session/parent-chain-guard.js";
14
15
  export { readToolCallArguments } from "../engine/loop/agent-loop.js";
15
16
  export { SessionError } from "../engine/harness/types.js";
16
17
  export { normalizeGitAnnouncement } from "../engine/harness/types.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/core",
3
- "version": "7.54.0",
3
+ "version": "7.54.1",
4
4
  "description": "Stateless, task-oriented AI agent core",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",
@@ -0,0 +1,37 @@
1
+ {
2
+ "repo": "core",
3
+ "version": "7.54.1",
4
+ "from": "371ab636fb4dc957f79229a654ff6c2fe840c9a3",
5
+ "to": "v7.54.1",
6
+ "added": [],
7
+ "removed": [],
8
+ "changed": [],
9
+ "narrowed": [],
10
+ "closedSetMembers": [],
11
+ "closedSetsUnreadable": [],
12
+ "typeKeys": [],
13
+ "typeKeysUnreadableArms": [],
14
+ "typeKeysFlattenedCount": 237,
15
+ "signatures": [],
16
+ "declarationMoves": [],
17
+ "mintMoves": [],
18
+ "criteriaObserves": [
19
+ {
20
+ "kind": "typeMember",
21
+ "name": "SessionError.code",
22
+ "assertions": [
23
+ "G1.a",
24
+ "G1.b"
25
+ ]
26
+ }
27
+ ],
28
+ "consumersHint": {
29
+ "cli": [],
30
+ "server": [],
31
+ "sdk": [],
32
+ "note": "scanned cli@5028feb7 server@41b72695 sdk@e8794dc — a name listed is MENTIONED there today, which is evidence of consumption, not proof of it"
33
+ },
34
+ "breakingCount": 0,
35
+ "breakingItems": [],
36
+ "toResolvedAt": "854e82a40701de98758a3f8a41e62431cf67facd"
37
+ }