@namzu/sdk 12.1.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 (82) hide show
  1. package/CHANGELOG.md +152 -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/runtime/query/__tests__/the-other-approval-surface-names-the-agent.test.d.ts +2 -0
  15. package/dist/runtime/query/__tests__/the-other-approval-surface-names-the-agent.test.d.ts.map +1 -0
  16. package/dist/runtime/query/__tests__/the-other-approval-surface-names-the-agent.test.js +81 -0
  17. package/dist/runtime/query/__tests__/the-other-approval-surface-names-the-agent.test.js.map +1 -0
  18. package/dist/runtime/query/index.d.ts.map +1 -1
  19. package/dist/runtime/query/index.js +1 -0
  20. package/dist/runtime/query/index.js.map +1 -1
  21. package/dist/runtime/query/iteration/phases/plan.d.ts.map +1 -1
  22. package/dist/runtime/query/iteration/phases/plan.js +1 -0
  23. package/dist/runtime/query/iteration/phases/plan.js.map +1 -1
  24. package/dist/session/__tests__/integration/a-successful-delegation-disposes-its-workspace.test.d.ts +21 -0
  25. package/dist/session/__tests__/integration/a-successful-delegation-disposes-its-workspace.test.d.ts.map +1 -0
  26. package/dist/session/__tests__/integration/a-successful-delegation-disposes-its-workspace.test.js +205 -0
  27. package/dist/session/__tests__/integration/a-successful-delegation-disposes-its-workspace.test.js.map +1 -0
  28. package/dist/session/__tests__/integration/handoff-single-e2e.test.js +14 -5
  29. package/dist/session/__tests__/integration/handoff-single-e2e.test.js.map +1 -1
  30. package/dist/session/errors.d.ts +25 -0
  31. package/dist/session/errors.d.ts.map +1 -1
  32. package/dist/session/errors.js +21 -0
  33. package/dist/session/errors.js.map +1 -1
  34. package/dist/session/handoff/broadcast.d.ts.map +1 -1
  35. package/dist/session/handoff/broadcast.js +10 -5
  36. package/dist/session/handoff/broadcast.js.map +1 -1
  37. package/dist/session/handoff/single.d.ts.map +1 -1
  38. package/dist/session/handoff/single.js +27 -4
  39. package/dist/session/handoff/single.js.map +1 -1
  40. package/dist/store/session/__tests__/a-session-has-one-writer.test.d.ts +2 -0
  41. package/dist/store/session/__tests__/a-session-has-one-writer.test.d.ts.map +1 -0
  42. package/dist/store/session/__tests__/a-session-has-one-writer.test.js +79 -0
  43. package/dist/store/session/__tests__/a-session-has-one-writer.test.js.map +1 -0
  44. package/dist/store/session/disk.d.ts +1 -1
  45. package/dist/store/session/disk.d.ts.map +1 -1
  46. package/dist/store/session/disk.js +15 -2
  47. package/dist/store/session/disk.js.map +1 -1
  48. package/dist/store/session/memory.d.ts +1 -1
  49. package/dist/store/session/memory.d.ts.map +1 -1
  50. package/dist/store/session/memory.js +14 -2
  51. package/dist/store/session/memory.js.map +1 -1
  52. package/dist/tools/coordinator/__tests__/an-approver-is-never-shown-an-impossible-step.test.d.ts +2 -0
  53. package/dist/tools/coordinator/__tests__/an-approver-is-never-shown-an-impossible-step.test.d.ts.map +1 -0
  54. package/dist/tools/coordinator/__tests__/an-approver-is-never-shown-an-impossible-step.test.js +115 -0
  55. package/dist/tools/coordinator/__tests__/an-approver-is-never-shown-an-impossible-step.test.js.map +1 -0
  56. package/dist/tools/coordinator/index.d.ts.map +1 -1
  57. package/dist/tools/coordinator/index.js +36 -0
  58. package/dist/tools/coordinator/index.js.map +1 -1
  59. package/dist/types/hitl/index.d.ts +16 -0
  60. package/dist/types/hitl/index.d.ts.map +1 -1
  61. package/dist/types/hitl/index.js.map +1 -1
  62. package/dist/types/session/store.d.ts +28 -1
  63. package/dist/types/session/store.d.ts.map +1 -1
  64. package/package.json +1 -1
  65. package/src/bridge/a2a/__tests__/project-is-the-a2a-context.test.ts +66 -0
  66. package/src/manager/agent/lifecycle.ts +71 -26
  67. package/src/public-runtime.ts +6 -0
  68. package/src/runtime/query/__tests__/the-other-approval-surface-names-the-agent.test.ts +95 -0
  69. package/src/runtime/query/index.ts +1 -0
  70. package/src/runtime/query/iteration/phases/plan.ts +1 -0
  71. package/src/session/__tests__/integration/a-successful-delegation-disposes-its-workspace.test.ts +270 -0
  72. package/src/session/__tests__/integration/handoff-single-e2e.test.ts +14 -5
  73. package/src/session/errors.ts +29 -0
  74. package/src/session/handoff/broadcast.ts +10 -5
  75. package/src/session/handoff/single.ts +27 -4
  76. package/src/store/session/__tests__/a-session-has-one-writer.test.ts +116 -0
  77. package/src/store/session/disk.ts +21 -2
  78. package/src/store/session/memory.ts +20 -2
  79. package/src/tools/coordinator/__tests__/an-approver-is-never-shown-an-impossible-step.test.ts +153 -0
  80. package/src/tools/coordinator/index.ts +40 -0
  81. package/src/types/hitl/index.ts +18 -0
  82. package/src/types/session/store.ts +28 -1
