@namzu/sdk 12.2.0 → 13.0.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.
Files changed (56) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/dist/bridge/a2a/__tests__/project-is-the-a2a-context.test.d.ts +2 -0
  3. package/dist/bridge/a2a/__tests__/project-is-the-a2a-context.test.d.ts.map +1 -0
  4. package/dist/bridge/a2a/__tests__/project-is-the-a2a-context.test.js +55 -0
  5. package/dist/bridge/a2a/__tests__/project-is-the-a2a-context.test.js.map +1 -0
  6. package/dist/manager/agent/lifecycle.d.ts +15 -0
  7. package/dist/manager/agent/lifecycle.d.ts.map +1 -1
  8. package/dist/manager/agent/lifecycle.js +70 -26
  9. package/dist/manager/agent/lifecycle.js.map +1 -1
  10. package/dist/public-runtime.d.ts +1 -1
  11. package/dist/public-runtime.d.ts.map +1 -1
  12. package/dist/public-runtime.js +7 -1
  13. package/dist/public-runtime.js.map +1 -1
  14. package/dist/session/__tests__/integration/a-successful-delegation-disposes-its-workspace.test.d.ts +21 -0
  15. package/dist/session/__tests__/integration/a-successful-delegation-disposes-its-workspace.test.d.ts.map +1 -0
  16. package/dist/session/__tests__/integration/a-successful-delegation-disposes-its-workspace.test.js +205 -0
  17. package/dist/session/__tests__/integration/a-successful-delegation-disposes-its-workspace.test.js.map +1 -0
  18. package/dist/session/__tests__/integration/handoff-single-e2e.test.js +14 -5
  19. package/dist/session/__tests__/integration/handoff-single-e2e.test.js.map +1 -1
  20. package/dist/session/errors.d.ts +25 -0
  21. package/dist/session/errors.d.ts.map +1 -1
  22. package/dist/session/errors.js +21 -0
  23. package/dist/session/errors.js.map +1 -1
  24. package/dist/session/handoff/broadcast.d.ts.map +1 -1
  25. package/dist/session/handoff/broadcast.js +10 -5
  26. package/dist/session/handoff/broadcast.js.map +1 -1
  27. package/dist/session/handoff/single.d.ts.map +1 -1
  28. package/dist/session/handoff/single.js +27 -4
  29. package/dist/session/handoff/single.js.map +1 -1
  30. package/dist/store/session/__tests__/a-session-has-one-writer.test.d.ts +2 -0
  31. package/dist/store/session/__tests__/a-session-has-one-writer.test.d.ts.map +1 -0
  32. package/dist/store/session/__tests__/a-session-has-one-writer.test.js +79 -0
  33. package/dist/store/session/__tests__/a-session-has-one-writer.test.js.map +1 -0
  34. package/dist/store/session/disk.d.ts +1 -1
  35. package/dist/store/session/disk.d.ts.map +1 -1
  36. package/dist/store/session/disk.js +15 -2
  37. package/dist/store/session/disk.js.map +1 -1
  38. package/dist/store/session/memory.d.ts +1 -1
  39. package/dist/store/session/memory.d.ts.map +1 -1
  40. package/dist/store/session/memory.js +14 -2
  41. package/dist/store/session/memory.js.map +1 -1
  42. package/dist/types/session/store.d.ts +28 -1
  43. package/dist/types/session/store.d.ts.map +1 -1
  44. package/package.json +1 -1
  45. package/src/bridge/a2a/__tests__/project-is-the-a2a-context.test.ts +66 -0
  46. package/src/manager/agent/lifecycle.ts +71 -26
  47. package/src/public-runtime.ts +6 -0
  48. package/src/session/__tests__/integration/a-successful-delegation-disposes-its-workspace.test.ts +270 -0
  49. package/src/session/__tests__/integration/handoff-single-e2e.test.ts +14 -5
  50. package/src/session/errors.ts +29 -0
  51. package/src/session/handoff/broadcast.ts +10 -5
  52. package/src/session/handoff/single.ts +27 -4
  53. package/src/store/session/__tests__/a-session-has-one-writer.test.ts +116 -0
  54. package/src/store/session/disk.ts +21 -2
  55. package/src/store/session/memory.ts +20 -2
  56. package/src/types/session/store.ts +28 -1
