@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.
- package/CHANGELOG.md +199 -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/__tests__/an-env-reaches-the-child-it-was-set-for.test.d.ts +2 -0
- package/dist/manager/agent/__tests__/an-env-reaches-the-child-it-was-set-for.test.d.ts.map +1 -0
- package/dist/manager/agent/__tests__/an-env-reaches-the-child-it-was-set-for.test.js +159 -0
- package/dist/manager/agent/__tests__/an-env-reaches-the-child-it-was-set-for.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 +103 -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/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/__tests__/a-workspace-can-be-configured.test.d.ts +2 -0
- package/dist/store/session/__tests__/a-workspace-can-be-configured.test.d.ts.map +1 -0
- package/dist/store/session/__tests__/a-workspace-can-be-configured.test.js +149 -0
- package/dist/store/session/__tests__/a-workspace-can-be-configured.test.js.map +1 -0
- package/dist/store/session/disk.d.ts +4 -2
- package/dist/store/session/disk.d.ts.map +1 -1
- package/dist/store/session/disk.js +71 -4
- package/dist/store/session/disk.js.map +1 -1
- package/dist/store/session/memory.d.ts +4 -2
- package/dist/store/session/memory.d.ts.map +1 -1
- package/dist/store/session/memory.js +53 -4
- package/dist/store/session/memory.js.map +1 -1
- package/dist/tools/coordinator/agent.d.ts.map +1 -1
- package/dist/tools/coordinator/agent.js +7 -0
- package/dist/tools/coordinator/agent.js.map +1 -1
- package/dist/tools/coordinator/index.d.ts.map +1 -1
- package/dist/tools/coordinator/index.js +6 -0
- package/dist/tools/coordinator/index.js.map +1 -1
- package/dist/types/agent/base.d.ts +22 -0
- package/dist/types/agent/base.d.ts.map +1 -1
- package/dist/types/session/store.d.ts +80 -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/__tests__/an-env-reaches-the-child-it-was-set-for.test.ts +196 -0
- package/src/manager/agent/lifecycle.ts +107 -26
- package/src/public-runtime.ts +6 -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/__tests__/a-workspace-can-be-configured.test.ts +210 -0
- package/src/store/session/disk.ts +84 -4
- package/src/store/session/memory.ts +66 -4
- package/src/tools/coordinator/agent.ts +7 -0
- package/src/tools/coordinator/index.ts +6 -0
- package/src/types/agent/base.ts +23 -0
- package/src/types/session/store.ts +88 -1
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { EMPTY_TOKEN_USAGE } from '../../../constants/limits.js'
|
|
4
|
+
import { AgentRegistry } from '../../../registry/agent/definitions.js'
|
|
5
|
+
import { DefaultCapacityValidator } from '../../../session/handoff/capacity.js'
|
|
6
|
+
import { SessionSummaryMaterializer } from '../../../session/summary/materialize.js'
|
|
7
|
+
import { WorkspaceBackendRegistry } from '../../../session/workspace/registry.js'
|
|
8
|
+
import { InMemorySessionStore } from '../../../store/session/memory.js'
|
|
9
|
+
import { InMemoryThreadStore } from '../../../store/thread/memory.js'
|
|
10
|
+
import type { BaseAgentConfig, BaseAgentResult } from '../../../types/agent/base.js'
|
|
11
|
+
import type { Agent } from '../../../types/agent/core.js'
|
|
12
|
+
import type { AgentDefinition } from '../../../types/agent/factory.js'
|
|
13
|
+
import type { AgentTaskContext } from '../../../types/agent/task.js'
|
|
14
|
+
import type { AgentId, TenantId } from '../../../types/ids/index.js'
|
|
15
|
+
import type { SummaryId } from '../../../types/session/ids.js'
|
|
16
|
+
import { ZERO_COST } from '../../../utils/cost.js'
|
|
17
|
+
import { ThreadManager } from '../../thread/lifecycle.js'
|
|
18
|
+
import { AgentManager } from '../lifecycle.js'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A delegate registered the normal way inherited no environment at all.
|
|
22
|
+
*
|
|
23
|
+
* `AgentManager` builds a child's config on two branches. The bare-config
|
|
24
|
+
* branch — taken only when a definition has NO `configBuilder` — has always
|
|
25
|
+
* carried `env`. The `configBuilder` branch, which is what a host registering
|
|
26
|
+
* a real agent actually uses, never stamped it. So a run given an environment
|
|
27
|
+
* handed its delegates none of it.
|
|
28
|
+
*
|
|
29
|
+
* This is the third field to go the same way: `parentSpan` and `resumeHandler`
|
|
30
|
+
* are both stamped after the builder returns, each with a comment saying the
|
|
31
|
+
* builder is written by whoever registered the agent and cannot be expected to
|
|
32
|
+
* forward something it was never told about. `env` was missed, and it went
|
|
33
|
+
* unnoticed because a missing environment does not fail — the child just runs
|
|
34
|
+
* somewhere else.
|
|
35
|
+
*
|
|
36
|
+
* These drive the real `AgentManager` rather than re-implementing the merge.
|
|
37
|
+
* A test that restates the logic proves the logic agrees with itself; what has
|
|
38
|
+
* to hold is that the manager applies it.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
const TENANT = 'tnt_env' as TenantId
|
|
42
|
+
|
|
43
|
+
/** Records the config it is run with, so the assertion is on what shipped. */
|
|
44
|
+
function recordingAgent(seen: { config?: BaseAgentConfig }) {
|
|
45
|
+
return {
|
|
46
|
+
type: 'reactive',
|
|
47
|
+
metadata: {
|
|
48
|
+
id: 'worker',
|
|
49
|
+
name: 'worker',
|
|
50
|
+
version: '1.0.0',
|
|
51
|
+
category: 'general',
|
|
52
|
+
description: 'records its config',
|
|
53
|
+
type: 'reactive',
|
|
54
|
+
capabilities: {},
|
|
55
|
+
},
|
|
56
|
+
async run(_input: unknown, config: BaseAgentConfig): Promise<BaseAgentResult> {
|
|
57
|
+
seen.config = config
|
|
58
|
+
return {
|
|
59
|
+
runId: 'run_child',
|
|
60
|
+
status: 'completed',
|
|
61
|
+
result: 'ok',
|
|
62
|
+
usage: { ...EMPTY_TOKEN_USAGE },
|
|
63
|
+
cost: { ...ZERO_COST },
|
|
64
|
+
iterations: 1,
|
|
65
|
+
durationMs: 0,
|
|
66
|
+
messages: [],
|
|
67
|
+
} as BaseAgentResult
|
|
68
|
+
},
|
|
69
|
+
async cancel() {},
|
|
70
|
+
getCapabilities() {
|
|
71
|
+
return {} as never
|
|
72
|
+
},
|
|
73
|
+
} as unknown as Agent<BaseAgentConfig, BaseAgentResult>
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A definition WITH a configBuilder — the branch that dropped `env`. */
|
|
77
|
+
function definitionWithBuilder(
|
|
78
|
+
agent: Agent<BaseAgentConfig, BaseAgentResult>,
|
|
79
|
+
builderEnv?: Record<string, string>,
|
|
80
|
+
): AgentDefinition {
|
|
81
|
+
return {
|
|
82
|
+
info: {
|
|
83
|
+
id: 'worker',
|
|
84
|
+
name: 'worker',
|
|
85
|
+
version: '1.0.0',
|
|
86
|
+
category: 'general',
|
|
87
|
+
description: 'a worker',
|
|
88
|
+
tools: [],
|
|
89
|
+
defaults: { model: 'test', tokenBudget: 1_000 },
|
|
90
|
+
},
|
|
91
|
+
typedAgent: agent,
|
|
92
|
+
configBuilder: () =>
|
|
93
|
+
({
|
|
94
|
+
model: 'test',
|
|
95
|
+
tokenBudget: 1_000,
|
|
96
|
+
timeoutMs: 10_000,
|
|
97
|
+
...(builderEnv ? { env: builderEnv } : {}),
|
|
98
|
+
}) as BaseAgentConfig,
|
|
99
|
+
} as AgentDefinition
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function spawnWith(options: {
|
|
103
|
+
builderEnv?: Record<string, string>
|
|
104
|
+
overrideEnv?: Record<string, string>
|
|
105
|
+
}): Promise<BaseAgentConfig | undefined> {
|
|
106
|
+
const seen: { config?: BaseAgentConfig } = {}
|
|
107
|
+
const store = new InMemorySessionStore()
|
|
108
|
+
const threadStore = new InMemoryThreadStore()
|
|
109
|
+
const project = await store.createProject({ tenantId: TENANT, name: 'p' }, TENANT)
|
|
110
|
+
const thread = await threadStore.createThread({ projectId: project.id, title: 't' }, TENANT)
|
|
111
|
+
const parentActor = { kind: 'agent', agentId: 'sup' as AgentId, tenantId: TENANT } as const
|
|
112
|
+
const parentSession = await store.createSession(
|
|
113
|
+
{ threadId: thread.id, projectId: project.id, currentActor: parentActor },
|
|
114
|
+
TENANT,
|
|
115
|
+
)
|
|
116
|
+
await store.updateSession({ ...parentSession, status: 'active' }, TENANT)
|
|
117
|
+
|
|
118
|
+
const registry = new AgentRegistry()
|
|
119
|
+
registry.register(definitionWithBuilder(recordingAgent(seen), options.builderEnv))
|
|
120
|
+
|
|
121
|
+
let n = 0
|
|
122
|
+
const manager = new AgentManager(registry, undefined, {
|
|
123
|
+
sessionStore: store,
|
|
124
|
+
threadManager: new ThreadManager({ threadStore, sessionStore: store }),
|
|
125
|
+
workspaceRegistry: new WorkspaceBackendRegistry(),
|
|
126
|
+
capacity: new DefaultCapacityValidator(store),
|
|
127
|
+
summaryMaterializer: new SessionSummaryMaterializer({
|
|
128
|
+
store,
|
|
129
|
+
generateSummaryId: () => `sum_${++n}` as SummaryId,
|
|
130
|
+
}),
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
const context: AgentTaskContext = {
|
|
134
|
+
parentRunId: 'run_parent' as never,
|
|
135
|
+
parentAgentId: 'sup',
|
|
136
|
+
parentAbortController: new AbortController(),
|
|
137
|
+
depth: 0,
|
|
138
|
+
budgetTracker: { total: 100_000, remaining: 100_000 },
|
|
139
|
+
tenantId: TENANT,
|
|
140
|
+
threadId: thread.id,
|
|
141
|
+
sessionId: parentSession.id,
|
|
142
|
+
projectId: project.id,
|
|
143
|
+
parentActor,
|
|
144
|
+
} as AgentTaskContext
|
|
145
|
+
|
|
146
|
+
const task = await manager.sendMessage(
|
|
147
|
+
{
|
|
148
|
+
agentId: 'worker',
|
|
149
|
+
input: { messages: [], workingDirectory: '/tmp' } as never,
|
|
150
|
+
parentSessionId: parentSession.id,
|
|
151
|
+
tenantId: TENANT,
|
|
152
|
+
projectId: project.id,
|
|
153
|
+
parentActor,
|
|
154
|
+
...(options.overrideEnv ? { configOverrides: { env: options.overrideEnv } } : {}),
|
|
155
|
+
} as never,
|
|
156
|
+
context,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
await manager.waitForCompletion(task.taskId)
|
|
160
|
+
return seen.config
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
describe('an environment survives a configBuilder that never heard of it', () => {
|
|
164
|
+
it('reaches a child whose builder sets none', async () => {
|
|
165
|
+
const config = await spawnWith({ overrideEnv: { API_BASE: 'https://staging' } })
|
|
166
|
+
|
|
167
|
+
expect(config?.env).toEqual({ API_BASE: 'https://staging' })
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('keeps the builder keys the caller did not restate', async () => {
|
|
171
|
+
// Why this is a merge and not an assignment: `configOverrides` is a
|
|
172
|
+
// Partial, so replacing the map would drop everything the builder set,
|
|
173
|
+
// and a caller overriding one variable is not saying the rest should
|
|
174
|
+
// vanish.
|
|
175
|
+
const config = await spawnWith({
|
|
176
|
+
builderEnv: { REGION: 'eu', TIER: 'free' },
|
|
177
|
+
overrideEnv: { TIER: 'paid' },
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
expect(config?.env).toEqual({ REGION: 'eu', TIER: 'paid' })
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('leaves the builder environment alone when nothing overrides it', async () => {
|
|
184
|
+
const config = await spawnWith({ builderEnv: { REGION: 'eu' } })
|
|
185
|
+
|
|
186
|
+
expect(config?.env).toEqual({ REGION: 'eu' })
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('gives a child with no environment undefined rather than an empty map', async () => {
|
|
190
|
+
// An agent that never had an environment must not gain an empty object
|
|
191
|
+
// that every reader then has to tell apart from a real one.
|
|
192
|
+
const config = await spawnWith({})
|
|
193
|
+
|
|
194
|
+
expect(config?.env).toBeUndefined()
|
|
195
|
+
})
|
|
196
|
+
})
|
|
@@ -82,6 +82,25 @@ interface ChildSpawnRecord {
|
|
|
82
82
|
workspaceRef?: WorkspaceRef
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Combine a child's own environment with what its parent passed down.
|
|
87
|
+
*
|
|
88
|
+
* Per key, override winning — not whole-value replacement, which would drop
|
|
89
|
+
* every key a `configBuilder` set and the caller did not happen to restate.
|
|
90
|
+
* `configOverrides` is a `Partial`, so replacement reads as "the caller
|
|
91
|
+
* supplied an environment" when what they supplied was one variable.
|
|
92
|
+
*
|
|
93
|
+
* Returns `undefined` when both sides are empty, so an agent that never had an
|
|
94
|
+
* environment does not gain an empty object it then has to be checked for.
|
|
95
|
+
*/
|
|
96
|
+
function mergeEnv(
|
|
97
|
+
base: Readonly<Record<string, string>> | undefined,
|
|
98
|
+
override: Readonly<Record<string, string>> | undefined,
|
|
99
|
+
): Record<string, string> | undefined {
|
|
100
|
+
if (!base && !override) return undefined
|
|
101
|
+
return { ...base, ...override }
|
|
102
|
+
}
|
|
103
|
+
|
|
85
104
|
export class AgentManager {
|
|
86
105
|
private registry: AgentRegistry
|
|
87
106
|
private instances: Map<TaskId, AgentTask> = new Map()
|
|
@@ -289,6 +308,23 @@ export class AgentManager {
|
|
|
289
308
|
// covered the top-level run and nothing it delegated.
|
|
290
309
|
const inheritedHandler = options.configOverrides?.resumeHandler ?? context.resumeHandler
|
|
291
310
|
if (inheritedHandler) childConfig.resumeHandler = inheritedHandler
|
|
311
|
+
|
|
312
|
+
// And the environment, for the third time and the same reason.
|
|
313
|
+
//
|
|
314
|
+
// The bare-config branch below has always carried `env`; this one
|
|
315
|
+
// never did, so a delegate registered WITH a `configBuilder` — the
|
|
316
|
+
// normal way, and what every host in this repo does — silently ran
|
|
317
|
+
// with none of the environment its parent had been given. The
|
|
318
|
+
// builder is written by whoever registered the agent and cannot be
|
|
319
|
+
// expected to forward a field it was never told about, which is
|
|
320
|
+
// exactly why `parentSpan` and `resumeHandler` are stamped here too.
|
|
321
|
+
//
|
|
322
|
+
// Merged per key rather than replaced. `configOverrides` is a
|
|
323
|
+
// `Partial`, so assigning the whole map would drop every key the
|
|
324
|
+
// builder set and the caller did not restate — the override wins per
|
|
325
|
+
// key, the same direction it already wins for `model` and `effort`.
|
|
326
|
+
const inheritedEnv = mergeEnv(childConfig.env, options.configOverrides?.env)
|
|
327
|
+
if (inheritedEnv) childConfig.env = inheritedEnv
|
|
292
328
|
} else {
|
|
293
329
|
this.log.warn('No configBuilder, using bare config', {
|
|
294
330
|
agentId: options.agentId,
|
|
@@ -641,6 +677,24 @@ export class AgentManager {
|
|
|
641
677
|
if (this.deps.workspaceRegistry.has(backend)) {
|
|
642
678
|
const driver = this.deps.workspaceRegistry.get(backend)
|
|
643
679
|
workspaceRef = await driver.create({ label: subSession.id })
|
|
680
|
+
|
|
681
|
+
// Write the workspace onto the record that outlives this process.
|
|
682
|
+
//
|
|
683
|
+
// The ref was kept only on the in-memory `ChildSpawnRecord`, so
|
|
684
|
+
// `SubSession.workspaceId` stayed `null` for every spawn-created
|
|
685
|
+
// child — and `ArchivalManager` resolves a workspace only when
|
|
686
|
+
// that field is set (`session/retention/archive.ts`). The one
|
|
687
|
+
// record that could have named the workspace said there was none,
|
|
688
|
+
// which is why the archival path could never act as a backstop
|
|
689
|
+
// for a leaked worktree.
|
|
690
|
+
//
|
|
691
|
+
// After `create` rather than in `createSubSession`, because the
|
|
692
|
+
// sub-session id is the workspace's label — the workspace cannot
|
|
693
|
+
// exist before the record it is named after. Inside the try, so
|
|
694
|
+
// the compensating rollback below covers it like every other
|
|
695
|
+
// mutation here.
|
|
696
|
+
subSession = { ...subSession, workspaceId: workspaceRef.id }
|
|
697
|
+
await store.updateSubSession(subSession, context.tenantId)
|
|
644
698
|
}
|
|
645
699
|
} catch (err) {
|
|
646
700
|
// Compensating rollback order is mandated by the store's
|
|
@@ -764,9 +818,9 @@ export class AgentManager {
|
|
|
764
818
|
)
|
|
765
819
|
}
|
|
766
820
|
} else {
|
|
767
|
-
// Non-success: mark sub-session failed
|
|
768
|
-
//
|
|
769
|
-
//
|
|
821
|
+
// Non-success: mark sub-session failed. Disposal is shared with
|
|
822
|
+
// the success branch below — the workspace was provisioned for
|
|
823
|
+
// this child either way, and it is this manager that owns it.
|
|
770
824
|
const subSession = await store.getSubSession(
|
|
771
825
|
spawnRecord.subSessionId,
|
|
772
826
|
spawnRecord.tenantId,
|
|
@@ -774,21 +828,25 @@ export class AgentManager {
|
|
|
774
828
|
if (subSession) {
|
|
775
829
|
await store.updateSubSession({ ...subSession, status: 'failed' }, spawnRecord.tenantId)
|
|
776
830
|
}
|
|
777
|
-
if (spawnRecord.workspaceRef) {
|
|
778
|
-
const backend = spawnRecord.workspaceRef.meta.backend
|
|
779
|
-
if (this.deps.workspaceRegistry.has(backend)) {
|
|
780
|
-
await this.deps.workspaceRegistry
|
|
781
|
-
.get(backend)
|
|
782
|
-
.dispose(spawnRecord.workspaceRef)
|
|
783
|
-
.catch((disposeErr) => {
|
|
784
|
-
this.log.warn('Workspace dispose failed', {
|
|
785
|
-
backend,
|
|
786
|
-
error: toErrorMessage(disposeErr),
|
|
787
|
-
})
|
|
788
|
-
})
|
|
789
|
-
}
|
|
790
|
-
}
|
|
791
831
|
}
|
|
832
|
+
|
|
833
|
+
// A child is done with its workspace however it ended.
|
|
834
|
+
//
|
|
835
|
+
// This used to live only in the branch above, so both dispose
|
|
836
|
+
// sites in this class were failure paths and a child that
|
|
837
|
+
// SUCCEEDED released nothing. `.namzu/worktrees/` then grew once
|
|
838
|
+
// per successful delegation — the more reliable the workers, the
|
|
839
|
+
// faster it filled, which is the opposite of the signal a leak
|
|
840
|
+
// usually gives.
|
|
841
|
+
//
|
|
842
|
+
// It runs after the summary is sealed and the sub-session flipped
|
|
843
|
+
// to `idle`, so nothing the terminalization path reads is gone
|
|
844
|
+
// before it reads it. It also runs BEFORE the `subsession_idled`
|
|
845
|
+
// emission below: a listener cannot reach into the workspace from
|
|
846
|
+
// that event. Stated rather than hedged — no consumer does today,
|
|
847
|
+
// and holding a worktree open for a hypothetical one is what this
|
|
848
|
+
// is fixing.
|
|
849
|
+
await this.disposeChildWorkspace(spawnRecord)
|
|
792
850
|
} catch (err) {
|
|
793
851
|
this.log.error('Sub-session finalization failed', {
|
|
794
852
|
taskId: agentTask.taskId,
|
|
@@ -937,15 +995,38 @@ export class AgentManager {
|
|
|
937
995
|
spawnRecord.tenantId,
|
|
938
996
|
)
|
|
939
997
|
}
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
998
|
+
await this.disposeChildWorkspace(spawnRecord)
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/**
|
|
1002
|
+
* Release the workspace this manager provisioned for a child.
|
|
1003
|
+
*
|
|
1004
|
+
* Called on every terminal path, success included. `has(backend)` before
|
|
1005
|
+
* `get(backend)` because the registry is deny-by-default and throws on an
|
|
1006
|
+
* unknown kind — a driver deregistered mid-run must not turn cleanup into
|
|
1007
|
+
* an exception on a child that already finished.
|
|
1008
|
+
*
|
|
1009
|
+
* Never throws. Disposal is cleanup, not part of the child's result: the
|
|
1010
|
+
* sub-session state is already persisted by the time this runs, and
|
|
1011
|
+
* failing here would report a delegation that worked as one that did not.
|
|
1012
|
+
* The failure is logged instead, because a worktree that could not be
|
|
1013
|
+
* removed is an operator's problem and silence is how it stays one.
|
|
1014
|
+
*/
|
|
1015
|
+
private async disposeChildWorkspace(spawnRecord: ChildSpawnRecord): Promise<void> {
|
|
1016
|
+
if (!spawnRecord.workspaceRef) return
|
|
1017
|
+
const backend = spawnRecord.workspaceRef.meta.backend
|
|
1018
|
+
if (!this.deps.workspaceRegistry.has(backend)) return
|
|
1019
|
+
await this.deps.workspaceRegistry
|
|
1020
|
+
.get(backend)
|
|
1021
|
+
.dispose(spawnRecord.workspaceRef)
|
|
1022
|
+
.catch((disposeErr) => {
|
|
1023
|
+
this.log.warn('Workspace dispose failed', {
|
|
1024
|
+
backend,
|
|
1025
|
+
workspaceId: spawnRecord.workspaceRef?.id,
|
|
1026
|
+
subSessionId: spawnRecord.subSessionId,
|
|
1027
|
+
error: toErrorMessage(disposeErr),
|
|
1028
|
+
})
|
|
1029
|
+
})
|
|
949
1030
|
}
|
|
950
1031
|
|
|
951
1032
|
private markCanceled(taskId: TaskId): void {
|
package/src/public-runtime.ts
CHANGED
|
@@ -483,6 +483,12 @@ export {
|
|
|
483
483
|
|
|
484
484
|
export {
|
|
485
485
|
AncestryCycleError,
|
|
486
|
+
// Exported with the CAS it announces. A host that opts into
|
|
487
|
+
// `expectedOwnerVersion` has to be able to tell "somebody else took this
|
|
488
|
+
// session" from any other failure, and string-matching a message is not a
|
|
489
|
+
// contract — which is the state `ThreadClosedError` and its siblings are
|
|
490
|
+
// still in, and a reason not to add a fourth.
|
|
491
|
+
StaleSessionError,
|
|
486
492
|
TenantIsolationError,
|
|
487
493
|
WorkspaceBackendError,
|
|
488
494
|
} from './session/errors.js'
|
package/src/session/__tests__/integration/a-successful-delegation-disposes-its-workspace.test.ts
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A worktree provisioned for a delegated child outlived the child that used it.
|
|
3
|
+
*
|
|
4
|
+
* `finalizeChild` had two dispose sites and both were failure paths — the
|
|
5
|
+
* non-success branch, and the rollback in `failSubSession`. The success branch
|
|
6
|
+
* disposed nothing, so `.namzu/worktrees/` grew once per successful delegation:
|
|
7
|
+
* the more reliable the workers, the faster it filled.
|
|
8
|
+
*
|
|
9
|
+
* The backstop could not fire either. `ArchivalManager` resolves a workspace
|
|
10
|
+
* only when `SubSession.workspaceId` is set, and for a spawn-created
|
|
11
|
+
* sub-session that field was written `null` and never updated —
|
|
12
|
+
* `provisionSpawn` kept the ref on the in-memory `ChildSpawnRecord` and nowhere
|
|
13
|
+
* else. So the one record that could have named the leaked worktree said there
|
|
14
|
+
* was none.
|
|
15
|
+
*
|
|
16
|
+
* Both halves are pinned here, and the failure path is re-asserted alongside
|
|
17
|
+
* them: a test that only counted disposals would pass on the old code by
|
|
18
|
+
* reading the failure branch's disposal and calling it the success branch's.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { describe, expect, it } from 'vitest'
|
|
22
|
+
import { EMPTY_TOKEN_USAGE } from '../../../constants/limits.js'
|
|
23
|
+
import { AgentManager } from '../../../manager/agent/lifecycle.js'
|
|
24
|
+
import { ThreadManager } from '../../../manager/thread/lifecycle.js'
|
|
25
|
+
import { AgentRegistry } from '../../../registry/agent/definitions.js'
|
|
26
|
+
import { InMemorySessionStore } from '../../../store/session/memory.js'
|
|
27
|
+
import { InMemoryThreadStore } from '../../../store/thread/memory.js'
|
|
28
|
+
import type {
|
|
29
|
+
AgentCapabilities,
|
|
30
|
+
AgentInput,
|
|
31
|
+
BaseAgentConfig,
|
|
32
|
+
BaseAgentResult,
|
|
33
|
+
} from '../../../types/agent/base.js'
|
|
34
|
+
import type { Agent } from '../../../types/agent/core.js'
|
|
35
|
+
import type { AgentDefinition } from '../../../types/agent/factory.js'
|
|
36
|
+
import type { AgentTaskContext, SendMessageOptions } from '../../../types/agent/task.js'
|
|
37
|
+
import type { RunId, TenantId, UserId, WorkspaceId } from '../../../types/ids/index.js'
|
|
38
|
+
import { createAssistantMessage } from '../../../types/message/index.js'
|
|
39
|
+
import type { ActorRef } from '../../../types/session/actor.js'
|
|
40
|
+
import type { SummaryId } from '../../../types/session/ids.js'
|
|
41
|
+
import type { WorkspaceRef } from '../../../types/workspace/ref.js'
|
|
42
|
+
import { ZERO_COST } from '../../../utils/cost.js'
|
|
43
|
+
import { DefaultCapacityValidator } from '../../handoff/capacity.js'
|
|
44
|
+
import { SessionSummaryMaterializer } from '../../summary/materialize.js'
|
|
45
|
+
import type {
|
|
46
|
+
BranchWorkspaceParams,
|
|
47
|
+
CreateWorkspaceParams,
|
|
48
|
+
WorkspaceBackendDriver,
|
|
49
|
+
WorkspaceInspection,
|
|
50
|
+
} from '../../workspace/driver.js'
|
|
51
|
+
import { WorkspaceBackendRegistry } from '../../workspace/registry.js'
|
|
52
|
+
|
|
53
|
+
const tenant = 'tnt_alpha' as TenantId
|
|
54
|
+
|
|
55
|
+
const capabilities: AgentCapabilities = {
|
|
56
|
+
supportsTools: false,
|
|
57
|
+
supportsStreaming: false,
|
|
58
|
+
supportsConcurrency: false,
|
|
59
|
+
supportsSubAgents: false,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A child that settles the way `outcome` says, so both branches are reachable. */
|
|
63
|
+
function buildAgent(
|
|
64
|
+
id: string,
|
|
65
|
+
outcome: 'completed' | 'failed',
|
|
66
|
+
): Agent<BaseAgentConfig, BaseAgentResult> {
|
|
67
|
+
return {
|
|
68
|
+
type: 'reactive',
|
|
69
|
+
metadata: {
|
|
70
|
+
type: 'reactive',
|
|
71
|
+
id,
|
|
72
|
+
name: id,
|
|
73
|
+
version: '1.0.0',
|
|
74
|
+
category: 'test',
|
|
75
|
+
description: id,
|
|
76
|
+
capabilities,
|
|
77
|
+
},
|
|
78
|
+
run: async (_input: AgentInput, _config: BaseAgentConfig): Promise<BaseAgentResult> => ({
|
|
79
|
+
runId: 'run_child' as RunId,
|
|
80
|
+
status: outcome,
|
|
81
|
+
usage: { ...EMPTY_TOKEN_USAGE },
|
|
82
|
+
cost: { ...ZERO_COST },
|
|
83
|
+
iterations: 1,
|
|
84
|
+
durationMs: 1,
|
|
85
|
+
messages: [createAssistantMessage('child did the work')],
|
|
86
|
+
result: 'child did the work',
|
|
87
|
+
}),
|
|
88
|
+
cancel: async () => undefined,
|
|
89
|
+
getCapabilities: () => capabilities,
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function buildDefinition(agent: Agent<BaseAgentConfig, BaseAgentResult>): AgentDefinition {
|
|
94
|
+
return {
|
|
95
|
+
info: {
|
|
96
|
+
id: agent.metadata.id,
|
|
97
|
+
name: agent.metadata.name,
|
|
98
|
+
version: agent.metadata.version,
|
|
99
|
+
category: agent.metadata.category,
|
|
100
|
+
description: agent.metadata.description,
|
|
101
|
+
tools: [],
|
|
102
|
+
defaults: { model: 'test', tokenBudget: 1_000 },
|
|
103
|
+
},
|
|
104
|
+
typedAgent: agent,
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Provisions successfully and records every ref it is asked to dispose. */
|
|
109
|
+
class RecordingWorkspaceDriver implements WorkspaceBackendDriver {
|
|
110
|
+
readonly kind = 'git-worktree' as const
|
|
111
|
+
readonly created: WorkspaceRef[] = []
|
|
112
|
+
readonly disposed: WorkspaceId[] = []
|
|
113
|
+
private counter = 0
|
|
114
|
+
|
|
115
|
+
async create(params: CreateWorkspaceParams): Promise<WorkspaceRef> {
|
|
116
|
+
const ref: WorkspaceRef = {
|
|
117
|
+
id: `wsp_test_${++this.counter}` as WorkspaceId,
|
|
118
|
+
meta: {
|
|
119
|
+
backend: 'git-worktree',
|
|
120
|
+
repoRoot: '/tmp/repo',
|
|
121
|
+
branch: `namzu/${params.label ?? 'unlabelled'}`,
|
|
122
|
+
worktreePath: `/tmp/repo/.namzu/worktrees/${params.label ?? 'unlabelled'}`,
|
|
123
|
+
},
|
|
124
|
+
createdAt: new Date(),
|
|
125
|
+
}
|
|
126
|
+
this.created.push(ref)
|
|
127
|
+
return ref
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async branch(_source: WorkspaceRef, _params: BranchWorkspaceParams): Promise<WorkspaceRef> {
|
|
131
|
+
throw new Error('unused in this test')
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async dispose(ref: WorkspaceRef): Promise<void> {
|
|
135
|
+
this.disposed.push(ref.id)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async inspect(_ref: WorkspaceRef): Promise<WorkspaceInspection> {
|
|
139
|
+
throw new Error('unused in this test')
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Stands up a Project → Thread → parent Session and an AgentManager wired to a
|
|
145
|
+
* recording workspace driver. `outcome` decides how the delegated child ends;
|
|
146
|
+
* `registerBackend: false` leaves the registry empty, which is the supported
|
|
147
|
+
* lazy-provisioning configuration rather than an error (pattern doc §7.1).
|
|
148
|
+
*/
|
|
149
|
+
async function harness(
|
|
150
|
+
outcome: 'completed' | 'failed',
|
|
151
|
+
{ registerBackend = true }: { registerBackend?: boolean } = {},
|
|
152
|
+
) {
|
|
153
|
+
const store = new InMemorySessionStore()
|
|
154
|
+
const threadStore = new InMemoryThreadStore()
|
|
155
|
+
const project = await store.createProject({ tenantId: tenant, name: 'workspace-project' }, tenant)
|
|
156
|
+
const thread = await threadStore.createThread(
|
|
157
|
+
{ projectId: project.id, title: 'workspace-topic' },
|
|
158
|
+
tenant,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
const userActor: ActorRef = { kind: 'user', userId: 'usr_root' as UserId, tenantId: tenant }
|
|
162
|
+
|
|
163
|
+
const parentSession = await store.createSession(
|
|
164
|
+
{ threadId: thread.id, projectId: project.id, currentActor: userActor },
|
|
165
|
+
tenant,
|
|
166
|
+
)
|
|
167
|
+
await store.updateSession({ ...parentSession, status: 'active' }, tenant)
|
|
168
|
+
|
|
169
|
+
let summaryCounter = 0
|
|
170
|
+
const materializer = new SessionSummaryMaterializer({
|
|
171
|
+
store,
|
|
172
|
+
generateSummaryId: () => `sum_test_${++summaryCounter}` as SummaryId,
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
const registry = new AgentRegistry()
|
|
176
|
+
registry.register(buildDefinition(buildAgent('worker', outcome)))
|
|
177
|
+
|
|
178
|
+
const workspaceRegistry = new WorkspaceBackendRegistry()
|
|
179
|
+
const driver = new RecordingWorkspaceDriver()
|
|
180
|
+
if (registerBackend) workspaceRegistry.register(driver)
|
|
181
|
+
|
|
182
|
+
const manager = new AgentManager(registry, undefined, {
|
|
183
|
+
sessionStore: store,
|
|
184
|
+
summaryMaterializer: materializer,
|
|
185
|
+
workspaceRegistry,
|
|
186
|
+
capacity: new DefaultCapacityValidator(store),
|
|
187
|
+
threadManager: new ThreadManager({ threadStore, sessionStore: store }),
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
const taskContext: AgentTaskContext = {
|
|
191
|
+
parentRunId: 'run_parent' as RunId,
|
|
192
|
+
parentAgentId: 'supervisor',
|
|
193
|
+
parentAbortController: new AbortController(),
|
|
194
|
+
depth: 0,
|
|
195
|
+
budgetTracker: { total: 100_000, remaining: 100_000 },
|
|
196
|
+
tenantId: tenant,
|
|
197
|
+
threadId: thread.id,
|
|
198
|
+
sessionId: parentSession.id,
|
|
199
|
+
projectId: project.id,
|
|
200
|
+
parentActor: userActor,
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const options: SendMessageOptions = {
|
|
204
|
+
agentId: 'worker',
|
|
205
|
+
input: { messages: [], workingDirectory: '/tmp' },
|
|
206
|
+
parentSessionId: parentSession.id,
|
|
207
|
+
tenantId: tenant,
|
|
208
|
+
projectId: project.id,
|
|
209
|
+
parentActor: userActor,
|
|
210
|
+
workspaceBackend: 'git-worktree',
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return { store, manager, driver, parentSession, options, taskContext }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
describe('a delegated child does not outlive its workspace', () => {
|
|
217
|
+
it('disposes the workspace when the child SUCCEEDS', async () => {
|
|
218
|
+
const { manager, driver, options, taskContext } = await harness('completed')
|
|
219
|
+
|
|
220
|
+
const task = await manager.sendMessage(options, taskContext)
|
|
221
|
+
await manager.waitForCompletion(task.taskId)
|
|
222
|
+
|
|
223
|
+
expect(manager.getState(task.taskId)).toBe('completed')
|
|
224
|
+
expect(driver.created).toHaveLength(1)
|
|
225
|
+
// The assertion the leak fails: one workspace made, the same one released.
|
|
226
|
+
expect(driver.disposed).toEqual([driver.created[0]?.id])
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('still disposes the workspace when the child FAILS', async () => {
|
|
230
|
+
const { manager, driver, options, taskContext } = await harness('failed')
|
|
231
|
+
|
|
232
|
+
const task = await manager.sendMessage(options, taskContext)
|
|
233
|
+
await manager.waitForCompletion(task.taskId)
|
|
234
|
+
|
|
235
|
+
expect(driver.created).toHaveLength(1)
|
|
236
|
+
expect(driver.disposed).toEqual([driver.created[0]?.id])
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('records the workspace on the sub-session, so archival can find it', async () => {
|
|
240
|
+
const { store, manager, driver, parentSession, options, taskContext } =
|
|
241
|
+
await harness('completed')
|
|
242
|
+
|
|
243
|
+
const task = await manager.sendMessage(options, taskContext)
|
|
244
|
+
await manager.waitForCompletion(task.taskId)
|
|
245
|
+
|
|
246
|
+
const [subSession] = await store.getChildren(parentSession.id, tenant)
|
|
247
|
+
expect(subSession).toBeDefined()
|
|
248
|
+
// Was `null` on every spawn-created sub-session, which is what made
|
|
249
|
+
// `ArchivalManager`'s `sub.workspaceId &&` guard unreachable here.
|
|
250
|
+
expect(subSession?.workspaceId).toBe(driver.created[0]?.id)
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it('leaves workspaceId null when no backend is registered', async () => {
|
|
254
|
+
// Lazy provisioning stays legal (pattern doc §7.1): an unregistered
|
|
255
|
+
// backend is not an error, and the record must not claim a workspace
|
|
256
|
+
// that was never made.
|
|
257
|
+
const { store, manager, driver, parentSession, options, taskContext } = await harness(
|
|
258
|
+
'completed',
|
|
259
|
+
{ registerBackend: false },
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
const task = await manager.sendMessage(options, taskContext)
|
|
263
|
+
await manager.waitForCompletion(task.taskId)
|
|
264
|
+
|
|
265
|
+
expect(manager.getState(task.taskId)).toBe('completed')
|
|
266
|
+
expect(driver.created).toHaveLength(0)
|
|
267
|
+
const [subSession] = await store.getChildren(parentSession.id, tenant)
|
|
268
|
+
expect(subSession?.workspaceId).toBeNull()
|
|
269
|
+
})
|
|
270
|
+
})
|
|
@@ -114,13 +114,22 @@ describe('Integration — single-recipient handoff E2E', () => {
|
|
|
114
114
|
expect(reloaded?.previousActors).toHaveLength(1)
|
|
115
115
|
expect(reloaded?.previousActors[0]).toEqual(sourceActor)
|
|
116
116
|
|
|
117
|
-
//
|
|
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
|
)
|