@namzu/sdk 12.2.0 → 13.1.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 (77) hide show
  1. package/CHANGELOG.md +199 -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/__tests__/an-env-reaches-the-child-it-was-set-for.test.d.ts +2 -0
  7. package/dist/manager/agent/__tests__/an-env-reaches-the-child-it-was-set-for.test.d.ts.map +1 -0
  8. package/dist/manager/agent/__tests__/an-env-reaches-the-child-it-was-set-for.test.js +159 -0
  9. package/dist/manager/agent/__tests__/an-env-reaches-the-child-it-was-set-for.test.js.map +1 -0
  10. package/dist/manager/agent/lifecycle.d.ts +15 -0
  11. package/dist/manager/agent/lifecycle.d.ts.map +1 -1
  12. package/dist/manager/agent/lifecycle.js +103 -26
  13. package/dist/manager/agent/lifecycle.js.map +1 -1
  14. package/dist/public-runtime.d.ts +1 -1
  15. package/dist/public-runtime.d.ts.map +1 -1
  16. package/dist/public-runtime.js +7 -1
  17. package/dist/public-runtime.js.map +1 -1
  18. package/dist/session/__tests__/integration/a-successful-delegation-disposes-its-workspace.test.d.ts +21 -0
  19. package/dist/session/__tests__/integration/a-successful-delegation-disposes-its-workspace.test.d.ts.map +1 -0
  20. package/dist/session/__tests__/integration/a-successful-delegation-disposes-its-workspace.test.js +205 -0
  21. package/dist/session/__tests__/integration/a-successful-delegation-disposes-its-workspace.test.js.map +1 -0
  22. package/dist/session/__tests__/integration/handoff-single-e2e.test.js +14 -5
  23. package/dist/session/__tests__/integration/handoff-single-e2e.test.js.map +1 -1
  24. package/dist/session/errors.d.ts +25 -0
  25. package/dist/session/errors.d.ts.map +1 -1
  26. package/dist/session/errors.js +21 -0
  27. package/dist/session/errors.js.map +1 -1
  28. package/dist/session/handoff/broadcast.d.ts.map +1 -1
  29. package/dist/session/handoff/broadcast.js +10 -5
  30. package/dist/session/handoff/broadcast.js.map +1 -1
  31. package/dist/session/handoff/single.d.ts.map +1 -1
  32. package/dist/session/handoff/single.js +27 -4
  33. package/dist/session/handoff/single.js.map +1 -1
  34. package/dist/store/session/__tests__/a-session-has-one-writer.test.d.ts +2 -0
  35. package/dist/store/session/__tests__/a-session-has-one-writer.test.d.ts.map +1 -0
  36. package/dist/store/session/__tests__/a-session-has-one-writer.test.js +79 -0
  37. package/dist/store/session/__tests__/a-session-has-one-writer.test.js.map +1 -0
  38. package/dist/store/session/__tests__/a-workspace-can-be-configured.test.d.ts +2 -0
  39. package/dist/store/session/__tests__/a-workspace-can-be-configured.test.d.ts.map +1 -0
  40. package/dist/store/session/__tests__/a-workspace-can-be-configured.test.js +149 -0
  41. package/dist/store/session/__tests__/a-workspace-can-be-configured.test.js.map +1 -0
  42. package/dist/store/session/disk.d.ts +4 -2
  43. package/dist/store/session/disk.d.ts.map +1 -1
  44. package/dist/store/session/disk.js +71 -4
  45. package/dist/store/session/disk.js.map +1 -1
  46. package/dist/store/session/memory.d.ts +4 -2
  47. package/dist/store/session/memory.d.ts.map +1 -1
  48. package/dist/store/session/memory.js +53 -4
  49. package/dist/store/session/memory.js.map +1 -1
  50. package/dist/tools/coordinator/agent.d.ts.map +1 -1
  51. package/dist/tools/coordinator/agent.js +7 -0
  52. package/dist/tools/coordinator/agent.js.map +1 -1
  53. package/dist/tools/coordinator/index.d.ts.map +1 -1
  54. package/dist/tools/coordinator/index.js +6 -0
  55. package/dist/tools/coordinator/index.js.map +1 -1
  56. package/dist/types/agent/base.d.ts +22 -0
  57. package/dist/types/agent/base.d.ts.map +1 -1
  58. package/dist/types/session/store.d.ts +80 -1
  59. package/dist/types/session/store.d.ts.map +1 -1
  60. package/package.json +1 -1
  61. package/src/bridge/a2a/__tests__/project-is-the-a2a-context.test.ts +66 -0
  62. package/src/manager/agent/__tests__/an-env-reaches-the-child-it-was-set-for.test.ts +196 -0
  63. package/src/manager/agent/lifecycle.ts +107 -26
  64. package/src/public-runtime.ts +6 -0
  65. package/src/session/__tests__/integration/a-successful-delegation-disposes-its-workspace.test.ts +270 -0
  66. package/src/session/__tests__/integration/handoff-single-e2e.test.ts +14 -5
  67. package/src/session/errors.ts +29 -0
  68. package/src/session/handoff/broadcast.ts +10 -5
  69. package/src/session/handoff/single.ts +27 -4
  70. package/src/store/session/__tests__/a-session-has-one-writer.test.ts +116 -0
  71. package/src/store/session/__tests__/a-workspace-can-be-configured.test.ts +210 -0
  72. package/src/store/session/disk.ts +84 -4
  73. package/src/store/session/memory.ts +66 -4
  74. package/src/tools/coordinator/agent.ts +7 -0
  75. package/src/tools/coordinator/index.ts +6 -0
  76. package/src/types/agent/base.ts +23 -0
  77. package/src/types/session/store.ts +88 -1
