@sema-agent/core 7.35.0 → 7.35.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,36 @@
1
+ ## 7.35.1 — 2026-09-29
2
+
3
+ ### A run that named no session holds the session its store minted — a second run on that id waits for it (#1206; behavior (tighten: a second run of a new session queues); +0 exports, +0 wire keys; +1 engine-invariant code `internal.session_lock_second_id` that no engine path reaches) (@server @test)
4
+
5
+ - **Before (v7.35.0).** A run that named its session (`TaskSpec.sessionId`) took the Runner's session lock before its prepare; a run that named none took no lock. Its session store minted the id inside prepare, and the id reached other callers through the run's tool contexts and events, so a second run started on it ran beside the first. Run A (`bypassPermissions`, a new session) spawned a child and was still running, or was cancelled with its tool pending; run B (`plan`) started on A's session id reached its first tool at once, and the child's ceiling kept answering A's `bypassPermissions` while B ran (KL-1192-c). Not a regression: v7.34.0's child held A's controller for life.
6
+ - **Now (one lock rule).** Every run holds ONE session lock from the moment it knows the session id to the run's end, decided by one read of the spec. A session the spec names is locked before prepare (unchanged). For a spec that names none, prepare takes the lock the moment the store mints the id. It is the same lock, released at the same place. Asking a run's hold again for the same id returns the same hold; asking it for another id is refused by name (`internal.session_lock_second_id`, an engine invariant no engine path reaches) rather than taking a second lock over the first. A second run on a minted id queues behind the first exactly as it does behind a named session; once the first run stops, the second enters, and a child of the first then follows it (`plan` refuses the child's write). Runs on different sessions are not serialized. The child ceiling rule (#1192) is unchanged.
7
+ - **Timing.** Measured on a prepare refusal: a run that named no session settles four microtasks later (46 → 50: the lock's await on the prepare path); a run naming its session reaches prepare two microtasks later (56 → 58: the hold's own step before the lock).
8
+ - **Who is hurt.** A caller that starts a second run on a new session's id while the first still runs sees that run start after the first ends: the order changes, not the outcome. The exception is a caller tool that starts a run on its OWN run's session id and awaits it: that now waits forever, as it already did for a run that named its session (no engine path does this). A probe that pinned the old overlap (two runs of one session side by side because the first named no session) goes red; rewrite it to expect the second run to queue, as the re-pinned cells below do. The lock is per Runner instance, so two Runners over one session store are not serialized, as for a named session. A caller that reads the freshly minted id off the store (`TtlSessionStore.list()`) and starts a run on it before the first run's prepare took the lock runs first; the first then queues behind it — the order is reversed and nothing waits forever (KL-1206).
9
+ - Pins: `test/backlog1206-new-session-lock.test.ts` ①–⑧, on a real Runner:
10
+ - ① / ② the second run queues behind a new-session run that was cancelled with its tool pending, or is still running. The reading is the named-session one, and the child then follows the second run's `plan`.
11
+ - ③ / ④ controls.
12
+ - ⑤ the lock is released on a prepare throw after the mint and on an abort.
13
+ - ⑥ tripwire for the retired shape: the second run's prepare does not begin while the first run runs, however the first got its session.
14
+ - ⑦ / ⑧ one decision per run: a `sessionId` inherited through the prototype chain, which the plan-mode rebind drops, with the run refused after prepare acquired, releases the named session's lock (the next run on it enters); a caller clearing its `sessionId` while the store's acquire is pending does not wait on its own lock and runs on the session it acquired. v7.35.0 green; the first cut of this fix red (it held twice and overwrote the first release, or waited on its own lock).
15
+
16
+ `test/backlog1206-session-lock-one-hold.test.ts` ⑨ / ⑩ (the lane's hold verb reached where prepare receives it): the same id again is the same hold, another id is refused by `internal.session_lock_second_id` and the first lock is still released at the run's end; a spec naming its session hands prepare no hold verb.
17
+
18
+ Re-pinned where the old overlap was the setup:
19
+ - `backlog1192-session-mode-seat` C1 / C3: the second run queues, and the child keeps the running leg's `plan`; `wrote` is 0 as before.
20
+ - `backlog744-notification-session-routing`, the two overlapping-run cells: the second run waits, the frame the idle first run cannot take parks, and a same-key retry folds on the producer's lane. The waiting run's turn-open carries it once.
21
+ - `design393-s6-run-leg-lanes`: rejection depth 46 → 50, because the lock's await is on the prepare path.
22
+
23
+ Mutations:
24
+ - D950, a new session is not locked: ①, ②, ⑥, C1, C3 and the rejection depth go red.
25
+ - D951, the minted lock is never released: ①, ②, ③, ⑤, ⑥, ⑨ go red.
26
+ - D952, the lock is released only when the run body resolves: ⑤ and ⑦ go red.
27
+ - D953, every minted session locks one shared key: control ④ goes red.
28
+ - D955, the hold keeps no owner: ⑨ goes red.
29
+ - D956, prepare is handed the hold for a named session too: ⑦ and ⑩ go red.
30
+ - The first cut of this fix (a second decision in prepare): ⑦, ⑧, ⑨ and ⑩ go red.
31
+
32
+ Known limits: KL-1192-c closed; KL-1206 (the mint-to-lock window).
33
+
1
34
  ## 7.35.0 — 2026-09-29
2
35
 
3
36
  **Hard-breaking changes — the whole release in three sentences.**
@@ -2771,7 +2771,7 @@ export interface RunnerSelfSeat {
2771
2771
  */
2772
2772
  export type PrepareTaskFn = (spec: TaskSpec, deps: RunnerDeps, sessions: SessionStore, resume?: PrepareResume, internals?: RunInternals, runnerSelf?: RunnerSelfSeat, runIdSink?: {
2773
2773
  runId?: string;
2774
- }) => Promise<Prepared>;
2774
+ }, holdSessionLock?: (sessionId: string) => Promise<void>) => Promise<Prepared>;
2775
2775
  /**
2776
2776
  * The Result of an INSTALLING phase: a lane whose every product is installed on a seat it borrowed
2777
2777
  * (the harness's stop gate and recovery chain, the run state's counter groups, the stream's result setter, the claimed
@@ -113,7 +113,9 @@ runnerSelf?: RunnerSelfSeat,
113
113
  * shape the Runner uses for the generated taskId/sessionId. */
114
114
  runIdSink?: {
115
115
  runId?: string;
116
- }): Promise<Prepared>;
116
+ },
117
+ /** The Runner's lock on this leg's session, handed only when the spec named none: taken the moment the store mints the id. */
118
+ holdSessionLock?: (sessionId: string) => Promise<void>): Promise<Prepared>;
117
119
  /** The entry IS the contract the notification lane's seat names ({@link PrepareTaskFn}, contracts.ts):
118
120
  * a signature change reds HERE, at the declaration, as `false` failing the `true` bound. IDENTITY, not assignability:
119
121
  * the parameter tuple (arity, optionality, each type) and the return type must be the SAME type in both directions —
@@ -282,7 +282,7 @@ function refusePermissionModeDoors(spec) {
282
282
  }
283
283
  return mode;
284
284
  }
285
- export async function prepareTask(spec, deps, sessions, resume, internals, runnerSelf, runIdSink) {
285
+ export async function prepareTask(spec, deps, sessions, resume, internals, runnerSelf, runIdSink, holdSessionLock) {
286
286
  const runId = uuidv7();
287
287
  if (runIdSink !== undefined)
288
288
  runIdSink.runId = runId;
@@ -308,6 +308,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
308
308
  const fileHistoryRequest = resolveFileHistoryRequest(spec, deps);
309
309
  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 } : {}; })() });
310
310
  const sessionId = acquired.sessionId;
311
+ await holdSessionLock?.(sessionId);
311
312
  const onceLedger = await loadAnnounceOnceLedger(session, resume?.seed.announcedListings, deps.onError);
312
313
  const hostTaskId = spec.taskId ?? sessionId;
313
314
  const delegation = effectiveDelegationFacts(internals, resume?.seed.isDelegatedChild);
@@ -17,6 +17,8 @@ export interface RunNotificationLaneInput<R> {
17
17
  internals: RunInternals | undefined;
18
18
  /** borrowed-mutable — the stream layer's backstop carrier, handed to prepare so a prepare THROW still names its run. */
