@namzu/sdk 12.1.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 +152 -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/runtime/query/__tests__/the-other-approval-surface-names-the-agent.test.d.ts +2 -0
- package/dist/runtime/query/__tests__/the-other-approval-surface-names-the-agent.test.d.ts.map +1 -0
- package/dist/runtime/query/__tests__/the-other-approval-surface-names-the-agent.test.js +81 -0
- package/dist/runtime/query/__tests__/the-other-approval-surface-names-the-agent.test.js.map +1 -0
- package/dist/runtime/query/index.d.ts.map +1 -1
- package/dist/runtime/query/index.js +1 -0
- package/dist/runtime/query/index.js.map +1 -1
- package/dist/runtime/query/iteration/phases/plan.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/plan.js +1 -0
- package/dist/runtime/query/iteration/phases/plan.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/tools/coordinator/__tests__/an-approver-is-never-shown-an-impossible-step.test.d.ts +2 -0
- package/dist/tools/coordinator/__tests__/an-approver-is-never-shown-an-impossible-step.test.d.ts.map +1 -0
- package/dist/tools/coordinator/__tests__/an-approver-is-never-shown-an-impossible-step.test.js +115 -0
- package/dist/tools/coordinator/__tests__/an-approver-is-never-shown-an-impossible-step.test.js.map +1 -0
- package/dist/tools/coordinator/index.d.ts.map +1 -1
- package/dist/tools/coordinator/index.js +36 -0
- package/dist/tools/coordinator/index.js.map +1 -1
- package/dist/types/hitl/index.d.ts +16 -0
- package/dist/types/hitl/index.d.ts.map +1 -1
- package/dist/types/hitl/index.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/runtime/query/__tests__/the-other-approval-surface-names-the-agent.test.ts +95 -0
- package/src/runtime/query/index.ts +1 -0
- package/src/runtime/query/iteration/phases/plan.ts +1 -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/tools/coordinator/__tests__/an-approver-is-never-shown-an-impossible-step.test.ts +153 -0
- package/src/tools/coordinator/index.ts +40 -0
- package/src/types/hitl/index.ts +18 -0
- package/src/types/session/store.ts +28 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/types/hitl/index.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/types/hitl/index.ts"],"names":[],"mappings":"AA4PA,MAAM,UAAU,kBAAkB,CAAC,OAA4B;IAC9D,QAAQ,OAAO,CAAC,IAAI,EAAE,CAAC;QACtB,KAAK,eAAe;YACnB,OAAO,OAAO,CAAC,OAAO,CAAC,EAAE,MAAM,EAAE,cAAc,EAAE,CAAC,CAAA;QACnD,KAAK,aAAa;YACjB,OAAO,OAAO,CAAC,OAAO,CAAC,EAAE,MAAM,EAAE,eAAe,EAAE,CAAC,CAAA;QACpD,KAAK,sBAAsB;YAC1B,OAAO,OAAO,CAAC,OAAO,CAAC,EAAE,MAAM,EAAE,UAAU,EAAE,CAAC,CAAA;QAC/C,KAAK,eAAe;YACnB,2DAA2D;YAC3D,yDAAyD;YACzD,6DAA6D;YAC7D,uCAAuC;YACvC,OAAO,OAAO,CAAC,OAAO,CAAC;gBACtB,MAAM,EAAE,iBAAiB;gBACzB,iBAAiB,EAAE,EAAE;gBACrB,QAAQ,EAAE,mEAAmE;gBAC7E,UAAU,EAAE,OAAO,CAAC,QAAQ,CAAC,UAAU;aACvC,CAAC,CAAA;QACH,OAAO,CAAC,CAAC,CAAC;YACT,MAAM,WAAW,GAAU,OAAO,CAAA;YAClC,MAAM,IAAI,KAAK,CAAC,gCAAiC,WAAmC,CAAC,IAAI,EAAE,CAAC,CAAA;QAC7F,CAAC;IACF,CAAC;AACF,CAAC"}
|
|
@@ -86,7 +86,34 @@ export interface SessionStore {
|
|
|
86
86
|
getProject(projectId: ProjectId, tenantId: TenantId): Promise<Project | null>;
|
|
87
87
|
createSession(params: CreateSessionParams, tenantId: TenantId): Promise<Session>;
|
|
88
88
|
getSession(sessionId: SessionId, tenantId: TenantId): Promise<Session | null>;
|
|
89
|
-
|
|
89
|
+
/**
|
|
90
|
+
* Write a Session back, optionally only if nobody else wrote it first.
|
|
91
|
+
*
|
|
92
|
+
* **`expectedOwnerVersion` is the single-writer lock this level is supposed
|
|
93
|
+
* to own, and it did not exist.** `Session.ownerVersion` is documented as
|
|
94
|
+
* the CAS counter for handoff, but nothing enforced it: both stores
|
|
95
|
+
* overwrote unconditionally, and the handoff's own check compared a
|
|
96
|
+
* snapshot it had read several awaits earlier against itself. Two
|
|
97
|
+
* concurrent handoffs on one idle session both passed, both provisioned a
|
|
98
|
+
* worktree, and one silently erased the other.
|
|
99
|
+
*
|
|
100
|
+
* Supply it and the store compares against the version it HAS STORED —
|
|
101
|
+
* not against the payload, which is the caller's stale copy — and throws
|
|
102
|
+
* {@link StaleSessionError} rather than writing. Omit it and the behaviour
|
|
103
|
+
* is exactly what it always was, which is the compatibility promise: this
|
|
104
|
+
* parameter is optional so that widening the interface stays invisible to
|
|
105
|
+
* callers and harmless to hosts implementing their own store. A required
|
|
106
|
+
* parameter would break every implementor for a guarantee they can opt
|
|
107
|
+
* into.
|
|
108
|
+
*
|
|
109
|
+
* **In-process only, stated rather than implied.** `DiskSessionStore`
|
|
110
|
+
* writes atomically, but its read-compare-write is not a critical section,
|
|
111
|
+
* so two PROCESSES can still both pass the check. Closing that needs a
|
|
112
|
+
* lease with an expiry — not a PID registry, because a Session is durable
|
|
113
|
+
* and written from hosts where a PID is not a checkable fact. The same
|
|
114
|
+
* honesty the spawn lock already carries.
|
|
115
|
+
*/
|
|
116
|
+
updateSession(session: Session, tenantId: TenantId, expectedOwnerVersion?: number): Promise<void>;
|
|
90
117
|
/**
|
|
91
118
|
* List every Session that belongs to the given Thread for the caller's
|
|
92
119
|
* tenant, ordered by `createdAt` ascending. Returns an empty array when
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../../src/types/session/store.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,+BAA+B,CAAA;AAC5D,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,8BAA8B,CAAA;AAC5D,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,+BAA+B,CAAA;AAC5D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iCAAiC,CAAA;AACrE,OAAO,KAAK,EACX,cAAc,EACd,WAAW,EACX,UAAU,EACV,cAAc,EACd,MAAM,oCAAoC,CAAA;AAC3C,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,4BAA4B,CAAA;AACnE,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAA;AACrE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAClD,OAAO,KAAK,EAAE,SAAS,EAAE,YAAY,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,mBAAmB,CAAA;AAErF;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,mBAAmB;IACnC,QAAQ,EAAE,QAAQ,CAAA;IAClB,SAAS,EAAE,SAAS,CAAA;IACpB;;;;OAIG;IACH,YAAY,EAAE,QAAQ,GAAG,IAAI,CAAA;CAC7B;AAED;;;;;GAKG;AACH,MAAM,WAAW,sBAAsB;IACtC,eAAe,EAAE,SAAS,CAAA;IAC1B,cAAc,EAAE,SAAS,CAAA;IACzB,IAAI,EAAE,cAAc,CAAA;IACpB,SAAS,EAAE,QAAQ,CAAA;IACnB,WAAW,CAAC,EAAE,WAAW,CAAA;IACzB,cAAc,CAAC,EAAE,cAAc,CAAA;CAC/B;AAED;;;;;GAKG;AACH,MAAM,WAAW,mBAAmB;IACnC,QAAQ,EAAE,QAAQ,CAAA;IAClB,IAAI,EAAE,MAAM,CAAA;CACZ;AAED;;;;;;GAMG;AACH,MAAM,WAAW,WAAW;IAC3B,OAAO,EAAE,OAAO,CAAA;IAChB,QAAQ,EAAE,SAAS,UAAU,EAAE,CAAA;IAC/B,QAAQ,EAAE,SAAS,SAAS,EAAE,CAAA;CAC9B;AAED;;;;;;;;GAQG;AACH,MAAM,WAAW,YAAY;IAG5B,aAAa,CAAC,MAAM,EAAE,mBAAmB,EAAE,QAAQ,EAAE,QAAQ,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;IAEhF,UAAU,CAAC,SAAS,EAAE,SAAS,EAAE,QAAQ,EAAE,QAAQ,GAAG,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAAA;IAI7E,aAAa,CAAC,MAAM,EAAE,mBAAmB,EAAE,QAAQ,EAAE,QAAQ,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;IAEhF,UAAU,CAAC,SAAS,EAAE,SAAS,EAAE,QAAQ,EAAE,QAAQ,GAAG,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAAA;IAE7E,aAAa,CAAC,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;
|
|
1
|
+
{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../../src/types/session/store.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import type { WireRun } from '../../../contracts/api.js'
|
|
4
|
+
import { a2aMessageToCreateRun, runToA2ATask } from '../task.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A2A's `contextId` is namzu's **Project**, and this pins it.
|
|
8
|
+
*
|
|
9
|
+
* The docs of record said the opposite for a long time: `a2a-threading.md`
|
|
10
|
+
* opened with "A2A connections attach at the Thread level, not the Project
|
|
11
|
+
* level" and called that the reason the Thread layer is first-class. The code
|
|
12
|
+
* never did it. `runToA2ATask` has always bound `contextId` to `project_id`,
|
|
13
|
+
* `a2aParamsToRunRequest` has always read it back as `projectId`, and
|
|
14
|
+
* `ThreadId` appears nowhere in this bridge.
|
|
15
|
+
*
|
|
16
|
+
* That gap is why this test exists rather than a comment. The claim was load-
|
|
17
|
+
* bearing — it was the single justification for a whole hierarchy level — and
|
|
18
|
+
* it survived for months because nothing asserted the actual binding. An
|
|
19
|
+
* unasserted invariant is a belief, and beliefs are what the prose was made of.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
function wireRun(overrides: Partial<WireRun> = {}): WireRun {
|
|
23
|
+
return {
|
|
24
|
+
id: 'run_ctx',
|
|
25
|
+
agent_id: 'worker',
|
|
26
|
+
status: 'completed',
|
|
27
|
+
project_id: 'prj_alpha',
|
|
28
|
+
created_at: new Date('2026-08-06').toISOString(),
|
|
29
|
+
...overrides,
|
|
30
|
+
} as WireRun
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('a2a contextId is the project, in both directions', () => {
|
|
34
|
+
it('maps a run onto a task whose context is its project', () => {
|
|
35
|
+
expect(runToA2ATask(wireRun()).contextId).toBe('prj_alpha')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('reads a task context back as the project to run under', () => {
|
|
39
|
+
const request = a2aMessageToCreateRun('worker', {
|
|
40
|
+
contextId: 'prj_beta',
|
|
41
|
+
message: { role: 'user', parts: [{ kind: 'text', text: 'go' }] },
|
|
42
|
+
} as never)
|
|
43
|
+
|
|
44
|
+
expect(request.projectId).toBe('prj_beta')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('round-trips, so a peer context names a project namzu can run under', () => {
|
|
48
|
+
// The property an interoperating peer actually depends on: the context
|
|
49
|
+
// it was handed is the context it can send back.
|
|
50
|
+
const task = runToA2ATask(wireRun({ project_id: 'prj_gamma' } as Partial<WireRun>))
|
|
51
|
+
const request = a2aMessageToCreateRun('worker', {
|
|
52
|
+
contextId: task.contextId,
|
|
53
|
+
message: { role: 'user', parts: [{ kind: 'text', text: 'again' }] },
|
|
54
|
+
} as never)
|
|
55
|
+
|
|
56
|
+
expect(request.projectId).toBe('prj_gamma')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('leaves the context absent when a run has no project', () => {
|
|
60
|
+
// Absent rather than an empty string: a peer must be able to tell "no
|
|
61
|
+
// context" from "a context named nothing".
|
|
62
|
+
expect(
|
|
63
|
+
runToA2ATask(wireRun({ project_id: undefined } as Partial<WireRun>)).contextId,
|
|
64
|
+
).toBeUndefined()
|
|
65
|
+
})
|
|
66
|
+
})
|
|
@@ -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'
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import type { PlanManager } from '../../../manager/plan/lifecycle.js'
|
|
4
|
+
import { MockLLMProvider, registerMock } from '../../../provider/index.js'
|
|
5
|
+
import { ToolRegistry } from '../../../registry/index.js'
|
|
6
|
+
import type { HITLResumeDecision, PlanApprovalData } from '../../../types/hitl/index.js'
|
|
7
|
+
import {
|
|
8
|
+
generateProjectId,
|
|
9
|
+
generateSessionId,
|
|
10
|
+
generateTenantId,
|
|
11
|
+
generateThreadId,
|
|
12
|
+
} from '../../../utils/id.js'
|
|
13
|
+
import { drainQuery } from '../index.js'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* There are TWO approval surfaces, and the fix landed on one of them.
|
|
17
|
+
*
|
|
18
|
+
* `PlanStep.agentId` was added so an approver could see WHICH agent a step goes
|
|
19
|
+
* to rather than only THAT it delegates. It reached `PlanApprovalRequest` — the
|
|
20
|
+
* shape a host sees when it installs its own handler on `PlanManager` — and the
|
|
21
|
+
* test written at the time asserted on exactly that, by constructing a
|
|
22
|
+
* `PlanManager` directly.
|
|
23
|
+
*
|
|
24
|
+
* It did not reach `PlanApprovalData`, which is what every `resumeHandler` host
|
|
25
|
+
* receives, because that type declared its own step shape and both mappers copy
|
|
26
|
+
* field by field. So the busier surface kept showing `toolName: 'create_task'`
|
|
27
|
+
* and nothing else — the precise behaviour the change was supposed to end.
|
|
28
|
+
*
|
|
29
|
+
* A live run did not catch it either: the run observes `plan_ready`, which
|
|
30
|
+
* carries whole `PlanStep`s and therefore always had `agentId`. Watching the
|
|
31
|
+
* event stream confirmed the field existed somewhere, which is not the same
|
|
32
|
+
* question as whether the approver gets it.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
registerMock()
|
|
36
|
+
|
|
37
|
+
async function agentIdSeenByResumeHandler(): Promise<PlanApprovalData['steps']> {
|
|
38
|
+
let seen: PlanApprovalData['steps'] | undefined
|
|
39
|
+
|
|
40
|
+
await drainQuery({
|
|
41
|
+
provider: new MockLLMProvider({ responses: [{ content: 'done' }] } as never),
|
|
42
|
+
tools: new ToolRegistry(),
|
|
43
|
+
agentId: 'a',
|
|
44
|
+
agentName: 'A',
|
|
45
|
+
messages: [{ role: 'user', content: 'go' }],
|
|
46
|
+
workingDirectory: process.cwd(),
|
|
47
|
+
runConfig: { model: 'mock', tokenBudget: 100_000, timeoutMs: 30_000, maxIterations: 4 },
|
|
48
|
+
projectId: generateProjectId(),
|
|
49
|
+
sessionId: generateSessionId(),
|
|
50
|
+
threadId: generateThreadId(),
|
|
51
|
+
tenantId: generateTenantId(),
|
|
52
|
+
// The ordinary host path: a resumeHandler, not a hand-installed
|
|
53
|
+
// PlanManager handler.
|
|
54
|
+
resumeHandler: async (request: { type: string; plan?: PlanApprovalData }) => {
|
|
55
|
+
if (request.type === 'plan_approval' && request.plan) seen = request.plan.steps
|
|
56
|
+
return { action: 'approve_plan' } as HITLResumeDecision
|
|
57
|
+
},
|
|
58
|
+
onContextCreated: ({ planManager }: { planManager: PlanManager }) => {
|
|
59
|
+
planManager.startGenerating('the work')
|
|
60
|
+
planManager.addStep({
|
|
61
|
+
id: 'step_1',
|
|
62
|
+
description: 'delegated work',
|
|
63
|
+
agentId: 'shell-runner',
|
|
64
|
+
toolName: 'create_task',
|
|
65
|
+
dependsOn: [],
|
|
66
|
+
order: 1,
|
|
67
|
+
})
|
|
68
|
+
planManager.addStep({ id: 'step_2', description: 'my own work', dependsOn: [], order: 2 })
|
|
69
|
+
planManager.markReady()
|
|
70
|
+
void planManager.requestApproval()
|
|
71
|
+
},
|
|
72
|
+
} as never)
|
|
73
|
+
|
|
74
|
+
if (!seen) throw new Error('the resume handler never received a plan approval')
|
|
75
|
+
return seen
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe('the resumeHandler approval surface names the agent too', () => {
|
|
79
|
+
it('carries agentId through to the host', async () => {
|
|
80
|
+
const steps = await agentIdSeenByResumeHandler()
|
|
81
|
+
|
|
82
|
+
expect(steps.map((s) => s.agentId)).toEqual(['shell-runner', undefined])
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('still distinguishes the two steps by more than toolName', async () => {
|
|
86
|
+
// The defect in the shape that matters: without agentId both delegated
|
|
87
|
+
// steps reach the approver identical, and an orchestrator-owned step is
|
|
88
|
+
// told apart only by an absent toolName.
|
|
89
|
+
const steps = await agentIdSeenByResumeHandler()
|
|
90
|
+
const delegated = steps.find((s) => s.id === 'step_1')
|
|
91
|
+
|
|
92
|
+
expect(delegated?.toolName).toBe('create_task')
|
|
93
|
+
expect(delegated?.agentId).toBe('shell-runner')
|
|
94
|
+
})
|
|
95
|
+
})
|
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
|
+
})
|