@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.
- package/CHANGELOG.md +152 -0
- package/dist/bridge/a2a/__tests__/project-is-the-a2a-context.test.d.ts +2 -0
- package/dist/bridge/a2a/__tests__/project-is-the-a2a-context.test.d.ts.map +1 -0
- package/dist/bridge/a2a/__tests__/project-is-the-a2a-context.test.js +55 -0
- package/dist/bridge/a2a/__tests__/project-is-the-a2a-context.test.js.map +1 -0
- package/dist/manager/agent/lifecycle.d.ts +15 -0
- package/dist/manager/agent/lifecycle.d.ts.map +1 -1
- package/dist/manager/agent/lifecycle.js +70 -26
- package/dist/manager/agent/lifecycle.js.map +1 -1
- package/dist/public-runtime.d.ts +1 -1
- package/dist/public-runtime.d.ts.map +1 -1
- package/dist/public-runtime.js +7 -1
- package/dist/public-runtime.js.map +1 -1
- package/dist/runtime/query/__tests__/the-other-approval-surface-names-the-agent.test.d.ts +2 -0
- package/dist/runtime/query/__tests__/the-other-approval-surface-names-the-agent.test.d.ts.map +1 -0
- package/dist/runtime/query/__tests__/the-other-approval-surface-names-the-agent.test.js +81 -0
- package/dist/runtime/query/__tests__/the-other-approval-surface-names-the-agent.test.js.map +1 -0
- package/dist/runtime/query/index.d.ts.map +1 -1
- package/dist/runtime/query/index.js +1 -0
- package/dist/runtime/query/index.js.map +1 -1
- package/dist/runtime/query/iteration/phases/plan.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/plan.js +1 -0
- package/dist/runtime/query/iteration/phases/plan.js.map +1 -1
- package/dist/session/__tests__/integration/a-successful-delegation-disposes-its-workspace.test.d.ts +21 -0
- package/dist/session/__tests__/integration/a-successful-delegation-disposes-its-workspace.test.d.ts.map +1 -0
- package/dist/session/__tests__/integration/a-successful-delegation-disposes-its-workspace.test.js +205 -0
- package/dist/session/__tests__/integration/a-successful-delegation-disposes-its-workspace.test.js.map +1 -0
- package/dist/session/__tests__/integration/handoff-single-e2e.test.js +14 -5
- package/dist/session/__tests__/integration/handoff-single-e2e.test.js.map +1 -1
- package/dist/session/errors.d.ts +25 -0
- package/dist/session/errors.d.ts.map +1 -1
- package/dist/session/errors.js +21 -0
- package/dist/session/errors.js.map +1 -1
- package/dist/session/handoff/broadcast.d.ts.map +1 -1
- package/dist/session/handoff/broadcast.js +10 -5
- package/dist/session/handoff/broadcast.js.map +1 -1
- package/dist/session/handoff/single.d.ts.map +1 -1
- package/dist/session/handoff/single.js +27 -4
- package/dist/session/handoff/single.js.map +1 -1
- package/dist/store/session/__tests__/a-session-has-one-writer.test.d.ts +2 -0
- package/dist/store/session/__tests__/a-session-has-one-writer.test.d.ts.map +1 -0
- package/dist/store/session/__tests__/a-session-has-one-writer.test.js +79 -0
- package/dist/store/session/__tests__/a-session-has-one-writer.test.js.map +1 -0
- package/dist/store/session/disk.d.ts +1 -1
- package/dist/store/session/disk.d.ts.map +1 -1
- package/dist/store/session/disk.js +15 -2
- package/dist/store/session/disk.js.map +1 -1
- package/dist/store/session/memory.d.ts +1 -1
- package/dist/store/session/memory.d.ts.map +1 -1
- package/dist/store/session/memory.js +14 -2
- package/dist/store/session/memory.js.map +1 -1
- package/dist/tools/coordinator/__tests__/an-approver-is-never-shown-an-impossible-step.test.d.ts +2 -0
- package/dist/tools/coordinator/__tests__/an-approver-is-never-shown-an-impossible-step.test.d.ts.map +1 -0
- package/dist/tools/coordinator/__tests__/an-approver-is-never-shown-an-impossible-step.test.js +115 -0
- package/dist/tools/coordinator/__tests__/an-approver-is-never-shown-an-impossible-step.test.js.map +1 -0
- package/dist/tools/coordinator/index.d.ts.map +1 -1
- package/dist/tools/coordinator/index.js +36 -0
- package/dist/tools/coordinator/index.js.map +1 -1
- package/dist/types/hitl/index.d.ts +16 -0
- package/dist/types/hitl/index.d.ts.map +1 -1
- package/dist/types/hitl/index.js.map +1 -1
- package/dist/types/session/store.d.ts +28 -1
- package/dist/types/session/store.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/bridge/a2a/__tests__/project-is-the-a2a-context.test.ts +66 -0
- package/src/manager/agent/lifecycle.ts +71 -26
- package/src/public-runtime.ts +6 -0
- package/src/runtime/query/__tests__/the-other-approval-surface-names-the-agent.test.ts +95 -0
- package/src/runtime/query/index.ts +1 -0
- package/src/runtime/query/iteration/phases/plan.ts +1 -0
- package/src/session/__tests__/integration/a-successful-delegation-disposes-its-workspace.test.ts +270 -0
- package/src/session/__tests__/integration/handoff-single-e2e.test.ts +14 -5
- package/src/session/errors.ts +29 -0
- package/src/session/handoff/broadcast.ts +10 -5
- package/src/session/handoff/single.ts +27 -4
- package/src/store/session/__tests__/a-session-has-one-writer.test.ts +116 -0
- package/src/store/session/disk.ts +21 -2
- package/src/store/session/memory.ts +20 -2
- package/src/tools/coordinator/__tests__/an-approver-is-never-shown-an-impossible-step.test.ts +153 -0
- package/src/tools/coordinator/index.ts +40 -0
- package/src/types/hitl/index.ts +18 -0
- 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
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
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:
|
|
132
|
+
{ status: 'locked', ownerVersion: 1 },
|
|
124
133
|
{ status: 'idle', ownerVersion: 1 },
|
|
125
134
|
]),
|
|
126
135
|
)
|
package/src/session/errors.ts
CHANGED
|
@@ -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
|
-
|
|
215
|
-
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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(
|
|
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(
|
|
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]
|
package/src/types/hitl/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|