@namzu/sdk 13.0.0 → 14.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/CHANGELOG.md +208 -0
  2. package/dist/agents/runAgent.d.ts +9 -0
  3. package/dist/agents/runAgent.d.ts.map +1 -1
  4. package/dist/agents/runAgent.js.map +1 -1
  5. package/dist/connector/mcp/__tests__/a-transport-forgets-its-listeners.test.d.ts +2 -0
  6. package/dist/connector/mcp/__tests__/a-transport-forgets-its-listeners.test.d.ts.map +1 -0
  7. package/dist/connector/mcp/__tests__/a-transport-forgets-its-listeners.test.js +101 -0
  8. package/dist/connector/mcp/__tests__/a-transport-forgets-its-listeners.test.js.map +1 -0
  9. package/dist/connector/mcp/http-sse.d.ts +10 -0
  10. package/dist/connector/mcp/http-sse.d.ts.map +1 -1
  11. package/dist/connector/mcp/http-sse.js +16 -0
  12. package/dist/connector/mcp/http-sse.js.map +1 -1
  13. package/dist/connector/mcp/stdio.d.ts +12 -0
  14. package/dist/connector/mcp/stdio.d.ts.map +1 -1
  15. package/dist/connector/mcp/stdio.js +33 -1
  16. package/dist/connector/mcp/stdio.js.map +1 -1
  17. package/dist/connector/mcp/streamable-http.d.ts +2 -0
  18. package/dist/connector/mcp/streamable-http.d.ts.map +1 -1
  19. package/dist/connector/mcp/streamable-http.js +13 -1
  20. package/dist/connector/mcp/streamable-http.js.map +1 -1
  21. package/dist/contracts/__tests__/an-id-the-sdk-mints-passes-its-own-schema.test.d.ts +2 -0
  22. package/dist/contracts/__tests__/an-id-the-sdk-mints-passes-its-own-schema.test.d.ts.map +1 -0
  23. package/dist/contracts/__tests__/an-id-the-sdk-mints-passes-its-own-schema.test.js +73 -0
  24. package/dist/contracts/__tests__/an-id-the-sdk-mints-passes-its-own-schema.test.js.map +1 -0
  25. package/dist/contracts/schemas.d.ts +15 -0
  26. package/dist/contracts/schemas.d.ts.map +1 -1
  27. package/dist/contracts/schemas.js +18 -1
  28. package/dist/contracts/schemas.js.map +1 -1
  29. package/dist/manager/agent/__tests__/an-env-reaches-the-child-it-was-set-for.test.d.ts +2 -0
  30. package/dist/manager/agent/__tests__/an-env-reaches-the-child-it-was-set-for.test.d.ts.map +1 -0
  31. package/dist/manager/agent/__tests__/an-env-reaches-the-child-it-was-set-for.test.js +159 -0
  32. package/dist/manager/agent/__tests__/an-env-reaches-the-child-it-was-set-for.test.js.map +1 -0
  33. package/dist/manager/agent/lifecycle.d.ts.map +1 -1
  34. package/dist/manager/agent/lifecycle.js +39 -4
  35. package/dist/manager/agent/lifecycle.js.map +1 -1
  36. package/dist/manager/index.d.ts +2 -0
  37. package/dist/manager/index.d.ts.map +1 -1
  38. package/dist/manager/index.js +1 -0
  39. package/dist/manager/index.js.map +1 -1
  40. package/dist/manager/project/__tests__/a-closed-workspace-takes-no-work.test.d.ts +2 -0
  41. package/dist/manager/project/__tests__/a-closed-workspace-takes-no-work.test.d.ts.map +1 -0
  42. package/dist/manager/project/__tests__/a-closed-workspace-takes-no-work.test.js +208 -0
  43. package/dist/manager/project/__tests__/a-closed-workspace-takes-no-work.test.js.map +1 -0
  44. package/dist/manager/project/lifecycle.d.ts +66 -0
  45. package/dist/manager/project/lifecycle.d.ts.map +1 -0
  46. package/dist/manager/project/lifecycle.js +125 -0
  47. package/dist/manager/project/lifecycle.js.map +1 -0
  48. package/dist/public-runtime.d.ts +2 -2
  49. package/dist/public-runtime.d.ts.map +1 -1
  50. package/dist/public-runtime.js +7 -1
  51. package/dist/public-runtime.js.map +1 -1
  52. package/dist/session/errors.d.ts +71 -1
  53. package/dist/session/errors.d.ts.map +1 -1
  54. package/dist/session/errors.js +50 -0
  55. package/dist/session/errors.js.map +1 -1
  56. package/dist/session/handoff/broadcast.d.ts.map +1 -1
  57. package/dist/session/handoff/broadcast.js +4 -5
  58. package/dist/session/handoff/broadcast.js.map +1 -1
  59. package/dist/session/handoff/single.d.ts.map +1 -1
  60. package/dist/session/handoff/single.js +5 -4
  61. package/dist/session/handoff/single.js.map +1 -1
  62. package/dist/session/migration/__tests__/a-migrated-project-id-is-an-id.test.d.ts +2 -0
  63. package/dist/session/migration/__tests__/a-migrated-project-id-is-an-id.test.d.ts.map +1 -0
  64. package/dist/session/migration/__tests__/a-migrated-project-id-is-an-id.test.js +75 -0
  65. package/dist/session/migration/__tests__/a-migrated-project-id-is-an-id.test.js.map +1 -0
  66. package/dist/session/migration/filesystem.d.ts.map +1 -1
  67. package/dist/session/migration/filesystem.js +24 -0
  68. package/dist/session/migration/filesystem.js.map +1 -1
  69. package/dist/store/session/__tests__/a-workspace-can-be-configured.test.d.ts +2 -0
  70. package/dist/store/session/__tests__/a-workspace-can-be-configured.test.d.ts.map +1 -0
  71. package/dist/store/session/__tests__/a-workspace-can-be-configured.test.js +149 -0
  72. package/dist/store/session/__tests__/a-workspace-can-be-configured.test.js.map +1 -0
  73. package/dist/store/session/disk.d.ts +6 -2
  74. package/dist/store/session/disk.d.ts.map +1 -1
  75. package/dist/store/session/disk.js +120 -3
  76. package/dist/store/session/disk.js.map +1 -1
  77. package/dist/store/session/memory.d.ts +6 -2
  78. package/dist/store/session/memory.d.ts.map +1 -1
  79. package/dist/store/session/memory.js +78 -3
  80. package/dist/store/session/memory.js.map +1 -1
  81. package/dist/tools/coordinator/agent.d.ts.map +1 -1
  82. package/dist/tools/coordinator/agent.js +7 -0
  83. package/dist/tools/coordinator/agent.js.map +1 -1
  84. package/dist/tools/coordinator/index.d.ts.map +1 -1
  85. package/dist/tools/coordinator/index.js +6 -0
  86. package/dist/tools/coordinator/index.js.map +1 -1
  87. package/dist/types/agent/base.d.ts +22 -0
  88. package/dist/types/agent/base.d.ts.map +1 -1
  89. package/dist/types/project/entity.d.ts +17 -0
  90. package/dist/types/project/entity.d.ts.map +1 -1
  91. package/dist/types/session/store.d.ts +80 -1
  92. package/dist/types/session/store.d.ts.map +1 -1
  93. package/package.json +1 -1
  94. package/src/agents/runAgent.ts +9 -0
  95. package/src/connector/mcp/__tests__/a-transport-forgets-its-listeners.test.ts +127 -0
  96. package/src/connector/mcp/http-sse.ts +17 -0
  97. package/src/connector/mcp/stdio.ts +34 -1
  98. package/src/connector/mcp/streamable-http.ts +15 -1
  99. package/src/contracts/__tests__/an-id-the-sdk-mints-passes-its-own-schema.test.ts +84 -0
  100. package/src/contracts/schemas.ts +18 -1
  101. package/src/manager/agent/__tests__/an-env-reaches-the-child-it-was-set-for.test.ts +196 -0
  102. package/src/manager/agent/lifecycle.ts +42 -6
  103. package/src/manager/index.ts +3 -0
  104. package/src/manager/project/__tests__/a-closed-workspace-takes-no-work.test.ts +268 -0
  105. package/src/manager/project/lifecycle.ts +160 -0
  106. package/src/public-runtime.ts +9 -0
  107. package/src/session/errors.ts +85 -1
  108. package/src/session/handoff/broadcast.ts +4 -5
  109. package/src/session/handoff/single.ts +5 -4
  110. package/src/session/migration/__tests__/a-migrated-project-id-is-an-id.test.ts +92 -0
  111. package/src/session/migration/filesystem.ts +28 -0
  112. package/src/store/session/__tests__/a-workspace-can-be-configured.test.ts +210 -0
  113. package/src/store/session/disk.ts +142 -4
  114. package/src/store/session/memory.ts +95 -4
  115. package/src/tools/coordinator/agent.ts +7 -0
  116. package/src/tools/coordinator/index.ts +6 -0
  117. package/src/types/agent/base.ts +23 -0
  118. package/src/types/project/entity.ts +18 -0
  119. package/src/types/session/store.ts +95 -1
