@namzu/sdk 0.2.0 → 0.4.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 (329) hide show
  1. package/CHANGELOG.md +74 -2
  2. package/dist/agents/ReactiveAgent.d.ts.map +1 -1
  3. package/dist/agents/ReactiveAgent.js +3 -2
  4. package/dist/agents/ReactiveAgent.js.map +1 -1
  5. package/dist/agents/SupervisorAgent.d.ts.map +1 -1
  6. package/dist/agents/SupervisorAgent.js +5 -2
  7. package/dist/agents/SupervisorAgent.js.map +1 -1
  8. package/dist/bridge/a2a/index.d.ts +1 -1
  9. package/dist/bridge/a2a/index.d.ts.map +1 -1
  10. package/dist/bridge/a2a/index.js +1 -1
  11. package/dist/bridge/a2a/index.js.map +1 -1
  12. package/dist/bridge/a2a/message.d.ts +0 -2
  13. package/dist/bridge/a2a/message.d.ts.map +1 -1
  14. package/dist/bridge/a2a/message.js +0 -26
  15. package/dist/bridge/a2a/message.js.map +1 -1
  16. package/dist/bridge/a2a/task.d.ts +4 -3
  17. package/dist/bridge/a2a/task.d.ts.map +1 -1
  18. package/dist/bridge/a2a/task.js +4 -4
  19. package/dist/bridge/a2a/task.js.map +1 -1
  20. package/dist/contracts/api.d.ts +6 -38
  21. package/dist/contracts/api.d.ts.map +1 -1
  22. package/dist/contracts/ids.d.ts +1 -1
  23. package/dist/contracts/ids.d.ts.map +1 -1
  24. package/dist/contracts/index.d.ts +3 -5
  25. package/dist/contracts/index.d.ts.map +1 -1
  26. package/dist/contracts/index.js +1 -1
  27. package/dist/contracts/index.js.map +1 -1
  28. package/dist/contracts/schemas.d.ts +1 -31
  29. package/dist/contracts/schemas.d.ts.map +1 -1
  30. package/dist/contracts/schemas.js +1 -7
  31. package/dist/contracts/schemas.js.map +1 -1
  32. package/dist/index.d.ts +2 -7
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +5 -6
  35. package/dist/index.js.map +1 -1
  36. package/dist/manager/agent/__tests__/lifecycle.test.js +27 -13
  37. package/dist/manager/agent/__tests__/lifecycle.test.js.map +1 -1
  38. package/dist/manager/agent/lifecycle.d.ts +9 -0
  39. package/dist/manager/agent/lifecycle.d.ts.map +1 -1
  40. package/dist/manager/agent/lifecycle.js +93 -31
  41. package/dist/manager/agent/lifecycle.js.map +1 -1
  42. package/dist/manager/index.d.ts +2 -0
  43. package/dist/manager/index.d.ts.map +1 -1
  44. package/dist/manager/index.js +1 -0
  45. package/dist/manager/index.js.map +1 -1
  46. package/dist/manager/run/persistence.d.ts +3 -1
  47. package/dist/manager/run/persistence.d.ts.map +1 -1
  48. package/dist/manager/run/persistence.js +5 -0
  49. package/dist/manager/run/persistence.js.map +1 -1
  50. package/dist/manager/thread/__tests__/lifecycle.test.d.ts +2 -0
  51. package/dist/manager/thread/__tests__/lifecycle.test.d.ts.map +1 -0
  52. package/dist/manager/thread/__tests__/lifecycle.test.js +216 -0
  53. package/dist/manager/thread/__tests__/lifecycle.test.js.map +1 -0
  54. package/dist/manager/thread/lifecycle.d.ts +105 -0
  55. package/dist/manager/thread/lifecycle.d.ts.map +1 -0
  56. package/dist/manager/thread/lifecycle.js +186 -0
  57. package/dist/manager/thread/lifecycle.js.map +1 -0
  58. package/dist/rag/retriever.js +2 -2
  59. package/dist/registry/tool/execute.js +1 -1
  60. package/dist/registry/tool/execute.js.map +1 -1
  61. package/dist/runtime/query/__tests__/context.test.js +8 -7
  62. package/dist/runtime/query/__tests__/context.test.js.map +1 -1
  63. package/dist/runtime/query/context-cache.d.ts +3 -3
  64. package/dist/runtime/query/context-cache.d.ts.map +1 -1
  65. package/dist/runtime/query/context-cache.js +2 -2
  66. package/dist/runtime/query/context-cache.js.map +1 -1
  67. package/dist/runtime/query/context.d.ts +12 -21
  68. package/dist/runtime/query/context.d.ts.map +1 -1
  69. package/dist/runtime/query/context.js +3 -1
  70. package/dist/runtime/query/context.js.map +1 -1
  71. package/dist/runtime/query/index.d.ts +13 -15
  72. package/dist/runtime/query/index.d.ts.map +1 -1
  73. package/dist/runtime/query/index.js +2 -1
  74. package/dist/runtime/query/index.js.map +1 -1
  75. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  76. package/dist/runtime/query/iteration/index.js +1 -1
  77. package/dist/runtime/query/iteration/index.js.map +1 -1
  78. package/dist/session/__tests__/integration/_fixtures.d.ts +11 -4
  79. package/dist/session/__tests__/integration/_fixtures.d.ts.map +1 -1
  80. package/dist/session/__tests__/integration/_fixtures.js +23 -6
  81. package/dist/session/__tests__/integration/_fixtures.js.map +1 -1
  82. package/dist/session/__tests__/integration/archive-gate.test.d.ts +15 -0
  83. package/dist/session/__tests__/integration/archive-gate.test.d.ts.map +1 -0
  84. package/dist/session/__tests__/integration/archive-gate.test.js +214 -0
  85. package/dist/session/__tests__/integration/archive-gate.test.js.map +1 -0
  86. package/dist/session/__tests__/integration/capacity-caps.test.js +13 -6
  87. package/dist/session/__tests__/integration/capacity-caps.test.js.map +1 -1
  88. package/dist/session/__tests__/integration/e2e-spawn.test.js +14 -2
  89. package/dist/session/__tests__/integration/e2e-spawn.test.js.map +1 -1
  90. package/dist/session/__tests__/integration/event-stream-ordering.test.js +14 -7
  91. package/dist/session/__tests__/integration/event-stream-ordering.test.js.map +1 -1
  92. package/dist/session/__tests__/integration/handoff-broadcast-e2e.test.js +26 -14
  93. package/dist/session/__tests__/integration/handoff-broadcast-e2e.test.js.map +1 -1
  94. package/dist/session/__tests__/integration/handoff-illegal-transition.test.js +30 -20
  95. package/dist/session/__tests__/integration/handoff-illegal-transition.test.js.map +1 -1
  96. package/dist/session/__tests__/integration/handoff-single-e2e.test.js +25 -9
  97. package/dist/session/__tests__/integration/handoff-single-e2e.test.js.map +1 -1
  98. package/dist/session/__tests__/integration/hierarchy-lifecycle.test.js +11 -10
  99. package/dist/session/__tests__/integration/hierarchy-lifecycle.test.js.map +1 -1
  100. package/dist/session/__tests__/integration/prev-artifact-dag.test.js +5 -4
  101. package/dist/session/__tests__/integration/prev-artifact-dag.test.js.map +1 -1
  102. package/dist/session/__tests__/integration/retention-archive.test.js +3 -2
  103. package/dist/session/__tests__/integration/retention-archive.test.js.map +1 -1
  104. package/dist/session/__tests__/integration/spawn-rollback.test.d.ts +26 -0
  105. package/dist/session/__tests__/integration/spawn-rollback.test.d.ts.map +1 -0
  106. package/dist/session/__tests__/integration/spawn-rollback.test.js +236 -0
  107. package/dist/session/__tests__/integration/spawn-rollback.test.js.map +1 -0
  108. package/dist/session/__tests__/integration/summary-materialization-e2e.test.js +2 -1
  109. package/dist/session/__tests__/integration/summary-materialization-e2e.test.js.map +1 -1
  110. package/dist/session/__tests__/integration/tenant-isolation.test.js +14 -5
  111. package/dist/session/__tests__/integration/tenant-isolation.test.js.map +1 -1
  112. package/dist/session/errors.d.ts +79 -0
  113. package/dist/session/errors.d.ts.map +1 -1
  114. package/dist/session/errors.js +57 -0
  115. package/dist/session/errors.js.map +1 -1
  116. package/dist/session/handoff/__tests__/broadcast.test.js +49 -31
  117. package/dist/session/handoff/__tests__/broadcast.test.js.map +1 -1
  118. package/dist/session/handoff/__tests__/capacity.test.js +21 -18
  119. package/dist/session/handoff/__tests__/capacity.test.js.map +1 -1
  120. package/dist/session/handoff/__tests__/single.test.js +39 -30
  121. package/dist/session/handoff/__tests__/single.test.js.map +1 -1
  122. package/dist/session/handoff/assignment.d.ts +13 -1
  123. package/dist/session/handoff/assignment.d.ts.map +1 -1
  124. package/dist/session/handoff/broadcast.d.ts +7 -0
  125. package/dist/session/handoff/broadcast.d.ts.map +1 -1
  126. package/dist/session/handoff/broadcast.js +16 -1
  127. package/dist/session/handoff/broadcast.js.map +1 -1
  128. package/dist/session/handoff/single.d.ts +7 -0
  129. package/dist/session/handoff/single.d.ts.map +1 -1
  130. package/dist/session/handoff/single.js +13 -1
  131. package/dist/session/handoff/single.js.map +1 -1
  132. package/dist/session/hierarchy/__tests__/session.test.js +2 -0
  133. package/dist/session/hierarchy/__tests__/session.test.js.map +1 -1
  134. package/dist/session/hierarchy/index.d.ts +1 -0
  135. package/dist/session/hierarchy/index.d.ts.map +1 -1
  136. package/dist/session/hierarchy/index.js.map +1 -1
  137. package/dist/session/hierarchy/session.d.ts +15 -3
  138. package/dist/session/hierarchy/session.d.ts.map +1 -1
  139. package/dist/session/hierarchy/session.js.map +1 -1
  140. package/dist/session/hierarchy/thread.d.ts +54 -0
  141. package/dist/session/hierarchy/thread.d.ts.map +1 -0
  142. package/dist/session/hierarchy/thread.js +2 -0
  143. package/dist/session/hierarchy/thread.js.map +1 -0
  144. package/dist/session/migration/id-prefix.d.ts +8 -13
  145. package/dist/session/migration/id-prefix.d.ts.map +1 -1
  146. package/dist/session/migration/id-prefix.js +8 -13
  147. package/dist/session/migration/id-prefix.js.map +1 -1
  148. package/dist/session/retention/__tests__/archive.test.js +3 -2
  149. package/dist/session/retention/__tests__/archive.test.js.map +1 -1
  150. package/dist/session/summary/__tests__/materialize.test.js +4 -3
  151. package/dist/session/summary/__tests__/materialize.test.js.map +1 -1
  152. package/dist/store/index.d.ts +0 -2
  153. package/dist/store/index.d.ts.map +1 -1
  154. package/dist/store/index.js +0 -1
  155. package/dist/store/index.js.map +1 -1
  156. package/dist/store/session/__tests__/disk.test.js +32 -5
  157. package/dist/store/session/__tests__/disk.test.js.map +1 -1
  158. package/dist/store/session/__tests__/memory.test.js +50 -9
  159. package/dist/store/session/__tests__/memory.test.js.map +1 -1
  160. package/dist/store/session/disk.d.ts +2 -1
  161. package/dist/store/session/disk.d.ts.map +1 -1
  162. package/dist/store/session/disk.js +61 -0
  163. package/dist/store/session/disk.js.map +1 -1
  164. package/dist/store/session/index.d.ts.map +1 -1
  165. package/dist/store/session/index.js +3 -4
  166. package/dist/store/session/index.js.map +1 -1
  167. package/dist/store/session/memory.d.ts +2 -1
  168. package/dist/store/session/memory.d.ts.map +1 -1
  169. package/dist/store/session/memory.js +13 -0
  170. package/dist/store/session/memory.js.map +1 -1
  171. package/dist/store/thread/disk.d.ts +41 -0
  172. package/dist/store/thread/disk.d.ts.map +1 -0
  173. package/dist/store/thread/disk.js +229 -0
  174. package/dist/store/thread/disk.js.map +1 -0
  175. package/dist/store/thread/index.d.ts +4 -0
  176. package/dist/store/thread/index.d.ts.map +1 -0
  177. package/dist/store/thread/index.js +6 -0
  178. package/dist/store/thread/index.js.map +1 -0
  179. package/dist/store/thread/memory.d.ts +23 -0
  180. package/dist/store/thread/memory.d.ts.map +1 -0
  181. package/dist/store/thread/memory.js +90 -0
  182. package/dist/store/thread/memory.js.map +1 -0
  183. package/dist/telemetry/runtime-accessors.d.ts +4 -0
  184. package/dist/telemetry/runtime-accessors.d.ts.map +1 -0
  185. package/dist/telemetry/runtime-accessors.js +17 -0
  186. package/dist/telemetry/runtime-accessors.js.map +1 -0
  187. package/dist/types/agent/base.d.ts +17 -21
  188. package/dist/types/agent/base.d.ts.map +1 -1
  189. package/dist/types/agent/factory.d.ts +8 -2
  190. package/dist/types/agent/factory.d.ts.map +1 -1
  191. package/dist/types/agent/task.d.ts +18 -11
  192. package/dist/types/agent/task.d.ts.map +1 -1
  193. package/dist/types/ids/index.d.ts +5 -9
  194. package/dist/types/ids/index.d.ts.map +1 -1
  195. package/dist/types/ids/index.js +4 -4
  196. package/dist/types/ids/index.js.map +1 -1
  197. package/dist/types/rag/retrieval.d.ts +4 -3
  198. package/dist/types/rag/retrieval.d.ts.map +1 -1
  199. package/dist/types/run/config.d.ts +6 -5
  200. package/dist/types/run/config.d.ts.map +1 -1
  201. package/dist/types/run/metadata.d.ts +5 -18
  202. package/dist/types/run/metadata.d.ts.map +1 -1
  203. package/dist/types/session/ids.d.ts +4 -13
  204. package/dist/types/session/ids.d.ts.map +1 -1
  205. package/dist/types/session/ids.js +3 -6
  206. package/dist/types/session/ids.js.map +1 -1
  207. package/dist/types/session/index.d.ts +1 -1
  208. package/dist/types/session/index.d.ts.map +1 -1
  209. package/dist/types/session/store.d.ts +32 -10
  210. package/dist/types/session/store.d.ts.map +1 -1
  211. package/dist/types/session/store.js +3 -8
  212. package/dist/types/session/store.js.map +1 -1
  213. package/dist/types/thread/index.d.ts +2 -0
  214. package/dist/types/thread/index.d.ts.map +1 -0
  215. package/dist/types/thread/index.js +5 -0
  216. package/dist/types/thread/index.js.map +1 -0
  217. package/dist/types/thread/store.d.ts +86 -0
  218. package/dist/types/thread/store.d.ts.map +1 -0
  219. package/dist/types/thread/store.js +22 -0
  220. package/dist/types/thread/store.js.map +1 -0
  221. package/dist/utils/id.d.ts +1 -12
  222. package/dist/utils/id.d.ts.map +1 -1
  223. package/dist/utils/id.js +3 -23
  224. package/dist/utils/id.js.map +1 -1
  225. package/package.json +11 -20
  226. package/src/agents/ReactiveAgent.ts +3 -2
  227. package/src/agents/SupervisorAgent.ts +5 -2
  228. package/src/bridge/a2a/index.ts +0 -1
  229. package/src/bridge/a2a/message.ts +0 -32
  230. package/src/bridge/a2a/task.ts +8 -7
  231. package/src/contracts/api.ts +6 -42
  232. package/src/contracts/ids.ts +1 -1
  233. package/src/contracts/index.ts +2 -8
  234. package/src/contracts/schemas.ts +1 -8
  235. package/src/index.ts +3 -15
  236. package/src/manager/agent/__tests__/lifecycle.test.ts +34 -13
  237. package/src/manager/agent/lifecycle.ts +114 -35
  238. package/src/manager/index.ts +3 -0
  239. package/src/manager/run/persistence.ts +7 -1
  240. package/src/manager/thread/__tests__/lifecycle.test.ts +286 -0
  241. package/src/manager/thread/lifecycle.ts +217 -0
  242. package/src/rag/retriever.ts +2 -2
  243. package/src/registry/tool/execute.ts +1 -1
  244. package/src/runtime/query/__tests__/context.test.ts +9 -8
  245. package/src/runtime/query/context-cache.ts +4 -4
  246. package/src/runtime/query/context.ts +15 -22
  247. package/src/runtime/query/index.ts +16 -17
  248. package/src/runtime/query/iteration/index.ts +1 -1
  249. package/src/session/__tests__/integration/_fixtures.ts +36 -8
  250. package/src/session/__tests__/integration/archive-gate.test.ts +288 -0
  251. package/src/session/__tests__/integration/capacity-caps.test.ts +13 -6
  252. package/src/session/__tests__/integration/e2e-spawn.test.ts +20 -2
  253. package/src/session/__tests__/integration/event-stream-ordering.test.ts +14 -7
  254. package/src/session/__tests__/integration/handoff-broadcast-e2e.test.ts +39 -13
  255. package/src/session/__tests__/integration/handoff-illegal-transition.test.ts +54 -19
  256. package/src/session/__tests__/integration/handoff-single-e2e.test.ts +40 -9
  257. package/src/session/__tests__/integration/hierarchy-lifecycle.test.ts +13 -10
  258. package/src/session/__tests__/integration/prev-artifact-dag.test.ts +12 -5
  259. package/src/session/__tests__/integration/retention-archive.test.ts +5 -3
  260. package/src/session/__tests__/integration/spawn-rollback.test.ts +313 -0
  261. package/src/session/__tests__/integration/summary-materialization-e2e.test.ts +4 -2
  262. package/src/session/__tests__/integration/tenant-isolation.test.ts +16 -6
  263. package/src/session/errors.ts +89 -0
  264. package/src/session/handoff/__tests__/broadcast.test.ts +56 -28
  265. package/src/session/handoff/__tests__/capacity.test.ts +26 -20
  266. package/src/session/handoff/__tests__/single.test.ts +45 -28
  267. package/src/session/handoff/assignment.ts +13 -1
  268. package/src/session/handoff/broadcast.ts +26 -1
  269. package/src/session/handoff/single.ts +23 -1
  270. package/src/session/hierarchy/__tests__/session.test.ts +9 -1
  271. package/src/session/hierarchy/index.ts +1 -0
  272. package/src/session/hierarchy/session.ts +15 -3
  273. package/src/session/hierarchy/thread.ts +55 -0
  274. package/src/session/migration/id-prefix.ts +8 -13
  275. package/src/session/retention/__tests__/archive.test.ts +5 -3
  276. package/src/session/summary/__tests__/materialize.test.ts +6 -4
  277. package/src/store/index.ts +0 -3
  278. package/src/store/session/__tests__/disk.test.ts +57 -6
  279. package/src/store/session/__tests__/memory.test.ts +84 -9
  280. package/src/store/session/disk.ts +57 -1
  281. package/src/store/session/index.ts +3 -4
  282. package/src/store/session/memory.ts +13 -1
  283. package/src/store/thread/disk.ts +261 -0
  284. package/src/store/thread/index.ts +7 -0
  285. package/src/store/thread/memory.ts +104 -0
  286. package/src/telemetry/runtime-accessors.ts +19 -0
  287. package/src/types/agent/base.ts +17 -21
  288. package/src/types/agent/factory.ts +8 -3
  289. package/src/types/agent/task.ts +19 -11
  290. package/src/types/ids/index.ts +8 -15
  291. package/src/types/rag/retrieval.ts +4 -3
  292. package/src/types/run/config.ts +6 -5
  293. package/src/types/run/metadata.ts +5 -18
  294. package/src/types/session/ids.ts +4 -15
  295. package/src/types/session/index.ts +1 -2
  296. package/src/types/session/store.ts +34 -11
  297. package/src/types/thread/index.ts +5 -0
  298. package/src/types/thread/store.ts +92 -0
  299. package/src/utils/id.ts +3 -24
  300. package/dist/provider/telemetry/setup.d.ts +0 -19
  301. package/dist/provider/telemetry/setup.d.ts.map +0 -1
  302. package/dist/provider/telemetry/setup.js +0 -102
  303. package/dist/provider/telemetry/setup.js.map +0 -1
  304. package/dist/store/conversation/memory.d.ts +0 -43
  305. package/dist/store/conversation/memory.d.ts.map +0 -1
  306. package/dist/store/conversation/memory.js +0 -108
  307. package/dist/store/conversation/memory.js.map +0 -1
  308. package/dist/telemetry/index.d.ts +0 -6
  309. package/dist/telemetry/index.d.ts.map +0 -1
  310. package/dist/telemetry/index.js +0 -4
  311. package/dist/telemetry/index.js.map +0 -1
  312. package/dist/telemetry/metrics.d.ts +0 -8
  313. package/dist/telemetry/metrics.d.ts.map +0 -1
  314. package/dist/telemetry/metrics.js +0 -53
  315. package/dist/telemetry/metrics.js.map +0 -1
  316. package/dist/types/conversation/index.d.ts +0 -14
  317. package/dist/types/conversation/index.d.ts.map +0 -1
  318. package/dist/types/conversation/index.js +0 -2
  319. package/dist/types/conversation/index.js.map +0 -1
  320. package/dist/types/telemetry/index.d.ts +0 -10
  321. package/dist/types/telemetry/index.d.ts.map +0 -1
  322. package/dist/types/telemetry/index.js +0 -2
  323. package/dist/types/telemetry/index.js.map +0 -1
  324. package/src/provider/telemetry/setup.ts +0 -125
  325. package/src/store/conversation/memory.ts +0 -144
  326. package/src/telemetry/index.ts +0 -14
  327. package/src/telemetry/metrics.ts +0 -69
  328. package/src/types/conversation/index.ts +0 -15
  329. package/src/types/telemetry/index.ts +0 -10