19
19
  taskIdRef: TaskIdRef | undefined;
20
+ /** borrowed-readonly — the stream layer's session-lock verb, handed to prepare; absent when the spec named its session. */
21
+ holdSessionLock: ((sessionId: string) => Promise<void>) | undefined;
20
22
  /** borrowed-readonly — the Runner's deployment deps, read LIVE (`onError` in the park closure — after the run, on a swapped
21
23
  * catalog it is still the CURRENT sink; `models` and prepare's `deps` at the natural-snapshot pin); the one home of why it
22
24
  * is a seat and not a captured object is {@link RunnerDepsSeat}. */
@@ -8,7 +8,7 @@ export function runNotificationLane(input) {
8
8
  return obligations.run(() => runNotificationLaneAs(input, obligations));
9
9
  }
10
10
  async function runNotificationLaneAs(input, obligations) {
11
- const { spec, queue, resume, internals, taskIdRef, runner, self, sessions, sessionReadStates, sessionModeSeats, pendingSessionNotifications, prepareTask, next } = input;
11
+ const { spec, queue, resume, internals, taskIdRef, holdSessionLock, runner, self, sessions, sessionReadStates, sessionModeSeats, pendingSessionNotifications, prepareTask, next } = input;
12
12
  const prepareResume = resume
13
13
  ? {
14
14
  leafId: resume.cp.leafId,
@@ -127,7 +127,7 @@ async function runNotificationLaneAs(input, obligations) {
127
127
  }
128
128
  return injectTaskNotification(notification, opts);
129
129
  },
130
- }, self, taskIdRef);
130
+ }, self, taskIdRef, holdSessionLock);
131
131
  pendingSessionNotifications.bindSessionBox(prepared.sessionId, prepared.sessionBox);