@@ -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
+ })
@@ -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'
@@ -308,7 +308,11 @@ export class DiskSessionStore implements SessionStore {
308
308
  return results
309
309
  }
310
310
 
311
- async updateSession(session: Session, tenantId: TenantId): Promise<void> {
311
+ async updateSession(
312
+ session: Session,
313
+ tenantId: TenantId,
314
+ expectedOwnerVersion?: number,
315
+ ): Promise<void> {
312
316
  const located = await this.locateSession(session.id)
313
317
  if (!located) {
314
318
  throw new Error(`Session ${session.id} not found`)
@@ -323,6 +327,21 @@ export class DiskSessionStore implements SessionStore {
323
327
  if (existing) {
324
328
  this.assertTenant(existing.tenantId, tenantId, `session(${session.id})`)
325
329
  }
330
+ // Against what is on disk, not against the payload. The write itself is
331
+ // atomic; this read-compare-write is NOT a critical section, so two
332
+ // processes can still both pass — see the contract note on
333
+ // `SessionStore.updateSession`. In one process it is the real lock.
334
+ if (
335
+ expectedOwnerVersion !== undefined &&
336
+ existing !== null &&
337
+ existing.ownerVersion !== expectedOwnerVersion
338
+ ) {
339
+ throw new StaleSessionError({
340
+ sessionId: session.id,
341
+ expectedVersion: expectedOwnerVersion,
342
+ actualVersion: existing.ownerVersion,
343
+ })
344
+ }
326
345
  const updated: Session = { ...session, updatedAt: new Date() }
327
346
  await atomicWriteJson(join(located.path, 'session.json'), serializeSession(updated))
328
347
  }
@@ -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'
@@ -151,7 +151,11 @@ export class InMemorySessionStore implements SessionStore {
151
151
  return matches
152
152
  }
153
153
 
154
- async updateSession(session: Session, tenantId: TenantId): Promise<void> {
154
+ async updateSession(
155
+ session: Session,
156
+ tenantId: TenantId,
157
+ expectedOwnerVersion?: number,
158
+ ): Promise<void> {
155
159
  const record = this.sessions.get(session.id)
156
160
  if (!record) {
157
161
  throw new Error(`Session ${session.id} not found`)
@@ -163,6 +167,20 @@ export class InMemorySessionStore implements SessionStore {
163
167
  resource: `session(${session.id}) payload`,
164
168
  })
165
169
  }
170
+ // Compared against the STORED version, not against `session.ownerVersion`
171
+ // — the payload is the caller's copy, and comparing it to itself is
172
+ // precisely the check the handoff path was already making and getting
173
+ // nothing from.
174
+ if (
175
+ expectedOwnerVersion !== undefined &&
176
+ record.session.ownerVersion !== expectedOwnerVersion
177
+ ) {
178
+ throw new StaleSessionError({
179
+ sessionId: session.id,
180
+ expectedVersion: expectedOwnerVersion,
181
+ actualVersion: record.session.ownerVersion,
182
+ })
183
+ }
166
184
  this.sessions.set(session.id, { tenantId, session: { ...session, updatedAt: new Date() } })
167
185
  }
168
186
 
@@ -0,0 +1,153 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { PlanManager } from '../../../manager/plan/lifecycle.js'
4
+ import type { TaskGateway } from '../../../types/agent/gateway.js'
5
+ import type { RunId } from '../../../types/ids/index.js'
6
+ import type { PlanApprovalRequest } from '../../../types/plan/index.js'
7
+ import type { ToolContext } from '../../../types/tool/index.js'
8
+ import { buildCoordinatorTools } from '../index.js'
9
+
10
+ /**
11
+ * A plan could name an agent the launch would then refuse.
12
+ *
13
+ * `create_task` constrains `agent_id` with a closed enum; `approve_plan` typed
14
+ * the same field as a bare string. So a model could propose, and a human could
15
+ * approve, "delegate to X" for an X that `create_task` rejects at schema-parse
16
+ * time — the run then burns a turn on a step a human had already blessed.
17
+ *
18
+ * The check lives in `execute`, not in the schema, and that is deliberate.
19
+ * `approve_plan` is mounted even with an EMPTY roster, because planning with no
20
+ * delegates and a human channel is a supported configuration — and `z.enum([])`
21
+ * renders as `{"not":{}}`, the shape `delegateSchema` already refuses because a
22
+ * strict tool-schema validator rejects the whole request over it rather than
23
+ * the one tool. `create_task` escapes that by being withheld entirely; this
24
+ * tool cannot be. Enforcing in `execute` as well is the precedent the canonical
25
+ * `Agent` tool set for complete mediation.
26
+ *
27
+ * It runs BEFORE `startGenerating`, so the refusal costs no half-built plan and
28
+ * the human is never shown the bad step at all.
29
+ */
30
+
31
+ const RUN = 'run_roster' as RunId
32
+
33
+ function unusedGateway(): TaskGateway {
34
+ return {
35
+ async createTask() {
36
+ throw new Error('this test never launches')
37
+ },
38
+ async waitForTask() {
39
+ throw new Error('this test never waits')
40
+ },
41
+ async continueTask() {},
42
+ cancelTask() {},
43
+ getTask() {
44
+ return undefined
45
+ },
46
+ listTasks() {
47
+ return []
48
+ },
49
+ onTaskCompleted() {
50
+ return () => {}
51
+ },
52
+ }
53
+ }
54
+
55
+ function ctx(): ToolContext {
56
+ return {
57
+ runId: RUN,
58
+ workingDirectory: '/tmp/test',
59
+ abortSignal: new AbortController().signal,
60
+ env: {},
61
+ log: () => {},
62
+ }
63
+ }
64
+
65
+ /** Run `approve_plan`, reporting both its result and what the approver saw. */
66
+ async function approve(
67
+ roster: string[],
68
+ steps: Array<{ description: string; agent_id?: string }>,
69
+ ): Promise<{ result: Awaited<ReturnType<ReturnType<typeof build>>>; seen?: PlanApprovalRequest }> {
70
+ let seen: PlanApprovalRequest | undefined
71
+ const pm = new PlanManager(RUN, async (request) => {
72
+ seen = request
73
+ return { approved: true }
74
+ })
75
+ const run = build(roster, pm)
76
+ const result = await run(steps)
77
+ return { result, ...(seen ? { seen } : {}) }
78
+ }
79
+
80
+ function build(roster: string[], pm: PlanManager) {
81
+ const tools = buildCoordinatorTools({
82
+ gateway: unusedGateway(),
83
+ workingDirectory: '/tmp/test',
84
+ allowedAgentIds: roster,
85
+ getPlanManager: () => pm,
86
+ })
87
+ const approvePlan = tools.find((t) => t.name === 'approve_plan')
88
+ if (!approvePlan) throw new Error('approve_plan missing from coordinator builder')
89
+ return (steps: Array<{ description: string; agent_id?: string }>) =>
90
+ approvePlan.execute({ title: 'do it', summary: 'a plan', steps }, ctx())
91
+ }
92
+
93
+ describe('a plan may only name an agent the run can actually launch', () => {
94
+ it('refuses a step naming an agent outside the roster', async () => {
95
+ const { result } = await approve(
96
+ ['researcher', 'writer'],
97
+ [{ description: 'audit the deps', agent_id: 'security-auditor' }],
98
+ )
99
+
100
+ expect(result.success).toBe(false)
101
+ expect(result.error).toContain('security-auditor')
102
+ })
103
+
104
+ it('names the roster, so the model can correct itself in one turn', async () => {
105
+ const { result } = await approve(
106
+ ['researcher', 'writer'],
107
+ [{ description: 'audit', agent_id: 'nobody' }],
108
+ )
109
+
110
+ expect(result.error).toContain('researcher')
111
+ expect(result.error).toContain('writer')
112
+ })
113
+
114
+ it('never shows the approver the step it refused', async () => {
115
+ // The whole point of checking before `startGenerating`: a human must
116
+ // not be asked to approve work that cannot run.
117
+ const { seen } = await approve(
118
+ ['researcher'],
119
+ [{ description: 'audit', agent_id: 'security-auditor' }],
120
+ )
121
+
122
+ expect(seen).toBeUndefined()
123
+ })
124
+
125
+ it('leaves no half-built plan behind', async () => {
126
+ const pm = new PlanManager(RUN, async () => ({ approved: true }))
127
+ const run = build(['researcher'], pm)
128
+
129
+ await run([{ description: 'audit', agent_id: 'ghost' }])
130
+
131
+ expect(pm.active).toBeNull()
132
+ })
133
+
134
+ it('says something different when the run has no delegates at all', async () => {
135
+ // `approve_plan` is mounted on an empty roster on purpose — planning
136
+ // without delegation is supported — so the message has to explain that
137
+ // rather than list an empty set.
138
+ const { result } = await approve([], [{ description: 'audit', agent_id: 'anyone' }])
139
+
140
+ expect(result.success).toBe(false)
141
+ expect(result.error).toContain('no delegates')
142
+ })
143
+
144
+ it('still admits a plan that delegates only within the roster', async () => {
145
+ const { result, seen } = await approve(
146
+ ['researcher', 'writer'],
147
+ [{ description: 'gather', agent_id: 'researcher' }, { description: 'summarize' }],
148
+ )
149
+
150
+ expect(result.success).toBe(true)
151
+ expect(seen?.steps.map((s) => s.agentId)).toEqual(['researcher', undefined])
152
+ })
153
+ })
@@ -1015,6 +1015,46 @@ export function buildCoordinatorTools(opts: CoordinatorToolsOptions): ToolDefini
1015
1015
  return { success: false, output: '', error: dependencies.error }
1016
1016
  }
1017
1017
 
1018
+ // The roster, checked here rather than in the schema, and BEFORE
1019
+ // the plan is built — so a human is never shown a step naming an
1020
+ // agent that cannot run it.
1021
+ //
1022
+ // `create_task` constrains the same field with a closed enum, so
1023
+ // a plan could name an agent the launch would then refuse. The
1024
+ // mismatch used to be invisible because the name was dropped on
1025
+ // the way to the approver; now that a step carries it, an
1026
+ // approver could read "delegate to X" for an X that does not
1027
+ // exist.
1028
+ //
1029
+ // NOT closed in the schema, deliberately. `approve_plan` is
1030
+ // mounted even with an empty roster — planning with no delegates
1031
+ // and a human channel is a supported configuration — and
1032
+ // `z.enum([])` renders as `{"not":{}}`, the shape `delegateSchema`
1033
+ // already refuses because a strict tool-schema validator rejects
1034
+ // the whole request over it rather than the one tool.
1035
+ // `create_task` escapes that by being withheld entirely; this
1036
+ // tool cannot be.
1037
+ //
1038
+ // Enforcing in `execute` as well as the schema is the precedent
1039
+ // the canonical `Agent` tool set for complete mediation.
1040
+ const unknownAgents = [
1041
+ ...new Set(
1042
+ steps
1043
+ .map((s) => s.agent_id)
1044
+ .filter((id): id is string => Boolean(id) && !agentIds.includes(id as string)),
1045
+ ),
1046
+ ]
1047
+ if (unknownAgents.length > 0) {
1048
+ return {
1049
+ success: false,
1050
+ output: '',
1051
+ error:
1052
+ agentIds.length === 0
1053
+ ? `This plan delegates to ${unknownAgents.join(', ')}, but this run has no delegates. Plan the work as your own steps and omit agent_id.`
1054
+ : `No such agent: ${unknownAgents.join(', ')}. Delegate only to ${agentIds.join(', ')}, or omit agent_id for a step you carry out yourself.`,
1055
+ }
1056
+ }
1057
+
1018
1058
  pm.startGenerating(title)
1019
1059
  for (let i = 0; i < steps.length; i++) {
1020
1060
  const step = steps[i]
@@ -109,6 +109,24 @@ export interface PlanApprovalData {
109
109
  id: string
110
110
  description: string
111
111
  toolName?: string
112
+
113
+ /**
114
+ * Which agent the step is to be delegated to, when it is delegated.
115
+ *
116
+ * `PlanStep` gained this so an approver could see WHICH agent a step
117
+ * goes to rather than only THAT it delegates — approving "delegate
118
+ * this" is not approving "delegate this to the agent with shell
119
+ * access". It reached `PlanApprovalRequest`, which is the shape a host
120
+ * sees when it installs its own handler on `PlanManager`.
121
+ *
122
+ * It did not reach here, and this is the ordinary path: every host
123
+ * using `resumeHandler` is served by this type, and both mappers that
124
+ * build it copy field by field. So the fix landed on one of the two
125
+ * approval surfaces and the busier one kept showing
126
+ * `toolName: 'create_task'` and nothing else.
127
+ */
128
+ agentId?: string
129
+
112
130
  dependsOn: string[]
113
131
  order: number
114
132
  }>
@@ -105,7 +105,34 @@ export interface SessionStore {
105
105
 
106
106
  getSession(sessionId: SessionId, tenantId: TenantId): Promise<Session | null>
107
107
 
108
- updateSession(session: Session, tenantId: TenantId): Promise<void>
108
+ /**
109
+ * Write a Session back, optionally only if nobody else wrote it first.
110
+ *
111
+ * **`expectedOwnerVersion` is the single-writer lock this level is supposed
112
+ * to own, and it did not exist.** `Session.ownerVersion` is documented as
113
+ * the CAS counter for handoff, but nothing enforced it: both stores
114
+ * overwrote unconditionally, and the handoff's own check compared a
115
+ * snapshot it had read several awaits earlier against itself. Two
116
+ * concurrent handoffs on one idle session both passed, both provisioned a
117
+ * worktree, and one silently erased the other.
118
+ *
119
+ * Supply it and the store compares against the version it HAS STORED —
120
+ * not against the payload, which is the caller's stale copy — and throws
121
+ * {@link StaleSessionError} rather than writing. Omit it and the behaviour
122
+ * is exactly what it always was, which is the compatibility promise: this
123
+ * parameter is optional so that widening the interface stays invisible to
124
+ * callers and harmless to hosts implementing their own store. A required
125
+ * parameter would break every implementor for a guarantee they can opt
126
+ * into.
127
+ *
128
+ * **In-process only, stated rather than implied.** `DiskSessionStore`
129
+ * writes atomically, but its read-compare-write is not a critical section,
130
+ * so two PROCESSES can still both pass the check. Closing that needs a
131
+ * lease with an expiry — not a PID registry, because a Session is durable
132
+ * and written from hosts where a PID is not a checkable fact. The same
133
+ * honesty the spawn lock already carries.
134
+ */
135
+ updateSession(session: Session, tenantId: TenantId, expectedOwnerVersion?: number): Promise<void>
109
136
 
110
137
  /**
111
138
  * List every Session that belongs to the given Thread for the caller's