@@ -641,6 +641,24 @@ export class AgentManager {
641
641
  if (this.deps.workspaceRegistry.has(backend)) {
642
642
  const driver = this.deps.workspaceRegistry.get(backend)
643
643
  workspaceRef = await driver.create({ label: subSession.id })
644
+
645
+ // Write the workspace onto the record that outlives this process.
646
+ //
647
+ // The ref was kept only on the in-memory `ChildSpawnRecord`, so
648
+ // `SubSession.workspaceId` stayed `null` for every spawn-created
649
+ // child — and `ArchivalManager` resolves a workspace only when
650
+ // that field is set (`session/retention/archive.ts`). The one
651
+ // record that could have named the workspace said there was none,
652
+ // which is why the archival path could never act as a backstop
653
+ // for a leaked worktree.
654
+ //
655
+ // After `create` rather than in `createSubSession`, because the
656
+ // sub-session id is the workspace's label — the workspace cannot
657
+ // exist before the record it is named after. Inside the try, so
658
+ // the compensating rollback below covers it like every other
659
+ // mutation here.
660
+ subSession = { ...subSession, workspaceId: workspaceRef.id }
661
+ await store.updateSubSession(subSession, context.tenantId)
644
662
  }
645
663
  } catch (err) {
646
664
  // Compensating rollback order is mandated by the store's
@@ -764,9 +782,9 @@ export class AgentManager {
764
782
  )
765
783
  }
