@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.
- package/CHANGELOG.md +74 -2
- package/dist/agents/ReactiveAgent.d.ts.map +1 -1
- package/dist/agents/ReactiveAgent.js +3 -2
- package/dist/agents/ReactiveAgent.js.map +1 -1
- package/dist/agents/SupervisorAgent.d.ts.map +1 -1
- package/dist/agents/SupervisorAgent.js +5 -2
- package/dist/agents/SupervisorAgent.js.map +1 -1
- package/dist/bridge/a2a/index.d.ts +1 -1
- package/dist/bridge/a2a/index.d.ts.map +1 -1
- package/dist/bridge/a2a/index.js +1 -1
- package/dist/bridge/a2a/index.js.map +1 -1
- package/dist/bridge/a2a/message.d.ts +0 -2
- package/dist/bridge/a2a/message.d.ts.map +1 -1
- package/dist/bridge/a2a/message.js +0 -26
- package/dist/bridge/a2a/message.js.map +1 -1
- package/dist/bridge/a2a/task.d.ts +4 -3
- package/dist/bridge/a2a/task.d.ts.map +1 -1
- package/dist/bridge/a2a/task.js +4 -4
- package/dist/bridge/a2a/task.js.map +1 -1
- package/dist/contracts/api.d.ts +6 -38
- package/dist/contracts/api.d.ts.map +1 -1
- package/dist/contracts/ids.d.ts +1 -1
- package/dist/contracts/ids.d.ts.map +1 -1
- package/dist/contracts/index.d.ts +3 -5
- package/dist/contracts/index.d.ts.map +1 -1
- package/dist/contracts/index.js +1 -1
- package/dist/contracts/index.js.map +1 -1
- package/dist/contracts/schemas.d.ts +1 -31
- package/dist/contracts/schemas.d.ts.map +1 -1
- package/dist/contracts/schemas.js +1 -7
- package/dist/contracts/schemas.js.map +1 -1
- package/dist/index.d.ts +2 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -6
- package/dist/index.js.map +1 -1
- package/dist/manager/agent/__tests__/lifecycle.test.js +27 -13
- package/dist/manager/agent/__tests__/lifecycle.test.js.map +1 -1
- package/dist/manager/agent/lifecycle.d.ts +9 -0
- package/dist/manager/agent/lifecycle.d.ts.map +1 -1
- package/dist/manager/agent/lifecycle.js +93 -31
- package/dist/manager/agent/lifecycle.js.map +1 -1
- package/dist/manager/index.d.ts +2 -0
- package/dist/manager/index.d.ts.map +1 -1
- package/dist/manager/index.js +1 -0
- package/dist/manager/index.js.map +1 -1
- package/dist/manager/run/persistence.d.ts +3 -1
- package/dist/manager/run/persistence.d.ts.map +1 -1
- package/dist/manager/run/persistence.js +5 -0
- package/dist/manager/run/persistence.js.map +1 -1
- package/dist/manager/thread/__tests__/lifecycle.test.d.ts +2 -0
- package/dist/manager/thread/__tests__/lifecycle.test.d.ts.map +1 -0
- package/dist/manager/thread/__tests__/lifecycle.test.js +216 -0
- package/dist/manager/thread/__tests__/lifecycle.test.js.map +1 -0
- package/dist/manager/thread/lifecycle.d.ts +105 -0
- package/dist/manager/thread/lifecycle.d.ts.map +1 -0
- package/dist/manager/thread/lifecycle.js +186 -0
- package/dist/manager/thread/lifecycle.js.map +1 -0
- package/dist/rag/retriever.js +2 -2
- package/dist/registry/tool/execute.js +1 -1
- package/dist/registry/tool/execute.js.map +1 -1
- package/dist/runtime/query/__tests__/context.test.js +8 -7
- package/dist/runtime/query/__tests__/context.test.js.map +1 -1
- package/dist/runtime/query/context-cache.d.ts +3 -3
- package/dist/runtime/query/context-cache.d.ts.map +1 -1
- package/dist/runtime/query/context-cache.js +2 -2
- package/dist/runtime/query/context-cache.js.map +1 -1
- package/dist/runtime/query/context.d.ts +12 -21
- package/dist/runtime/query/context.d.ts.map +1 -1
- package/dist/runtime/query/context.js +3 -1
- package/dist/runtime/query/context.js.map +1 -1
- package/dist/runtime/query/index.d.ts +13 -15
- package/dist/runtime/query/index.d.ts.map +1 -1
- package/dist/runtime/query/index.js +2 -1
- package/dist/runtime/query/index.js.map +1 -1
- package/dist/runtime/query/iteration/index.d.ts.map +1 -1
- package/dist/runtime/query/iteration/index.js +1 -1
- package/dist/runtime/query/iteration/index.js.map +1 -1
- package/dist/session/__tests__/integration/_fixtures.d.ts +11 -4
- package/dist/session/__tests__/integration/_fixtures.d.ts.map +1 -1
- package/dist/session/__tests__/integration/_fixtures.js +23 -6
- package/dist/session/__tests__/integration/_fixtures.js.map +1 -1
- package/dist/session/__tests__/integration/archive-gate.test.d.ts +15 -0
- package/dist/session/__tests__/integration/archive-gate.test.d.ts.map +1 -0
- package/dist/session/__tests__/integration/archive-gate.test.js +214 -0
- package/dist/session/__tests__/integration/archive-gate.test.js.map +1 -0
- package/dist/session/__tests__/integration/capacity-caps.test.js +13 -6
- package/dist/session/__tests__/integration/capacity-caps.test.js.map +1 -1
- package/dist/session/__tests__/integration/e2e-spawn.test.js +14 -2
- package/dist/session/__tests__/integration/e2e-spawn.test.js.map +1 -1
- package/dist/session/__tests__/integration/event-stream-ordering.test.js +14 -7
- package/dist/session/__tests__/integration/event-stream-ordering.test.js.map +1 -1
- package/dist/session/__tests__/integration/handoff-broadcast-e2e.test.js +26 -14
- package/dist/session/__tests__/integration/handoff-broadcast-e2e.test.js.map +1 -1
- package/dist/session/__tests__/integration/handoff-illegal-transition.test.js +30 -20
- package/dist/session/__tests__/integration/handoff-illegal-transition.test.js.map +1 -1
- package/dist/session/__tests__/integration/handoff-single-e2e.test.js +25 -9
- package/dist/session/__tests__/integration/handoff-single-e2e.test.js.map +1 -1
- package/dist/session/__tests__/integration/hierarchy-lifecycle.test.js +11 -10
- package/dist/session/__tests__/integration/hierarchy-lifecycle.test.js.map +1 -1
- package/dist/session/__tests__/integration/prev-artifact-dag.test.js +5 -4
- package/dist/session/__tests__/integration/prev-artifact-dag.test.js.map +1 -1
- package/dist/session/__tests__/integration/retention-archive.test.js +3 -2
- package/dist/session/__tests__/integration/retention-archive.test.js.map +1 -1
- package/dist/session/__tests__/integration/spawn-rollback.test.d.ts +26 -0
- package/dist/session/__tests__/integration/spawn-rollback.test.d.ts.map +1 -0
- package/dist/session/__tests__/integration/spawn-rollback.test.js +236 -0
- package/dist/session/__tests__/integration/spawn-rollback.test.js.map +1 -0
- package/dist/session/__tests__/integration/summary-materialization-e2e.test.js +2 -1
- package/dist/session/__tests__/integration/summary-materialization-e2e.test.js.map +1 -1
- package/dist/session/__tests__/integration/tenant-isolation.test.js +14 -5
- package/dist/session/__tests__/integration/tenant-isolation.test.js.map +1 -1
- package/dist/session/errors.d.ts +79 -0
- package/dist/session/errors.d.ts.map +1 -1
- package/dist/session/errors.js +57 -0
- package/dist/session/errors.js.map +1 -1
- package/dist/session/handoff/__tests__/broadcast.test.js +49 -31
- package/dist/session/handoff/__tests__/broadcast.test.js.map +1 -1
- package/dist/session/handoff/__tests__/capacity.test.js +21 -18
- package/dist/session/handoff/__tests__/capacity.test.js.map +1 -1
- package/dist/session/handoff/__tests__/single.test.js +39 -30
- package/dist/session/handoff/__tests__/single.test.js.map +1 -1
- package/dist/session/handoff/assignment.d.ts +13 -1
- package/dist/session/handoff/assignment.d.ts.map +1 -1
- package/dist/session/handoff/broadcast.d.ts +7 -0
- package/dist/session/handoff/broadcast.d.ts.map +1 -1
- package/dist/session/handoff/broadcast.js +16 -1
- package/dist/session/handoff/broadcast.js.map +1 -1
- package/dist/session/handoff/single.d.ts +7 -0
- package/dist/session/handoff/single.d.ts.map +1 -1
- package/dist/session/handoff/single.js +13 -1
- package/dist/session/handoff/single.js.map +1 -1
- package/dist/session/hierarchy/__tests__/session.test.js +2 -0
- package/dist/session/hierarchy/__tests__/session.test.js.map +1 -1
- package/dist/session/hierarchy/index.d.ts +1 -0
- package/dist/session/hierarchy/index.d.ts.map +1 -1
- package/dist/session/hierarchy/index.js.map +1 -1
- package/dist/session/hierarchy/session.d.ts +15 -3
- package/dist/session/hierarchy/session.d.ts.map +1 -1
- package/dist/session/hierarchy/session.js.map +1 -1
- package/dist/session/hierarchy/thread.d.ts +54 -0
- package/dist/session/hierarchy/thread.d.ts.map +1 -0
- package/dist/session/hierarchy/thread.js +2 -0
- package/dist/session/hierarchy/thread.js.map +1 -0
- package/dist/session/migration/id-prefix.d.ts +8 -13
- package/dist/session/migration/id-prefix.d.ts.map +1 -1
- package/dist/session/migration/id-prefix.js +8 -13
- package/dist/session/migration/id-prefix.js.map +1 -1
- package/dist/session/retention/__tests__/archive.test.js +3 -2
- package/dist/session/retention/__tests__/archive.test.js.map +1 -1
- package/dist/session/summary/__tests__/materialize.test.js +4 -3
- package/dist/session/summary/__tests__/materialize.test.js.map +1 -1
- package/dist/store/index.d.ts +0 -2
- package/dist/store/index.d.ts.map +1 -1
- package/dist/store/index.js +0 -1
- package/dist/store/index.js.map +1 -1
- package/dist/store/session/__tests__/disk.test.js +32 -5
- package/dist/store/session/__tests__/disk.test.js.map +1 -1
- package/dist/store/session/__tests__/memory.test.js +50 -9
- package/dist/store/session/__tests__/memory.test.js.map +1 -1
- package/dist/store/session/disk.d.ts +2 -1
- package/dist/store/session/disk.d.ts.map +1 -1
- package/dist/store/session/disk.js +61 -0
- package/dist/store/session/disk.js.map +1 -1
- package/dist/store/session/index.d.ts.map +1 -1
- package/dist/store/session/index.js +3 -4
- package/dist/store/session/index.js.map +1 -1
- package/dist/store/session/memory.d.ts +2 -1
- package/dist/store/session/memory.d.ts.map +1 -1
- package/dist/store/session/memory.js +13 -0
- package/dist/store/session/memory.js.map +1 -1
- package/dist/store/thread/disk.d.ts +41 -0
- package/dist/store/thread/disk.d.ts.map +1 -0
- package/dist/store/thread/disk.js +229 -0
- package/dist/store/thread/disk.js.map +1 -0
- package/dist/store/thread/index.d.ts +4 -0
- package/dist/store/thread/index.d.ts.map +1 -0
- package/dist/store/thread/index.js +6 -0
- package/dist/store/thread/index.js.map +1 -0
- package/dist/store/thread/memory.d.ts +23 -0
- package/dist/store/thread/memory.d.ts.map +1 -0
- package/dist/store/thread/memory.js +90 -0
- package/dist/store/thread/memory.js.map +1 -0
- package/dist/telemetry/runtime-accessors.d.ts +4 -0
- package/dist/telemetry/runtime-accessors.d.ts.map +1 -0
- package/dist/telemetry/runtime-accessors.js +17 -0
- package/dist/telemetry/runtime-accessors.js.map +1 -0
- package/dist/types/agent/base.d.ts +17 -21
- package/dist/types/agent/base.d.ts.map +1 -1
- package/dist/types/agent/factory.d.ts +8 -2
- package/dist/types/agent/factory.d.ts.map +1 -1
- package/dist/types/agent/task.d.ts +18 -11
- package/dist/types/agent/task.d.ts.map +1 -1
- package/dist/types/ids/index.d.ts +5 -9
- package/dist/types/ids/index.d.ts.map +1 -1
- package/dist/types/ids/index.js +4 -4
- package/dist/types/ids/index.js.map +1 -1
- package/dist/types/rag/retrieval.d.ts +4 -3
- package/dist/types/rag/retrieval.d.ts.map +1 -1
- package/dist/types/run/config.d.ts +6 -5
- package/dist/types/run/config.d.ts.map +1 -1
- package/dist/types/run/metadata.d.ts +5 -18
- package/dist/types/run/metadata.d.ts.map +1 -1
- package/dist/types/session/ids.d.ts +4 -13
- package/dist/types/session/ids.d.ts.map +1 -1
- package/dist/types/session/ids.js +3 -6
- package/dist/types/session/ids.js.map +1 -1
- package/dist/types/session/index.d.ts +1 -1
- package/dist/types/session/index.d.ts.map +1 -1
- package/dist/types/session/store.d.ts +32 -10
- package/dist/types/session/store.d.ts.map +1 -1
- package/dist/types/session/store.js +3 -8
- package/dist/types/session/store.js.map +1 -1
- package/dist/types/thread/index.d.ts +2 -0
- package/dist/types/thread/index.d.ts.map +1 -0
- package/dist/types/thread/index.js +5 -0
- package/dist/types/thread/index.js.map +1 -0
- package/dist/types/thread/store.d.ts +86 -0
- package/dist/types/thread/store.d.ts.map +1 -0
- package/dist/types/thread/store.js +22 -0
- package/dist/types/thread/store.js.map +1 -0
- package/dist/utils/id.d.ts +1 -12
- package/dist/utils/id.d.ts.map +1 -1
- package/dist/utils/id.js +3 -23
- package/dist/utils/id.js.map +1 -1
- package/package.json +11 -20
- package/src/agents/ReactiveAgent.ts +3 -2
- package/src/agents/SupervisorAgent.ts +5 -2
- package/src/bridge/a2a/index.ts +0 -1
- package/src/bridge/a2a/message.ts +0 -32
- package/src/bridge/a2a/task.ts +8 -7
- package/src/contracts/api.ts +6 -42
- package/src/contracts/ids.ts +1 -1
- package/src/contracts/index.ts +2 -8
- package/src/contracts/schemas.ts +1 -8
- package/src/index.ts +3 -15
- package/src/manager/agent/__tests__/lifecycle.test.ts +34 -13
- package/src/manager/agent/lifecycle.ts +114 -35
- package/src/manager/index.ts +3 -0
- package/src/manager/run/persistence.ts +7 -1
- package/src/manager/thread/__tests__/lifecycle.test.ts +286 -0
- package/src/manager/thread/lifecycle.ts +217 -0
- package/src/rag/retriever.ts +2 -2
- package/src/registry/tool/execute.ts +1 -1
- package/src/runtime/query/__tests__/context.test.ts +9 -8
- package/src/runtime/query/context-cache.ts +4 -4
- package/src/runtime/query/context.ts +15 -22
- package/src/runtime/query/index.ts +16 -17
- package/src/runtime/query/iteration/index.ts +1 -1
- package/src/session/__tests__/integration/_fixtures.ts +36 -8
- package/src/session/__tests__/integration/archive-gate.test.ts +288 -0
- package/src/session/__tests__/integration/capacity-caps.test.ts +13 -6
- package/src/session/__tests__/integration/e2e-spawn.test.ts +20 -2
- package/src/session/__tests__/integration/event-stream-ordering.test.ts +14 -7
- package/src/session/__tests__/integration/handoff-broadcast-e2e.test.ts +39 -13
- package/src/session/__tests__/integration/handoff-illegal-transition.test.ts +54 -19
- package/src/session/__tests__/integration/handoff-single-e2e.test.ts +40 -9
- package/src/session/__tests__/integration/hierarchy-lifecycle.test.ts +13 -10
- package/src/session/__tests__/integration/prev-artifact-dag.test.ts +12 -5
- package/src/session/__tests__/integration/retention-archive.test.ts +5 -3
- package/src/session/__tests__/integration/spawn-rollback.test.ts +313 -0
- package/src/session/__tests__/integration/summary-materialization-e2e.test.ts +4 -2
- package/src/session/__tests__/integration/tenant-isolation.test.ts +16 -6
- package/src/session/errors.ts +89 -0
- package/src/session/handoff/__tests__/broadcast.test.ts +56 -28
- package/src/session/handoff/__tests__/capacity.test.ts +26 -20
- package/src/session/handoff/__tests__/single.test.ts +45 -28
- package/src/session/handoff/assignment.ts +13 -1
- package/src/session/handoff/broadcast.ts +26 -1
- package/src/session/handoff/single.ts +23 -1
- package/src/session/hierarchy/__tests__/session.test.ts +9 -1
- package/src/session/hierarchy/index.ts +1 -0
- package/src/session/hierarchy/session.ts +15 -3
- package/src/session/hierarchy/thread.ts +55 -0
- package/src/session/migration/id-prefix.ts +8 -13
- package/src/session/retention/__tests__/archive.test.ts +5 -3
- package/src/session/summary/__tests__/materialize.test.ts +6 -4
- package/src/store/index.ts +0 -3
- package/src/store/session/__tests__/disk.test.ts +57 -6
- package/src/store/session/__tests__/memory.test.ts +84 -9
- package/src/store/session/disk.ts +57 -1
- package/src/store/session/index.ts +3 -4
- package/src/store/session/memory.ts +13 -1
- package/src/store/thread/disk.ts +261 -0
- package/src/store/thread/index.ts +7 -0
- package/src/store/thread/memory.ts +104 -0
- package/src/telemetry/runtime-accessors.ts +19 -0
- package/src/types/agent/base.ts +17 -21
- package/src/types/agent/factory.ts +8 -3
- package/src/types/agent/task.ts +19 -11
- package/src/types/ids/index.ts +8 -15
- package/src/types/rag/retrieval.ts +4 -3
- package/src/types/run/config.ts +6 -5
- package/src/types/run/metadata.ts +5 -18
- package/src/types/session/ids.ts +4 -15
- package/src/types/session/index.ts +1 -2
- package/src/types/session/store.ts +34 -11
- package/src/types/thread/index.ts +5 -0
- package/src/types/thread/store.ts +92 -0
- package/src/utils/id.ts +3 -24
- package/dist/provider/telemetry/setup.d.ts +0 -19
- package/dist/provider/telemetry/setup.d.ts.map +0 -1
- package/dist/provider/telemetry/setup.js +0 -102
- package/dist/provider/telemetry/setup.js.map +0 -1
- package/dist/store/conversation/memory.d.ts +0 -43
- package/dist/store/conversation/memory.d.ts.map +0 -1
- package/dist/store/conversation/memory.js +0 -108
- package/dist/store/conversation/memory.js.map +0 -1
- package/dist/telemetry/index.d.ts +0 -6
- package/dist/telemetry/index.d.ts.map +0 -1
- package/dist/telemetry/index.js +0 -4
- package/dist/telemetry/index.js.map +0 -1
- package/dist/telemetry/metrics.d.ts +0 -8
- package/dist/telemetry/metrics.d.ts.map +0 -1
- package/dist/telemetry/metrics.js +0 -53
- package/dist/telemetry/metrics.js.map +0 -1
- package/dist/types/conversation/index.d.ts +0 -14
- package/dist/types/conversation/index.d.ts.map +0 -1
- package/dist/types/conversation/index.js +0 -2
- package/dist/types/conversation/index.js.map +0 -1
- package/dist/types/telemetry/index.d.ts +0 -10
- package/dist/types/telemetry/index.d.ts.map +0 -1
- package/dist/types/telemetry/index.js +0 -2
- package/dist/types/telemetry/index.js.map +0 -1
- package/src/provider/telemetry/setup.ts +0 -125
- package/src/store/conversation/memory.ts +0 -144
- package/src/telemetry/index.ts +0 -14
- package/src/telemetry/metrics.ts +0 -69
- package/src/types/conversation/index.ts +0 -15
- 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
|
|
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
|
|
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
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
//
|
|
426
|
-
//
|
|
427
|
-
//
|
|
428
|
-
//
|
|
429
|
-
//
|
|
430
|
-
//
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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 {
|
package/src/manager/index.ts
CHANGED
|
@@ -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
|
+
})
|