132
132
  const notificationLane = {
133
133
  get harness() { return notificationHarness; }, set harness(h) { notificationHarness = h; },
@@ -94,7 +94,8 @@ export declare class Runner implements RunnerSelfSeat {
94
94
  toolPolicy?: RunnerDeps["toolPolicy"];
95
95
  hooks?: RunnerDeps["hooks"];
96
96
  };
97
- /** Acquire the lock for a sessionId; returns a release fn. New (undefined) sessions need no lock. */
97
+ /** Acquire the lock for a sessionId; returns a release fn. The one lock every leg holds on its session, from the moment it
98
+ * knows the id to the leg's end (through `holdSessionLock` in `runTaskStream`, one per leg). */
98
99
  private acquireSessionLock;
99
100
  /**
100
101
  * Hot-swap the deployment seats a running Runner may change without a restart — the ONE door for every
@@ -330,14 +330,24 @@ export class Runner {
330
330
  };
331
331
  const runStartedAt = Date.now();
332
332
  const taskIdRef = { current: spec.taskId, sessionId: spec.sessionId };
333
+ let held;
334
+ let releaseLock;
335
+ const holdSessionLock = (sessionId) => {
336
+ if (held === undefined)
337
+ held = { sessionId, taken: this.acquireSessionLock(sessionId).then((release) => void (releaseLock = release)) };
338
+ else if (held.sessionId !== sessionId)
339
+ return Promise.reject(Object.assign(new Error(`internal invariant: a leg holds one session lock — it holds "${held.sessionId}" and was asked for "${sessionId}"`), { code: "internal.session_lock_second_id" }));
340
+ return held.taken;
341
+ };
342
+ const namedSessionId = spec.sessionId;
333
343
  const run = (async () => {
334
- const releaseLock = spec.sessionId
335
- ? await this.acquireSessionLock(spec.sessionId)
336
- : undefined;
344
+ if (namedSessionId)
345
+ await holdSessionLock(namedSessionId);
337
346
  try {
338
347
  await this.runLocked({
339
348
  spec,
340
349
  queue,
350
+ holdSessionLock: namedSessionId ? undefined : holdSessionLock,
341
351
  setResult: (r) => {
342
352
  resultValue = r;
343
353
  const pcs = internals?.inheritedGate?.parentConstraints;
@@ -436,9 +446,9 @@ export class Runner {
436
446
  };
437
447
  }
438
448
  runLocked(input) {
439
- const { spec, queue, setResult, onSuggestions, onReady, onSuspend, manualCompactRef, taskIdRef, resume, internals, notifyRef, captureOptOutRef, mcpRedialRef, entryActor, entryTracer } = input;
449
+ const { spec, queue, setResult, onSuggestions, onReady, onSuspend, manualCompactRef, taskIdRef, holdSessionLock, resume, internals, notifyRef, captureOptOutRef, mcpRedialRef, entryActor, entryTracer } = input;
440
450
  return runNotificationLane({
441
- spec, queue, resume, internals, taskIdRef,
451
+ spec, queue, resume, internals, taskIdRef, holdSessionLock,
442
452
  runner: this.depsSeat, self: this, sessions: this.sessions, sessionReadStates: this.sessionReadStates, sessionModeSeats: this.sessionModeSeats, pendingSessionNotifications: this.pendingSessionNotifications,
443
453
  prepareTask,
444
454
  next: ({ prepared, injectTaskNotification, deliveredAtTurnOpen, peerInboundChainRef, modelCatalog, notificationLane, unsubscribeTaskNotifications }) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/core",
3
- "version": "7.35.0",
3
+ "version": "7.35.1",
4
4
  "description": "Stateless, task-oriented AI agent core",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",
@@ -0,0 +1,59 @@
1
+ {
2
+ "repo": "core",
3
+ "version": "7.35.1",
4
+ "from": "b0a5a6ddd0b430433d12452260aa51cf96f05972",
5
+ "to": "v7.35.1",
6
+ "added": [],
7
+ "removed": [],
8
+ "changed": [],
9
+ "narrowed": [],
10
+ "closedSetMembers": [],
11
+ "closedSetsUnreadable": [],
12
+ "typeKeys": [],
13
+ "typeKeysUnreadableArms": [],
14
+ "typeKeysFlattenedCount": 219,
15
+ "declarationMoves": [],
16
+ "mintMoves": [],
17
+ "criteriaObserves": [
18
+ {
19
+ "kind": "typeMember",
20
+ "name": "SessionStore.acquire",
21
+ "assertions": [
22
+ "G1.c"
23
+ ]
24
+ },
25
+ {
26
+ "kind": "typeMember",
27
+ "name": "TaskResult.sessionId",
28
+ "assertions": [
29
+ "G1.b"
30
+ ]
31
+ },
32
+ {
33
+ "kind": "typeMember",
34
+ "name": "TaskSpec.permissionMode",
35
+ "assertions": [
36
+ "G1.a",
37
+ "G1.d"
38
+ ]
39
+ },
40
+ {
41
+ "kind": "typeMember",
42
+ "name": "TaskSpec.sessionId",
43
+ "assertions": [
44
+ "G1.a",
45
+ "G1.c",
46
+ "G1.d"
47
+ ]
48
+ }
49
+ ],
50
+ "consumersHint": {
51
+ "cli": [],
52
+ "server": [],
53
+ "sdk": [],
54
+ "note": "scanned cli@6b6025f7 server@25d6b7a6 sdk@e8f6f0d — a name listed is MENTIONED there today, which is evidence of consumption, not proof of it"
55
+ },
56
+ "breakingCount": 0,
57
+ "breakingItems": [],
58
+ "toResolvedAt": "27a9b09aced4d24b63a68ca78f4186c6fd5fa944"
59
+ }