@@ -35,6 +35,7 @@ import { ZERO_COST } from '../../utils/cost.js'
35
35
  import { toErrorMessage } from '../../utils/error.js'
36
36
  import { generateTaskId } from '../../utils/id.js'
37
37
  import { type Logger, getRootLogger } from '../../utils/logger.js'
38
+ import type { ThreadManager } from '../thread/lifecycle.js'
38
39
 
39
40
  /**
40
41
  * Dependencies threaded into {@link AgentManager}. Phase 6 promoted the
@@ -60,6 +61,14 @@ export interface AgentManagerDeps {
60
61
  readonly workspaceRegistry: WorkspaceBackendRegistry
61
62
  readonly summaryMaterializer: SessionSummaryMaterializer
62
63
  readonly capacity: CapacityValidator
64
+ /**
65
+ * Gate session creation on the parent Thread being `'open'` via
66
+ * {@link ThreadManager.requireOpen}. Added in Phase 2.6 to close the
67
+ * archive-gate gap flagged by the Phase 2.5 commit: without this,
68
+ * `ThreadManager.archive` was best-effort because spawn could still
69
+ * attach a live session under an archived Thread.
70
+ */
71
+ readonly threadManager: ThreadManager
63
72
  }
64
73
 
65
74
  interface ChildSpawnRecord {
@@ -146,6 +155,7 @@ export class AgentManager {
146
155
  budgetTracker: context.budgetTracker,
147
156
  factoryOptions: context.factoryOptions,
148
157
  tenantId: context.tenantId,
158
+ threadId: context.threadId,
149
159
  sessionId: spawnRecord.childSessionId,
150
160
  projectId: context.projectId,
151
161
  parentActor: childParentActor,
@@ -203,12 +213,16 @@ export class AgentManager {
203
213
 
204
214
  const definition = this.registry.getOrThrow(options.agentId)
205
215
  let childConfig: BaseAgentConfig
206
- if (definition.configBuilder && context.factoryOptions) {
216
+ if (definition.configBuilder) {
217
+ // Call the configBuilder regardless of whether factoryOptions were
218
+ // supplied. BYO-provider flows (Bedrock IAM, custom ProviderRegistry)
219
+ // commonly omit factoryOptions because the provider resolves its own
220
+ // credentials; the builder still needs to run to wire provider+tools.
221
+ // Defaults: empty factoryOptions when omitted; configOverrides win.
207
222
  childConfig = await definition.configBuilder({
208
- ...context.factoryOptions,
223
+ ...(context.factoryOptions ?? {}),
209
224
  tokenBudget: allocatedTokens,
210
225
  timeoutMs: options.budgetAllocation?.timeoutMs ?? context.budgetTracker.remaining,
211
- threadId: context.projectId as string,
212
226
  parentRunId: context.parentRunId as string | undefined,
213
227
  depth: context.depth + 1,
214
228
  ...options.configOverrides,
@@ -222,10 +236,11 @@ export class AgentManager {
222
236
  // configBuilder may not have been updated to emit these yet; we
223
237
  // stamp them here so query() sees them regardless.
224
238
  childConfig.sessionId = spawnRecord?.childSessionId ?? context.sessionId
239
+ childConfig.threadId = context.threadId
225
240
  childConfig.projectId = context.projectId
226
241
  childConfig.tenantId = context.tenantId
227
242
  } else {
228
- this.log.warn('No configBuilder or factoryOptions, using bare config', {
243
+ this.log.warn('No configBuilder, using bare config', {
229
244
  agentId: options.agentId,
230
245
  })
231
246
  childConfig = {
@@ -236,8 +251,8 @@ export class AgentManager {
236
251
  maxIterations: options.configOverrides?.maxIterations,
237
252
  maxResponseTokens: options.configOverrides?.maxResponseTokens,
238
253
  env: options.configOverrides?.env,
239
- threadId: context.projectId,
240
254
  sessionId: spawnRecord.childSessionId,
255
+ threadId: context.threadId,
241
256
  projectId: context.projectId,
242
257
  tenantId: context.tenantId,
243
258
  parentRunId: context.parentRunId,
@@ -367,6 +382,42 @@ export class AgentManager {
367
382
  // partial/legacy path).
368
383
  const store = this.deps.sessionStore
369
384
 
385
+ // Thread archive gate — runs FIRST so an archived thread fails fastest
386
+ // with the correct error (not DelegationCapacityExceeded or a project
387
+ // lookup error). Phase 2.6 closes the gap the Phase 2.5 commit
388
+ // flagged: without it, `ThreadManager.archive` could be undermined by
389
+ // a concurrent spawn landing a live session post-archival.
390
+ // Scope: this gate enforces the archive invariant at the production
391
+ // ingress path (AgentManager.sendMessage + handoff flows). Direct
392
+ // callers of `SessionStore.createSession` bypass it — the store layer
393
+ // is intentionally unaware of thread status to preserve its
394
+ // single-responsibility boundary.
395
+ await this.deps.threadManager.requireOpen(context.threadId, context.tenantId)
396
+
397
+ // Parent session cross-check: validate that `options.parentSessionId`
398
+ // exists for this tenant AND lives under the same thread as the
399
+ // context. A mismatched `context.threadId` would otherwise attach the
400
+ // child's sub-session edge to a parent in a different thread —
401
+ // corrupting the hierarchy invariant (cross-thread spawn is forbidden
402
+ // by design). Mirrors the `source.threadId === assignment.threadId`
403
+ // check in handoff (Phase 2.4).
404
+ const parentSession = await store.getSession(options.parentSessionId, context.tenantId)
405
+ if (!parentSession) {
406
+ throw new Error(
407
+ `Parent session ${options.parentSessionId} not found for tenant ${context.tenantId} — spawn rejected`,
408
+ )
409
+ }
410
+ if (parentSession.threadId !== context.threadId) {
411
+ throw new Error(
412
+ `Thread mismatch on spawn: parent session ${parentSession.id} is on thread ${parentSession.threadId}, but context.threadId=${context.threadId}. Cross-thread spawn is forbidden (session-hierarchy.md §6.3).`,
413
+ )
414
+ }
415
+ if (parentSession.projectId !== context.projectId) {
416
+ throw new Error(
417
+ `Project mismatch on spawn: parent session ${parentSession.id} is on project ${parentSession.projectId}, but context.projectId=${context.projectId}.`,
418
+ )
419
+ }
420
+
370
421
  const project = await store.getProject(context.projectId, context.tenantId)
371
422
  if (!project) {
372
423
  throw new Error(
@@ -401,45 +452,73 @@ export class AgentManager {
401
452
  parentActor: context.parentActor,
402
453
  }
403
454
 
455
+ // Child session inherits the parent's threadId verbatim (cross-thread
456
+ // spawn is forbidden by design — a delegated sub-agent stays on the
457
+ // same topic). Phase 2.6 elides the previous parent-session read by
458
+ // carrying `threadId` on `AgentTaskContext`.
404
459
  const childSession = await store.createSession(
405
- { projectId: context.projectId, currentActor: childActor },
406
- context.tenantId,
407
- )
408
-
409
- // Flip to 'active' so the materializer's atomic write + status flip
410
- // lands on terminal — §5.3: pending→active→idle.
411
- await store.updateSession({ ...childSession, status: 'active' }, context.tenantId)
412
-
413
- const subSession = await store.createSubSession(
414
460
  {
415
- parentSessionId: options.parentSessionId,
416
- childSessionId: childSession.id,
417
- kind: 'agent_spawn',
418
- spawnedBy: context.parentActor,
419
- failureMode: 'delegate',
420
- completionMode: 'summary_ref',
461
+ threadId: context.threadId,
462
+ projectId: context.projectId,
463
+ currentActor: childActor,
421
464
  },
422
465
  context.tenantId,
423
466
  )
424
467
 
425
- // Workspace provisioning best-effort. When the requested backend is
426
- // registered we create a new workspace for the child; failures surface
427
- // as WorkspaceBackendError and abort the spawn (Convention #0: no
428
- // silent fallback). Pattern doc §7.1 allows lazy provisioning: an
429
- // unregistered backend leaves `workspaceRef: undefined` on the spawn
430
- // record, not a hard error the registry is the capability surface.
468
+ // Compensating rollback wraps every mutation after createSession so a
469
+ // mid-flight failure (status flip, subsession insert, workspace driver)
470
+ // leaves no orphan child session. Codex SPAWN-ROLLBACK critique (Phase
471
+ // 2 review, 2026-04-18): without this, `workspaceRegistry.get().create`
472
+ // throwing or a concurrent `updateSession` race strands an
473
+ // `active` child session with no subsession edge, invisible to the
474
+ // parent but counted against `maxDelegationWidth`.
475
+ let subSession: Awaited<ReturnType<typeof store.createSubSession>> | undefined
431
476
  let workspaceRef: WorkspaceRef | undefined
432
- const backend = options.workspaceBackend ?? 'git-worktree'
433
- if (this.deps.workspaceRegistry.has(backend)) {
434
- const driver = this.deps.workspaceRegistry.get(backend)
435
- try {
477
+ try {
478
+ // Flip to 'active' so the materializer's atomic write + status flip
479
+ // lands on terminal — §5.3: pending→active→idle.
480
+ await store.updateSession({ ...childSession, status: 'active' }, context.tenantId)
481
+
482
+ subSession = await store.createSubSession(
483
+ {
484
+ parentSessionId: options.parentSessionId,
485
+ childSessionId: childSession.id,
486
+ kind: 'agent_spawn',
487
+ spawnedBy: context.parentActor,
488
+ failureMode: 'delegate',
489
+ completionMode: 'summary_ref',
490
+ },
491
+ context.tenantId,
492
+ )
493
+
494
+ // Workspace provisioning — best-effort. When the requested backend
495
+ // is registered we create a new workspace for the child; failures
496
+ // surface as WorkspaceBackendError and abort the spawn (Convention
497
+ // #0: no silent fallback). Pattern doc §7.1 allows lazy
498
+ // provisioning: an unregistered backend leaves `workspaceRef:
499
+ // undefined` on the spawn record, not a hard error — the registry
500
+ // is the capability surface.
501
+ const backend = options.workspaceBackend ?? 'git-worktree'
502
+ if (this.deps.workspaceRegistry.has(backend)) {
503
+ const driver = this.deps.workspaceRegistry.get(backend)
436
504
  workspaceRef = await driver.create({ label: subSession.id })
437
- } catch (err) {
438
- // Surface the failure — the subsession record exists but is
439
- // unusable without a workspace. Dispose any partial state.
440
- await store.updateSubSession({ ...subSession, status: 'failed' }, context.tenantId)
441
- throw err
442
505
  }
506
+ } catch (err) {
507
+ // Compensating rollback order is mandated by the store's
508
+ // deny-by-default cascade policy (Convention #5): `deleteSession`
509
+ // throws when any subsession still references it, so the subsession
510
+ // record must be removed first. No failed-subsession audit row is
511
+ // kept — the `subsession_spawned` run event never fired (we aborted
512
+ // before `buildSpawnRecord`), so no observer is expecting one, and
513
+ // leaving a `status: 'failed'` breadcrumb would be a dangling
514
+ // record with no corresponding emission. The original `err` is the
515
+ // caller-visible signal; cleanup errors are swallowed so they
516
+ // cannot mask it.
517
+ if (subSession !== undefined) {
518
+ await store.deleteSubSession(subSession.id, context.tenantId).catch(() => undefined)
519
+ }
520
+ await store.deleteSession(childSession.id, context.tenantId).catch(() => undefined)
521
+ throw err
443
522
  }
444
523
 
445
524
  return {
@@ -16,4 +16,7 @@ export type {
16
16
  export { PlanManager } from './plan/lifecycle.js'
17
17
  export type { PlanEvent, PlanEventListener, PlanApprovalHandler } from './plan/lifecycle.js'
18
18
 
19
+ export { ThreadManager } from './thread/lifecycle.js'
20
+ export type { ThreadManagerDeps } from './thread/lifecycle.js'
21
+
19
22
  export { AgentManager } from './agent/lifecycle.js'
@@ -5,7 +5,7 @@ import type { RunId, SessionId, TenantId } from '../../types/ids/index.js'
5
5
  import type { AssistantMessage, Message } from '../../types/message/index.js'
6
6
  import type { EmergencySaveData } from '../../types/run/emergency.js'
7
7
  import type { AgentRun, RunPersistenceConfig, StopReason } from '../../types/run/index.js'
8
- import type { ProjectId } from '../../types/session/ids.js'
8
+ import type { ProjectId, ThreadId } from '../../types/session/ids.js'
9
9
  import { type ModelPricing, ZERO_COST, accumulateCost } from '../../utils/cost.js'
10
10
  import { generateEmergencySaveId } from '../../utils/id.js'
11
11
  import type { Logger } from '../../utils/logger.js'
@@ -16,6 +16,7 @@ export class RunPersistence {
16
16
  private pricing?: ModelPricing
17
17
  private log: Logger
18
18
  private readonly _sessionId: SessionId
19
+ private readonly _threadId: ThreadId
19
20
  private readonly _tenantId: TenantId
20
21
  private readonly _projectId: ProjectId
21
22
 
@@ -23,6 +24,7 @@ export class RunPersistence {
23
24
  this.pricing = config.pricing
24
25
  this.log = config.log
25
26
  this._sessionId = config.sessionId
27
+ this._threadId = config.threadId
26
28
  this._tenantId = config.tenantId
27
29
  this._projectId = config.projectId
28
30
 
@@ -58,6 +60,10 @@ export class RunPersistence {
58
60
  return this._sessionId
59
61
  }
60
62
 
63
+ get threadId(): ThreadId {
64
+ return this._threadId
65
+ }
66
+
61
67
  get tenantId(): TenantId {
62
68
  return this._tenantId
63
69
  }
@@ -0,0 +1,286 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { ThreadClosedError, ThreadNotEmptyError } from '../../../session/errors.js'
3
+ import type { ActorRef } from '../../../session/hierarchy/actor.js'
4
+ import { InMemorySessionStore } from '../../../store/session/memory.js'
5
+ import { InMemoryThreadStore } from '../../../store/thread/memory.js'
6
+ import type { AgentId, TenantId, UserId } from '../../../types/ids/index.js'
7
+ import type { ThreadId } from '../../../types/session/ids.js'
8
+ import { ThreadManager } from '../lifecycle.js'
9
+
10
+ const MISSING_THREAD_ID = 'thd_missing' as ThreadId
11
+
12
+ const tenantA = 'tnt_alpha' as TenantId
13
+ const tenantB = 'tnt_beta' as TenantId
14
+
15
+ function userActor(tenantId: TenantId): ActorRef {
16
+ return { kind: 'user', userId: 'usr_a' as UserId, tenantId }
17
+ }
18
+
19
+ function agentActor(tenantId: TenantId): ActorRef {
20
+ return { kind: 'agent', agentId: 'agt_a' as AgentId, tenantId }
21
+ }
22
+
23
+ async function harness(tenantId: TenantId = tenantA) {
24
+ const threadStore = new InMemoryThreadStore()
25
+ const sessionStore = new InMemorySessionStore()
26
+ const project = await sessionStore.createProject({ tenantId, name: 'p1' }, tenantId)
27
+ const thread = await threadStore.createThread({ projectId: project.id, title: 't' }, tenantId)
28
+ const manager = new ThreadManager({ threadStore, sessionStore })
29
+ return { threadStore, sessionStore, project, thread, manager }
30
+ }
31
+
32
+ describe('ThreadManager', () => {
33
+ describe('requireOpen', () => {
34
+ it('returns the thread when open', async () => {
35
+ const { thread, manager } = await harness()
36
+ await expect(manager.requireOpen(thread.id, tenantA)).resolves.toMatchObject({
37
+ id: thread.id,
38
+ status: 'open',
39
+ })
40
+ })
41
+
42
+ it('throws ThreadClosedError when archived', async () => {
43
+ const { thread, manager, threadStore } = await harness()
44
+ await threadStore.updateThread({ ...thread, status: 'archived' }, tenantA)
45
+ await expect(manager.requireOpen(thread.id, tenantA)).rejects.toBeInstanceOf(
46
+ ThreadClosedError,
47
+ )
48
+ })
49
+
50
+ it('throws when the thread does not exist', async () => {
51
+ const { manager } = await harness()
52
+ await expect(manager.requireOpen(MISSING_THREAD_ID, tenantA)).rejects.toThrow(/not found/)
53
+ })
54
+ })
55
+
56
+ describe('archive', () => {
57
+ it('flips status to archived and bumps ownerVersion', async () => {
58
+ const { thread, manager } = await harness()
59
+ const archived = await manager.archive(thread.id, tenantA)
60
+ expect(archived.status).toBe('archived')
61
+ expect(archived.ownerVersion).toBe(thread.ownerVersion + 1)
62
+ })
63
+
64
+ it('is idempotent on an already-archived thread (no store write)', async () => {
65
+ const { thread, manager, threadStore } = await harness()
66
+ await threadStore.updateThread({ ...thread, status: 'archived' }, tenantA)
67
+ const before = await threadStore.getThread(thread.id, tenantA)
68
+
69
+ const result = await manager.archive(thread.id, tenantA)
70
+ expect(result.status).toBe('archived')
71
+ // Re-archival must NOT advance ownerVersion — the store would have
72
+ // rejected a second updateThread as stale anyway; we assert the
73
+ // short-circuit path held instead.
74
+ expect(result.ownerVersion).toBe(before?.ownerVersion)
75
+ })
76
+
77
+ it('throws when the thread does not exist', async () => {
78
+ const { manager } = await harness()
79
+ await expect(manager.archive(MISSING_THREAD_ID, tenantA)).rejects.toThrow(/not found/)
80
+ })
81
+
82
+ it('rejects with ThreadNotEmptyError when a session is active', async () => {
83
+ const { thread, project, manager, sessionStore } = await harness()
84
+ const session = await sessionStore.createSession(
85
+ {
86
+ threadId: thread.id,
87
+ projectId: project.id,
88
+ currentActor: userActor(tenantA),
89
+ },
90
+ tenantA,
91
+ )
92
+ await sessionStore.updateSession({ ...session, status: 'active' }, tenantA)
93
+
94
+ await expect(manager.archive(thread.id, tenantA)).rejects.toMatchObject({
95
+ name: 'ThreadNotEmptyError',
96
+ details: {
97
+ threadId: thread.id,
98
+ tenantId: tenantA,
99
+ op: 'archive',
100
+ totalBlockingSessions: 1,
101
+ blockingSessions: [{ sessionId: session.id, status: 'active' }],
102
+ },
103
+ })
104
+ })
105
+
106
+ it('defensive re-check: already-archived thread with a smuggled active session still rejects', async () => {
107
+ // Flip the thread to archived directly (bypassing manager.archive so
108
+ // no check runs), then attach an active session via direct store
109
+ // mutation. A subsequent manager.archive() must surface the offender
110
+ // as ThreadNotEmptyError, not short-circuit as "already archived".
111
+ const { thread, project, manager, sessionStore, threadStore } = await harness()
112
+ await threadStore.updateThread({ ...thread, status: 'archived' }, tenantA)
113
+ const smuggled = await sessionStore.createSession(
114
+ {
115
+ threadId: thread.id,
116
+ projectId: project.id,
117
+ currentActor: userActor(tenantA),
118
+ },
119
+ tenantA,
120
+ )
121
+ await sessionStore.updateSession({ ...smuggled, status: 'active' }, tenantA)
122
+
123
+ await expect(manager.archive(thread.id, tenantA)).rejects.toMatchObject({
124
+ name: 'ThreadNotEmptyError',
125
+ details: {
126
+ op: 'archive',
127
+ totalBlockingSessions: 1,
128
+ blockingSessions: [{ sessionId: smuggled.id, status: 'active' }],
129
+ },
130
+ })
131
+ })
132
+
133
+ it.each(['locked', 'awaiting_hitl', 'awaiting_merge'] as const)(
134
+ 'rejects when a session is %s',
135
+ async (status) => {
136
+ const { thread, project, manager, sessionStore } = await harness()
137
+ const session = await sessionStore.createSession(
138
+ {
139
+ threadId: thread.id,
140
+ projectId: project.id,
141
+ currentActor: userActor(tenantA),
142
+ },
143
+ tenantA,
144
+ )
145
+ await sessionStore.updateSession({ ...session, status }, tenantA)
146
+
147
+ await expect(manager.archive(thread.id, tenantA)).rejects.toBeInstanceOf(
148
+ ThreadNotEmptyError,
149
+ )
150
+ },
151
+ )
152
+
153
+ it('allows archival when every session is quiescent (idle / failed / archived)', async () => {
154
+ const { thread, project, manager, sessionStore } = await harness()
155
+ // `createSession` defaults to `idle`; force the others via updateSession.
156
+ await sessionStore.createSession(
157
+ {
158
+ threadId: thread.id,
159
+ projectId: project.id,
160
+ currentActor: userActor(tenantA),
161
+ },
162
+ tenantA,
163
+ )
164
+ const sFailed = await sessionStore.createSession(
165
+ {
166
+ threadId: thread.id,
167
+ projectId: project.id,
168
+ currentActor: agentActor(tenantA),
169
+ },
170
+ tenantA,
171
+ )
172
+ await sessionStore.updateSession({ ...sFailed, status: 'failed' }, tenantA)
173
+
174
+ const archived = await manager.archive(thread.id, tenantA)
175
+ expect(archived.status).toBe('archived')
176
+ })
177
+
178
+ it('ignores sessions attached to a sibling thread', async () => {
179
+ const { thread, project, manager, sessionStore, threadStore } = await harness()
180
+ const other = await threadStore.createThread(
181
+ { projectId: project.id, title: 'other' },
182
+ tenantA,
183
+ )
184
+ // Active session under the OTHER thread must not block archival of
185
+ // `thread`.
186
+ const otherSession = await sessionStore.createSession(
187
+ {
188
+ threadId: other.id,
189
+ projectId: project.id,
190
+ currentActor: userActor(tenantA),
191
+ },
192
+ tenantA,
193
+ )
194
+ await sessionStore.updateSession({ ...otherSession, status: 'active' }, tenantA)
195
+
196
+ await expect(manager.archive(thread.id, tenantA)).resolves.toMatchObject({
197
+ status: 'archived',
198
+ })
199
+ })
200
+
201
+ it('does not leak cross-tenant sessions into the precondition', async () => {
202
+ // Shared stores across tenants (production shape). A session with
203
+ // the same threadId string under tenantB must not block archival
204
+ // of tenantA's thread.
205
+ const threadStore = new InMemoryThreadStore()
206
+ const sessionStore = new InMemorySessionStore()
207
+ const manager = new ThreadManager({ threadStore, sessionStore })
208
+
209
+ const pA = await sessionStore.createProject({ tenantId: tenantA, name: 'pa' }, tenantA)
210
+ const pB = await sessionStore.createProject({ tenantId: tenantB, name: 'pb' }, tenantB)
211
+ const tA = await threadStore.createThread({ projectId: pA.id, title: 'ta' }, tenantA)
212
+
213
+ // Cross-tenant session with the same threadId string as tA.
214
+ const bSession = await sessionStore.createSession(
215
+ { threadId: tA.id, projectId: pB.id, currentActor: userActor(tenantB) },
216
+ tenantB,
217
+ )
218
+ await sessionStore.updateSession({ ...bSession, status: 'active' }, tenantB)
219
+
220
+ await expect(manager.archive(tA.id, tenantA)).resolves.toMatchObject({
221
+ status: 'archived',
222
+ })
223
+ })
224
+ })
225
+
226
+ describe('delete', () => {
227
+ it('deletes an empty thread', async () => {
228
+ const { thread, manager, threadStore } = await harness()
229
+ await manager.delete(thread.id, tenantA)
230
+ expect(await threadStore.getThread(thread.id, tenantA)).toBeNull()
231
+ })
232
+
233
+ it('rejects with ThreadNotEmptyError when any session references the thread', async () => {
234
+ const { thread, project, manager, sessionStore } = await harness()
235
+ const session = await sessionStore.createSession(
236
+ {
237
+ threadId: thread.id,
238
+ projectId: project.id,
239
+ currentActor: userActor(tenantA),
240
+ },
241
+ tenantA,
242
+ )
243
+ // Idle — allowed under archive, still blocks delete.
244
+ await expect(manager.delete(thread.id, tenantA)).rejects.toMatchObject({
245
+ name: 'ThreadNotEmptyError',
246
+ details: {
247
+ threadId: thread.id,
248
+ tenantId: tenantA,
249
+ op: 'delete',
250
+ totalBlockingSessions: 1,
251
+ blockingSessions: [{ sessionId: session.id, status: 'idle' }],
252
+ },
253
+ })
254
+ })
255
+
256
+ it('detects orphaned sessions referencing a missing thread', async () => {
257
+ // Thread record is destroyed via the store directly, but a session
258
+ // still carries its threadId. Manager.delete must reject rather
259
+ // than silently succeed on the "thread is already gone" short-cut
260
+ // (the session scan runs unconditionally).
261
+ const { thread, project, manager, sessionStore, threadStore } = await harness()
262
+ const orphan = await sessionStore.createSession(
263
+ {
264
+ threadId: thread.id,
265
+ projectId: project.id,
266
+ currentActor: userActor(tenantA),
267
+ },
268
+ tenantA,
269
+ )
270
+ await threadStore.deleteThread(thread.id, tenantA)
271
+
272
+ await expect(manager.delete(thread.id, tenantA)).rejects.toMatchObject({
273
+ name: 'ThreadNotEmptyError',
274
+ details: {
275
+ op: 'delete',
276
+ blockingSessions: [{ sessionId: orphan.id, status: 'idle' }],
277
+ },
278
+ })
279
+ })
280
+
281
+ it('is idempotent for an absent thread with no orphans', async () => {
282
+ const { manager } = await harness()
283
+ await expect(manager.delete(MISSING_THREAD_ID, tenantA)).resolves.toBeUndefined()
284
+ })
285
+ })
286
+ })