@namzu/sdk 12.2.0 → 13.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +113 -0
- package/dist/bridge/a2a/__tests__/project-is-the-a2a-context.test.d.ts +2 -0
- package/dist/bridge/a2a/__tests__/project-is-the-a2a-context.test.d.ts.map +1 -0
- package/dist/bridge/a2a/__tests__/project-is-the-a2a-context.test.js +55 -0
- package/dist/bridge/a2a/__tests__/project-is-the-a2a-context.test.js.map +1 -0
- package/dist/manager/agent/lifecycle.d.ts +15 -0
- package/dist/manager/agent/lifecycle.d.ts.map +1 -1
- package/dist/manager/agent/lifecycle.js +70 -26
- package/dist/manager/agent/lifecycle.js.map +1 -1
- package/dist/public-runtime.d.ts +1 -1
- package/dist/public-runtime.d.ts.map +1 -1
- package/dist/public-runtime.js +7 -1
- package/dist/public-runtime.js.map +1 -1
- package/dist/session/__tests__/integration/a-successful-delegation-disposes-its-workspace.test.d.ts +21 -0
- package/dist/session/__tests__/integration/a-successful-delegation-disposes-its-workspace.test.d.ts.map +1 -0
- package/dist/session/__tests__/integration/a-successful-delegation-disposes-its-workspace.test.js +205 -0
- package/dist/session/__tests__/integration/a-successful-delegation-disposes-its-workspace.test.js.map +1 -0
- package/dist/session/__tests__/integration/handoff-single-e2e.test.js +14 -5
- package/dist/session/__tests__/integration/handoff-single-e2e.test.js.map +1 -1
- package/dist/session/errors.d.ts +25 -0
- package/dist/session/errors.d.ts.map +1 -1
- package/dist/session/errors.js +21 -0
- package/dist/session/errors.js.map +1 -1
- package/dist/session/handoff/broadcast.d.ts.map +1 -1
- package/dist/session/handoff/broadcast.js +10 -5
- package/dist/session/handoff/broadcast.js.map +1 -1
- package/dist/session/handoff/single.d.ts.map +1 -1
- package/dist/session/handoff/single.js +27 -4
- package/dist/session/handoff/single.js.map +1 -1
- package/dist/store/session/__tests__/a-session-has-one-writer.test.d.ts +2 -0
- package/dist/store/session/__tests__/a-session-has-one-writer.test.d.ts.map +1 -0
- package/dist/store/session/__tests__/a-session-has-one-writer.test.js +79 -0
- package/dist/store/session/__tests__/a-session-has-one-writer.test.js.map +1 -0
- package/dist/store/session/disk.d.ts +1 -1
- package/dist/store/session/disk.d.ts.map +1 -1
- package/dist/store/session/disk.js +15 -2
- package/dist/store/session/disk.js.map +1 -1
- package/dist/store/session/memory.d.ts +1 -1
- package/dist/store/session/memory.d.ts.map +1 -1
- package/dist/store/session/memory.js +14 -2
- package/dist/store/session/memory.js.map +1 -1
- package/dist/types/session/store.d.ts +28 -1
- package/dist/types/session/store.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/bridge/a2a/__tests__/project-is-the-a2a-context.test.ts +66 -0
- package/src/manager/agent/lifecycle.ts +71 -26
- package/src/public-runtime.ts +6 -0
- package/src/session/__tests__/integration/a-successful-delegation-disposes-its-workspace.test.ts +270 -0
- package/src/session/__tests__/integration/handoff-single-e2e.test.ts +14 -5
- package/src/session/errors.ts +29 -0
- package/src/session/handoff/broadcast.ts +10 -5
- package/src/session/handoff/single.ts +27 -4
- package/src/store/session/__tests__/a-session-has-one-writer.test.ts +116 -0
- package/src/store/session/disk.ts +21 -2
- package/src/store/session/memory.ts +20 -2
- package/src/types/session/store.ts +28 -1
|
@@ -641,6 +641,24 @@ export class AgentManager {
|
|
|
641
641
|
if (this.deps.workspaceRegistry.has(backend)) {
|
|
642
642
|
const driver = this.deps.workspaceRegistry.get(backend)
|
|
643
643
|
workspaceRef = await driver.create({ label: subSession.id })
|
|
644
|
+
|
|
645
|
+
// Write the workspace onto the record that outlives this process.
|
|
646
|
+
//
|
|
647
|
+
// The ref was kept only on the in-memory `ChildSpawnRecord`, so
|
|
648
|
+
// `SubSession.workspaceId` stayed `null` for every spawn-created
|
|
649
|
+
// child — and `ArchivalManager` resolves a workspace only when
|
|
650
|
+
// that field is set (`session/retention/archive.ts`). The one
|
|
651
|
+
// record that could have named the workspace said there was none,
|
|
652
|
+
// which is why the archival path could never act as a backstop
|
|
653
|
+
// for a leaked worktree.
|
|
654
|
+
//
|
|
655
|
+
// After `create` rather than in `createSubSession`, because the
|
|
656
|
+
// sub-session id is the workspace's label — the workspace cannot
|
|
657
|
+
// exist before the record it is named after. Inside the try, so
|
|
658
|
+
// the compensating rollback below covers it like every other
|
|
659
|
+
// mutation here.
|
|
660
|
+
subSession = { ...subSession, workspaceId: workspaceRef.id }
|
|
661
|
+
await store.updateSubSession(subSession, context.tenantId)
|
|
644
662
|
}
|
|
645
663
|
} catch (err) {
|
|
646
664
|
// Compensating rollback order is mandated by the store's
|
|
@@ -764,9 +782,9 @@ export class AgentManager {
|
|
|
764
782
|
)
|
|
765
783
|
}
|
|
766
784
|
} else {
|
|
767
|
-
// Non-success: mark sub-session failed
|
|
768
|
-
//
|
|
769
|
-
//
|
|
785
|
+
// Non-success: mark sub-session failed. Disposal is shared with
|
|
786
|
+
// the success branch below — the workspace was provisioned for
|
|
787
|
+
// this child either way, and it is this manager that owns it.
|
|
770
788
|
const subSession = await store.getSubSession(
|
|
771
789
|
spawnRecord.subSessionId,
|
|
772
790
|
spawnRecord.tenantId,
|
|
@@ -774,21 +792,25 @@ export class AgentManager {
|
|
|
774
792
|
if (subSession) {
|
|
775
793
|
await store.updateSubSession({ ...subSession, status: 'failed' }, spawnRecord.tenantId)
|
|
776
794
|
}
|
|
777
|
-
if (spawnRecord.workspaceRef) {
|
|
778
|
-
const backend = spawnRecord.workspaceRef.meta.backend
|
|
779
|
-
if (this.deps.workspaceRegistry.has(backend)) {
|
|
780
|
-
await this.deps.workspaceRegistry
|
|
781
|
-
.get(backend)
|
|
782
|
-
.dispose(spawnRecord.workspaceRef)
|
|
783
|
-
.catch((disposeErr) => {
|
|
784
|
-
this.log.warn('Workspace dispose failed', {
|
|
785
|
-
backend,
|
|
786
|
-
error: toErrorMessage(disposeErr),
|
|
787
|
-
})
|
|
788
|
-
})
|
|
789
|
-
}
|
|
790
|
-
}
|
|
791
795
|
}
|
|
796
|
+
|
|
797
|
+
// A child is done with its workspace however it ended.
|
|
798
|
+
//
|
|
799
|
+
// This used to live only in the branch above, so both dispose
|
|
800
|
+
// sites in this class were failure paths and a child that
|
|
801
|
+
// SUCCEEDED released nothing. `.namzu/worktrees/` then grew once
|
|
802
|
+
// per successful delegation — the more reliable the workers, the
|
|
803
|
+
// faster it filled, which is the opposite of the signal a leak
|
|
804
|
+
// usually gives.
|
|
805
|
+
//
|
|
806
|
+
// It runs after the summary is sealed and the sub-session flipped
|
|
807
|
+
// to `idle`, so nothing the terminalization path reads is gone
|
|
808
|
+
// before it reads it. It also runs BEFORE the `subsession_idled`
|
|
809
|
+
// emission below: a listener cannot reach into the workspace from
|
|
810
|
+
// that event. Stated rather than hedged — no consumer does today,
|
|
811
|
+
// and holding a worktree open for a hypothetical one is what this
|
|
812
|
+
// is fixing.
|
|
813
|
+
await this.disposeChildWorkspace(spawnRecord)
|
|
792
814
|
} catch (err) {
|
|
793
815
|
this.log.error('Sub-session finalization failed', {
|
|
794
816
|
taskId: agentTask.taskId,
|
|
@@ -937,15 +959,38 @@ export class AgentManager {
|
|
|
937
959
|
spawnRecord.tenantId,
|
|
938
960
|
)
|
|
939
961
|
}
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
962
|
+
await this.disposeChildWorkspace(spawnRecord)
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/**
|
|
966
|
+
* Release the workspace this manager provisioned for a child.
|
|
967
|
+
*
|
|
968
|
+
* Called on every terminal path, success included. `has(backend)` before
|
|
969
|
+
* `get(backend)` because the registry is deny-by-default and throws on an
|
|
970
|
+
* unknown kind — a driver deregistered mid-run must not turn cleanup into
|
|
971
|
+
* an exception on a child that already finished.
|
|
972
|
+
*
|
|
973
|
+
* Never throws. Disposal is cleanup, not part of the child's result: the
|
|
974
|
+
* sub-session state is already persisted by the time this runs, and
|
|
975
|
+
* failing here would report a delegation that worked as one that did not.
|
|
976
|
+
* The failure is logged instead, because a worktree that could not be
|
|
977
|
+
* removed is an operator's problem and silence is how it stays one.
|
|
978
|
+
*/
|
|
979
|
+
private async disposeChildWorkspace(spawnRecord: ChildSpawnRecord): Promise<void> {
|
|
980
|
+
if (!spawnRecord.workspaceRef) return
|
|
981
|
+
const backend = spawnRecord.workspaceRef.meta.backend
|
|
982
|
+
if (!this.deps.workspaceRegistry.has(backend)) return
|
|
983
|
+
await this.deps.workspaceRegistry
|
|
984
|
+
.get(backend)
|
|
985
|
+
.dispose(spawnRecord.workspaceRef)
|
|
986
|
+
.catch((disposeErr) => {
|
|
987
|
+
this.log.warn('Workspace dispose failed', {
|
|
988
|
+
backend,
|
|
989
|
+
workspaceId: spawnRecord.workspaceRef?.id,
|
|
990
|
+
subSessionId: spawnRecord.subSessionId,
|
|
991
|
+
error: toErrorMessage(disposeErr),
|
|
992
|
+
})
|
|
993
|
+
})
|
|
949
994
|
}
|
|
950
995
|
|
|
951
996
|
private markCanceled(taskId: TaskId): void {
|
package/src/public-runtime.ts
CHANGED
|
@@ -483,6 +483,12 @@ export {
|
|
|
483
483
|
|
|
484
484
|
export {
|
|
485
485
|
AncestryCycleError,
|
|
486
|
+
// Exported with the CAS it announces. A host that opts into
|
|
487
|
+
// `expectedOwnerVersion` has to be able to tell "somebody else took this
|
|
488
|
+
// session" from any other failure, and string-matching a message is not a
|
|
489
|
+
// contract — which is the state `ThreadClosedError` and its siblings are
|
|
490
|
+
// still in, and a reason not to add a fourth.
|
|
491
|
+
StaleSessionError,
|
|
486
492
|
TenantIsolationError,
|
|
487
493
|
WorkspaceBackendError,
|
|
488
494
|
} from './session/errors.js'
|
package/src/session/__tests__/integration/a-successful-delegation-disposes-its-workspace.test.ts
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A worktree provisioned for a delegated child outlived the child that used it.
|
|
3
|
+
*
|
|
4
|
+
* `finalizeChild` had two dispose sites and both were failure paths — the
|
|
5
|
+
* non-success branch, and the rollback in `failSubSession`. The success branch
|
|
6
|
+
* disposed nothing, so `.namzu/worktrees/` grew once per successful delegation:
|
|
7
|
+
* the more reliable the workers, the faster it filled.
|
|
8
|
+
*
|
|
9
|
+
* The backstop could not fire either. `ArchivalManager` resolves a workspace
|
|
10
|
+
* only when `SubSession.workspaceId` is set, and for a spawn-created
|
|
11
|
+
* sub-session that field was written `null` and never updated —
|
|
12
|
+
* `provisionSpawn` kept the ref on the in-memory `ChildSpawnRecord` and nowhere
|
|
13
|
+
* else. So the one record that could have named the leaked worktree said there
|
|
14
|
+
* was none.
|
|
15
|
+
*
|
|
16
|
+
* Both halves are pinned here, and the failure path is re-asserted alongside
|
|
17
|
+
* them: a test that only counted disposals would pass on the old code by
|
|
18
|
+
* reading the failure branch's disposal and calling it the success branch's.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { describe, expect, it } from 'vitest'
|
|
22
|
+
import { EMPTY_TOKEN_USAGE } from '../../../constants/limits.js'
|
|
23
|
+
import { AgentManager } from '../../../manager/agent/lifecycle.js'
|
|
24
|
+
import { ThreadManager } from '../../../manager/thread/lifecycle.js'
|
|
25
|
+
import { AgentRegistry } from '../../../registry/agent/definitions.js'
|
|
26
|
+
import { InMemorySessionStore } from '../../../store/session/memory.js'
|
|
27
|
+
import { InMemoryThreadStore } from '../../../store/thread/memory.js'
|
|
28
|
+
import type {
|
|
29
|
+
AgentCapabilities,
|
|
30
|
+
AgentInput,
|
|
31
|
+
BaseAgentConfig,
|
|
32
|
+
BaseAgentResult,
|
|
33
|
+
} from '../../../types/agent/base.js'
|
|
34
|
+
import type { Agent } from '../../../types/agent/core.js'
|
|
35
|
+
import type { AgentDefinition } from '../../../types/agent/factory.js'
|
|
36
|
+
import type { AgentTaskContext, SendMessageOptions } from '../../../types/agent/task.js'
|
|
37
|
+
import type { RunId, TenantId, UserId, WorkspaceId } from '../../../types/ids/index.js'
|
|
38
|
+
import { createAssistantMessage } from '../../../types/message/index.js'
|
|
39
|
+
import type { ActorRef } from '../../../types/session/actor.js'
|
|
40
|
+
import type { SummaryId } from '../../../types/session/ids.js'
|
|
41
|
+
import type { WorkspaceRef } from '../../../types/workspace/ref.js'
|
|
42
|
+
import { ZERO_COST } from '../../../utils/cost.js'
|
|
43
|
+
import { DefaultCapacityValidator } from '../../handoff/capacity.js'
|
|
44
|
+
import { SessionSummaryMaterializer } from '../../summary/materialize.js'
|
|
45
|
+
import type {
|
|
46
|
+
BranchWorkspaceParams,
|
|
47
|
+
CreateWorkspaceParams,
|
|
48
|
+
WorkspaceBackendDriver,
|
|
49
|
+
WorkspaceInspection,
|
|
50
|
+
} from '../../workspace/driver.js'
|
|
51
|
+
import { WorkspaceBackendRegistry } from '../../workspace/registry.js'
|
|
52
|
+
|
|
53
|
+
const tenant = 'tnt_alpha' as TenantId
|
|
54
|
+
|
|
55
|
+
const capabilities: AgentCapabilities = {
|
|
56
|
+
supportsTools: false,
|
|
57
|
+
supportsStreaming: false,
|
|
58
|
+
supportsConcurrency: false,
|
|
59
|
+
supportsSubAgents: false,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A child that settles the way `outcome` says, so both branches are reachable. */
|
|
63
|
+
function buildAgent(
|
|
64
|
+
id: string,
|
|
65
|
+
outcome: 'completed' | 'failed',
|
|
66
|
+
): Agent<BaseAgentConfig, BaseAgentResult> {
|
|
67
|
+
return {
|
|
68
|
+
type: 'reactive',
|
|
69
|
+
metadata: {
|
|
70
|
+
type: 'reactive',
|
|
71
|
+
id,
|
|
72
|
+
name: id,
|
|
73
|
+
version: '1.0.0',
|
|
74
|
+
category: 'test',
|
|
75
|
+
description: id,
|
|
76
|
+
capabilities,
|
|
77
|
+
},
|
|
78
|
+
run: async (_input: AgentInput, _config: BaseAgentConfig): Promise<BaseAgentResult> => ({
|
|
79
|
+
runId: 'run_child' as RunId,
|
|
80
|
+
status: outcome,
|
|
81
|
+
usage: { ...EMPTY_TOKEN_USAGE },
|
|
82
|
+
cost: { ...ZERO_COST },
|
|
83
|
+
iterations: 1,
|
|
84
|
+
durationMs: 1,
|
|
85
|
+
messages: [createAssistantMessage('child did the work')],
|
|
86
|
+
result: 'child did the work',
|
|
87
|
+
}),
|
|
88
|
+
cancel: async () => undefined,
|
|
89
|
+
getCapabilities: () => capabilities,
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function buildDefinition(agent: Agent<BaseAgentConfig, BaseAgentResult>): AgentDefinition {
|
|
94
|
+
return {
|
|
95
|
+
info: {
|
|
96
|
+
id: agent.metadata.id,
|
|
97
|
+
name: agent.metadata.name,
|
|
98
|
+
version: agent.metadata.version,
|
|
99
|
+
category: agent.metadata.category,
|
|
100
|
+
description: agent.metadata.description,
|
|
101
|
+
tools: [],
|
|
102
|
+
defaults: { model: 'test', tokenBudget: 1_000 },
|
|
103
|
+
},
|
|
104
|
+
typedAgent: agent,
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Provisions successfully and records every ref it is asked to dispose. */
|
|
109
|
+
class RecordingWorkspaceDriver implements WorkspaceBackendDriver {
|
|
110
|
+
readonly kind = 'git-worktree' as const
|
|
111
|
+
readonly created: WorkspaceRef[] = []
|
|
112
|
+
readonly disposed: WorkspaceId[] = []
|
|
113
|
+
private counter = 0
|
|
114
|
+
|
|
115
|
+
async create(params: CreateWorkspaceParams): Promise<WorkspaceRef> {
|
|
116
|
+
const ref: WorkspaceRef = {
|
|
117
|
+
id: `wsp_test_${++this.counter}` as WorkspaceId,
|
|
118
|
+
meta: {
|
|
119
|
+
backend: 'git-worktree',
|
|
120
|
+
repoRoot: '/tmp/repo',
|
|
121
|
+
branch: `namzu/${params.label ?? 'unlabelled'}`,
|
|
122
|
+
worktreePath: `/tmp/repo/.namzu/worktrees/${params.label ?? 'unlabelled'}`,
|
|
123
|
+
},
|
|
124
|
+
createdAt: new Date(),
|
|
125
|
+
}
|
|
126
|
+
this.created.push(ref)
|
|
127
|
+
return ref
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async branch(_source: WorkspaceRef, _params: BranchWorkspaceParams): Promise<WorkspaceRef> {
|
|
131
|
+
throw new Error('unused in this test')
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async dispose(ref: WorkspaceRef): Promise<void> {
|
|
135
|
+
this.disposed.push(ref.id)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async inspect(_ref: WorkspaceRef): Promise<WorkspaceInspection> {
|
|
139
|
+
throw new Error('unused in this test')
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Stands up a Project → Thread → parent Session and an AgentManager wired to a
|
|
145
|
+
* recording workspace driver. `outcome` decides how the delegated child ends;
|
|
146
|
+
* `registerBackend: false` leaves the registry empty, which is the supported
|
|
147
|
+
* lazy-provisioning configuration rather than an error (pattern doc §7.1).
|
|
148
|
+
*/
|
|
149
|
+
async function harness(
|
|
150
|
+
outcome: 'completed' | 'failed',
|
|
151
|
+
{ registerBackend = true }: { registerBackend?: boolean } = {},
|
|
152
|
+
) {
|
|
153
|
+
const store = new InMemorySessionStore()
|
|
154
|
+
const threadStore = new InMemoryThreadStore()
|
|
155
|
+
const project = await store.createProject({ tenantId: tenant, name: 'workspace-project' }, tenant)
|
|
156
|
+
const thread = await threadStore.createThread(
|
|
157
|
+
{ projectId: project.id, title: 'workspace-topic' },
|
|
158
|
+
tenant,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
const userActor: ActorRef = { kind: 'user', userId: 'usr_root' as UserId, tenantId: tenant }
|
|
162
|
+
|
|
163
|
+
const parentSession = await store.createSession(
|
|
164
|
+
{ threadId: thread.id, projectId: project.id, currentActor: userActor },
|
|
165
|
+
tenant,
|
|
166
|
+
)
|
|
167
|
+
await store.updateSession({ ...parentSession, status: 'active' }, tenant)
|
|
168
|
+
|
|
169
|
+
let summaryCounter = 0
|
|
170
|
+
const materializer = new SessionSummaryMaterializer({
|
|
171
|
+
store,
|
|
172
|
+
generateSummaryId: () => `sum_test_${++summaryCounter}` as SummaryId,
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
const registry = new AgentRegistry()
|
|
176
|
+
registry.register(buildDefinition(buildAgent('worker', outcome)))
|
|
177
|
+
|
|
178
|
+
const workspaceRegistry = new WorkspaceBackendRegistry()
|
|
179
|
+
const driver = new RecordingWorkspaceDriver()
|
|
180
|
+
if (registerBackend) workspaceRegistry.register(driver)
|
|
181
|
+
|
|
182
|
+
const manager = new AgentManager(registry, undefined, {
|
|
183
|
+
sessionStore: store,
|
|
184
|
+
summaryMaterializer: materializer,
|
|
185
|
+
workspaceRegistry,
|
|
186
|
+
capacity: new DefaultCapacityValidator(store),
|
|
187
|
+
threadManager: new ThreadManager({ threadStore, sessionStore: store }),
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
const taskContext: AgentTaskContext = {
|
|
191
|
+
parentRunId: 'run_parent' as RunId,
|
|
192
|
+
parentAgentId: 'supervisor',
|
|
193
|
+
parentAbortController: new AbortController(),
|
|
194
|
+
depth: 0,
|
|
195
|
+
budgetTracker: { total: 100_000, remaining: 100_000 },
|
|
196
|
+
tenantId: tenant,
|
|
197
|
+
threadId: thread.id,
|
|
198
|
+
sessionId: parentSession.id,
|
|
199
|
+
projectId: project.id,
|
|
200
|
+
parentActor: userActor,
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const options: SendMessageOptions = {
|
|
204
|
+
agentId: 'worker',
|
|
205
|
+
input: { messages: [], workingDirectory: '/tmp' },
|
|
206
|
+
parentSessionId: parentSession.id,
|
|
207
|
+
tenantId: tenant,
|
|
208
|
+
projectId: project.id,
|
|
209
|
+
parentActor: userActor,
|
|
210
|
+
workspaceBackend: 'git-worktree',
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return { store, manager, driver, parentSession, options, taskContext }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
describe('a delegated child does not outlive its workspace', () => {
|
|
217
|
+
it('disposes the workspace when the child SUCCEEDS', async () => {
|
|
218
|
+
const { manager, driver, options, taskContext } = await harness('completed')
|
|
219
|
+
|
|
220
|
+
const task = await manager.sendMessage(options, taskContext)
|
|
221
|
+
await manager.waitForCompletion(task.taskId)
|
|
222
|
+
|
|
223
|
+
expect(manager.getState(task.taskId)).toBe('completed')
|
|
224
|
+
expect(driver.created).toHaveLength(1)
|
|
225
|
+
// The assertion the leak fails: one workspace made, the same one released.
|
|
226
|
+
expect(driver.disposed).toEqual([driver.created[0]?.id])
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('still disposes the workspace when the child FAILS', async () => {
|
|
230
|
+
const { manager, driver, options, taskContext } = await harness('failed')
|
|
231
|
+
|
|
232
|
+
const task = await manager.sendMessage(options, taskContext)
|
|
233
|
+
await manager.waitForCompletion(task.taskId)
|
|
234
|
+
|
|
235
|
+
expect(driver.created).toHaveLength(1)
|
|
236
|
+
expect(driver.disposed).toEqual([driver.created[0]?.id])
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('records the workspace on the sub-session, so archival can find it', async () => {
|
|
240
|
+
const { store, manager, driver, parentSession, options, taskContext } =
|
|
241
|
+
await harness('completed')
|
|
242
|
+
|
|
243
|
+
const task = await manager.sendMessage(options, taskContext)
|
|
244
|
+
await manager.waitForCompletion(task.taskId)
|
|
245
|
+
|
|
246
|
+
const [subSession] = await store.getChildren(parentSession.id, tenant)
|
|
247
|
+
expect(subSession).toBeDefined()
|
|
248
|
+
// Was `null` on every spawn-created sub-session, which is what made
|
|
249
|
+
// `ArchivalManager`'s `sub.workspaceId &&` guard unreachable here.
|
|
250
|
+
expect(subSession?.workspaceId).toBe(driver.created[0]?.id)
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it('leaves workspaceId null when no backend is registered', async () => {
|
|
254
|
+
// Lazy provisioning stays legal (pattern doc §7.1): an unregistered
|
|
255
|
+
// backend is not an error, and the record must not claim a workspace
|
|
256
|
+
// that was never made.
|
|
257
|
+
const { store, manager, driver, parentSession, options, taskContext } = await harness(
|
|
258
|
+
'completed',
|
|
259
|
+
{ registerBackend: false },
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
const task = await manager.sendMessage(options, taskContext)
|
|
263
|
+
await manager.waitForCompletion(task.taskId)
|
|
264
|
+
|
|
265
|
+
expect(manager.getState(task.taskId)).toBe('completed')
|
|
266
|
+
expect(driver.created).toHaveLength(0)
|
|
267
|
+
const [subSession] = await store.getChildren(parentSession.id, tenant)
|
|
268
|
+
expect(subSession?.workspaceId).toBeNull()
|
|
269
|
+
})
|
|
270
|
+
})
|
|
@@ -114,13 +114,22 @@ describe('Integration — single-recipient handoff E2E', () => {
|
|
|
114
114
|
expect(reloaded?.previousActors).toHaveLength(1)
|
|
115
115
|
expect(reloaded?.previousActors[0]).toEqual(sourceActor)
|
|
116
116
|
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
117
|
+
// The lock MOVES the version, and this assertion used to say it did not.
|
|
118
|
+
//
|
|
119
|
+
// It expected `locked` at v0 — the version the source was already at —
|
|
120
|
+
// which is precisely why the lock was not a lock: a second handoff
|
|
121
|
+
// holding the same snapshot saw an unchanged `ownerVersion`, passed the
|
|
122
|
+
// check, and locked the session again. Both provisioned a worktree and
|
|
123
|
+
// one erased the other.
|
|
124
|
+
//
|
|
125
|
+
// The bump moved to the lock and the commit now keeps it, so a handoff
|
|
126
|
+
// still consumes exactly one version and `committedOwnerVersion` is
|
|
127
|
+
// unchanged at 1 — asserted above, and that is the host-visible
|
|
128
|
+
// contract. What changed is only the intermediate state, which is the
|
|
129
|
+
// state that had to become visible.
|
|
121
130
|
expect(updateCalls).toEqual(
|
|
122
131
|
expect.arrayContaining([
|
|
123
|
-
{ status: 'locked', ownerVersion:
|
|
132
|
+
{ status: 'locked', ownerVersion: 1 },
|
|
124
133
|
{ status: 'idle', ownerVersion: 1 },
|
|
125
134
|
]),
|
|
126
135
|
)
|
package/src/session/errors.ts
CHANGED
|
@@ -94,6 +94,35 @@ export class StaleThreadError extends Error {
|
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Raised when a Session write names a version the store no longer has.
|
|
99
|
+
*
|
|
100
|
+
* The sibling of {@link StaleThreadError}, and it arrived much later: Thread
|
|
101
|
+
* had a working compare-and-set from the start while `Session.ownerVersion`
|
|
102
|
+
* was documented as a CAS counter that nothing enforced. Two concurrent
|
|
103
|
+
* handoffs could both pass, both provision a worktree, and one silently erase
|
|
104
|
+
* the other.
|
|
105
|
+
*
|
|
106
|
+
* `actualVersion` is what the store holds, not what the caller sent — the
|
|
107
|
+
* caller already knows what it sent, and the useful half of the answer is how
|
|
108
|
+
* far behind it is.
|
|
109
|
+
*/
|
|
110
|
+
export class StaleSessionError extends Error {
|
|
111
|
+
readonly details: {
|
|
112
|
+
sessionId: SessionId
|
|
113
|
+
expectedVersion: number
|
|
114
|
+
actualVersion: number
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
constructor(details: { sessionId: SessionId; expectedVersion: number; actualVersion: number }) {
|
|
118
|
+
super(
|
|
119
|
+
`Stale Session ${details.sessionId}: expected ownerVersion=${details.expectedVersion}, actual=${details.actualVersion}. Another writer took ownership; re-read the session before retrying.`,
|
|
120
|
+
)
|
|
121
|
+
this.name = 'StaleSessionError'
|
|
122
|
+
this.details = details
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
97
126
|
/**
|
|
98
127
|
* Raised by the spawn path (and any caller that enforces the open-thread
|
|
99
128
|
* precondition) when a Thread is in `'archived'` state and would-be mutations
|
|
@@ -211,8 +211,12 @@ export async function executeBroadcastHandoff(
|
|
|
211
211
|
})
|
|
212
212
|
}
|
|
213
213
|
|
|
214
|
-
|
|
215
|
-
|
|
214
|
+
// The lock BUMPS the version — see the same change in single.ts. Writing
|
|
215
|
+
// the lock at the version it read left the locked window invisible, so a
|
|
216
|
+
// second broadcast holding the same snapshot passed the check above and
|
|
217
|
+
// fanned out over a session that was already locked.
|
|
218
|
+
const locked: Session = { ...source, status: 'locked', ownerVersion: source.ownerVersion + 1 }
|
|
219
|
+
await deps.store.updateSession(locked, tenantId, first.expectedOwnerVersion)
|
|
216
220
|
emit(deps.events.onLocked, { sessionId: source.id, at: new Date() })
|
|
217
221
|
|
|
218
222
|
// 8. Fan-out provisioning. Track per-recipient partial state so rollback
|
|
@@ -269,12 +273,13 @@ export async function executeBroadcastHandoff(
|
|
|
269
273
|
|
|
270
274
|
// 9. Commit source: `locked → awaiting_merge` (§5.4 — broadcast source is
|
|
271
275
|
// not `idle` until all recipients terminalize; coordinator role).
|
|
276
|
+
// Keeps the lock's version: one handoff, one ownership change, one
|
|
277
|
+
// version. The bump moved to the lock where it is load-bearing.
|
|
272
278
|
const committed: Session = {
|
|
273
|
-
...
|
|
279
|
+
...locked,
|
|
274
280
|
status: 'awaiting_merge',
|
|
275
|
-
ownerVersion: source.ownerVersion + 1,
|
|
276
281
|
}
|
|
277
|
-
await deps.store.updateSession(committed, tenantId)
|
|
282
|
+
await deps.store.updateSession(committed, tenantId, locked.ownerVersion)
|
|
278
283
|
|
|
279
284
|
emit(deps.events.onCommitted, {
|
|
280
285
|
sessionId: source.id,
|
|
@@ -160,11 +160,24 @@ export async function executeSingleHandoff(
|
|
|
160
160
|
})
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
// The lock BUMPS the version, and that is what makes it a lock.
|
|
164
|
+
//
|
|
165
|
+
// It used to write `status: 'locked'` at the version it read, so a second
|
|
166
|
+
// handoff that had read the same snapshot saw an unchanged `ownerVersion`,
|
|
167
|
+
// passed the check above, and locked the session a second time. Both then
|
|
168
|
+
// provisioned a worktree and one silently erased the other. The comparison
|
|
169
|
+
// above was against a value nothing had moved.
|
|
170
|
+
//
|
|
171
|
+
// Passing `expectedOwnerVersion` makes the store the arbiter rather than
|
|
172
|
+
// this function: the check above is a fast fail on a stale snapshot, and
|
|
173
|
+
// this is the one that cannot be raced, because the store compares against
|
|
174
|
+
// what it holds at the instant of the write.
|
|
163
175
|
const locked: Session = {
|
|
164
176
|
...source,
|
|
165
177
|
status: 'locked',
|
|
178
|
+
ownerVersion: source.ownerVersion + 1,
|
|
166
179
|
}
|
|
167
|
-
await deps.store.updateSession(locked, tenantId)
|
|
180
|
+
await deps.store.updateSession(locked, tenantId, assignment.expectedOwnerVersion)
|
|
168
181
|
emit(deps.events.onLocked, { sessionId: source.id, at: new Date() })
|
|
169
182
|
|
|
170
183
|
// 6. Provision recipient resources. Track partial state for rollback.
|
|
@@ -197,16 +210,26 @@ export async function executeSingleHandoff(
|
|
|
197
210
|
// 7. Commit source: `locked → idle` with appended actor + bumped version.
|
|
198
211
|
// The source transitions ownership to the recipient — the previous
|
|
199
212
|
// owner is permanently read-only (§6.1).
|
|
213
|
+
// Keeps the LOCK's version rather than taking a second one.
|
|
214
|
+
//
|
|
215
|
+
// One handoff is one ownership change, so it consumes one version. The
|
|
216
|
+
// bump moved to the lock — where it has to be, or the locked window is
|
|
217
|
+
// invisible to a concurrent reader — and the commit finishes the
|
|
218
|
+
// transition the lock reserved. A host reading `committedOwnerVersion`
|
|
219
|
+
// sees the same number it always did.
|
|
220
|
+
//
|
|
221
|
+
// The expected version is the lock's, so anything that wrote to this
|
|
222
|
+
// session between the lock and here loses to the store rather than
|
|
223
|
+
// being overwritten by us.
|
|
200
224
|
const committed: Session = {
|
|
201
|
-
...
|
|
225
|
+
...locked,
|
|
202
226
|
status: 'idle',
|
|
203
227
|
currentActor: assignment.recipientActor,
|
|
204
228
|
previousActors: source.currentActor
|
|
205
229
|
? [...source.previousActors, source.currentActor]
|
|
206
230
|
: [...source.previousActors],
|
|
207
|
-
ownerVersion: source.ownerVersion + 1,
|
|
208
231
|
}
|
|
209
|
-
await deps.store.updateSession(committed, tenantId)
|
|
232
|
+
await deps.store.updateSession(committed, tenantId, locked.ownerVersion)
|
|
210
233
|
|
|
211
234
|
emit(deps.events.onCommitted, {
|
|
212
235
|
sessionId: source.id,
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { StaleSessionError } from '../../../session/errors.js'
|
|
4
|
+
import type { TenantId } from '../../../types/ids/index.js'
|
|
5
|
+
import type { Session } from '../../../types/session/entity.js'
|
|
6
|
+
import { InMemorySessionStore } from '../memory.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `Session.ownerVersion` was documented as the CAS counter for handoff, and
|
|
10
|
+
* nothing enforced it.
|
|
11
|
+
*
|
|
12
|
+
* Both stores overwrote unconditionally, so the only check was in the handoff
|
|
13
|
+
* path — where it compared a snapshot read several awaits earlier against
|
|
14
|
+
* itself. Two concurrent handoffs on one idle session both passed, both
|
|
15
|
+
* provisioned a worktree, and one silently erased the other.
|
|
16
|
+
*
|
|
17
|
+
* The store is the arbiter now, because it is the only party that can compare
|
|
18
|
+
* against the value at the instant of the write.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const TENANT = 'tnt_cas' as TenantId
|
|
22
|
+
|
|
23
|
+
async function seed(): Promise<{ store: InMemorySessionStore; session: Session }> {
|
|
24
|
+
const store = new InMemorySessionStore()
|
|
25
|
+
const project = await store.createProject({ tenantId: TENANT, name: 'cas' }, TENANT)
|
|
26
|
+
const session = await store.createSession(
|
|
27
|
+
{ threadId: 'thd_cas' as never, projectId: project.id, currentActor: null },
|
|
28
|
+
TENANT,
|
|
29
|
+
)
|
|
30
|
+
return { store, session }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('a session write can require that nobody else wrote first', () => {
|
|
34
|
+
it('accepts a write naming the version the store holds', async () => {
|
|
35
|
+
const { store, session } = await seed()
|
|
36
|
+
|
|
37
|
+
await store.updateSession({ ...session, status: 'locked' }, TENANT, session.ownerVersion)
|
|
38
|
+
|
|
39
|
+
expect((await store.getSession(session.id, TENANT))?.status).toBe('locked')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('refuses a write naming a version that has moved on', async () => {
|
|
43
|
+
const { store, session } = await seed()
|
|
44
|
+
|
|
45
|
+
// Somebody else takes it first.
|
|
46
|
+
await store.updateSession({ ...session, ownerVersion: 1 }, TENANT, 0)
|
|
47
|
+
|
|
48
|
+
await expect(
|
|
49
|
+
store.updateSession({ ...session, status: 'locked' }, TENANT, 0),
|
|
50
|
+
).rejects.toBeInstanceOf(StaleSessionError)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('reports what the store holds, not what the caller sent', async () => {
|
|
54
|
+
// The caller already knows what it sent; the useful half of the answer
|
|
55
|
+
// is how far behind it is.
|
|
56
|
+
const { store, session } = await seed()
|
|
57
|
+
await store.updateSession({ ...session, ownerVersion: 7 }, TENANT, 0)
|
|
58
|
+
|
|
59
|
+
await store
|
|
60
|
+
.updateSession({ ...session, status: 'locked' }, TENANT, 3)
|
|
61
|
+
.then(() => expect.unreachable('the stale write should have been refused'))
|
|
62
|
+
.catch((err: StaleSessionError) => {
|
|
63
|
+
expect(err.details.expectedVersion).toBe(3)
|
|
64
|
+
expect(err.details.actualVersion).toBe(7)
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('compares against the STORED version, not the payload', async () => {
|
|
69
|
+
// The payload is the caller's own copy. Comparing it to itself is the
|
|
70
|
+
// check the handoff path was already making and getting nothing from,
|
|
71
|
+
// so a write whose payload agrees with itself must still be refused
|
|
72
|
+
// when the store has moved.
|
|
73
|
+
const { store, session } = await seed()
|
|
74
|
+
await store.updateSession({ ...session, ownerVersion: 5 }, TENANT, 0)
|
|
75
|
+
|
|
76
|
+
const selfConsistentButStale: Session = { ...session, ownerVersion: 2, status: 'locked' }
|
|
77
|
+
|
|
78
|
+
await expect(store.updateSession(selfConsistentButStale, TENANT, 2)).rejects.toBeInstanceOf(
|
|
79
|
+
StaleSessionError,
|
|
80
|
+
)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('writes unconditionally when no version is named', async () => {
|
|
84
|
+
// The compatibility promise: every caller that existed before this
|
|
85
|
+
// parameter behaves exactly as it did.
|
|
86
|
+
const { store, session } = await seed()
|
|
87
|
+
await store.updateSession({ ...session, ownerVersion: 9 }, TENANT, 0)
|
|
88
|
+
|
|
89
|
+
await store.updateSession({ ...session, status: 'locked' }, TENANT)
|
|
90
|
+
|
|
91
|
+
expect((await store.getSession(session.id, TENANT))?.status).toBe('locked')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('lets exactly one of two concurrent writers win', async () => {
|
|
95
|
+
// The defect, in the shape it actually took: two writers holding the
|
|
96
|
+
// same snapshot, racing for the same session.
|
|
97
|
+
const { store, session } = await seed()
|
|
98
|
+
const snapshot = session.ownerVersion
|
|
99
|
+
|
|
100
|
+
const results = await Promise.allSettled([
|
|
101
|
+
store.updateSession(
|
|
102
|
+
{ ...session, status: 'locked', ownerVersion: snapshot + 1 },
|
|
103
|
+
TENANT,
|
|
104
|
+
snapshot,
|
|
105
|
+
),
|
|
106
|
+
store.updateSession(
|
|
107
|
+
{ ...session, status: 'locked', ownerVersion: snapshot + 1 },
|
|
108
|
+
TENANT,
|
|
109
|
+
snapshot,
|
|
110
|
+
),
|
|
111
|
+
])
|
|
112
|
+
|
|
113
|
+
expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1)
|
|
114
|
+
expect(results.filter((r) => r.status === 'rejected')).toHaveLength(1)
|
|
115
|
+
})
|
|
116
|
+
})
|