@@ -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
+ })
@@ -0,0 +1,210 @@
1
+ import { mkdtemp, rm } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { afterEach, describe, expect, it } from 'vitest'
5
+
6
+ import { TenantIsolationError } from '../../../session/errors.js'
7
+ import type { TenantId } from '../../../types/ids/index.js'
8
+ import type { SessionStore } from '../../../types/session/store.js'
9
+ import { DiskSessionStore } from '../disk.js'
10
+ import { InMemorySessionStore } from '../memory.js'
11
+
12
+ /**
13
+ * Every project in existence ran at depth 4 and width 8.
14
+ *
15
+ * The config was hardcoded identically in both stores, `CreateProjectParams`
16
+ * was `{tenantId, name}`, and there was no `updateProject`. So a tenant with
17
+ * several workspaces could not give them different limits — which is most of
18
+ * what having several workspaces is for.
19
+ *
20
+ * Only the two fields something READS are settable. `ProjectConfig` declares
21
+ * eight; five enforcement sites read two of them. Exposing the other six would
22
+ * make dead fields easier to set, and a host that configures a retention policy
23
+ * and gets no error believes retention is on. `maxInterventionDepth` looks like
24
+ * an exception and is not: its three apparent readers are all comments.
25
+ *
26
+ * Both stores are driven by the same cases, because a reference implementation
27
+ * that disagrees with the durable one is worse than having only one.
28
+ */
29
+
30
+ const TENANT = 'tnt_cfg' as TenantId
31
+ const OTHER = 'tnt_other' as TenantId
32
+
33
+ const dirs: string[] = []
34
+ afterEach(async () => {
35
+ await Promise.all(dirs.map((d) => rm(d, { recursive: true, force: true })))
36
+ dirs.length = 0
37
+ })
38
+
39
+ async function diskStore(): Promise<SessionStore> {
40
+ const rootDir = await mkdtemp(join(tmpdir(), 'namzu-cfg-'))
41
+ dirs.push(rootDir)
42
+ return new DiskSessionStore({ rootDir })
43
+ }
44
+
45
+ const IMPLEMENTATIONS: ReadonlyArray<readonly [string, () => Promise<SessionStore>]> = [
46
+ ['in memory', async () => new InMemorySessionStore()],
47
+ ['on disk', diskStore],
48
+ ]
49
+
50
+ describe.each(IMPLEMENTATIONS)('a workspace carries its own limits (%s)', (_name, build) => {
51
+ it('takes the limits it was created with', async () => {
52
+ const store = await build()
53
+
54
+ const project = await store.createProject(
55
+ { tenantId: TENANT, name: 'w', config: { maxDelegationDepth: 2, maxDelegationWidth: 3 } },
56
+ TENANT,
57
+ )
58
+
59
+ expect(project.config.maxDelegationDepth).toBe(2)
60
+ expect(project.config.maxDelegationWidth).toBe(3)
61
+ })
62
+
63
+ it('keeps the defaults for anything not given', async () => {
64
+ const store = await build()
65
+
66
+ const project = await store.createProject(
67
+ { tenantId: TENANT, name: 'w', config: { maxDelegationDepth: 2 } },
68
+ TENANT,
69
+ )
70
+
71
+ expect(project.config.maxDelegationDepth).toBe(2)
72
+ expect(project.config.maxDelegationWidth).toBe(8)
73
+ })
74
+
75
+ it('lets two workspaces of one tenant differ, which is the point', async () => {
76
+ const store = await build()
77
+
78
+ const narrow = await store.createProject(
79
+ { tenantId: TENANT, name: 'narrow', config: { maxDelegationWidth: 1 } },
80
+ TENANT,
81
+ )
82
+ const wide = await store.createProject(
83
+ { tenantId: TENANT, name: 'wide', config: { maxDelegationWidth: 16 } },
84
+ TENANT,
85
+ )
86
+
87
+ expect(narrow.config.maxDelegationWidth).toBe(1)
88
+ expect(wide.config.maxDelegationWidth).toBe(16)
89
+ })
90
+
91
+ it('changes a limit after the fact, and the change is readable back', async () => {
92
+ const store = await build()
93
+ const project = await store.createProject({ tenantId: TENANT, name: 'w' }, TENANT)
94
+
95
+ await store.updateProject?.(project.id, { maxDelegationWidth: 12 }, TENANT)
96
+
97
+ expect((await store.getProject(project.id, TENANT))?.config.maxDelegationWidth).toBe(12)
98
+ })
99
+
100
+ it('leaves a limit it was not asked to change', async () => {
101
+ // Per field, not whole-value: raising the width says nothing about the
102
+ // depth, and resetting it would be an answer to a question nobody asked.
103
+ const store = await build()
104
+ const project = await store.createProject(
105
+ { tenantId: TENANT, name: 'w', config: { maxDelegationDepth: 2 } },
106
+ TENANT,
107
+ )
108
+
109
+ await store.updateProject?.(project.id, { maxDelegationWidth: 12 }, TENANT)
110
+
111
+ const reloaded = await store.getProject(project.id, TENANT)
112
+ expect(reloaded?.config.maxDelegationDepth).toBe(2)
113
+ expect(reloaded?.config.maxDelegationWidth).toBe(12)
114
+ })
115
+
116
+ it('treats an explicitly undefined limit as "leave it", not "clear it"', async () => {
117
+ // The case the per-field guard exists for, and the one a plain spread
118
+ // gets wrong: `{...config}` with an explicit `undefined` key writes the
119
+ // undefined through and erases a limit the caller never mentioned. A
120
+ // caller building an update object programmatically produces exactly
121
+ // this shape.
122
+ const store = await build()
123
+ const project = await store.createProject(
124
+ { tenantId: TENANT, name: 'w', config: { maxDelegationDepth: 3 } },
125
+ TENANT,
126
+ )
127
+
128
+ await store.updateProject?.(
129
+ project.id,
130
+ { maxDelegationDepth: undefined, maxDelegationWidth: 12 },
131
+ TENANT,
132
+ )
133
+
134
+ const reloaded = await store.getProject(project.id, TENANT)
135
+ expect(reloaded?.config.maxDelegationDepth).toBe(3)
136
+ expect(reloaded?.config.maxDelegationWidth).toBe(12)
137
+ })
138
+
139
+ it('lists what this tenant owns, oldest first', async () => {
140
+ // Eight, spaced past the millisecond `createdAt` is measured in, so this
141
+ // is an assertion about age rather than about the tie-break below. With
142
+ // the sort dropped the disk store returns them in directory order, which
143
+ // is id-ascending — it matches creation order once in 8!.
144
+ const store = await build()
145
+ const created = []
146
+ for (const name of ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']) {
147
+ created.push(await store.createProject({ tenantId: TENANT, name }, TENANT))
148
+ await new Promise((resolve) => setTimeout(resolve, 2))
149
+ }
150
+
151
+ const listed = await store.listProjects?.(TENANT)
152
+
153
+ expect(listed?.map((p) => p.id)).toEqual(created.map((p) => p.id))
154
+ })
155
+
156
+ it('stays a total order when several are created in the same millisecond', async () => {
157
+ // CI found this and a slower machine could not: on a fast filesystem
158
+ // projects routinely share a `createdAt`, and "oldest first" alone left
159
+ // the rest to `readdir`. A caller paginating a listing that reorders
160
+ // under it sees the same project twice and never sees another.
161
+ const store = await build()
162
+ for (const name of ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']) {
163
+ await store.createProject({ tenantId: TENANT, name }, TENANT)
164
+ }
165
+
166
+ const listed = (await store.listProjects?.(TENANT)) ?? []
167
+
168
+ expect(listed).toHaveLength(8)
169
+ for (let i = 1; i < listed.length; i++) {
170
+ const before = listed[i - 1]
171
+ const after = listed[i]
172
+ if (before === undefined || after === undefined) throw new Error('short listing')
173
+ const olderFirst = before.createdAt.getTime() < after.createdAt.getTime()
174
+ const tieByName =
175
+ before.createdAt.getTime() === after.createdAt.getTime() && before.id < after.id
176
+ expect({ pair: [before.name, after.name], ordered: olderFirst || tieByName }).toEqual({
177
+ pair: [before.name, after.name],
178
+ ordered: true,
179
+ })
180
+ }
181
+ })
182
+
183
+ it('omits another tenant from the listing rather than refusing', async () => {
184
+ // A listing is a question about what you own. Refusing would confirm
185
+ // that somebody else's project is there, which is the leak the tenant
186
+ // boundary exists to prevent.
187
+ const store = await build()
188
+ await store.createProject({ tenantId: TENANT, name: 'mine' }, TENANT)
189
+ await store.createProject({ tenantId: OTHER, name: 'theirs' }, OTHER)
190
+
191
+ expect((await store.listProjects?.(TENANT))?.map((p) => p.name)).toEqual(['mine'])
192
+ })
193
+
194
+ it('refuses to reconfigure another tenant project', async () => {
195
+ // Reading a listing is a question; writing to somebody else's workspace
196
+ // is not, so this one throws.
197
+ const store = await build()
198
+ const theirs = await store.createProject({ tenantId: OTHER, name: 'theirs' }, OTHER)
199
+
200
+ await expect(
201
+ store.updateProject?.(theirs.id, { maxDelegationWidth: 99 }, TENANT),
202
+ ).rejects.toBeInstanceOf(TenantIsolationError)
203
+ })
204
+
205
+ it('returns null for a project that does not exist', async () => {
206
+ const store = await build()
207
+
208
+ expect(await store.updateProject?.('prj_missing' as never, {}, TENANT)).toBeNull()
209
+ })
210
+ })
@@ -23,7 +23,7 @@
23
23
 
