@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
@@ -1,4 +1,5 @@
1
1
  import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { ThreadManager } from '../../../manager/thread/lifecycle.js'
2
3
  import type { ActorRef } from '../../../session/hierarchy/actor.js'
3
4
  import {
4
5
  type ExecFile,
@@ -7,6 +8,7 @@ import {
7
8
  } from '../../../session/workspace/git-worktree.js'
8
9
  import { WorkspaceBackendRegistry } from '../../../session/workspace/registry.js'
9
10
  import { InMemorySessionStore } from '../../../store/session/memory.js'
11
+ import { InMemoryThreadStore } from '../../../store/thread/memory.js'
10
12
  import type { SessionId, TenantId, UserId } from '../../../types/ids/index.js'
11
13
  import type { ProjectId } from '../../../types/session/ids.js'
12
14
  import { generateHandoffId } from '../../../utils/id.js'
@@ -56,7 +58,11 @@ interface DepsBundle {
56
58
  events: MockedHandoffEventSink
57
59
  }
58
60
 
59
- function buildDeps(store: InMemorySessionStore, execOverride?: ExecFile): DepsBundle {
61
+ function buildDeps(
62
+ store: InMemorySessionStore,
63
+ threadStore: InMemoryThreadStore,
64
+ execOverride?: ExecFile,
65
+ ): DepsBundle {
60
66
  const exec: ExecFile = execOverride ? execOverride : async (_file, _args) => okExec()
61
67
  const driver = new GitWorktreeDriver({
62
68
  repoRoot: '/repo',
@@ -73,29 +79,36 @@ function buildDeps(store: InMemorySessionStore, execOverride?: ExecFile): DepsBu
73
79
  onBroadcastRollback: vi.fn<(ev: HandoffBroadcastRollbackEvent) => void>(),
74
80
  }
75
81
 
82
+ const threadManager = new ThreadManager({ threadStore, sessionStore: store })
76
83
  return {
77
84
  deps: {
78
85
  store,
79
86
  workspaceRegistry: registry,
80
87
  capacity: new DefaultCapacityValidator(store),
81
88
  events,
89
+ threadManager,
82
90
  },
83
91
  events,
84
92
  }
85
93
  }
86
94
 
87
- async function seedIdle(store: InMemorySessionStore) {
95
+ async function seedIdle(store: InMemorySessionStore, threadStore: InMemoryThreadStore) {
88
96
  const project = await store.createProject({ tenantId: tenant, name: 'p' }, tenant)
97
+ const thread = await threadStore.createThread(
98
+ { projectId: project.id, title: 'handoff-broadcast-test' },
99
+ tenant,
100
+ )
89
101
  const session = await store.createSession(
90
- { projectId: project.id, currentActor: user('usr_source') },
102
+ { threadId: thread.id, projectId: project.id, currentActor: user('usr_source') },
91
103
  tenant,
92
104
  )
93
- return { project, session }
105
+ return { project, thread, session }
94
106
  }
95
107
 
96
108
  function buildAssignments(
97
109
  sourceSessionId: SessionId,
98
110
  projectId: ProjectId,
111
+ threadId: Awaited<ReturnType<InMemoryThreadStore['createThread']>>['id'],
99
112
  expectedOwnerVersion: number,
100
113
  recipients: ActorRef[],
101
114
  broadcastId = 'bc_1',
@@ -105,6 +118,7 @@ function buildAssignments(
105
118
  mode: 'broadcast' as const,
106
119
  sourceSessionId,
107
120
  tenantId: tenant,
121
+ threadId,
108
122
  projectId,
109
123
  sourceActor: user('usr_source'),
110
124
  recipientActor,
@@ -116,16 +130,18 @@ function buildAssignments(
116
130
 
117
131
  describe('executeBroadcastHandoff', () => {
118
132
  let store: InMemorySessionStore
133
+ let threadStore: InMemoryThreadStore
119
134
 
120
135
  beforeEach(() => {
121
136
  store = new InMemorySessionStore()
137
+ threadStore = new InMemoryThreadStore()
122
138
  })
123
139
 
124
140
  it('happy path: 3 recipients → source ends in awaiting_merge with 3 new children', async () => {
125
- const { project, session } = await seedIdle(store)
126
- const { deps, events } = buildDeps(store)
141
+ const { project, thread, session } = await seedIdle(store, threadStore)
142
+ const { deps, events } = buildDeps(store, threadStore)
127
143
 
128
- const assignments = buildAssignments(session.id, project.id, 0, [
144
+ const assignments = buildAssignments(session.id, project.id, thread.id, 0, [
129
145
  user('usr_bob'),
130
146
  user('usr_carol'),
131
147
  user('usr_dan'),
@@ -149,7 +165,7 @@ describe('executeBroadcastHandoff', () => {
149
165
  })
150
166
 
151
167
  it('rollback on mid-fan-out failure (2nd recipient worktree add fails): source reverts, rollback emits accurate partialState', async () => {
152
- const { project, session } = await seedIdle(store)
168
+ const { project, thread, session } = await seedIdle(store, threadStore)
153
169
 
154
170
  let addCount = 0
155
171
  const exec: ExecFile = async (_file, args) => {
@@ -159,9 +175,9 @@ describe('executeBroadcastHandoff', () => {
159
175
  }
160
176
  return okExec()
161
177
  }
162
- const { deps, events } = buildDeps(store, exec)
178
+ const { deps, events } = buildDeps(store, threadStore, exec)
163
179
 
164
- const assignments = buildAssignments(session.id, project.id, 0, [
180
+ const assignments = buildAssignments(session.id, project.id, thread.id, 0, [
165
181
  user('usr_b'),
166
182
  user('usr_c'),
167
183
  user('usr_d'),
@@ -196,7 +212,7 @@ describe('executeBroadcastHandoff', () => {
196
212
  })
197
213
 
198
214
  it('rollback performs full cleanup via deleteSubSession/deleteSession (no status-flip stopgap)', async () => {
199
- const { project, session } = await seedIdle(store)
215
+ const { project, thread, session } = await seedIdle(store, threadStore)
200
216
 
201
217
  let addCount = 0
202
218
  const exec: ExecFile = async (_file, args) => {
@@ -206,9 +222,12 @@ describe('executeBroadcastHandoff', () => {
206
222
  }
207
223
  return okExec()
208
224
  }
209
- const { deps } = buildDeps(store, exec)
225
+ const { deps } = buildDeps(store, threadStore, exec)
210
226
 
211
- const assignments = buildAssignments(session.id, project.id, 0, [user('usr_b'), user('usr_c')])
227
+ const assignments = buildAssignments(session.id, project.id, thread.id, 0, [
228
+ user('usr_b'),
229
+ user('usr_c'),
230
+ ])
212
231
 
213
232
  await expect(executeBroadcastHandoff(deps, assignments, tenant)).rejects.toThrow()
214
233
 
@@ -224,7 +243,7 @@ describe('executeBroadcastHandoff', () => {
224
243
  })
225
244
 
226
245
  it('rollback idempotency: worktree dispose throwing during rollback does not bubble a secondary failure', async () => {
227
- const { project, session } = await seedIdle(store)
246
+ const { project, thread, session } = await seedIdle(store, threadStore)
228
247
 
229
248
  let addCount = 0
230
249
  let removeCount = 0
@@ -242,9 +261,12 @@ describe('executeBroadcastHandoff', () => {
242
261
  }
243
262
  return okExec()
244
263
  }
245
- const { deps, events } = buildDeps(store, exec)
264
+ const { deps, events } = buildDeps(store, threadStore, exec)
246
265
 
247
- const assignments = buildAssignments(session.id, project.id, 0, [user('usr_b'), user('usr_c')])
266
+ const assignments = buildAssignments(session.id, project.id, thread.id, 0, [
267
+ user('usr_b'),
268
+ user('usr_c'),
269
+ ])
248
270
 
249
271
  // Outer failure is the PRIMARY one — the secondary dispose failure is
250
272
  // swallowed. Primary wraps in WorkspaceBackendError (create op).
@@ -262,11 +284,15 @@ describe('executeBroadcastHandoff', () => {
262
284
  })
263
285
 
264
286
  it('dedupe: two assignments targeting same recipient → rejected pre-lock (no side effects)', async () => {
265
- const { project, session } = await seedIdle(store)
266
- const { deps, events } = buildDeps(store)
287
+ const { project, thread, session } = await seedIdle(store, threadStore)
288
+ const { deps, events } = buildDeps(store, threadStore)
267
289
 
268
290
  const bob = user('usr_bob')
269
- const assignments = buildAssignments(session.id, project.id, 0, [bob, bob, user('usr_dan')])
291
+ const assignments = buildAssignments(session.id, project.id, thread.id, 0, [
292
+ bob,
293
+ bob,
294
+ user('usr_dan'),
295
+ ])
270
296
 
271
297
  await expect(executeBroadcastHandoff(deps, assignments, tenant)).rejects.toThrow(
272
298
  /duplicate recipient/,
@@ -281,11 +307,11 @@ describe('executeBroadcastHandoff', () => {
281
307
  })
282
308
 
283
309
  it('width cap: 9 recipients exceeds default maxWidth=8 → rejected before source lock', async () => {
284
- const { project, session } = await seedIdle(store)
285
- const { deps, events } = buildDeps(store)
310
+ const { project, thread, session } = await seedIdle(store, threadStore)
311
+ const { deps, events } = buildDeps(store, threadStore)
286
312
 
287
313
  const recipients = Array.from({ length: 9 }, (_, i) => user(`usr_${i}`))
288
- const assignments = buildAssignments(session.id, project.id, 0, recipients)
314
+ const assignments = buildAssignments(session.id, project.id, thread.id, 0, recipients)
289
315
 
290
316
  await expect(executeBroadcastHandoff(deps, assignments, tenant)).rejects.toThrow(
291
317
  /Delegation capacity exceeded/,
@@ -298,12 +324,13 @@ describe('executeBroadcastHandoff', () => {
298
324
  })
299
325
 
300
326
  it('concurrent broadcast on same source: second attempt rejected with HandoffVersionConflict', async () => {
301
- const { project, session } = await seedIdle(store)
302
- const { deps } = buildDeps(store)
327
+ const { project, thread, session } = await seedIdle(store, threadStore)
328
+ const { deps } = buildDeps(store, threadStore)
303
329
 
304
330
  const firstAssignments = buildAssignments(
305
331
  session.id,
306
332
  project.id,
333
+ thread.id,
307
334
  0,
308
335
  [user('usr_b'), user('usr_c')],
309
336
  'bc_1',
@@ -322,6 +349,7 @@ describe('executeBroadcastHandoff', () => {
322
349
  const second = buildAssignments(
323
350
  session.id,
324
351
  project.id,
352
+ thread.id,
325
353
  0, // stale — actual is 1
326
354
  [user('usr_d'), user('usr_e')],
327
355
  'bc_2',
@@ -332,16 +360,16 @@ describe('executeBroadcastHandoff', () => {
332
360
  })
333
361
 
334
362
  it('empty assignments → throws a descriptive error', async () => {
335
- const { deps } = buildDeps(store)
363
+ const { deps } = buildDeps(store, threadStore)
336
364
  await expect(executeBroadcastHandoff(deps, [], tenant)).rejects.toThrow(
337
365
  /assignments must not be empty/,
338
366
  )
339
367
  })
340
368
 
341
369
  it('single-row broadcast → rejected (caller must use executeSingleHandoff)', async () => {
342
- const { project, session } = await seedIdle(store)
343
- const { deps } = buildDeps(store)
344
- const assignments = buildAssignments(session.id, project.id, 0, [user('usr_b')])
370
+ const { project, thread, session } = await seedIdle(store, threadStore)
371
+ const { deps } = buildDeps(store, threadStore)
372
+ const assignments = buildAssignments(session.id, project.id, thread.id, 0, [user('usr_b')])
345
373
 
346
374
  await expect(executeBroadcastHandoff(deps, assignments, tenant)).rejects.toThrow(
347
375
  /single-recipient handoffs must use executeSingleHandoff/,
@@ -1,7 +1,9 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import type { ActorRef } from '../../../session/hierarchy/actor.js'
3
3
  import { InMemorySessionStore } from '../../../store/session/memory.js'
4
+ import { InMemoryThreadStore } from '../../../store/thread/memory.js'
4
5
  import type { AgentId, SessionId, TenantId, UserId } from '../../../types/ids/index.js'
6
+ import type { ProjectId, ThreadId } from '../../../types/session/ids.js'
5
7
  import { DefaultCapacityValidator, DelegationCapacityExceeded } from '../capacity.js'
6
8
 
7
9
  const tenant = 'tnt_alpha' as TenantId
@@ -16,18 +18,22 @@ function agent(): ActorRef {
16
18
 
17
19
  async function seedProject(store: InMemorySessionStore) {
18
20
  const project = await store.createProject({ tenantId: tenant, name: 'p' }, tenant)
19
- const root = await store.createSession({ projectId: project.id, currentActor: user() }, tenant)
20
- return { project, root }
21
+ const threadStore = new InMemoryThreadStore()
22
+ const thread = await threadStore.createThread({ projectId: project.id, title: 'default' }, tenant)
23
+ const root = await store.createSession(
24
+ { threadId: thread.id, projectId: project.id, currentActor: user() },
25
+ tenant,
26
+ )
27
+ return { project, thread, root }
21
28
  }
22
29
 
23
30
  async function spawnChild(
24
31
  store: InMemorySessionStore,
25
32
  parentId: SessionId,
26
- projectId: ReturnType<typeof String> extends never
27
- ? never
28
- : Parameters<InMemorySessionStore['createSession']>[0]['projectId'],
33
+ projectId: ProjectId,
34
+ threadId: ThreadId,
29
35
  ): Promise<{ childId: SessionId }> {
30
- const child = await store.createSession({ projectId, currentActor: user() }, tenant)
36
+ const child = await store.createSession({ threadId, projectId, currentActor: user() }, tenant)
31
37
  await store.createSubSession(
32
38
  {
33
39
  parentSessionId: parentId,
@@ -51,11 +57,11 @@ describe('DefaultCapacityValidator', () => {
51
57
 
52
58
  it('depth: chain of 4 (root→c1→c2→c3→c4) allows a 5th (depth 5) to pass when limit = 5', async () => {
53
59
  const store = new InMemorySessionStore()
54
- const { project, root } = await seedProject(store)
55
- const c1 = await spawnChild(store, root.id, project.id)
56
- const c2 = await spawnChild(store, c1.childId, project.id)
57
- const c3 = await spawnChild(store, c2.childId, project.id)
58
- const c4 = await spawnChild(store, c3.childId, project.id)
60
+ const { project, thread, root } = await seedProject(store)
61
+ const c1 = await spawnChild(store, root.id, project.id, thread.id)
62
+ const c2 = await spawnChild(store, c1.childId, project.id, thread.id)
63
+ const c3 = await spawnChild(store, c2.childId, project.id, thread.id)
64
+ const c4 = await spawnChild(store, c3.childId, project.id, thread.id)
59
65
 
60
66
  const validator = new DefaultCapacityValidator(store)
61
67
  // Ancestry of c4: root→c1→c2→c3→c4 = length 5. Spawning under c4 = depth 5.
@@ -64,11 +70,11 @@ describe('DefaultCapacityValidator', () => {
64
70
 
65
71
  it('depth: over-limit throws DelegationCapacityExceeded with dimension=depth', async () => {
66
72
  const store = new InMemorySessionStore()
67
- const { project, root } = await seedProject(store)
68
- const c1 = await spawnChild(store, root.id, project.id)
69
- const c2 = await spawnChild(store, c1.childId, project.id)
70
- const c3 = await spawnChild(store, c2.childId, project.id)
71
- const c4 = await spawnChild(store, c3.childId, project.id)
73
+ const { project, thread, root } = await seedProject(store)
74
+ const c1 = await spawnChild(store, root.id, project.id, thread.id)
75
+ const c2 = await spawnChild(store, c1.childId, project.id, thread.id)
76
+ const c3 = await spawnChild(store, c2.childId, project.id, thread.id)
77
+ const c4 = await spawnChild(store, c3.childId, project.id, thread.id)
72
78
 
73
79
  const validator = new DefaultCapacityValidator(store)
74
80
  try {
@@ -94,9 +100,9 @@ describe('DefaultCapacityValidator', () => {
94
100
 
95
101
  it('width: existing 5 + pending 3 = 8 passes exactly at the limit', async () => {
96
102
  const store = new InMemorySessionStore()
97
- const { project, root } = await seedProject(store)
103
+ const { project, thread, root } = await seedProject(store)
98
104
  for (let i = 0; i < 5; i++) {
99
- await spawnChild(store, root.id, project.id)
105
+ await spawnChild(store, root.id, project.id, thread.id)
100
106
  }
101
107
  const validator = new DefaultCapacityValidator(store)
102
108
  await expect(validator.validateWidth(root.id, 3, 8, tenant)).resolves.toBeUndefined()
@@ -104,9 +110,9 @@ describe('DefaultCapacityValidator', () => {
104
110
 
105
111
  it('width: existing 6 + pending 3 = 9 exceeds 8, throws dimension=width', async () => {
106
112
  const store = new InMemorySessionStore()
107
- const { project, root } = await seedProject(store)
113
+ const { project, thread, root } = await seedProject(store)
108
114
  for (let i = 0; i < 6; i++) {
109
- await spawnChild(store, root.id, project.id)
115
+ await spawnChild(store, root.id, project.id, thread.id)
110
116
  }
111
117
  const validator = new DefaultCapacityValidator(store)
112
118
  try {
@@ -1,4 +1,5 @@
1
1
  import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { ThreadManager } from '../../../manager/thread/lifecycle.js'
2
3
  import { TenantIsolationError } from '../../../session/errors.js'
3
4
  import type { ActorRef } from '../../../session/hierarchy/actor.js'
4
5
  import {
@@ -8,6 +9,7 @@ import {
8
9
  } from '../../../session/workspace/git-worktree.js'
9
10
  import { WorkspaceBackendRegistry } from '../../../session/workspace/registry.js'
10
11
  import { InMemorySessionStore } from '../../../store/session/memory.js'
12
+ import { InMemoryThreadStore } from '../../../store/thread/memory.js'
11
13
  import type { AgentId, SessionId, TenantId, UserId } from '../../../types/ids/index.js'
12
14
  import { generateHandoffId } from '../../../utils/id.js'
13
15
  import type { HandoffAssignment } from '../assignment.js'
@@ -59,6 +61,7 @@ interface MockedHandoffEventSink extends HandoffEventSink {
59
61
 
60
62
  function buildDeps(
61
63
  store: InMemorySessionStore,
64
+ threadStore: InMemoryThreadStore,
62
65
  execOverride?: ExecFile,
63
66
  runResolver?: RunStatusResolver,
64
67
  ): { deps: SingleHandoffDeps; events: MockedHandoffEventSink; execCalls: string[] } {
@@ -84,29 +87,36 @@ function buildDeps(
84
87
  onBroadcastRollback: vi.fn<(ev: HandoffBroadcastRollbackEvent) => void>(),
85
88
  }
86
89
 
90
+ const threadManager = new ThreadManager({ threadStore, sessionStore: store })
87
91
  const deps: SingleHandoffDeps = {
88
92
  store,
89
93
  workspaceRegistry: registry,
90
94
  capacity: new DefaultCapacityValidator(store),
91
95
  events,
96
+ threadManager,
92
97
  ...(runResolver !== undefined && { runStatus: runResolver }),
93
98
  }
94
99
 
95
100
  return { deps, events, execCalls }
96
101
  }
97
102
 
98
- async function seedIdle(store: InMemorySessionStore) {
103
+ async function seedIdle(store: InMemorySessionStore, threadStore: InMemoryThreadStore) {
99
104
  const project = await store.createProject({ tenantId: tenant, name: 'p' }, tenant)
105
+ const thread = await threadStore.createThread(
106
+ { projectId: project.id, title: 'handoff-single-test' },
107
+ tenant,
108
+ )
100
109
  const session = await store.createSession(
101
- { projectId: project.id, currentActor: user('usr_source') },
110
+ { threadId: thread.id, projectId: project.id, currentActor: user('usr_source') },
102
111
  tenant,
103
112
  )
104
- return { project, session }
113
+ return { project, thread, session }
105
114
  }
106
115
 
107
116
  function buildAssignment(
108
117
  sourceSessionId: SessionId,
109
118
  projectId: Awaited<ReturnType<InMemorySessionStore['createProject']>>['id'],
119
+ threadId: Awaited<ReturnType<InMemoryThreadStore['createThread']>>['id'],
110
120
  expectedOwnerVersion: number,
111
121
  recipient: ActorRef = user('usr_target'),
112
122
  ): HandoffAssignment {
@@ -115,6 +125,7 @@ function buildAssignment(
115
125
  mode: 'single',
116
126
  sourceSessionId,
117
127
  tenantId: tenant,
128
+ threadId,
118
129
  projectId,
119
130
  sourceActor: user('usr_source'),
120
131
  recipientActor: recipient,
@@ -125,16 +136,18 @@ function buildAssignment(
125
136
 
126
137
  describe('executeSingleHandoff', () => {
127
138
  let store: InMemorySessionStore
139
+ let threadStore: InMemoryThreadStore
128
140
 
129
141
  beforeEach(() => {
130
142
  store = new InMemorySessionStore()
143
+ threadStore = new InMemoryThreadStore()
131
144
  })
132
145
 
133
146
  it('happy path: idle source → lock → commit → outcome populated + source mutated', async () => {
134
- const { project, session } = await seedIdle(store)
135
- const { deps, events } = buildDeps(store)
147
+ const { project, thread, session } = await seedIdle(store, threadStore)
148
+ const { deps, events } = buildDeps(store, threadStore)
136
149
 
137
- const assignment = buildAssignment(session.id, project.id, 0)
150
+ const assignment = buildAssignment(session.id, project.id, thread.id, 0)
138
151
  const outcome = await executeSingleHandoff(deps, assignment, tenant)
139
152
 
140
153
  expect(outcome.assignmentId).toBe(assignment.id)
@@ -155,11 +168,11 @@ describe('executeSingleHandoff', () => {
155
168
  })
156
169
 
157
170
  it('rejects when source session is non-idle (active → HandoffLockRejected with active_run)', async () => {
158
- const { project, session } = await seedIdle(store)
171
+ const { project, thread, session } = await seedIdle(store, threadStore)
159
172
  await store.updateSession({ ...session, status: 'active' }, tenant)
160
173
 
161
- const { deps } = buildDeps(store)
162
- const assignment = buildAssignment(session.id, project.id, 0)
174
+ const { deps } = buildDeps(store, threadStore)
175
+ const assignment = buildAssignment(session.id, project.id, thread.id, 0)
163
176
 
164
177
  try {
165
178
  await executeSingleHandoff(deps, assignment, tenant)
@@ -171,14 +184,14 @@ describe('executeSingleHandoff', () => {
171
184
  })
172
185
 
173
186
  it('rejects when Run resolver reports pending_hitl', async () => {
174
- const { project, session } = await seedIdle(store)
187
+ const { project, thread, session } = await seedIdle(store, threadStore)
175
188
  const resolver: RunStatusResolver = {
176
189
  async blockingRun() {
177
190
  return { reason: 'pending_hitl' }
178
191
  },
179
192
  }
180
- const { deps } = buildDeps(store, undefined, resolver)
181
- const assignment = buildAssignment(session.id, project.id, 0)
193
+ const { deps } = buildDeps(store, threadStore, undefined, resolver)
194
+ const assignment = buildAssignment(session.id, project.id, thread.id, 0)
182
195
 
183
196
  try {
184
197
  await executeSingleHandoff(deps, assignment, tenant)
@@ -190,11 +203,11 @@ describe('executeSingleHandoff', () => {
190
203
  })
191
204
 
192
205
  it('rejects on tenant mismatch (TenantIsolationError)', async () => {
193
- const { project, session } = await seedIdle(store)
194
- const { deps } = buildDeps(store)
206
+ const { project, thread, session } = await seedIdle(store, threadStore)
207
+ const { deps } = buildDeps(store, threadStore)
195
208
  // Assignment tenant differs from the call-site tenant.
196
209
  const assignment: HandoffAssignment = {
197
- ...buildAssignment(session.id, project.id, 0),
210
+ ...buildAssignment(session.id, project.id, thread.id, 0),
198
211
  tenantId: otherTenant,
199
212
  }
200
213
  await expect(executeSingleHandoff(deps, assignment, otherTenant)).rejects.toBeInstanceOf(
@@ -203,13 +216,13 @@ describe('executeSingleHandoff', () => {
203
216
  })
204
217
 
205
218
  it('rejects on CAS mismatch (HandoffVersionConflict)', async () => {
206
- const { project, session } = await seedIdle(store)
207
- const { deps } = buildDeps(store)
219
+ const { project, thread, session } = await seedIdle(store, threadStore)
220
+ const { deps } = buildDeps(store, threadStore)
208
221
 
209
222
  // Simulate a concurrent bump: move ownerVersion to 1 before the assignment
210
223
  // with expectedOwnerVersion=0 is executed.
211
224
  await store.updateSession({ ...session, ownerVersion: 1 }, tenant)
212
- const assignment = buildAssignment(session.id, project.id, 0)
225
+ const assignment = buildAssignment(session.id, project.id, thread.id, 0)
213
226
 
214
227
  try {
215
228
  await executeSingleHandoff(deps, assignment, tenant)
@@ -224,18 +237,22 @@ describe('executeSingleHandoff', () => {
224
237
  it('depth cap enforcement rejects with DelegationCapacityExceeded (dimension=depth)', async () => {
225
238
  // Build a chain so the handoff source already sits at max depth.
226
239
  const project = await store.createProject({ tenantId: tenant, name: 'p' }, tenant)
240
+ const thread = await threadStore.createThread(
241
+ { projectId: project.id, title: 'depth-cap' },
242
+ tenant,
243
+ )
227
244
  // Set a tight limit on the project via a second createProject? — no, the
228
245
  // store hardcodes defaults {4,8,10}. Build a depth-4 chain then attempt
229
246
  // handoff on depth-4 node (ancestry length 5 > 4).
230
247
  const root = await store.createSession(
231
- { projectId: project.id, currentActor: user('usr_source') },
248
+ { threadId: thread.id, projectId: project.id, currentActor: user('usr_source') },
232
249
  tenant,
233
250
  )
234
251
  let parent = root.id
235
252
  let tail: SessionId = root.id
236
253
  for (let i = 0; i < 4; i++) {
237
254
  const child = await store.createSession(
238
- { projectId: project.id, currentActor: user(`usr_${i}`) },
255
+ { threadId: thread.id, projectId: project.id, currentActor: user(`usr_${i}`) },
239
256
  tenant,
240
257
  )
241
258
  await store.createSubSession(
@@ -252,15 +269,15 @@ describe('executeSingleHandoff', () => {
252
269
  }
253
270
 
254
271
  // Source is `tail` at ancestry length 5 → depth-capacity with limit 4 rejects.
255
- const { deps } = buildDeps(store)
256
- const assignment = buildAssignment(tail, project.id, 0)
272
+ const { deps } = buildDeps(store, threadStore)
273
+ const assignment = buildAssignment(tail, project.id, thread.id, 0)
257
274
  await expect(executeSingleHandoff(deps, assignment, tenant)).rejects.toBeInstanceOf(
258
275
  DelegationCapacityExceeded,
259
276
  )
260
277
  })
261
278
 
262
279
  it('compensating revert: workspace provisioning failure reverts source to idle, version unchanged, onUnlocked fires', async () => {
263
- const { project, session } = await seedIdle(store)
280
+ const { project, thread, session } = await seedIdle(store, threadStore)
264
281
 
265
282
  // Fail only on `worktree add` but pass for everything else. Here we fail
266
283
  // the single worktree add.
@@ -270,8 +287,8 @@ describe('executeSingleHandoff', () => {
270
287
  }
271
288
  return okExec()
272
289
  }
273
- const { deps, events } = buildDeps(store, exec)
274
- const assignment = buildAssignment(session.id, project.id, 0)
290
+ const { deps, events } = buildDeps(store, threadStore, exec)
291
+ const assignment = buildAssignment(session.id, project.id, thread.id, 0)
275
292
 
276
293
  await expect(executeSingleHandoff(deps, assignment, tenant)).rejects.toThrow(
277
294
  /Workspace backend git-worktree failed on create/,
@@ -290,7 +307,7 @@ describe('executeSingleHandoff', () => {
290
307
  })
291
308
 
292
309
  it('compensating revert: store.createSubSession failure still reverts + archives partial recipient', async () => {
293
- const { project, session } = await seedIdle(store)
310
+ const { project, thread, session } = await seedIdle(store, threadStore)
294
311
 
295
312
  // Monkey-patch createSubSession on the store to throw.
296
313
  const original = store.createSubSession.bind(store)
@@ -298,8 +315,8 @@ describe('executeSingleHandoff', () => {
298
315
  throw new Error('simulated createSubSession failure')
299
316
  }
300
317
 
301
- const { deps, events } = buildDeps(store)
302
- const assignment = buildAssignment(session.id, project.id, 0)
318
+ const { deps, events } = buildDeps(store, threadStore)
319
+ const assignment = buildAssignment(session.id, project.id, thread.id, 0)
303
320
 
304
321
  await expect(executeSingleHandoff(deps, assignment, tenant)).rejects.toThrow(
305
322
  /createSubSession failure/,
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import type { SessionId, TenantId } from '../../types/ids/index.js'
12
- import type { HandoffId, ProjectId, WorkspaceId } from '../../types/session/ids.js'
12
+ import type { HandoffId, ProjectId, ThreadId, WorkspaceId } from '../../types/session/ids.js'
13
13
  import type { ActorRef } from '../hierarchy/actor.js'
14
14
 
15
15
  /**
@@ -32,6 +32,18 @@ export interface HandoffAssignment {
32
32
  mode: HandoffMode
33
33
  sourceSessionId: SessionId
34
34
  tenantId: TenantId
35
+ /**
36
+ * Topic-layer scope the source session belongs to. Handoff recipients
37
+ * always land on the same Thread (cross-thread handoff is forbidden —
38
+ * a new actor taking over a conversation stays on the same topic).
39
+ * Validated against `source.threadId` at execute time.
40
+ */
41
+ threadId: ThreadId
42
+ /**
43
+ * Denormalized from the owning Thread. Kept alongside `threadId` as the
44
+ * Session record itself carries both (see `Session` JSDoc). Consistency
45
+ * validated against `source.projectId` at execute time.
46
+ */
35
47
  projectId: ProjectId
36
48
  /** The actor initiating the handoff (must be the source's current owner). */
37
49
  sourceActor: ActorRef
@@ -21,6 +21,7 @@
21
21
  * ```
22
22
  */
23
23
 
24
+ import type { ThreadManager } from '../../manager/thread/lifecycle.js'
24
25
  import type { SessionId, TenantId } from '../../types/ids/index.js'
25
26
  import type { SubSessionId } from '../../types/session/ids.js'
26
27
  import type { SessionStore } from '../../types/session/store.js'
@@ -41,6 +42,12 @@ export interface BroadcastHandoffDeps {
41
42
  capacity: CapacityValidator
42
43
  events: HandoffEventSink
43
44
  runStatus?: RunStatusResolver
45
+ /**
46
+ * Gate every recipient-session creation on the Thread being `'open'`.
47
+ * Added in Phase 2.6; checked once per broadcast (all recipients share
48
+ * a threadId by the fan-out invariant validated above).
49
+ */
50
+ threadManager: ThreadManager
44
51
  }
45
52
 
46
53
  /**
@@ -108,6 +115,9 @@ export async function executeBroadcastHandoff(
108
115
  if (a.expectedOwnerVersion !== first.expectedOwnerVersion) {
109
116
  throw new Error('executeBroadcastHandoff: all assignments must share expectedOwnerVersion')
110
117
  }
118
+ if (a.threadId !== first.threadId) {
119
+ throw new Error('executeBroadcastHandoff: all assignments must share threadId')
120
+ }
111
121
  if (a.projectId !== first.projectId) {
112
122
  throw new Error('executeBroadcastHandoff: all assignments must share projectId')
113
123
  }
@@ -130,6 +140,12 @@ export async function executeBroadcastHandoff(
130
140
  seen.add(key)
131
141
  }
132
142
 
143
+ // Thread archive gate (Phase 2.6) — runs BEFORE source load/capacity so an
144
+ // archived thread fails fastest with `ThreadClosedError`. All assignments
145
+ // share `threadId` by the shape validation above. Runs BEFORE the CAS
146
+ // lock so a denied fan-out leaves the source session untouched.
147
+ await deps.threadManager.requireOpen(first.threadId, tenantId)
148
+
133
149
  // 3. Load source + tenant check.
134
150
  const source = await deps.store.getSession(first.sourceSessionId, tenantId)
135
151
  if (!source) {
@@ -141,6 +157,11 @@ export async function executeBroadcastHandoff(
141
157
  resource: `session(${source.id})`,
142
158
  })
143
159
  }
160
+ if (source.threadId !== first.threadId) {
161
+ throw new Error(
162
+ `Assignment threadId ${first.threadId} does not match source threadId ${source.threadId}`,
163
+ )
164
+ }
144
165
  if (source.projectId !== first.projectId) {
145
166
  throw new Error(
146
167
  `Assignment projectId ${first.projectId} does not match source projectId ${source.projectId}`,
@@ -220,7 +241,11 @@ export async function executeBroadcastHandoff(
220
241
  worktreesProvisioned += 1
221
242
 
222
243
  const childSession = await deps.store.createSession(
223
- { projectId: source.projectId, currentActor: assignment.recipientActor },
244
+ {
245
+ threadId: source.threadId,
246
+ projectId: source.projectId,
247
+ currentActor: assignment.recipientActor,
248
+ },
224
249
  tenantId,
225
250
  )
226
251
  partial.createdSessionId = childSession.id