@@ -0,0 +1,84 @@
1
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { afterEach, describe, expect, it } from 'vitest'
5
+
6
+ import {
7
+ DefaultFilesystemMigrator,
8
+ NOOP_FILESYSTEM_MIGRATION_SINK,
9
+ } from '../../session/migration/filesystem.js'
10
+ import { generateProjectId } from '../../utils/id.js'
11
+ import { ProjectIdSchema } from '../schemas.js'
12
+
13
+ /**
14
+ * `ProjectIdSchema` was `/^prj_[a-z0-9]+$/` while the v0.2.0 filesystem
15
+ * migration minted `prj_legacy_<suffix>`. So the SDK's own public validator
16
+ * rejected ids the SDK itself had written to disk: a host that validated an
17
+ * inbound project id — the reason the schema is exported at all — refused
18
+ * every project it had migrated, with "Invalid project ID format" and no hint
19
+ * that the id came from the SDK.
20
+ *
21
+ * Both minters are driven here rather than restated. A test that spells out
22
+ * `prj_legacy_abc` as a literal would still pass if the migration changed its
23
+ * shape tomorrow; running the migration means the two cannot drift apart
24
+ * without this failing.
25
+ */
26
+
27
+ const dirs: string[] = []
28
+ afterEach(async () => {
29
+ await Promise.all(dirs.map((d) => rm(d, { recursive: true, force: true })))
30
+ dirs.length = 0
31
+ })
32
+
33
+ describe('every project id the SDK mints passes the schema the SDK exports', () => {
34
+ it('accepts what the id generator produces', () => {
35
+ // 200 draws, because the generator is random over [0-9a-z] and one
36
+ // sample proves nothing about the alphabet it can reach.
37
+ for (let i = 0; i < 200; i++) {
38
+ const id = generateProjectId()
39
+ const parsed = ProjectIdSchema.safeParse(id)
40
+ expect({ id, ok: parsed.success }).toEqual({ id, ok: true })
41
+ }
42
+ })
43
+
44
+ it('accepts what the filesystem migration produces', async () => {
45
+ const root = await mkdtemp(join(tmpdir(), 'namzu-idschema-'))
46
+ dirs.push(root)
47
+ const runDir = join(root, 'threads', 'thd_a1b2c3d4e5f6', 'runs', 'run_seed')
48
+ await mkdir(runDir, { recursive: true })
49
+ await writeFile(join(runDir, 'run.json'), JSON.stringify({ id: 'run_seed' }), 'utf-8')
50
+
51
+ const result = await new DefaultFilesystemMigrator(NOOP_FILESYSTEM_MIGRATION_SINK).migrate(root)
52
+
53
+ expect(result.kind).toBe('migrated')
54
+ expect(result.migratedThreads).toHaveLength(1)
55
+ for (const { newProjectId } of result.migratedThreads) {
56
+ const parsed = ProjectIdSchema.safeParse(newProjectId)
57
+ expect({ newProjectId, ok: parsed.success }).toEqual({ newProjectId, ok: true })
58
+ }
59
+ })
60
+
61
+ it('still refuses what no minter produces, because the id is also a directory name', () => {
62
+ // Widening to accept the legacy form must not widen to accept a path.
63
+ // Everything here would be joined onto the store root if it got through.
64
+ const refused = [
65
+ 'prj_../../etc',
66
+ 'prj_..',
67
+ 'prj_a/b',
68
+ 'prj_a\\b',
69
+ 'prj_',
70
+ 'prj_legacy_',
71
+ 'prj_ABC',
72
+ 'prj_a-b',
73
+ 'prj_a b',
74
+ 'proj_abc',
75
+ 'thd_abc',
76
+ '',
77
+ ]
78
+
79
+ for (const candidate of refused) {
80
+ const parsed = ProjectIdSchema.safeParse(candidate)
81
+ expect({ candidate, ok: parsed.success }).toEqual({ candidate, ok: false })
82
+ }
83
+ })
84
+ })
@@ -1,6 +1,23 @@
1
1
  import { z } from 'zod'