24
24
  import { appendFile, mkdir, readFile, readdir, rm } from 'node:fs/promises'
25
25
  import { join } from 'node:path'
26
- import { TenantIsolationError } from '../../session/errors.js'
26
+ import { StaleSessionError, TenantIsolationError } from '../../session/errors.js'
27
27
  import { SessionAlreadySummarizedError } from '../../session/summary/errors.js'
28
28
  import type { MessageId, SessionId, TenantId } from '../../types/ids/index.js'
29
29
  import type { Message } from '../../types/message/index.js'
@@ -35,6 +35,7 @@ import type {
35
35
  CreateProjectParams,
36
36
  CreateSessionParams,
37
37
  CreateSubSessionParams,
38
+ ProjectConfigInput,
38
39
  SessionStore,
39
40
  SessionView,
40
41
  } from '../../types/session/store.js'
@@ -193,8 +194,8 @@ export class DiskSessionStore implements SessionStore {
193
194
  tenantId,
194
195
  name: params.name,
195
196
  config: {
196
- maxDelegationDepth: 4,
197
- maxDelegationWidth: 8,
197
+ maxDelegationDepth: params.config?.maxDelegationDepth ?? 4,
198
+ maxDelegationWidth: params.config?.maxDelegationWidth ?? 8,
198
199
  maxInterventionDepth: 10,
199
200
  },
200
201
  createdAt: now,
@@ -215,6 +216,66 @@ export class DiskSessionStore implements SessionStore {
215
216
  return deserializeProject(raw)
216
217
  }
217
218
 
219
+ async updateProject(
220
+ projectId: ProjectId,
221
+ config: ProjectConfigInput,
222
+ tenantId: TenantId,
223
+ ): Promise<Project | null> {
224
+ const existing = await this.getProject(projectId, tenantId)
225
+ if (!existing) return null
226
+ // Per field, like the in-memory store: an omitted limit is left alone
227
+ // rather than reset.
228
+ const project: Project = {
229
+ ...existing,
230
+ config: {
231
+ ...existing.config,
232
+ ...(config.maxDelegationDepth !== undefined
233
+ ? { maxDelegationDepth: config.maxDelegationDepth }
234
+ : {}),
235
+ ...(config.maxDelegationWidth !== undefined
236
+ ? { maxDelegationWidth: config.maxDelegationWidth }
237
+ : {}),
238
+ },
239
+ updatedAt: new Date(),
240
+ }
241
+ await atomicWriteJson(
242
+ join(this.projectDir(projectId), 'project.json'),
243
+ serializeProject(project),
244
+ )
245
+ return project
246
+ }
247
+
248
+ async listProjects(tenantId: TenantId): Promise<readonly Project[]> {
249
+ // Read from the directory rather than the lazily-built index: the index
250
+ // only knows about projects this instance has already touched, so a
251
+ // listing built from it would omit everything written by a previous
252
+ // process — which for a store whose whole point is durability is the
253
+ // wrong answer.
254
+ const projectsRoot = join(this.rootDir, 'projects')
255
+ let entries: string[]
256
+ try {
257
+ entries = await readdir(projectsRoot)
258
+ } catch {
259
+ return []
260
+ }
261
+
262
+ const found: Project[] = []
263
+ for (const entry of entries) {
264
+ const raw = await readJson<PersistedProject>(join(projectsRoot, entry, 'project.json'))
265
+ if (!raw) continue
266
+ // Another tenant's project is absent, not an error — a listing is a
267
+ // question about what you own, and refusing would leak that
268
+ // somebody else's project is there.
269
+ if (raw.tenantId !== tenantId) continue
270
+ found.push(deserializeProject(raw))
271
+ }
272
+ // Tie-broken by id — see the in-memory store. On a fast filesystem two
273
+ // projects share a millisecond routinely, and without this the order
274
+ // came from readdir.
275
+ found.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime() || a.id.localeCompare(b.id))
276
+ return found
277
+ }
278
+
218
279
  // Session CRUD ------------------------------------------------------------
