@namzu/sdk 12.2.0 → 13.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +199 -0
  2. package/dist/bridge/a2a/__tests__/project-is-the-a2a-context.test.d.ts +2 -0
  3. package/dist/bridge/a2a/__tests__/project-is-the-a2a-context.test.d.ts.map +1 -0
  4. package/dist/bridge/a2a/__tests__/project-is-the-a2a-context.test.js +55 -0
  5. package/dist/bridge/a2a/__tests__/project-is-the-a2a-context.test.js.map +1 -0
  6. package/dist/manager/agent/__tests__/an-env-reaches-the-child-it-was-set-for.test.d.ts +2 -0
  7. package/dist/manager/agent/__tests__/an-env-reaches-the-child-it-was-set-for.test.d.ts.map +1 -0
  8. package/dist/manager/agent/__tests__/an-env-reaches-the-child-it-was-set-for.test.js +159 -0
  9. package/dist/manager/agent/__tests__/an-env-reaches-the-child-it-was-set-for.test.js.map +1 -0
  10. package/dist/manager/agent/lifecycle.d.ts +15 -0
  11. package/dist/manager/agent/lifecycle.d.ts.map +1 -1
  12. package/dist/manager/agent/lifecycle.js +103 -26
  13. package/dist/manager/agent/lifecycle.js.map +1 -1
  14. package/dist/public-runtime.d.ts +1 -1
  15. package/dist/public-runtime.d.ts.map +1 -1
  16. package/dist/public-runtime.js +7 -1
  17. package/dist/public-runtime.js.map +1 -1
  18. package/dist/session/__tests__/integration/a-successful-delegation-disposes-its-workspace.test.d.ts +21 -0
  19. package/dist/session/__tests__/integration/a-successful-delegation-disposes-its-workspace.test.d.ts.map +1 -0
  20. package/dist/session/__tests__/integration/a-successful-delegation-disposes-its-workspace.test.js +205 -0
  21. package/dist/session/__tests__/integration/a-successful-delegation-disposes-its-workspace.test.js.map +1 -0
  22. package/dist/session/__tests__/integration/handoff-single-e2e.test.js +14 -5
  23. package/dist/session/__tests__/integration/handoff-single-e2e.test.js.map +1 -1
  24. package/dist/session/errors.d.ts +25 -0
  25. package/dist/session/errors.d.ts.map +1 -1
  26. package/dist/session/errors.js +21 -0
  27. package/dist/session/errors.js.map +1 -1
  28. package/dist/session/handoff/broadcast.d.ts.map +1 -1
  29. package/dist/session/handoff/broadcast.js +10 -5
  30. package/dist/session/handoff/broadcast.js.map +1 -1
  31. package/dist/session/handoff/single.d.ts.map +1 -1
  32. package/dist/session/handoff/single.js +27 -4
  33. package/dist/session/handoff/single.js.map +1 -1
  34. package/dist/store/session/__tests__/a-session-has-one-writer.test.d.ts +2 -0
  35. package/dist/store/session/__tests__/a-session-has-one-writer.test.d.ts.map +1 -0
  36. package/dist/store/session/__tests__/a-session-has-one-writer.test.js +79 -0
  37. package/dist/store/session/__tests__/a-session-has-one-writer.test.js.map +1 -0
  38. package/dist/store/session/__tests__/a-workspace-can-be-configured.test.d.ts +2 -0
  39. package/dist/store/session/__tests__/a-workspace-can-be-configured.test.d.ts.map +1 -0
  40. package/dist/store/session/__tests__/a-workspace-can-be-configured.test.js +149 -0
  41. package/dist/store/session/__tests__/a-workspace-can-be-configured.test.js.map +1 -0
  42. package/dist/store/session/disk.d.ts +4 -2
  43. package/dist/store/session/disk.d.ts.map +1 -1
  44. package/dist/store/session/disk.js +71 -4
  45. package/dist/store/session/disk.js.map +1 -1
  46. package/dist/store/session/memory.d.ts +4 -2
  47. package/dist/store/session/memory.d.ts.map +1 -1
  48. package/dist/store/session/memory.js +53 -4
  49. package/dist/store/session/memory.js.map +1 -1
  50. package/dist/tools/coordinator/agent.d.ts.map +1 -1
  51. package/dist/tools/coordinator/agent.js +7 -0
  52. package/dist/tools/coordinator/agent.js.map +1 -1
  53. package/dist/tools/coordinator/index.d.ts.map +1 -1
  54. package/dist/tools/coordinator/index.js +6 -0
  55. package/dist/tools/coordinator/index.js.map +1 -1
  56. package/dist/types/agent/base.d.ts +22 -0
  57. package/dist/types/agent/base.d.ts.map +1 -1
  58. package/dist/types/session/store.d.ts +80 -1
  59. package/dist/types/session/store.d.ts.map +1 -1
  60. package/package.json +1 -1
  61. package/src/bridge/a2a/__tests__/project-is-the-a2a-context.test.ts +66 -0
  62. package/src/manager/agent/__tests__/an-env-reaches-the-child-it-was-set-for.test.ts +196 -0
  63. package/src/manager/agent/lifecycle.ts +107 -26
  64. package/src/public-runtime.ts +6 -0
  65. package/src/session/__tests__/integration/a-successful-delegation-disposes-its-workspace.test.ts +270 -0
  66. package/src/session/__tests__/integration/handoff-single-e2e.test.ts +14 -5
  67. package/src/session/errors.ts +29 -0
  68. package/src/session/handoff/broadcast.ts +10 -5
  69. package/src/session/handoff/single.ts +27 -4
  70. package/src/store/session/__tests__/a-session-has-one-writer.test.ts +116 -0
  71. package/src/store/session/__tests__/a-workspace-can-be-configured.test.ts +210 -0
  72. package/src/store/session/disk.ts +84 -4
  73. package/src/store/session/memory.ts +66 -4
  74. package/src/tools/coordinator/agent.ts +7 -0
  75. package/src/tools/coordinator/index.ts +6 -0
  76. package/src/types/agent/base.ts +23 -0
  77. package/src/types/session/store.ts +88 -1
