@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.
Files changed (82) hide show
  1. package/CHANGELOG.md +152 -0
  2. package/dist/bridge/a2a/__tests__/project-is-the-a2a-context.test.d.ts +2 -0
  3. package/dist/bridge/a2a/__tests__/project-is-the-a2a-context.test.d.ts.map +1 -0
  4. package/dist/bridge/a2a/__tests__/project-is-the-a2a-context.test.js +55 -0
  5. package/dist/bridge/a2a/__tests__/project-is-the-a2a-context.test.js.map +1 -0
  6. package/dist/manager/agent/lifecycle.d.ts +15 -0
  7. package/dist/manager/agent/lifecycle.d.ts.map +1 -1
  8. package/dist/manager/agent/lifecycle.js +70 -26
  9. package/dist/manager/agent/lifecycle.js.map +1 -1
  10. package/dist/public-runtime.d.ts +1 -1
  11. package/dist/public-runtime.d.ts.map +1 -1
  12. package/dist/public-runtime.js +7 -1
  13. package/dist/public-runtime.js.map +1 -1
  14. package/dist/runtime/query/__tests__/the-other-approval-surface-names-the-agent.test.d.ts +2 -0
  15. package/dist/runtime/query/__tests__/the-other-approval-surface-names-the-agent.test.d.ts.map +1 -0
  16. package/dist/runtime/query/__tests__/the-other-approval-surface-names-the-agent.test.js +81 -0
  17. package/dist/runtime/query/__tests__/the-other-approval-surface-names-the-agent.test.js.map +1 -0
  18. package/dist/runtime/query/index.d.ts.map +1 -1
  19. package/dist/runtime/query/index.js +1 -0
  20. package/dist/runtime/query/index.js.map +1 -1
  21. package/dist/runtime/query/iteration/phases/plan.d.ts.map +1 -1
  22. package/dist/runtime/query/iteration/phases/plan.js +1 -0
  23. package/dist/runtime/query/iteration/phases/plan.js.map +1 -1
  24. package/dist/session/__tests__/integration/a-successful-delegation-disposes-its-workspace.test.d.ts +21 -0
  25. package/dist/session/__tests__/integration/a-successful-delegation-disposes-its-workspace.test.d.ts.map +1 -0
  26. package/dist/session/__tests__/integration/a-successful-delegation-disposes-its-workspace.test.js +205 -0
  27. package/dist/session/__tests__/integration/a-successful-delegation-disposes-its-workspace.test.js.map +1 -0
  28. package/dist/session/__tests__/integration/handoff-single-e2e.test.js +14 -5
  29. package/dist/session/__tests__/integration/handoff-single-e2e.test.js.map +1 -1
  30. package/dist/session/errors.d.ts +25 -0
  31. package/dist/session/errors.d.ts.map +1 -1
  32. package/dist/session/errors.js +21 -0
  33. package/dist/session/errors.js.map +1 -1
  34. package/dist/session/handoff/broadcast.d.ts.map +1 -1
  35. package/dist/session/handoff/broadcast.js +10 -5
  36. package/dist/session/handoff/broadcast.js.map +1 -1
  37. package/dist/session/handoff/single.d.ts.map +1 -1
  38. package/dist/session/handoff/single.js +27 -4
  39. package/dist/session/handoff/single.js.map +1 -1
  40. package/dist/store/session/__tests__/a-session-has-one-writer.test.d.ts +2 -0
  41. package/dist/store/session/__tests__/a-session-has-one-writer.test.d.ts.map +1 -0
  42. package/dist/store/session/__tests__/a-session-has-one-writer.test.js +79 -0
  43. package/dist/store/session/__tests__/a-session-has-one-writer.test.js.map +1 -0
  44. package/dist/store/session/disk.d.ts +1 -1
  45. package/dist/store/session/disk.d.ts.map +1 -1
  46. package/dist/store/session/disk.js +15 -2
  47. package/dist/store/session/disk.js.map +1 -1
  48. package/dist/store/session/memory.d.ts +1 -1
  49. package/dist/store/session/memory.d.ts.map +1 -1
  50. package/dist/store/session/memory.js +14 -2
  51. package/dist/store/session/memory.js.map +1 -1
  52. package/dist/tools/coordinator/__tests__/an-approver-is-never-shown-an-impossible-step.test.d.ts +2 -0
  53. package/dist/tools/coordinator/__tests__/an-approver-is-never-shown-an-impossible-step.test.d.ts.map +1 -0
  54. package/dist/tools/coordinator/__tests__/an-approver-is-never-shown-an-impossible-step.test.js +115 -0
  55. package/dist/tools/coordinator/__tests__/an-approver-is-never-shown-an-impossible-step.test.js.map +1 -0
  56. package/dist/tools/coordinator/index.d.ts.map +1 -1
  57. package/dist/tools/coordinator/index.js +36 -0
  58. package/dist/tools/coordinator/index.js.map +1 -1
  59. package/dist/types/hitl/index.d.ts +16 -0
  60. package/dist/types/hitl/index.d.ts.map +1 -1
  61. package/dist/types/hitl/index.js.map +1 -1
  62. package/dist/types/session/store.d.ts +28 -1
  63. package/dist/types/session/store.d.ts.map +1 -1
  64. package/package.json +1 -1
  65. package/src/bridge/a2a/__tests__/project-is-the-a2a-context.test.ts +66 -0
  66. package/src/manager/agent/lifecycle.ts +71 -26
  67. package/src/public-runtime.ts +6 -0
  68. package/src/runtime/query/__tests__/the-other-approval-surface-names-the-agent.test.ts +95 -0
  69. package/src/runtime/query/index.ts +1 -0
  70. package/src/runtime/query/iteration/phases/plan.ts +1 -0
  71. package/src/session/__tests__/integration/a-successful-delegation-disposes-its-workspace.test.ts +270 -0
  72. package/src/session/__tests__/integration/handoff-single-e2e.test.ts +14 -5
  73. package/src/session/errors.ts +29 -0
  74. package/src/session/handoff/broadcast.ts +10 -5
  75. package/src/session/handoff/single.ts +27 -4
  76. package/src/store/session/__tests__/a-session-has-one-writer.test.ts +116 -0
  77. package/src/store/session/disk.ts +21 -2
  78. package/src/store/session/memory.ts +20 -2
  79. package/src/tools/coordinator/__tests__/an-approver-is-never-shown-an-impossible-step.test.ts +153 -0
  80. package/src/tools/coordinator/index.ts +40 -0
  81. package/src/types/hitl/index.ts +18 -0
  82. 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":"AA0OA,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"}
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
- updateSession(session: Session, tenantId: TenantId): Promise<void>;
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":"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"}
1
+ {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../../src/types/session/store.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@namzu/sdk",
3
- "version": "12.1.0",
3
+ "version": "13.0.0",
4
4
  "description": "Open-source AI agent SDK with a built-in runtime. Nothing between you and your agents.",
5
5
  "license": "FSL-1.1-MIT",
6
6
  "type": "module",
@@ -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 and, when we own a
768
- // workspace, dispose it. Dispose errors are logged but not
769
- // propagated the sub-session state is already persisted.
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
- if (spawnRecord.workspaceRef) {
941
- const backend = spawnRecord.workspaceRef.meta.backend
942
- if (this.deps.workspaceRegistry.has(backend)) {
943
- await this.deps.workspaceRegistry
944
- .get(backend)
945
- .dispose(spawnRecord.workspaceRef)
946
- .catch(() => undefined)
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 {
@@ -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
+ })
@@ -527,6 +527,7 @@ export async function* query(params: QueryParams): AsyncGenerator<RunEvent, Run>
527
527
  id: s.id,
528
528
  description: s.description,
529
529
  toolName: s.toolName,
530
+ agentId: s.agentId,
530
531
  dependsOn: s.dependsOn,
531
532
  order: s.order ?? i + 1,
532
533
  })),
@@ -29,6 +29,7 @@ export async function* runPlanGate(ctx: IterationContext): AsyncGenerator<RunEve
29
29
  id: s.id,
30
30
  description: s.description,
31
31
  toolName: s.toolName,
32
+ agentId: s.agentId,
32
33
  dependsOn: s.dependsOn,
33
34
  order: s.order,
34
35
  })),
@@ -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
+ })