219
280
 
220
281
  async createSession(params: CreateSessionParams, tenantId: TenantId): Promise<Session> {
@@ -308,7 +369,11 @@ export class DiskSessionStore implements SessionStore {
308
369
  return results
309
370
  }
310
371
 
311
- async updateSession(session: Session, tenantId: TenantId): Promise<void> {
372
+ async updateSession(
373
+ session: Session,
374
+ tenantId: TenantId,
375
+ expectedOwnerVersion?: number,
376
+ ): Promise<void> {
312
377
  const located = await this.locateSession(session.id)
313
378
  if (!located) {
314
379
  throw new Error(`Session ${session.id} not found`)
@@ -323,6 +388,21 @@ export class DiskSessionStore implements SessionStore {
323
388
  if (existing) {
324
389
  this.assertTenant(existing.tenantId, tenantId, `session(${session.id})`)
325
390
  }
391
+ // Against what is on disk, not against the payload. The write itself is
392
+ // atomic; this read-compare-write is NOT a critical section, so two
393
+ // processes can still both pass — see the contract note on
394
+ // `SessionStore.updateSession`. In one process it is the real lock.
395
+ if (
396
+ expectedOwnerVersion !== undefined &&
397
+ existing !== null &&
398
+ existing.ownerVersion !== expectedOwnerVersion
399
+ ) {
400
+ throw new StaleSessionError({
401
+ sessionId: session.id,
402
+ expectedVersion: expectedOwnerVersion,
403
+ actualVersion: existing.ownerVersion,
404
+ })
405
+ }
326
406
  const updated: Session = { ...session, updatedAt: new Date() }
327
407
  await atomicWriteJson(join(located.path, 'session.json'), serializeSession(updated))
328
408
  }
@@ -8,7 +8,7 @@
8
8
  * (Convention #5 deny-by-default, session-hierarchy.md §12.2).
9
9
  */
10
10
 
11
- import { TenantIsolationError } from '../../session/errors.js'
11
+ import { StaleSessionError, TenantIsolationError } from '../../session/errors.js'
12
12
  import { SessionAlreadySummarizedError } from '../../session/summary/errors.js'
13
13
  import type { MessageId, SessionId, TenantId } from '../../types/ids/index.js'
14
14
  import type { Message } from '../../types/message/index.js'
@@ -20,6 +20,7 @@ import type {
20
20
  CreateProjectParams,
21
21
  CreateSessionParams,
22
22
  CreateSubSessionParams,
23
+ ProjectConfigInput,
23
24
  SessionStore,
24
25
  SessionView,
25
26
  } from '../../types/session/store.js'
@@ -88,8 +89,8 @@ export class InMemorySessionStore implements SessionStore {
88
89
  tenantId,
89
90
  name: params.name,
90
91
  config: {
91
- maxDelegationDepth: 4,
92
- maxDelegationWidth: 8,
92
+ maxDelegationDepth: params.config?.maxDelegationDepth ?? 4,
93
+ maxDelegationWidth: params.config?.maxDelegationWidth ?? 8,
93
94
  maxInterventionDepth: 10,
94
95
  },
95
96
  createdAt: now,
@@ -106,6 +107,49 @@ export class InMemorySessionStore implements SessionStore {
106
107
  return record.project
107
108
  }
108
109
 
110
+ async updateProject(
111
+ projectId: ProjectId,
112
+ config: ProjectConfigInput,
113
+ tenantId: TenantId,
114
+ ): Promise<Project | null> {
115
+ const record = this.projects.get(projectId)
116
+ if (!record) return null
117
+ this.assertTenant(record.tenantId, tenantId, `project(${projectId})`)
118
+ // Per field: an omitted limit is left alone rather than reset, because a
119
+ // caller raising the width is saying nothing about the depth.
120
+ const project: Project = {
121
+ ...record.project,
122
+ config: {
123
+ ...record.project.config,
124
+ ...(config.maxDelegationDepth !== undefined
125
+ ? { maxDelegationDepth: config.maxDelegationDepth }
126
+ : {}),
127
+ ...(config.maxDelegationWidth !== undefined
128
+ ? { maxDelegationWidth: config.maxDelegationWidth }
129
+ : {}),
130
+ },
131
+ updatedAt: new Date(),
132
+ }
133
+ this.projects.set(projectId, { tenantId, project })
134
+ return project
135
+ }
136
+
137
+ async listProjects(tenantId: TenantId): Promise<readonly Project[]> {
138
+ const matches: Project[] = []
139
+ for (const record of this.projects.values()) {
140
+ if (record.tenantId !== tenantId) continue
141
+ matches.push(record.project)
142
+ }
143
+ // Tie-broken by id, because two projects created in the same
144
+ // millisecond otherwise fall back to insertion or directory order and
145
+ // "oldest first" stops being a total order. A caller paginating a
146
+ // listing that reorders under it sees items move between pages.
147
+ matches.sort(
148
+ (a, b) => a.createdAt.getTime() - b.createdAt.getTime() || a.id.localeCompare(b.id),
149
+ )
150
+ return matches
151
+ }
152
+
109
153
  // Session CRUD ------------------------------------------------------------
110
154
 
111
155
  async createSession(params: CreateSessionParams, tenantId: TenantId): Promise<Session> {
@@ -151,7 +195,11 @@ export class InMemorySessionStore implements SessionStore {
151
195
  return matches
152
196
  }
153
197
 
154
- async updateSession(session: Session, tenantId: TenantId): Promise<void> {
198
+ async updateSession(
199
+ session: Session,
200
+ tenantId: TenantId,
201
+ expectedOwnerVersion?: number,
202
+ ): Promise<void> {
155
203
  const record = this.sessions.get(session.id)
156
204
  if (!record) {
157
205
  throw new Error(`Session ${session.id} not found`)
@@ -163,6 +211,20 @@ export class InMemorySessionStore implements SessionStore {
163
211
  resource: `session(${session.id}) payload`,
164
212
  })
165
213
  }
214
+ // Compared against the STORED version, not against `session.ownerVersion`
215
+ // — the payload is the caller's copy, and comparing it to itself is
216
+ // precisely the check the handoff path was already making and getting
217
+ // nothing from.
218
+ if (
219
+ expectedOwnerVersion !== undefined &&
220
+ record.session.ownerVersion !== expectedOwnerVersion
221
+ ) {
222
+ throw new StaleSessionError({
223
+ sessionId: session.id,
224
+ expectedVersion: expectedOwnerVersion,
225
+ actualVersion: record.session.ownerVersion,
226
+ })
227
+ }
166
228
  this.sessions.set(session.id, { tenantId, session: { ...session, updatedAt: new Date() } })
167
229
  }
168
230