766
784
  } else {
767
- // Non-success: mark sub-session failed and, when we own a
768
- // workspace, dispose it. Dispose errors are logged but not
769
- // propagated the sub-session state is already persisted.
785
+ // Non-success: mark sub-session failed. Disposal is shared with
786
+ // the success branch below the workspace was provisioned for
787
+ // this child either way, and it is this manager that owns it.
770
788
  const subSession = await store.getSubSession(
771
789
  spawnRecord.subSessionId,
772
790
  spawnRecord.tenantId,
@@ -774,21 +792,25 @@ export class AgentManager {
774
792
  if (subSession) {
775
793
  await store.updateSubSession({ ...subSession, status: 'failed' }, spawnRecord.tenantId)
776
794
  }
777
- if (spawnRecord.workspaceRef) {
778
- const backend = spawnRecord.workspaceRef.meta.backend
779
- if (this.deps.workspaceRegistry.has(backend)) {
780
- await this.deps.workspaceRegistry
781
- .get(backend)
782
- .dispose(spawnRecord.workspaceRef)
783
- .catch((disposeErr) => {
784
- this.log.warn('Workspace dispose failed', {
785
- backend,
786
- error: toErrorMessage(disposeErr),
787
- })
788
- })
789
- }
790
- }
791
795
  }
796
+
797
+ // A child is done with its workspace however it ended.
798
+ //
799
+ // This used to live only in the branch above, so both dispose
800
+ // sites in this class were failure paths and a child that
801
+ // SUCCEEDED released nothing. `.namzu/worktrees/` then grew once
802
+ // per successful delegation — the more reliable the workers, the
803
+ // faster it filled, which is the opposite of the signal a leak
804
+ // usually gives.
805
+ //
806
+ // It runs after the summary is sealed and the sub-session flipped
807
+ // to `idle`, so nothing the terminalization path reads is gone
808
+ // before it reads it. It also runs BEFORE the `subsession_idled`
809
+ // emission below: a listener cannot reach into the workspace from
810
+ // that event. Stated rather than hedged — no consumer does today,
811
+ // and holding a worktree open for a hypothetical one is what this
812
+ // is fixing.
813
+ await this.disposeChildWorkspace(spawnRecord)
792
814
  } catch (err) {
793
815
  this.log.error('Sub-session finalization failed', {
794
816
  taskId: agentTask.taskId,
@@ -937,15 +959,38 @@ export class AgentManager {
937
959
  spawnRecord.tenantId,
938
960
  )
939
961
  }
940
- if (spawnRecord.workspaceRef) {
941
- const backend = spawnRecord.workspaceRef.meta.backend
942
- if (this.deps.workspaceRegistry.has(backend)) {
943
- await this.deps.workspaceRegistry
944
- .get(backend)
945
- .dispose(spawnRecord.workspaceRef)
946
- .catch(() => undefined)
947
- }
948
- }
962
+ await this.disposeChildWorkspace(spawnRecord)
963
+ }
964
+
965
+ /**
966
+ * Release the workspace this manager provisioned for a child.
967
+ *
968
+ * Called on every terminal path, success included. `has(backend)` before
969
+ * `get(backend)` because the registry is deny-by-default and throws on an
970
+ * unknown kind — a driver deregistered mid-run must not turn cleanup into
971
+ * an exception on a child that already finished.
972
+ *
973
+ * Never throws. Disposal is cleanup, not part of the child's result: the
974
+ * sub-session state is already persisted by the time this runs, and
975
+ * failing here would report a delegation that worked as one that did not.
976
+ * The failure is logged instead, because a worktree that could not be
977
+ * removed is an operator's problem and silence is how it stays one.
978
+ */
979
+ private async disposeChildWorkspace(spawnRecord: ChildSpawnRecord): Promise<void> {
980
+ if (!spawnRecord.workspaceRef) return
981
+ const backend = spawnRecord.workspaceRef.meta.backend
982
+ if (!this.deps.workspaceRegistry.has(backend)) return
983
+ await this.deps.workspaceRegistry
984
+ .get(backend)
985
+ .dispose(spawnRecord.workspaceRef)
986
+ .catch((disposeErr) => {
987
+ this.log.warn('Workspace dispose failed', {
988
+ backend,
989
+ workspaceId: spawnRecord.workspaceRef?.id,
990
+ subSessionId: spawnRecord.subSessionId,
991
+ error: toErrorMessage(disposeErr),
992
+ })
993
+ })
949
994
  }
950
995
 
951
996
  private markCanceled(taskId: TaskId): void {
@@ -483,6 +483,12 @@ export {
483
483
 
484
484
  export {
485
485
  AncestryCycleError,
486
+ // Exported with the CAS it announces. A host that opts into
487
+ // `expectedOwnerVersion` has to be able to tell "somebody else took this
488
+ // session" from any other failure, and string-matching a message is not a
489
+ // contract — which is the state `ThreadClosedError` and its siblings are
490
+ // still in, and a reason not to add a fourth.
491
+ StaleSessionError,
486
492
  TenantIsolationError,
487
493
  WorkspaceBackendError,
488
494
  } from './session/errors.js'
@@ -0,0 +1,270 @@
1
+ /**
2
+ * A worktree provisioned for a delegated child outlived the child that used it.
3
+ *
4
+ * `finalizeChild` had two dispose sites and both were failure paths — the
5
+ * non-success branch, and the rollback in `failSubSession`. The success branch
6
+ * disposed nothing, so `.namzu/worktrees/` grew once per successful delegation:
7
+ * the more reliable the workers, the faster it filled.
8
+ *
9
+ * The backstop could not fire either. `ArchivalManager` resolves a workspace
10
+ * only when `SubSession.workspaceId` is set, and for a spawn-created
11
+ * sub-session that field was written `null` and never updated —
12
+ * `provisionSpawn` kept the ref on the in-memory `ChildSpawnRecord` and nowhere
13
+ * else. So the one record that could have named the leaked worktree said there
14
+ * was none.
15
+ *
16
+ * Both halves are pinned here, and the failure path is re-asserted alongside
17
+ * them: a test that only counted disposals would pass on the old code by
18
+ * reading the failure branch's disposal and calling it the success branch's.
19
+ */
20
+
21
+ import { describe, expect, it } from 'vitest'
22
+ import { EMPTY_TOKEN_USAGE } from '../../../constants/limits.js'
23
+ import { AgentManager } from '../../../manager/agent/lifecycle.js'
24
+ import { ThreadManager } from '../../../manager/thread/lifecycle.js'
25
+ import { AgentRegistry } from '../../../registry/agent/definitions.js'
26
+ import { InMemorySessionStore } from '../../../store/session/memory.js'
27
+ import { InMemoryThreadStore } from '../../../store/thread/memory.js'
28
+ import type {
29
+ AgentCapabilities,
30
+ AgentInput,
31
+ BaseAgentConfig,
32
+ BaseAgentResult,
33
+ } from '../../../types/agent/base.js'
34
+ import type { Agent } from '../../../types/agent/core.js'
35
+ import type { AgentDefinition } from '../../../types/agent/factory.js'
36
+ import type { AgentTaskContext, SendMessageOptions } from '../../../types/agent/task.js'
37
+ import type { RunId, TenantId, UserId, WorkspaceId } from '../../../types/ids/index.js'
38
+ import { createAssistantMessage } from '../../../types/message/index.js'
39
+ import type { ActorRef } from '../../../types/session/actor.js'
40
+ import type { SummaryId } from '../../../types/session/ids.js'
41
+ import type { WorkspaceRef } from '../../../types/workspace/ref.js'
42
+ import { ZERO_COST } from '../../../utils/cost.js'
43
+ import { DefaultCapacityValidator } from '../../handoff/capacity.js'
44
+ import { SessionSummaryMaterializer } from '../../summary/materialize.js'
45
+ import type {
46
+ BranchWorkspaceParams,
47
+ CreateWorkspaceParams,
48
+ WorkspaceBackendDriver,
49
+ WorkspaceInspection,
50
+ } from '../../workspace/driver.js'
51
+ import { WorkspaceBackendRegistry } from '../../workspace/registry.js'
52
+
53
+ const tenant = 'tnt_alpha' as TenantId
54
+
55
+ const capabilities: AgentCapabilities = {
56
+ supportsTools: false,
57
+ supportsStreaming: false,
58
+ supportsConcurrency: false,
59
+ supportsSubAgents: false,
60
+ }
61
+
62
+ /** A child that settles the way `outcome` says, so both branches are reachable. */
63
+ function buildAgent(
64
+ id: string,
65
+ outcome: 'completed' | 'failed',
66
+ ): Agent<BaseAgentConfig, BaseAgentResult> {
67
+ return {
68
+ type: 'reactive',
69
+ metadata: {
70
+ type: 'reactive',
71
+ id,
72
+ name: id,
73
+ version: '1.0.0',
74
+ category: 'test',
75
+ description: id,
76
+ capabilities,
77
+ },
78
+ run: async (_input: AgentInput, _config: BaseAgentConfig): Promise<BaseAgentResult> => ({
79
+ runId: 'run_child' as RunId,
80
+ status: outcome,
81
+ usage: { ...EMPTY_TOKEN_USAGE },
82
+ cost: { ...ZERO_COST },
83
+ iterations: 1,
84
+ durationMs: 1,
85
+ messages: [createAssistantMessage('child did the work')],
86
+ result: 'child did the work',
87
+ }),
88
+ cancel: async () => undefined,
89
+ getCapabilities: () => capabilities,
90
+ }
91
+ }
92
+
93
+ function buildDefinition(agent: Agent<BaseAgentConfig, BaseAgentResult>): AgentDefinition {
94
+ return {
95
+ info: {
96
+ id: agent.metadata.id,
97
+ name: agent.metadata.name,
98
+ version: agent.metadata.version,
99
+ category: agent.metadata.category,
100
+ description: agent.metadata.description,
101
+ tools: [],
102
+ defaults: { model: 'test', tokenBudget: 1_000 },
103
+ },
104
+ typedAgent: agent,
105
+ }
106
+ }
107
+
108
+ /** Provisions successfully and records every ref it is asked to dispose. */
109
+ class RecordingWorkspaceDriver implements WorkspaceBackendDriver {
110
+ readonly kind = 'git-worktree' as const
111
+ readonly created: WorkspaceRef[] = []
112
+ readonly disposed: WorkspaceId[] = []
113
+ private counter = 0
114
+
115
+ async create(params: CreateWorkspaceParams): Promise<WorkspaceRef> {
116
+ const ref: WorkspaceRef = {
117
+ id: `wsp_test_${++this.counter}` as WorkspaceId,
118
+ meta: {
119
+ backend: 'git-worktree',
120
+ repoRoot: '/tmp/repo',
121
+ branch: `namzu/${params.label ?? 'unlabelled'}`,
122
+ worktreePath: `/tmp/repo/.namzu/worktrees/${params.label ?? 'unlabelled'}`,
123
+ },
124
+ createdAt: new Date(),
125
+ }
126
+ this.created.push(ref)
127
+ return ref
128
+ }
129
+
130
+ async branch(_source: WorkspaceRef, _params: BranchWorkspaceParams): Promise<WorkspaceRef> {
131
+ throw new Error('unused in this test')
132
+ }
133
+
134
+ async dispose(ref: WorkspaceRef): Promise<void> {
135
+ this.disposed.push(ref.id)
136
+ }
137
+
138
+ async inspect(_ref: WorkspaceRef): Promise<WorkspaceInspection> {
139
+ throw new Error('unused in this test')
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Stands up a Project → Thread → parent Session and an AgentManager wired to a
145
+ * recording workspace driver. `outcome` decides how the delegated child ends;
146
+ * `registerBackend: false` leaves the registry empty, which is the supported
147
+ * lazy-provisioning configuration rather than an error (pattern doc §7.1).
148
+ */
149
+ async function harness(
150
+ outcome: 'completed' | 'failed',
151
+ { registerBackend = true }: { registerBackend?: boolean } = {},
152
+ ) {
153
+ const store = new InMemorySessionStore()
154
+ const threadStore = new InMemoryThreadStore()
155
+ const project = await store.createProject({ tenantId: tenant, name: 'workspace-project' }, tenant)
156
+ const thread = await threadStore.createThread(
157
+ { projectId: project.id, title: 'workspace-topic' },
158
+ tenant,
159
+ )
160
+
161
+ const userActor: ActorRef = { kind: 'user', userId: 'usr_root' as UserId, tenantId: tenant }
162
+
163
+ const parentSession = await store.createSession(
164
+ { threadId: thread.id, projectId: project.id, currentActor: userActor },
165
+ tenant,
166
+ )
167
+ await store.updateSession({ ...parentSession, status: 'active' }, tenant)
168
+
169
+ let summaryCounter = 0
170
+ const materializer = new SessionSummaryMaterializer({
171
+ store,
172
+ generateSummaryId: () => `sum_test_${++summaryCounter}` as SummaryId,
173
+ })
174
+
175
+ const registry = new AgentRegistry()
176
+ registry.register(buildDefinition(buildAgent('worker', outcome)))
177
+
178
+ const workspaceRegistry = new WorkspaceBackendRegistry()
179
+ const driver = new RecordingWorkspaceDriver()
180
+ if (registerBackend) workspaceRegistry.register(driver)
181
+
182
+ const manager = new AgentManager(registry, undefined, {
183
+ sessionStore: store,
184
+ summaryMaterializer: materializer,
185
+ workspaceRegistry,
186
+ capacity: new DefaultCapacityValidator(store),
187
+ threadManager: new ThreadManager({ threadStore, sessionStore: store }),
188
+ })
189
+
190
+ const taskContext: AgentTaskContext = {
191
+ parentRunId: 'run_parent' as RunId,
192
+ parentAgentId: 'supervisor',
193
+ parentAbortController: new AbortController(),
194
+ depth: 0,
195
+ budgetTracker: { total: 100_000, remaining: 100_000 },
196
+ tenantId: tenant,
197
+ threadId: thread.id,
198
+ sessionId: parentSession.id,
199
+ projectId: project.id,
200
+ parentActor: userActor,
201
+ }
202
+
203
+ const options: SendMessageOptions = {
204
+ agentId: 'worker',
205
+ input: { messages: [], workingDirectory: '/tmp' },
206
+ parentSessionId: parentSession.id,
207
+ tenantId: tenant,
208
+ projectId: project.id,
209
+ parentActor: userActor,
210
+ workspaceBackend: 'git-worktree',
211
+ }
212
+
213
+ return { store, manager, driver, parentSession, options, taskContext }
214
+ }
215
+
216
+ describe('a delegated child does not outlive its workspace', () => {
217
+ it('disposes the workspace when the child SUCCEEDS', async () => {
218
+ const { manager, driver, options, taskContext } = await harness('completed')
219
+
220
+ const task = await manager.sendMessage(options, taskContext)
221
+ await manager.waitForCompletion(task.taskId)
222
+
223
+ expect(manager.getState(task.taskId)).toBe('completed')
224
+ expect(driver.created).toHaveLength(1)
225
+ // The assertion the leak fails: one workspace made, the same one released.
226
+ expect(driver.disposed).toEqual([driver.created[0]?.id])
227
+ })
228
+
229
+ it('still disposes the workspace when the child FAILS', async () => {
230
+ const { manager, driver, options, taskContext } = await harness('failed')
231
+
232
+ const task = await manager.sendMessage(options, taskContext)
233
+ await manager.waitForCompletion(task.taskId)
234
+
235
+ expect(driver.created).toHaveLength(1)
236
+ expect(driver.disposed).toEqual([driver.created[0]?.id])
237
+ })
238
+
239
+ it('records the workspace on the sub-session, so archival can find it', async () => {
240
+ const { store, manager, driver, parentSession, options, taskContext } =
241
+ await harness('completed')
242
+
243
+ const task = await manager.sendMessage(options, taskContext)
244
+ await manager.waitForCompletion(task.taskId)
245
+
246
+ const [subSession] = await store.getChildren(parentSession.id, tenant)
247
+ expect(subSession).toBeDefined()
248
+ // Was `null` on every spawn-created sub-session, which is what made
249
+ // `ArchivalManager`'s `sub.workspaceId &&` guard unreachable here.
250
+ expect(subSession?.workspaceId).toBe(driver.created[0]?.id)
251
+ })
252
+
253
+ it('leaves workspaceId null when no backend is registered', async () => {
254
+ // Lazy provisioning stays legal (pattern doc §7.1): an unregistered
255
+ // backend is not an error, and the record must not claim a workspace
256
+ // that was never made.
257
+ const { store, manager, driver, parentSession, options, taskContext } = await harness(
258
+ 'completed',
259
+ { registerBackend: false },
260
+ )
261
+
262
+ const task = await manager.sendMessage(options, taskContext)
263
+ await manager.waitForCompletion(task.taskId)
264
+
265
+ expect(manager.getState(task.taskId)).toBe('completed')
266
+ expect(driver.created).toHaveLength(0)
267
+ const [subSession] = await store.getChildren(parentSession.id, tenant)
268
+ expect(subSession?.workspaceId).toBeNull()
269
+ })
270
+ })
@@ -114,13 +114,22 @@ describe('Integration — single-recipient handoff E2E', () => {
114
114
  expect(reloaded?.previousActors).toHaveLength(1)
115
115
  expect(reloaded?.previousActors[0]).toEqual(sourceActor)
116
116
 
117
- // Wired assertion: updateSession was invoked at least with the lock
118
- // transition + the final commit. That sequence is what makes the
119
- // handoff "atomic at the store layer".
120
- // Expect at minimum locked (v0) followed by committed (v1).
117
+ // The lock MOVES the version, and this assertion used to say it did not.
118
+ //
119
+ // It expected `locked` at v0 — the version the source was already at —
120
+ // which is precisely why the lock was not a lock: a second handoff
121
+ // holding the same snapshot saw an unchanged `ownerVersion`, passed the
122
+ // check, and locked the session again. Both provisioned a worktree and
123
+ // one erased the other.
124
+ //
125
+ // The bump moved to the lock and the commit now keeps it, so a handoff
126
+ // still consumes exactly one version and `committedOwnerVersion` is
127
+ // unchanged at 1 — asserted above, and that is the host-visible
128
+ // contract. What changed is only the intermediate state, which is the
129
+ // state that had to become visible.
121
130
  expect(updateCalls).toEqual(
122
131
  expect.arrayContaining([
123
- { status: 'locked', ownerVersion: 0 },
132
+ { status: 'locked', ownerVersion: 1 },
124
133
  { status: 'idle', ownerVersion: 1 },
125
134
  ]),
126
135
  )
@@ -94,6 +94,35 @@ export class StaleThreadError extends Error {
94
94
  }
95
95
  }
96
96
 
97
+ /**
98
+ * Raised when a Session write names a version the store no longer has.
99
+ *
100
+ * The sibling of {@link StaleThreadError}, and it arrived much later: Thread
101
+ * had a working compare-and-set from the start while `Session.ownerVersion`
102
+ * was documented as a CAS counter that nothing enforced. Two concurrent
103
+ * handoffs could both pass, both provision a worktree, and one silently erase
104
+ * the other.
105
+ *
106
+ * `actualVersion` is what the store holds, not what the caller sent — the
107
+ * caller already knows what it sent, and the useful half of the answer is how
108
+ * far behind it is.
109
+ */
110
+ export class StaleSessionError extends Error {
111
+ readonly details: {
112
+ sessionId: SessionId
113
+ expectedVersion: number
114
+ actualVersion: number
115
+ }
116
+
117
+ constructor(details: { sessionId: SessionId; expectedVersion: number; actualVersion: number }) {
118
+ super(
119
+ `Stale Session ${details.sessionId}: expected ownerVersion=${details.expectedVersion}, actual=${details.actualVersion}. Another writer took ownership; re-read the session before retrying.`,
120
+ )
121
+ this.name = 'StaleSessionError'
122
+ this.details = details
123
+ }
124
+ }
125
+
97
126
  /**
98
127
  * Raised by the spawn path (and any caller that enforces the open-thread
99
128
  * precondition) when a Thread is in `'archived'` state and would-be mutations
@@ -211,8 +211,12 @@ export async function executeBroadcastHandoff(
211
211
  })
212
212
  }
213
213
 
214
- const locked: Session = { ...source, status: 'locked' }
215
- await deps.store.updateSession(locked, tenantId)
214
+ // The lock BUMPS the version see the same change in single.ts. Writing
215
+ // the lock at the version it read left the locked window invisible, so a
216
+ // second broadcast holding the same snapshot passed the check above and
217
+ // fanned out over a session that was already locked.
218
+ const locked: Session = { ...source, status: 'locked', ownerVersion: source.ownerVersion + 1 }
219
+ await deps.store.updateSession(locked, tenantId, first.expectedOwnerVersion)
216
220
  emit(deps.events.onLocked, { sessionId: source.id, at: new Date() })
217
221
 
218
222
  // 8. Fan-out provisioning. Track per-recipient partial state so rollback
@@ -269,12 +273,13 @@ export async function executeBroadcastHandoff(
269
273
 
270
274
  // 9. Commit source: `locked → awaiting_merge` (§5.4 — broadcast source is
271
275
  // not `idle` until all recipients terminalize; coordinator role).
276
+ // Keeps the lock's version: one handoff, one ownership change, one
277
+ // version. The bump moved to the lock where it is load-bearing.
272
278
  const committed: Session = {
273
- ...source,
279
+ ...locked,
274
280
  status: 'awaiting_merge',
275
- ownerVersion: source.ownerVersion + 1,
276
281
  }
277
- await deps.store.updateSession(committed, tenantId)
282
+ await deps.store.updateSession(committed, tenantId, locked.ownerVersion)
278
283
 
279
284
  emit(deps.events.onCommitted, {
280
285
  sessionId: source.id,
@@ -160,11 +160,24 @@ export async function executeSingleHandoff(
160
160
  })
161
161
  }
162
162
 
163
+ // The lock BUMPS the version, and that is what makes it a lock.
164
+ //
165
+ // It used to write `status: 'locked'` at the version it read, so a second
166
+ // handoff that had read the same snapshot saw an unchanged `ownerVersion`,
167
+ // passed the check above, and locked the session a second time. Both then
168
+ // provisioned a worktree and one silently erased the other. The comparison
169
+ // above was against a value nothing had moved.
170
+ //
171
+ // Passing `expectedOwnerVersion` makes the store the arbiter rather than
172
+ // this function: the check above is a fast fail on a stale snapshot, and
173
+ // this is the one that cannot be raced, because the store compares against
174
+ // what it holds at the instant of the write.
163
175
  const locked: Session = {
164
176
  ...source,
165
177
  status: 'locked',
178
+ ownerVersion: source.ownerVersion + 1,
166
179
  }
167
- await deps.store.updateSession(locked, tenantId)
180
+ await deps.store.updateSession(locked, tenantId, assignment.expectedOwnerVersion)
168
181
  emit(deps.events.onLocked, { sessionId: source.id, at: new Date() })
169
182
 
170
183
  // 6. Provision recipient resources. Track partial state for rollback.
@@ -197,16 +210,26 @@ export async function executeSingleHandoff(
197
210
  // 7. Commit source: `locked → idle` with appended actor + bumped version.
198
211
  // The source transitions ownership to the recipient — the previous
199
212
  // owner is permanently read-only (§6.1).
213
+ // Keeps the LOCK's version rather than taking a second one.
214
+ //
215
+ // One handoff is one ownership change, so it consumes one version. The
216
+ // bump moved to the lock — where it has to be, or the locked window is
217
+ // invisible to a concurrent reader — and the commit finishes the
218
+ // transition the lock reserved. A host reading `committedOwnerVersion`
219
+ // sees the same number it always did.
220
+ //
221
+ // The expected version is the lock's, so anything that wrote to this
222
+ // session between the lock and here loses to the store rather than
223
+ // being overwritten by us.
200
224
  const committed: Session = {
201
- ...source,
225
+ ...locked,
202
226
  status: 'idle',
203
227
  currentActor: assignment.recipientActor,
204
228
  previousActors: source.currentActor
205
229
  ? [...source.previousActors, source.currentActor]
206
230
  : [...source.previousActors],
207
- ownerVersion: source.ownerVersion + 1,
208
231
  }
209
- await deps.store.updateSession(committed, tenantId)
232
+ await deps.store.updateSession(committed, tenantId, locked.ownerVersion)
210
233
 
211
234
  emit(deps.events.onCommitted, {
212
235
  sessionId: source.id,
@@ -0,0 +1,116 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { StaleSessionError } from '../../../session/errors.js'
4
+ import type { TenantId } from '../../../types/ids/index.js'
5
+ import type { Session } from '../../../types/session/entity.js'
6
+ import { InMemorySessionStore } from '../memory.js'
7
+
8
+ /**
9
+ * `Session.ownerVersion` was documented as the CAS counter for handoff, and
10
+ * nothing enforced it.
11
+ *
12
+ * Both stores overwrote unconditionally, so the only check was in the handoff
13
+ * path — where it compared a snapshot read several awaits earlier against
14
+ * itself. Two concurrent handoffs on one idle session both passed, both
15
+ * provisioned a worktree, and one silently erased the other.
16
+ *
17
+ * The store is the arbiter now, because it is the only party that can compare
18
+ * against the value at the instant of the write.
19
+ */
20
+
21
+ const TENANT = 'tnt_cas' as TenantId
22
+
23
+ async function seed(): Promise<{ store: InMemorySessionStore; session: Session }> {
24
+ const store = new InMemorySessionStore()
25
+ const project = await store.createProject({ tenantId: TENANT, name: 'cas' }, TENANT)
26
+ const session = await store.createSession(
27
+ { threadId: 'thd_cas' as never, projectId: project.id, currentActor: null },
28
+ TENANT,
29
+ )
30
+ return { store, session }
31
+ }
32
+
33
+ describe('a session write can require that nobody else wrote first', () => {
34
+ it('accepts a write naming the version the store holds', async () => {
35
+ const { store, session } = await seed()
36
+
37
+ await store.updateSession({ ...session, status: 'locked' }, TENANT, session.ownerVersion)
38
+
39
+ expect((await store.getSession(session.id, TENANT))?.status).toBe('locked')
40
+ })
41
+
42
+ it('refuses a write naming a version that has moved on', async () => {
43
+ const { store, session } = await seed()
44
+
45
+ // Somebody else takes it first.
46
+ await store.updateSession({ ...session, ownerVersion: 1 }, TENANT, 0)
47
+
48
+ await expect(
49
+ store.updateSession({ ...session, status: 'locked' }, TENANT, 0),
50
+ ).rejects.toBeInstanceOf(StaleSessionError)
51
+ })
52
+
53
+ it('reports what the store holds, not what the caller sent', async () => {
54
+ // The caller already knows what it sent; the useful half of the answer
55
+ // is how far behind it is.
56
+ const { store, session } = await seed()
57
+ await store.updateSession({ ...session, ownerVersion: 7 }, TENANT, 0)
58
+
59
+ await store
60
+ .updateSession({ ...session, status: 'locked' }, TENANT, 3)
61
+ .then(() => expect.unreachable('the stale write should have been refused'))
62
+ .catch((err: StaleSessionError) => {
63
+ expect(err.details.expectedVersion).toBe(3)
64
+ expect(err.details.actualVersion).toBe(7)
65
+ })
66
+ })
67
+
68
+ it('compares against the STORED version, not the payload', async () => {
69
+ // The payload is the caller's own copy. Comparing it to itself is the
70
+ // check the handoff path was already making and getting nothing from,
71
+ // so a write whose payload agrees with itself must still be refused
72
+ // when the store has moved.
73
+ const { store, session } = await seed()
74
+ await store.updateSession({ ...session, ownerVersion: 5 }, TENANT, 0)
75
+
76
+ const selfConsistentButStale: Session = { ...session, ownerVersion: 2, status: 'locked' }
77
+
78
+ await expect(store.updateSession(selfConsistentButStale, TENANT, 2)).rejects.toBeInstanceOf(
79
+ StaleSessionError,
80
+ )
81
+ })
82
+
83
+ it('writes unconditionally when no version is named', async () => {
84
+ // The compatibility promise: every caller that existed before this
85
+ // parameter behaves exactly as it did.
86
+ const { store, session } = await seed()
87
+ await store.updateSession({ ...session, ownerVersion: 9 }, TENANT, 0)
88
+
89
+ await store.updateSession({ ...session, status: 'locked' }, TENANT)
90
+
91
+ expect((await store.getSession(session.id, TENANT))?.status).toBe('locked')
92
+ })
93
+
94
+ it('lets exactly one of two concurrent writers win', async () => {
95
+ // The defect, in the shape it actually took: two writers holding the
96
+ // same snapshot, racing for the same session.
97
+ const { store, session } = await seed()
98
+ const snapshot = session.ownerVersion
99
+
100
+ const results = await Promise.allSettled([
101
+ store.updateSession(
102
+ { ...session, status: 'locked', ownerVersion: snapshot + 1 },
103
+ TENANT,
104
+ snapshot,
105
+ ),
106
+ store.updateSession(
107
+ { ...session, status: 'locked', ownerVersion: snapshot + 1 },
108
+ TENANT,
109
+ snapshot,
110
+ ),
111
+ ])
112
+
113
+ expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1)
114
+ expect(results.filter((r) => r.status === 'rejected')).toHaveLength(1)
115
+ })
116
+ })