2
2
 
3
- export const ProjectIdSchema = z.string().regex(/^prj_[a-z0-9]+$/, 'Invalid project ID format')
3
+ /**
4
+ * The two shapes the SDK mints, and nothing else.
5
+ *
6
+ * `prj_<12 lowercase alphanumerics>` comes from `generateProjectId()`.
7
+ * `prj_legacy_<suffix>` comes from the v0.2.0 filesystem migration, which
8
+ * synthesises a project per legacy `thd_*` folder. The `legacy_` segment is
9
+ * spelled out rather than covered by a permissive character class, because a
10
+ * project id is also a directory name: everything this accepts is joined to a
11
+ * path, so the set it accepts is a containment boundary, not a formatting
12
+ * preference.
13
+ *
14
+ * A host that supplies its own `SessionStore` and mints its own ids should
15
+ * validate with its own schema — `ProjectId` is `prj_${string}` at the type
16
+ * level and this schema is deliberately narrower.
17
+ */
18
+ export const ProjectIdSchema = z
19
+ .string()
20
+ .regex(/^prj_(legacy_)?[a-z0-9]+$/, 'Invalid project ID format')
4
21
  export const RunIdSchema = z.string().regex(/^run_[a-z0-9]+$/, 'Invalid run ID format')
5
22
  export const MessageIdSchema = z.string().regex(/^msg_[a-z0-9]+$/, 'Invalid message ID format')