@@ -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 and, when we own a
768
- // workspace, dispose it. Dispose errors are logged but not
769
- // propagated the sub-session state is already persisted.
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
- if (spawnRecord.workspaceRef) {
941
- const backend = spawnRecord.workspaceRef.meta.backend
942
- if (this.deps.workspaceRegistry.has(backend)) {
943
- await this.deps.workspaceRegistry
944
- .get(backend)
945
- .dispose(spawnRecord.workspaceRef)
946
- .catch(() => undefined)
947
- }
948
- }
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 {
@@ -483,6 +483,12 @@ export {
483
483
 
484
484
  export {
485
485
  AncestryCycleError,
486
+ // Exported with the CAS it announces. A host that opts into
487
+ // `expectedOwnerVersion` has to be able to tell "somebody else took this
488
+ // session" from any other failure, and string-matching a message is not a
489
+ // contract — which is the state `ThreadClosedError` and its siblings are
490
+ // still in, and a reason not to add a fourth.
491
+ StaleSessionError,
486
492
  TenantIsolationError,
487
493
  WorkspaceBackendError,
488
494
  } from './session/errors.js'
@@ -0,0 +1,270 @@
1
+ /**
2
+ * A worktree provisioned for a delegated child outlived the child that used it.
3
+ *
4
+ * `finalizeChild` had two dispose sites and both were failure paths — the
5
+ * non-success branch, and the rollback in `failSubSession`. The success branch
6
+ * disposed nothing, so `.namzu/worktrees/` grew once per successful delegation:
7
+ * the more reliable the workers, the faster it filled.
8
+ *
9
+ * The backstop could not fire either. `ArchivalManager` resolves a workspace
10
+ * only when `SubSession.workspaceId` is set, and for a spawn-created
11
+ * sub-session that field was written `null` and never updated —
12
+ * `provisionSpawn` kept the ref on the in-memory `ChildSpawnRecord` and nowhere
13
+ * else. So the one record that could have named the leaked worktree said there
14
+ * was none.
15
+ *
16
+ * Both halves are pinned here, and the failure path is re-asserted alongside
17
+ * them: a test that only counted disposals would pass on the old code by
18
+ * reading the failure branch's disposal and calling it the success branch's.
19
+ */
20
+
21
+ import { describe, expect, it } from 'vitest'
22
+ import { EMPTY_TOKEN_USAGE } from '../../../constants/limits.js'
23
+ import { AgentManager } from '../../../manager/agent/lifecycle.js'
24
+ import { ThreadManager } from '../../../manager/thread/lifecycle.js'
25
+ import { AgentRegistry } from '../../../registry/agent/definitions.js'
26
+ import { InMemorySessionStore } from '../../../store/session/memory.js'
27
+ import { InMemoryThreadStore } from '../../../store/thread/memory.js'
28
+ import type {
29
+ AgentCapabilities,
30
+ AgentInput,
31
+ BaseAgentConfig,
32
+ BaseAgentResult,
33
+ } from '../../../types/agent/base.js'
34
+ import type { Agent } from '../../../types/agent/core.js'
35
+ import type { AgentDefinition } from '../../../types/agent/factory.js'
36
+ import type { AgentTaskContext, SendMessageOptions } from '../../../types/agent/task.js'
37
+ import type { RunId, TenantId, UserId, WorkspaceId } from '../../../types/ids/index.js'
38
+ import { createAssistantMessage } from '../../../types/message/index.js'
39
+ import type { ActorRef } from '../../../types/session/actor.js'
40
+ import type { SummaryId } from '../../../types/session/ids.js'
41
+ import type { WorkspaceRef } from '../../../types/workspace/ref.js'
42
+ import { ZERO_COST } from '../../../utils/cost.js'
43
+ import { DefaultCapacityValidator } from '../../handoff/capacity.js'
44
+ import { SessionSummaryMaterializer } from '../../summary/materialize.js'
45
+ import type {
46
+ BranchWorkspaceParams,
47
+ CreateWorkspaceParams,
48
+ WorkspaceBackendDriver,
49
+ WorkspaceInspection,
50
+ } from '../../workspace/driver.js'
51
+ import { WorkspaceBackendRegistry } from '../../workspace/registry.js'
52
+
53
+ const tenant = 'tnt_alpha' as TenantId
54
+
55
+ const capabilities: AgentCapabilities = {
56
+ supportsTools: false,
57
+ supportsStreaming: false,
58
+ supportsConcurrency: false,
59
+ supportsSubAgents: false,
60
+ }
61
+
62
+ /** A child that settles the way `outcome` says, so both branches are reachable. */
63
+ function buildAgent(
64
+ id: string,
65
+ outcome: 'completed' | 'failed',
66
+ ): Agent<BaseAgentConfig, BaseAgentResult> {
67
+ return {
68
+ type: 'reactive',
69
+ metadata: {
70
+ type: 'reactive',
71
+ id,
72
+ name: id,
73
+ version: '1.0.0',
74
+ category: 'test',
75
+ description: id,
76
+ capabilities,
77
+ },
78
+ run: async (_input: AgentInput, _config: BaseAgentConfig): Promise<BaseAgentResult> => ({
79
+ runId: 'run_child' as RunId,
80
+ status: outcome,
81
+ usage: { ...EMPTY_TOKEN_USAGE },
82
+ cost: { ...ZERO_COST },
83
+ iterations: 1,
84
+ durationMs: 1,
85
+ messages: [createAssistantMessage('child did the work')],
86
+ result: 'child did the work',
87
+ }),
88
+ cancel: async () => undefined,
89
+ getCapabilities: () => capabilities,
90
+ }
91
+ }
92
+
93
+ function buildDefinition(agent: Agent<BaseAgentConfig, BaseAgentResult>): AgentDefinition {
94
+ return {
95
+ info: {
96
+ id: agent.metadata.id,
97
+ name: agent.metadata.name,
98
+ version: agent.metadata.version,
99
+ category: agent.metadata.category,
100
+ description: agent.metadata.description,
101
+ tools: [],
102
+ defaults: { model: 'test', tokenBudget: 1_000 },
103
+ },
104
+ typedAgent: agent,
105
+ }
106
+ }
107
+
108
+ /** Provisions successfully and records every ref it is asked to dispose. */
109
+ class RecordingWorkspaceDriver implements WorkspaceBackendDriver {
110
+ readonly kind = 'git-worktree' as const
111
+ readonly created: WorkspaceRef[] = []
112
+ readonly disposed: WorkspaceId[] = []
113
+ private counter = 0
114
+
115
+ async create(params: CreateWorkspaceParams): Promise<WorkspaceRef> {
116
+ const ref: WorkspaceRef = {
117
+ id: `wsp_test_${++this.counter}` as WorkspaceId,
118
+ meta: {
119
+ backend: 'git-worktree',
120
+ repoRoot: '/tmp/repo',
121
+ branch: `namzu/${params.label ?? 'unlabelled'}`,
122
+ worktreePath: `/tmp/repo/.namzu/worktrees/${params.label ?? 'unlabelled'}`,
123
+ },
124
+ createdAt: new Date(),
125
+ }
126
+ this.created.push(ref)
127
+ return ref
128
+ }
129
+
130
+ async branch(_source: WorkspaceRef, _params: BranchWorkspaceParams): Promise<WorkspaceRef> {
131
+ throw new Error('unused in this test')
132
+ }
133
+
134
+ async dispose(ref: WorkspaceRef): Promise<void> {
135
+ this.disposed.push(ref.id)
136
+ }
137
+
138
+ async inspect(_ref: WorkspaceRef): Promise<WorkspaceInspection> {
139
+ throw new Error('unused in this test')
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Stands up a Project → Thread → parent Session and an AgentManager wired to a
145
+ * recording workspace driver. `outcome` decides how the delegated child ends;
146
+ * `registerBackend: false` leaves the registry empty, which is the supported
147
+ * lazy-provisioning configuration rather than an error (pattern doc §7.1).
148
+ */
149
+ async function harness(
150
+ outcome: 'completed' | 'failed',
151
+ { registerBackend = true }: { registerBackend?: boolean } = {},
152
+ ) {
153
+ const store = new InMemorySessionStore()
154
+ const threadStore = new InMemoryThreadStore()
155
+ const project = await store.createProject({ tenantId: tenant, name: 'workspace-project' }, tenant)
156
+ const thread = await threadStore.createThread(
157
+ { projectId: project.id, title: 'workspace-topic' },
158
+ tenant,
159
+ )
160
+
161
+ const userActor: ActorRef = { kind: 'user', userId: 'usr_root' as UserId, tenantId: tenant }
162
+
163
+ const parentSession = await store.createSession(
164
+ { threadId: thread.id, projectId: project.id, currentActor: userActor },
165
+ tenant,
166
+ )
167
+ await store.updateSession({ ...parentSession, status: 'active' }, tenant)
168
+
169
+ let summaryCounter = 0
170
+ const materializer = new SessionSummaryMaterializer({
171
+ store,
172
+ generateSummaryId: () => `sum_test_${++summaryCounter}` as SummaryId,
173
+ })
174
+
175
+ const registry = new AgentRegistry()
176
+ registry.register(buildDefinition(buildAgent('worker', outcome)))
177
+
178
+ const workspaceRegistry = new WorkspaceBackendRegistry()
179
+ const driver = new RecordingWorkspaceDriver()
180
+ if (registerBackend) workspaceRegistry.register(driver)
181
+
182
+ const manager = new AgentManager(registry, undefined, {
183
+ sessionStore: store,
184
+ summaryMaterializer: materializer,
185
+ workspaceRegistry,
186
+ capacity: new DefaultCapacityValidator(store),
187
+ threadManager: new ThreadManager({ threadStore, sessionStore: store }),
188
+ })
189
+
190
+ const taskContext: AgentTaskContext = {
191
+ parentRunId: 'run_parent' as RunId,
192
+ parentAgentId: 'supervisor',
193
+ parentAbortController: new AbortController(),
194
+ depth: 0,
195
+ budgetTracker: { total: 100_000, remaining: 100_000 },
196
+ tenantId: tenant,
197
+ threadId: thread.id,
198
+ sessionId: parentSession.id,
199
+ projectId: project.id,
200
+ parentActor: userActor,
201
+ }
202
+
203
+ const options: SendMessageOptions = {
204
+ agentId: 'worker',
205
+ input: { messages: [], workingDirectory: '/tmp' },
206
+ parentSessionId: parentSession.id,
207
+ tenantId: tenant,
208
+ projectId: project.id,
209
+ parentActor: userActor,
210
+ workspaceBackend: 'git-worktree',
211
+ }
212
+
213
+ return { store, manager, driver, parentSession, options, taskContext }
214
+ }
215
+
216
+ describe('a delegated child does not outlive its workspace', () => {
217
+ it('disposes the workspace when the child SUCCEEDS', async () => {
218
+ const { manager, driver, options, taskContext } = await harness('completed')
219
+
220
+ const task = await manager.sendMessage(options, taskContext)
221
+ await manager.waitForCompletion(task.taskId)
222
+
223
+ expect(manager.getState(task.taskId)).toBe('completed')
224
+ expect(driver.created).toHaveLength(1)
225
+ // The assertion the leak fails: one workspace made, the same one released.
226
+ expect(driver.disposed).toEqual([driver.created[0]?.id])
227
+ })
228
+
229
+ it('still disposes the workspace when the child FAILS', async () => {
230
+ const { manager, driver, options, taskContext } = await harness('failed')
231
+
232
+ const task = await manager.sendMessage(options, taskContext)
233
+ await manager.waitForCompletion(task.taskId)
234
+
235
+ expect(driver.created).toHaveLength(1)
236
+ expect(driver.disposed).toEqual([driver.created[0]?.id])
237
+ })
238
+
239
+ it('records the workspace on the sub-session, so archival can find it', async () => {
240
+ const { store, manager, driver, parentSession, options, taskContext } =
241
+ await harness('completed')
242
+
243
+ const task = await manager.sendMessage(options, taskContext)
244
+ await manager.waitForCompletion(task.taskId)
245
+
246
+ const [subSession] = await store.getChildren(parentSession.id, tenant)
247
+ expect(subSession).toBeDefined()
248
+ // Was `null` on every spawn-created sub-session, which is what made
249
+ // `ArchivalManager`'s `sub.workspaceId &&` guard unreachable here.
250
+ expect(subSession?.workspaceId).toBe(driver.created[0]?.id)
251
+ })
252
+
253
+ it('leaves workspaceId null when no backend is registered', async () => {
254
+ // Lazy provisioning stays legal (pattern doc §7.1): an unregistered
255
+ // backend is not an error, and the record must not claim a workspace
256
+ // that was never made.
257
+ const { store, manager, driver, parentSession, options, taskContext } = await harness(
258
+ 'completed',
259
+ { registerBackend: false },
260
+ )
261
+
262
+ const task = await manager.sendMessage(options, taskContext)
263
+ await manager.waitForCompletion(task.taskId)
264
+
265
+ expect(manager.getState(task.taskId)).toBe('completed')
266
+ expect(driver.created).toHaveLength(0)
267
+ const [subSession] = await store.getChildren(parentSession.id, tenant)
268
+ expect(subSession?.workspaceId).toBeNull()
269
+ })
270
+ })
@@ -114,13 +114,22 @@ describe('Integration — single-recipient handoff E2E', () => {
114
114
  expect(reloaded?.previousActors).toHaveLength(1)
115
115
  expect(reloaded?.previousActors[0]).toEqual(sourceActor)
116
116
 
117
- // Wired assertion: updateSession was invoked at least with the lock
118
- // transition + the final commit. That sequence is what makes the
119
- // handoff "atomic at the store layer".
120
- // Expect at minimum locked (v0) followed by committed (v1).
117
+ // The lock MOVES the version, and this assertion used to say it did not.
118
+ //
119
+ // It expected `locked` at v0 — the version the source was already at —
120
+ // which is precisely why the lock was not a lock: a second handoff
121
+ // holding the same snapshot saw an unchanged `ownerVersion`, passed the
122
+ // check, and locked the session again. Both provisioned a worktree and
123
+ // one erased the other.
124
+ //
125
+ // The bump moved to the lock and the commit now keeps it, so a handoff
126
+ // still consumes exactly one version and `committedOwnerVersion` is
127
+ // unchanged at 1 — asserted above, and that is the host-visible
128
+ // contract. What changed is only the intermediate state, which is the
129
+ // state that had to become visible.
121
130
  expect(updateCalls).toEqual(
122
131
  expect.arrayContaining([
123
- { status: 'locked', ownerVersion: 0 },
132
+ { status: 'locked', ownerVersion: 1 },
124
133
  { status: 'idle', ownerVersion: 1 },
125
134
  ]),
126
135
  )