6
23
 
@@ -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
+ })
@@ -36,6 +36,7 @@ import { ZERO_COST } from '../../utils/cost.js'
36
36
  import { toErrorMessage } from '../../utils/error.js'
37
37
  import { generateTaskId } from '../../utils/id.js'
38
38
  import { type Logger, getRootLogger } from '../../utils/logger.js'
39
+ import { requireOpenProject } from '../project/lifecycle.js'
39
40
  import type { ThreadManager } from '../thread/lifecycle.js'
40
41
 
41
42
  /**
@@ -82,6 +83,25 @@ interface ChildSpawnRecord {
82
83
  workspaceRef?: WorkspaceRef
83
84
  }
84
85
 
86
+ /**
87
+ * Combine a child's own environment with what its parent passed down.
88
+ *
89
+ * Per key, override winning — not whole-value replacement, which would drop
90
+ * every key a `configBuilder` set and the caller did not happen to restate.
91
+ * `configOverrides` is a `Partial`, so replacement reads as "the caller
92
+ * supplied an environment" when what they supplied was one variable.
93
+ *
94
+ * Returns `undefined` when both sides are empty, so an agent that never had an
95
+ * environment does not gain an empty object it then has to be checked for.
96
+ */
97
+ function mergeEnv(
98
+ base: Readonly<Record<string, string>> | undefined,
99
+ override: Readonly<Record<string, string>> | undefined,
100
+ ): Record<string, string> | undefined {
101
+ if (!base && !override) return undefined
102
+ return { ...base, ...override }
103
+ }
104
+
85
105
  export class AgentManager {
86
106
  private registry: AgentRegistry
87
107
  private instances: Map<TaskId, AgentTask> = new Map()
@@ -289,6 +309,23 @@ export class AgentManager {
289
309
  // covered the top-level run and nothing it delegated.
290
310
  const inheritedHandler = options.configOverrides?.resumeHandler ?? context.resumeHandler
291
311
  if (inheritedHandler) childConfig.resumeHandler = inheritedHandler
312
+
313
+ // And the environment, for the third time and the same reason.
314
+ //
315
+ // The bare-config branch below has always carried `env`; this one
316
+ // never did, so a delegate registered WITH a `configBuilder` — the
317
+ // normal way, and what every host in this repo does — silently ran
318
+ // with none of the environment its parent had been given. The
319
+ // builder is written by whoever registered the agent and cannot be
320
+ // expected to forward a field it was never told about, which is
321
+ // exactly why `parentSpan` and `resumeHandler` are stamped here too.
322
+ //
323
+ // Merged per key rather than replaced. `configOverrides` is a
324
+ // `Partial`, so assigning the whole map would drop every key the
325
+ // builder set and the caller did not restate — the override wins per
326
+ // key, the same direction it already wins for `model` and `effort`.
327
+ const inheritedEnv = mergeEnv(childConfig.env, options.configOverrides?.env)
328
+ if (inheritedEnv) childConfig.env = inheritedEnv
292
329
  } else {
293
330
  this.log.warn('No configBuilder, using bare config', {
294
331
  agentId: options.agentId,
@@ -557,12 +594,11 @@ export class AgentManager {
557
594
  )
558
595
  }
559
596
 
560
- const project = await store.getProject(context.projectId, context.tenantId)
561
- if (!project) {
562
- throw new Error(
563
- `Project ${context.projectId} not found for tenant ${context.tenantId} — spawn rejected`,
564
- )
565
- }
597
+ // Same read that loads the limits, now also the gate: an archived
598
+ // workspace accepts no new session. It replaces a bare `getProject` +
599
+ // null check rather than adding a round-trip, because a gate that costs
600
+ // something is a gate someone eventually moves.
601
+ const project = await requireOpenProject(store, context.projectId, context.tenantId, 'spawn')
566
602
 
567
603
  // Capacity: depth + width. Depth uses the parent session's ancestry
568
604
  // chain; width counts existing direct children of the parent.
@@ -20,3 +20,6 @@ export { ThreadManager } from './thread/lifecycle.js'
20
20
  export type { ThreadManagerDeps } from './thread/lifecycle.js'
21
21
 
22
22
  export { AgentManager } from './agent/lifecycle.js'
23
+
24
+ export { ProjectManager, requireOpenProject } from './project/lifecycle.js'
25
+ export type { ProjectManagerDeps } from './project/lifecycle.js'
@@ -0,0 +1,268 @@
1
+ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { afterEach, describe, expect, it } from 'vitest'
5
+
6
+ import { EMPTY_TOKEN_USAGE } from '../../../constants/limits.js'
7
+ import { AgentRegistry } from '../../../registry/agent/definitions.js'
8
+ import {
9
+ ProjectClosedError,
10
+ ProjectNotEmptyError,
11
+ StaleProjectError,
12
+ } from '../../../session/errors.js'
13
+ import { DefaultCapacityValidator } from '../../../session/handoff/capacity.js'
14
+ import { SessionSummaryMaterializer } from '../../../session/summary/materialize.js'
15
+ import { WorkspaceBackendRegistry } from '../../../session/workspace/registry.js'
16
+ import { DiskSessionStore } from '../../../store/session/disk.js'
17
+ import { InMemorySessionStore } from '../../../store/session/memory.js'
18
+ import { InMemoryThreadStore } from '../../../store/thread/memory.js'
19
+ import type { BaseAgentConfig, BaseAgentResult } from '../../../types/agent/base.js'
20
+ import type { Agent } from '../../../types/agent/core.js'
21
+ import type { AgentDefinition } from '../../../types/agent/factory.js'
22
+ import type { AgentTaskContext } from '../../../types/agent/task.js'
23
+ import type { AgentId, TenantId } from '../../../types/ids/index.js'
24
+ import type { SummaryId } from '../../../types/session/ids.js'
25
+ import { ZERO_COST } from '../../../utils/cost.js'
26
+ import { AgentManager } from '../../agent/lifecycle.js'
27
+ import { ThreadManager } from '../../thread/lifecycle.js'
28
+ import { ProjectManager } from '../lifecycle.js'
29
+
30
+ /**
31
+ * Archiving a workspace meant nothing to the code.
32
+ *
33
+ * `Thread` carried a status and a gate; `Project` — the thing a tenant owns,
34
+ * configures, gives an environment, and actually closes — carried neither. So
35
+ * a host could archive a workspace and the kernel would keep spawning agents
36
+ * into it, because there was no state for the spawn path to read.
37
+ *
38
+ * The spawn case drives the real `AgentManager` rather than calling the gate
39
+ * directly. A test that calls `requireOpenProject` proves the function throws;
40
+ * what has to hold is that the spawn path reaches it, and the only assertion
41
+ * that cannot pass with the call deleted is one made through the front door.
42
+ */
43
+
44
+ const TENANT = 'tnt_close' as TenantId
45
+
46
+ function silentAgent(): Agent<BaseAgentConfig, BaseAgentResult> {
47
+ return {
48
+ type: 'reactive',
49
+ metadata: {
50
+ id: 'worker',
51
+ name: 'worker',
52
+ version: '1.0.0',
53
+ category: 'general',
54
+ description: 'does nothing',
55
+ type: 'reactive',
56
+ capabilities: {},
57
+ },
58
+ async run(): Promise<BaseAgentResult> {
59
+ return {
60
+ runId: 'run_child',
61
+ status: 'completed',
62
+ result: 'ok',
63
+ usage: { ...EMPTY_TOKEN_USAGE },
64
+ cost: { ...ZERO_COST },
65
+ iterations: 1,
66
+ durationMs: 0,
67
+ messages: [],
68
+ } as BaseAgentResult
69
+ },
70
+ async cancel() {},
71
+ getCapabilities() {
72
+ return {} as never
73
+ },
74
+ } as unknown as Agent<BaseAgentConfig, BaseAgentResult>
75
+ }
76
+
77
+ function definition(): AgentDefinition {
78
+ return {
79
+ info: {
80
+ id: 'worker',
81
+ name: 'worker',
82
+ version: '1.0.0',
83
+ category: 'general',
84
+ description: 'a worker',
85
+ tools: [],
86
+ defaults: { model: 'test', tokenBudget: 1_000 },
87
+ },
88
+ typedAgent: silentAgent(),
89
+ } as AgentDefinition
90
+ }
91
+
92
+ /** A parent session in a real workspace, plus the manager that spawns into it. */
93
+ async function harness() {
94
+ const store = new InMemorySessionStore()
95
+ const threadStore = new InMemoryThreadStore()
96
+ const project = await store.createProject({ tenantId: TENANT, name: 'w' }, TENANT)
97
+ const thread = await threadStore.createThread({ projectId: project.id, title: 't' }, TENANT)
98
+ const parentActor = { kind: 'agent', agentId: 'sup' as AgentId, tenantId: TENANT } as const
99
+ const parentSession = await store.createSession(
100
+ { threadId: thread.id, projectId: project.id, currentActor: parentActor },
101
+ TENANT,
102
+ )
103
+
104
+ const registry = new AgentRegistry()
105
+ registry.register(definition())
106
+
107
+ let n = 0
108
+ const manager = new AgentManager(registry, undefined, {
109
+ sessionStore: store,
110
+ threadManager: new ThreadManager({ threadStore, sessionStore: store }),
111
+ workspaceRegistry: new WorkspaceBackendRegistry(),
112
+ capacity: new DefaultCapacityValidator(store),
113
+ summaryMaterializer: new SessionSummaryMaterializer({
114
+ store,
115
+ generateSummaryId: () => `sum_${++n}` as SummaryId,
116
+ }),
117
+ })
118
+
119
+ const context = {
120
+ parentRunId: 'run_parent' as never,
121
+ parentAgentId: 'sup',
122
+ parentAbortController: new AbortController(),
123
+ depth: 0,
124
+ budgetTracker: { total: 100_000, remaining: 100_000 },
125
+ tenantId: TENANT,
126
+ threadId: thread.id,
127
+ sessionId: parentSession.id,
128
+ projectId: project.id,
129
+ parentActor,
130
+ } as AgentTaskContext
131
+
132
+ const spawn = () =>
133
+ manager.sendMessage(
134
+ {
135
+ agentId: 'worker',
136
+ input: { messages: [], workingDirectory: '/tmp' } as never,
137
+ parentSessionId: parentSession.id,
138
+ tenantId: TENANT,
139
+ } as never,
140
+ context,
141
+ )
142
+
143
+ return { store, project, parentSession, projects: new ProjectManager({ store }), spawn }
144
+ }
145
+
146
+ describe('a closed workspace takes no new work', () => {
147
+ it('spawns while the workspace is open', async () => {
148
+ // The control. Without it, every assertion below could be passing
149
+ // because the harness cannot spawn at all.
150
+ const h = await harness()
151
+
152
+ await expect(h.spawn()).resolves.toBeDefined()
153
+ })
154
+
155
+ it('refuses a spawn into an archived workspace, through the real spawn path', async () => {
156
+ const h = await harness()
157
+ await h.projects.archive(h.project.id, TENANT)
158
+
159
+ await expect(h.spawn()).rejects.toBeInstanceOf(ProjectClosedError)
160
+ })
161
+
162
+ it('names the operation it refused', async () => {
163
+ // "Archived" alone does not tell a caller what they were denied.
164
+ const h = await harness()
165
+ await h.projects.archive(h.project.id, TENANT)
166
+
167
+ const error = await h.spawn().then(
168
+ () => null,
169
+ (e: unknown) => e,
170
+ )
171
+
172
+ expect((error as ProjectClosedError).details).toEqual({
173
+ projectId: h.project.id,
174
+ op: 'spawn',
175
+ })
176
+ })
177
+
178
+ it('spawns again after the workspace is reopened', async () => {
179
+ // Closing has to be reversible, or a mistaken archive is permanent.
180
+ const h = await harness()
181
+ await h.projects.archive(h.project.id, TENANT)
182
+ await h.projects.reopen(h.project.id, TENANT)
183
+
184
+ await expect(h.spawn()).resolves.toBeDefined()
185
+ })
186
+
187
+ it('refuses to close a workspace with a live session, and says which', async () => {
188
+ // Archival does not cascade and does not kill: a live session is a
189
+ // running agent whose owner is still watching.
190
+ const h = await harness()
191
+ await h.store.updateSession({ ...h.parentSession, status: 'active' }, TENANT)
192
+
193
+ const error = await h.projects.archive(h.project.id, TENANT).then(
194
+ () => null,
195
+ (e: unknown) => e,
196
+ )
197
+
198
+ expect(error).toBeInstanceOf(ProjectNotEmptyError)
199
+ const details = (error as ProjectNotEmptyError).details
200
+ expect(details.totalBlockingSessions).toBe(1)
201
+ expect(details.blockingSessions).toEqual([{ sessionId: h.parentSession.id, status: 'active' }])
202
+ })
203
+
204
+ it('closes a workspace whose sessions have settled', async () => {
205
+ const h = await harness()
206
+
207
+ const archived = await h.projects.archive(h.project.id, TENANT)
208
+
209
+ expect(archived.status).toBe('archived')
210
+ expect((await h.store.getProject(h.project.id, TENANT))?.status).toBe('archived')
211
+ })
212
+
213
+ it('re-archiving is a no-op that does not burn a version', async () => {
214
+ // An idempotent call that bumped the counter would invalidate a version
215
+ // a concurrent caller is holding, turning a retry into a conflict.
216
+ const h = await harness()
217
+ const first = await h.projects.archive(h.project.id, TENANT)
218
+
219
+ const second = await h.projects.archive(h.project.id, TENANT)
220
+
221
+ expect(second.ownerVersion).toBe(first.ownerVersion)
222
+ })
223
+
224
+ it('loses the second of two writes that read the same version', async () => {
225
+ // The store compares against what it holds, not against the caller's
226
+ // copy of it — the mistake the session CAS was written with first.
227
+ const h = await harness()
228
+ const read = await h.store.getProject(h.project.id, TENANT)
229
+ if (!read) throw new Error('project vanished')
230
+
231
+ await h.store.setProjectStatus?.(h.project.id, 'archived', TENANT, read.ownerVersion)
232
+
233
+ await expect(
234
+ h.store.setProjectStatus?.(h.project.id, 'open', TENANT, read.ownerVersion),
235
+ ).rejects.toBeInstanceOf(StaleProjectError)
236
+ })
237
+ })
238
+
239
+ describe('a workspace stored before it had a status', () => {
240
+ const dirs: string[] = []
241
+ afterEach(async () => {
242
+ await Promise.all(dirs.map((d) => rm(d, { recursive: true, force: true })))
243
+ dirs.length = 0
244
+ })
245
+
246
+ it('reads as open at version 0, and can still be closed', async () => {
247
+ // Every project.json on disk today lacks both fields. Reading them as
248
+ // `undefined` would be worse than a wrong default: the compare-and-set
249
+ // would never match, so an existing workspace could never be closed.
250
+ const root = await mkdtemp(join(tmpdir(), 'namzu-oldproj-'))
251
+ dirs.push(root)
252
+ const store = new DiskSessionStore({ rootDir: root })
253
+ const project = await store.createProject({ tenantId: TENANT, name: 'w' }, TENANT)
254
+
255
+ const file = join(root, 'projects', project.id, 'project.json')
256
+ const raw = JSON.parse(await readFile(file, 'utf-8'))
257
+ raw.status = undefined
258
+ raw.ownerVersion = undefined
259
+ await writeFile(file, JSON.stringify(raw), 'utf-8')
260
+
261
+ const reloaded = await store.getProject(project.id, TENANT)
262
+ expect(reloaded?.status).toBe('open')
263
+ expect(reloaded?.ownerVersion).toBe(0)
264
+
265
+ const archived = await new ProjectManager({ store }).archive(project.id, TENANT)
266
+ expect(archived.status).toBe('archived')
267
+ })
268
+ })