@namzu/sdk 7.0.0 → 9.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 (206) hide show
  1. package/CHANGELOG.md +652 -0
  2. package/dist/agents/SupervisorAgent.d.ts.map +1 -1
  3. package/dist/agents/SupervisorAgent.js +186 -163
  4. package/dist/agents/SupervisorAgent.js.map +1 -1
  5. package/dist/agents/__tests__/a-policy-nobody-can-select.test.d.ts +2 -0
  6. package/dist/agents/__tests__/a-policy-nobody-can-select.test.d.ts.map +1 -0
  7. package/dist/agents/__tests__/a-policy-nobody-can-select.test.js +177 -0
  8. package/dist/agents/__tests__/a-policy-nobody-can-select.test.js.map +1 -0
  9. package/dist/agents/__tests__/supervisor-inbox-scope.test.d.ts +2 -0
  10. package/dist/agents/__tests__/supervisor-inbox-scope.test.d.ts.map +1 -0
  11. package/dist/agents/__tests__/supervisor-inbox-scope.test.js +125 -0
  12. package/dist/agents/__tests__/supervisor-inbox-scope.test.js.map +1 -0
  13. package/dist/bridge/a2a/mapper.d.ts.map +1 -1
  14. package/dist/bridge/a2a/mapper.js +4 -0
  15. package/dist/bridge/a2a/mapper.js.map +1 -1
  16. package/dist/bridge/sse/mapper.d.ts.map +1 -1
  17. package/dist/bridge/sse/mapper.js +24 -0
  18. package/dist/bridge/sse/mapper.js.map +1 -1
  19. package/dist/contracts/api.d.ts +1 -1
  20. package/dist/contracts/api.d.ts.map +1 -1
  21. package/dist/gateway/__tests__/a-knob-nobody-can-turn.test.d.ts +2 -0
  22. package/dist/gateway/__tests__/a-knob-nobody-can-turn.test.d.ts.map +1 -0
  23. package/dist/gateway/__tests__/a-knob-nobody-can-turn.test.js +114 -0
  24. package/dist/gateway/__tests__/a-knob-nobody-can-turn.test.js.map +1 -0
  25. package/dist/gateway/__tests__/completion-inbox.test.js +292 -2
  26. package/dist/gateway/__tests__/completion-inbox.test.js.map +1 -1
  27. package/dist/gateway/completion-inbox.d.ts +94 -6
  28. package/dist/gateway/completion-inbox.d.ts.map +1 -1
  29. package/dist/gateway/completion-inbox.js +235 -15
  30. package/dist/gateway/completion-inbox.js.map +1 -1
  31. package/dist/gateway/local.d.ts.map +1 -1
  32. package/dist/gateway/local.js +19 -6
  33. package/dist/gateway/local.js.map +1 -1
  34. package/dist/manager/agent/__tests__/lifecycle.test.js +56 -0
  35. package/dist/manager/agent/__tests__/lifecycle.test.js.map +1 -1
  36. package/dist/manager/agent/lifecycle.d.ts.map +1 -1
  37. package/dist/manager/agent/lifecycle.js +62 -33
  38. package/dist/manager/agent/lifecycle.js.map +1 -1
  39. package/dist/manager/plan/__tests__/a-plan-that-succeeded-is-not-failed.test.d.ts +2 -0
  40. package/dist/manager/plan/__tests__/a-plan-that-succeeded-is-not-failed.test.d.ts.map +1 -0
  41. package/dist/manager/plan/__tests__/a-plan-that-succeeded-is-not-failed.test.js +73 -0
  42. package/dist/manager/plan/__tests__/a-plan-that-succeeded-is-not-failed.test.js.map +1 -0
  43. package/dist/manager/plan/lifecycle.d.ts +48 -0
  44. package/dist/manager/plan/lifecycle.d.ts.map +1 -1
  45. package/dist/manager/plan/lifecycle.js +56 -0
  46. package/dist/manager/plan/lifecycle.js.map +1 -1
  47. package/dist/manager/run/persistence.d.ts +8 -0
  48. package/dist/manager/run/persistence.d.ts.map +1 -1
  49. package/dist/manager/run/persistence.js +12 -0
  50. package/dist/manager/run/persistence.js.map +1 -1
  51. package/dist/run/reporter.d.ts.map +1 -1
  52. package/dist/run/reporter.js +11 -0
  53. package/dist/run/reporter.js.map +1 -1
  54. package/dist/runtime/query/__tests__/completion-does-not-erase-the-answer.test.d.ts +2 -0
  55. package/dist/runtime/query/__tests__/completion-does-not-erase-the-answer.test.d.ts.map +1 -0
  56. package/dist/runtime/query/__tests__/completion-does-not-erase-the-answer.test.js +142 -0
  57. package/dist/runtime/query/__tests__/completion-does-not-erase-the-answer.test.js.map +1 -0
  58. package/dist/runtime/query/__tests__/completion-notification.test.js +414 -32
  59. package/dist/runtime/query/__tests__/completion-notification.test.js.map +1 -1
  60. package/dist/runtime/query/__tests__/context-size-on-the-wire.test.d.ts +2 -0
  61. package/dist/runtime/query/__tests__/context-size-on-the-wire.test.d.ts.map +1 -0
  62. package/dist/runtime/query/__tests__/context-size-on-the-wire.test.js +100 -0
  63. package/dist/runtime/query/__tests__/context-size-on-the-wire.test.js.map +1 -0
  64. package/dist/runtime/query/__tests__/the-plan-graph-reaches-a-host.test.d.ts +2 -0
  65. package/dist/runtime/query/__tests__/the-plan-graph-reaches-a-host.test.d.ts.map +1 -0
  66. package/dist/runtime/query/__tests__/the-plan-graph-reaches-a-host.test.js +62 -0
  67. package/dist/runtime/query/__tests__/the-plan-graph-reaches-a-host.test.js.map +1 -0
  68. package/dist/runtime/query/events.d.ts.map +1 -1
  69. package/dist/runtime/query/events.js +5 -0
  70. package/dist/runtime/query/events.js.map +1 -1
  71. package/dist/runtime/query/guard.d.ts +28 -0
  72. package/dist/runtime/query/guard.d.ts.map +1 -1
  73. package/dist/runtime/query/guard.js +31 -0
  74. package/dist/runtime/query/guard.js.map +1 -1
  75. package/dist/runtime/query/iteration/__tests__/settle-grace.test.d.ts +2 -0
  76. package/dist/runtime/query/iteration/__tests__/settle-grace.test.d.ts.map +1 -0
  77. package/dist/runtime/query/iteration/__tests__/settle-grace.test.js +226 -0
  78. package/dist/runtime/query/iteration/__tests__/settle-grace.test.js.map +1 -0
  79. package/dist/runtime/query/iteration/index.d.ts +92 -0
  80. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  81. package/dist/runtime/query/iteration/index.js +814 -566
  82. package/dist/runtime/query/iteration/index.js.map +1 -1
  83. package/dist/runtime/query/iteration/phases/__tests__/compaction-declined.test.d.ts +2 -0
  84. package/dist/runtime/query/iteration/phases/__tests__/compaction-declined.test.d.ts.map +1 -0
  85. package/dist/runtime/query/iteration/phases/__tests__/compaction-declined.test.js +95 -0
  86. package/dist/runtime/query/iteration/phases/__tests__/compaction-declined.test.js.map +1 -0
  87. package/dist/runtime/query/iteration/phases/compaction.d.ts +34 -0
  88. package/dist/runtime/query/iteration/phases/compaction.d.ts.map +1 -1
  89. package/dist/runtime/query/iteration/phases/compaction.js +61 -4
  90. package/dist/runtime/query/iteration/phases/compaction.js.map +1 -1
  91. package/dist/store/task/disk.d.ts.map +1 -1
  92. package/dist/store/task/disk.js +14 -0
  93. package/dist/store/task/disk.js.map +1 -1
  94. package/dist/store/task/memory.d.ts.map +1 -1
  95. package/dist/store/task/memory.js +14 -0
  96. package/dist/store/task/memory.js.map +1 -1
  97. package/dist/tools/__tests__/untrusted-envelope.test.js +16 -0
  98. package/dist/tools/__tests__/untrusted-envelope.test.js.map +1 -1
  99. package/dist/tools/coordinator/__tests__/a-failed-worker-is-not-an-answer.test.d.ts +2 -0
  100. package/dist/tools/coordinator/__tests__/a-failed-worker-is-not-an-answer.test.d.ts.map +1 -0
  101. package/dist/tools/coordinator/__tests__/a-failed-worker-is-not-an-answer.test.js +101 -0
  102. package/dist/tools/coordinator/__tests__/a-failed-worker-is-not-an-answer.test.js.map +1 -0
  103. package/dist/tools/coordinator/__tests__/completion-delivery.test.js +117 -0
  104. package/dist/tools/coordinator/__tests__/completion-delivery.test.js.map +1 -1
  105. package/dist/tools/coordinator/__tests__/task-list.test.js +57 -0
  106. package/dist/tools/coordinator/__tests__/task-list.test.js.map +1 -1
  107. package/dist/tools/coordinator/__tests__/the-plan-a-human-approves.test.d.ts +2 -0
  108. package/dist/tools/coordinator/__tests__/the-plan-a-human-approves.test.d.ts.map +1 -0
  109. package/dist/tools/coordinator/__tests__/the-plan-a-human-approves.test.js +99 -0
  110. package/dist/tools/coordinator/__tests__/the-plan-a-human-approves.test.js.map +1 -0
  111. package/dist/tools/coordinator/agent.d.ts.map +1 -1
  112. package/dist/tools/coordinator/agent.js +17 -23
  113. package/dist/tools/coordinator/agent.js.map +1 -1
  114. package/dist/tools/coordinator/index.d.ts.map +1 -1
  115. package/dist/tools/coordinator/index.js +142 -14
  116. package/dist/tools/coordinator/index.js.map +1 -1
  117. package/dist/tools/coordinator/outcome.d.ts +60 -0
  118. package/dist/tools/coordinator/outcome.d.ts.map +1 -0
  119. package/dist/tools/coordinator/outcome.js +68 -0
  120. package/dist/tools/coordinator/outcome.js.map +1 -0
  121. package/dist/tools/untrusted-envelope.d.ts.map +1 -1
  122. package/dist/tools/untrusted-envelope.js +9 -1
  123. package/dist/tools/untrusted-envelope.js.map +1 -1
  124. package/dist/types/agent/gateway.d.ts +37 -2
  125. package/dist/types/agent/gateway.d.ts.map +1 -1
  126. package/dist/types/agent/supervisor.d.ts +38 -1
  127. package/dist/types/agent/supervisor.d.ts.map +1 -1
  128. package/dist/types/plan/index.d.ts +26 -0
  129. package/dist/types/plan/index.d.ts.map +1 -1
  130. package/dist/types/provider/interface.d.ts +35 -0
  131. package/dist/types/provider/interface.d.ts.map +1 -1
  132. package/dist/types/run/entity.d.ts +16 -0
  133. package/dist/types/run/entity.d.ts.map +1 -1
  134. package/dist/types/run/events.d.ts +92 -0
  135. package/dist/types/run/events.d.ts.map +1 -1
  136. package/dist/types/run/events.js.map +1 -1
  137. package/dist/types/run/prepare-step.d.ts +17 -2
  138. package/dist/types/run/prepare-step.d.ts.map +1 -1
  139. package/dist/types/task/index.d.ts +19 -1
  140. package/dist/types/task/index.d.ts.map +1 -1
  141. package/dist/types/task/index.js +10 -1
  142. package/dist/types/task/index.js.map +1 -1
  143. package/dist/types/verification/index.d.ts +98 -0
  144. package/dist/types/verification/index.d.ts.map +1 -1
  145. package/dist/types/verification/index.js +10 -0
  146. package/dist/types/verification/index.js.map +1 -1
  147. package/dist/verification/__tests__/argument-pattern.test.d.ts +2 -0
  148. package/dist/verification/__tests__/argument-pattern.test.d.ts.map +1 -0
  149. package/dist/verification/__tests__/argument-pattern.test.js +122 -0
  150. package/dist/verification/__tests__/argument-pattern.test.js.map +1 -0
  151. package/dist/verification/gate.d.ts.map +1 -1
  152. package/dist/verification/gate.js +44 -0
  153. package/dist/verification/gate.js.map +1 -1
  154. package/dist/verification/rules.d.ts.map +1 -1
  155. package/dist/verification/rules.js +27 -0
  156. package/dist/verification/rules.js.map +1 -1
  157. package/package.json +1 -1
  158. package/src/agents/SupervisorAgent.ts +189 -167
  159. package/src/agents/__tests__/a-policy-nobody-can-select.test.ts +202 -0
  160. package/src/agents/__tests__/supervisor-inbox-scope.test.ts +149 -0
  161. package/src/bridge/a2a/mapper.ts +4 -0
  162. package/src/bridge/sse/mapper.ts +25 -0
  163. package/src/contracts/api.ts +1 -0
  164. package/src/gateway/__tests__/a-knob-nobody-can-turn.test.ts +139 -0
  165. package/src/gateway/__tests__/completion-inbox.test.ts +348 -2
  166. package/src/gateway/completion-inbox.ts +248 -16
  167. package/src/gateway/local.ts +19 -7
  168. package/src/manager/agent/__tests__/lifecycle.test.ts +74 -0
  169. package/src/manager/agent/lifecycle.ts +72 -42
  170. package/src/manager/plan/__tests__/a-plan-that-succeeded-is-not-failed.test.ts +88 -0
  171. package/src/manager/plan/lifecycle.ts +61 -0
  172. package/src/manager/run/persistence.ts +12 -0
  173. package/src/run/reporter.ts +12 -0
  174. package/src/runtime/query/__tests__/completion-does-not-erase-the-answer.test.ts +163 -0
  175. package/src/runtime/query/__tests__/completion-notification.test.ts +486 -34
  176. package/src/runtime/query/__tests__/context-size-on-the-wire.test.ts +122 -0
  177. package/src/runtime/query/__tests__/the-plan-graph-reaches-a-host.test.ts +79 -0
  178. package/src/runtime/query/events.ts +5 -0
  179. package/src/runtime/query/guard.ts +32 -0
  180. package/src/runtime/query/iteration/__tests__/settle-grace.test.ts +265 -0
  181. package/src/runtime/query/iteration/index.ts +902 -636
  182. package/src/runtime/query/iteration/phases/__tests__/compaction-declined.test.ts +124 -0
  183. package/src/runtime/query/iteration/phases/compaction.ts +83 -10
  184. package/src/store/task/disk.ts +15 -0
  185. package/src/store/task/memory.ts +15 -0
  186. package/src/tools/__tests__/untrusted-envelope.test.ts +23 -0
  187. package/src/tools/coordinator/__tests__/a-failed-worker-is-not-an-answer.test.ts +133 -0
  188. package/src/tools/coordinator/__tests__/completion-delivery.test.ts +147 -0
  189. package/src/tools/coordinator/__tests__/task-list.test.ts +72 -0
  190. package/src/tools/coordinator/__tests__/the-plan-a-human-approves.test.ts +121 -0
  191. package/src/tools/coordinator/agent.ts +17 -25
  192. package/src/tools/coordinator/index.ts +155 -18
  193. package/src/tools/coordinator/outcome.ts +70 -0
  194. package/src/tools/untrusted-envelope.ts +9 -1
  195. package/src/types/agent/gateway.ts +37 -2
  196. package/src/types/agent/supervisor.ts +40 -1
  197. package/src/types/plan/index.ts +29 -0
  198. package/src/types/provider/interface.ts +39 -0
  199. package/src/types/run/entity.ts +17 -0
  200. package/src/types/run/events.ts +92 -0
  201. package/src/types/run/prepare-step.ts +17 -2
  202. package/src/types/task/index.ts +22 -3
  203. package/src/types/verification/index.ts +61 -0
  204. package/src/verification/__tests__/argument-pattern.test.ts +158 -0
  205. package/src/verification/gate.ts +45 -0
  206. package/src/verification/rules.ts +28 -0
@@ -1,5 +1,29 @@
1
+ import { wrapUntrusted } from '../tools/untrusted-envelope.js'
1
2
  import type { TaskGateway, TaskHandle } from '../types/agent/gateway.js'
3
+ import { isTerminalAgentTaskState } from '../types/agent/task.js'
2
4
  import type { TaskId } from '../types/ids/index.js'
5
+ import { getRootLogger } from '../utils/logger.js'
6
+
7
+ /**
8
+ * How many unclaimed announcements may wait for an owner at once.
9
+ *
10
+ * Derived from what it has to survive rather than picked. An entry lives here
11
+ * only between a gateway announcing a task and this run saying whether the
12
+ * task is its own — one microtask, for a launch made through `create_task`.
13
+ * The number that has to fit is therefore the largest batch of launches that
14
+ * can be in flight together before any of them is claimed: one assistant turn
15
+ * of `create_task` blocks, which this codebase's own tool description
16
+ * illustrates as "fan out 8 specialists" and which a provider bounds at a few
17
+ * dozen tool_use blocks per response. 32 clears that with room, and a batch
18
+ * bigger than it is announced rather than silently truncated.
19
+ *
20
+ * The ceiling is what stops this being the retention half of the leak it
21
+ * exists beside: on a gateway shared with other runs, every foreign completion
22
+ * lands here and is never claimed, and each one holds a whole worker result —
23
+ * kilobytes at least. Bounded, the cost is 32 handles; unbounded, it is every
24
+ * result every other run on that gateway ever produced.
25
+ */
26
+ const UNOWNED_BUFFER_LIMIT = 32
3
27
 
4
28
  /**
5
29
  * Completions that finished with nobody left to hear them.
@@ -37,8 +61,42 @@ export class CompletionInbox {
37
61
  private readonly claimed = new Set<TaskId>()
38
62
  /** Launched with nothing waiting on it, and not settled yet. */
39
63
  private readonly outstanding = new Set<TaskId>()
64
+ /**
65
+ * Tasks THIS run launched.
66
+ *
67
+ * `onTaskCompleted` is a broadcast and `TaskHandle` carries no run id, so
68
+ * a gateway shared between two supervisors hands every completion to both
69
+ * of their inboxes. Measured: with two inboxes on one gateway, the run
70
+ * that launched nothing drained the other run's task and would have been
71
+ * told "a task you launched has finished" — a claim that was false, over
72
+ * another run's worker output, in a transcript whose model then has to
73
+ * account for it.
74
+ *
75
+ * A shared gateway is not an abuse of the API: `SupervisorAgentConfig`
76
+ * takes one, and a host that owns a gateway naturally reuses it.
77
+ */
78
+ private readonly ours = new Set<TaskId>()
79
+ /**
80
+ * Announcements that arrived before anyone said whose task it was.
81
+ *
82
+ * `gateway.createTask` resolves one microtask before its caller can name
83
+ * the task, and a worker that finishes inside that window is announced
84
+ * first — `LocalTaskGateway` attaches its completion continuation before
85
+ * it returns the handle, so the ordering is guaranteed to be reachable
86
+ * rather than merely possible. Dropping an unowned announcement outright
87
+ * would therefore turn the leak fix into a LOST RESULT for exactly the
88
+ * fast completions the inbox exists to catch.
89
+ *
90
+ * So they wait here, and ownership may be claimed retroactively. What
91
+ * makes that safe rather than a second leak is the bound: on a gateway
92
+ * shared with other runs this fills with completions that will never be
93
+ * claimed, each holding a whole worker result.
94
+ */
95
+ private readonly unowned = new Map<TaskId, TaskHandle>()
40
96
  private readonly arrivals = new Set<() => void>()
41
97
  private detach?: () => void
98
+ /** Kept for {@link launched}: the source of truth about a task's state. */
99
+ private gateway?: TaskGateway
42
100
 
43
101
  /**
44
102
  * Start listening.
@@ -50,7 +108,16 @@ export class CompletionInbox {
50
108
  */
51
109
  attach(gateway: TaskGateway): () => void {
52
110
  if (this.detach) return this.detach
111
+ this.gateway = gateway
53
112
  this.detach = gateway.onTaskCompleted((handle) => {
113
+ // Not known to be ours — either another run's worker on a shared
114
+ // gateway, or ours announced before the launch could be recorded.
115
+ // The two are indistinguishable here, so it waits rather than
116
+ // being delivered or dropped. See {@link unowned}.
117
+ if (!this.ours.has(handle.taskId)) {
118
+ this.hold(handle)
119
+ return
120
+ }
54
121
  // A completion claimed before it was announced — a tool that
55
122
  // finished its wait faster than the listener ran — is already
56
123
  // delivered. Nothing to queue.
@@ -62,16 +129,88 @@ export class CompletionInbox {
62
129
  return this.detach
63
130
  }
64
131
 
132
+ /**
133
+ * Park an announcement nobody has claimed yet, evicting the oldest if the
134
+ * buffer is full.
135
+ *
136
+ * An eviction is logged at WARN, and that is not decoration. If the entry
137
+ * turned out to be ours, its completion has just been dropped — the
138
+ * original defect, wearing the cap as a disguise — and the only evidence
139
+ * would otherwise be an absence, which is precisely the shape of failure
140
+ * this whole session has been closing. A reader who sees this line knows
141
+ * where to look.
142
+ */
143
+ private hold(handle: TaskHandle): void {
144
+ if (this.unowned.size >= UNOWNED_BUFFER_LIMIT && !this.unowned.has(handle.taskId)) {
145
+ const oldest = this.unowned.keys().next()
146
+ if (!oldest.done) {
147
+ this.unowned.delete(oldest.value)
148
+ getRootLogger()
149
+ .child({ component: 'CompletionInbox' })
150
+ .warn(
151
+ "Unclaimed completion buffer is full — dropped the oldest. If that task was this run's, its result is now unreachable; raise UNOWNED_BUFFER_LIMIT or launch fewer tasks per turn.",
152
+ { dropped: oldest.value, limit: UNOWNED_BUFFER_LIMIT },
153
+ )
154
+ }
155
+ }
156
+ this.unowned.set(handle.taskId, handle)
157
+ }
158
+
159
+ /**
160
+ * Say that this run launched the task.
161
+ *
162
+ * Required before anything about the task can reach this inbox — see
163
+ * {@link ours}. Every launch says it, whether or not something is waiting
164
+ * on the result, because the case the inbox exists for is precisely the
165
+ * one where the waiter gave up.
166
+ *
167
+ * **The late-announcement branches are not defensive.**
168
+ * `gateway.createTask` resolves one microtask before its caller can say
169
+ * who owns the task, and a worker that finishes inside that window is
170
+ * announced first — the same ordering that used to leave a permanent
171
+ * pending flag. Without recovery the ownership check would turn that race
172
+ * from a stale flag into a lost result.
173
+ *
174
+ * There are two, and the second is the safety net for the first. The
175
+ * buffer ({@link unowned}) needs nothing from the gateway beyond the
176
+ * announcement it already made. Asking `getTask` covers the case the
177
+ * buffer cannot — an announcement evicted under load — but it rests on a
178
+ * gateway still knowing about a task it has just settled, which is a
179
+ * property of the implementations here rather than of the `TaskGateway`
180
+ * contract, and `getTask`'s own docs now say so.
181
+ */
182
+ launched(taskId: TaskId): void {
183
+ if (this.ours.has(taskId)) return
184
+ this.ours.add(taskId)
185
+
186
+ if (this.claimed.has(taskId) || this.unheard.has(taskId)) return
187
+
188
+ const parked = this.unowned.get(taskId)
189
+ if (parked) {
190
+ this.unowned.delete(taskId)
191
+ this.unheard.set(taskId, parked)
192
+ for (const wake of [...this.arrivals]) wake()
193
+ return
194
+ }
195
+
196
+ const settled = this.gateway?.getTask(taskId)
197
+ if (!settled || !isTerminalAgentTaskState(settled.state)) return
198
+ this.unheard.set(taskId, settled)
199
+ for (const wake of [...this.arrivals]) wake()
200
+ }
201
+
65
202
  /**
66
203
  * Say that a task was launched with nothing waiting on it.
67
204
  *
68
- * Without this the inbox can only see completions that have already
69
- * happened, and a run whose supervisor launched a background worker and
70
- * then answered would settle while the worker was still going — throwing
71
- * away the very result the launch existed to produce. Knowing a task is
72
- * outstanding is what lets the loop hold the run open for it.
205
+ * {@link launched} plus the statement that no call will deliver the
206
+ * result. Without the second half the inbox can only see completions that
207
+ * have already happened, and a run whose supervisor launched a background
208
+ * worker and then answered would settle while the worker was still going
209
+ * throwing away the very result the launch existed to produce. Knowing a
210
+ * task is outstanding is what lets the loop hold the run open for it.
73
211
  */
74
212
  expect(taskId: TaskId): void {
213
+ this.launched(taskId)
75
214
  if (this.claimed.has(taskId)) return
76
215
  this.outstanding.add(taskId)
77
216
  }
@@ -134,6 +273,17 @@ export class CompletionInbox {
134
273
  return this.unheard.size > 0
135
274
  }
136
275
 
276
+ /**
277
+ * Tasks this run launched that are still running.
278
+ *
279
+ * Read when a run ends, so it can say which work it walked away from.
280
+ * Nothing here is cancelled by being read — the ids are a statement, and
281
+ * what to do about them is the host's call.
282
+ */
283
+ get outstandingTaskIds(): readonly TaskId[] {
284
+ return [...this.outstanding]
285
+ }
286
+
137
287
  /**
138
288
  * Take every unheard completion, leaving the inbox empty.
139
289
  *
@@ -144,7 +294,22 @@ export class CompletionInbox {
144
294
  if (this.unheard.size === 0) return []
145
295
  const handles = [...this.unheard.values()]
146
296
  this.unheard.clear()
147
- for (const handle of handles) this.claimed.add(handle.taskId)
297
+ for (const handle of handles) {
298
+ this.claimed.add(handle.taskId)
299
+ // A delivered result is not pending WORK, and `outstanding` can
300
+ // still be holding this id — the listener clears it, but only if
301
+ // the announcement came AFTER the launching tool said `expect`.
302
+ // The other order is reachable: `expect` runs one microtask after
303
+ // `gateway.createTask` resolves, and the gateway's own completion
304
+ // callback can win that race for a task that finished fast. Then
305
+ // `expect` re-adds an id the listener had nothing to remove, and
306
+ // nothing else ever takes it off — so `hasPendingWork` stayed true
307
+ // for the rest of the run and every attempt to settle paid the
308
+ // full grace period waiting for a result already in the transcript.
309
+ //
310
+ // Symmetric with `claim`, which clears it for the same reason.
311
+ this.outstanding.delete(handle.taskId)
312
+ }
148
313
  return handles
149
314
  }
150
315
 
@@ -179,12 +344,25 @@ export class CompletionInbox {
179
344
  for (const wake of [...this.arrivals]) wake()
180
345
  }
181
346
 
182
- /** Stop listening. Safe to call more than once. */
347
+ /**
348
+ * Stop listening. Safe to call more than once.
349
+ *
350
+ * A run that ends without this leaves its listener on the gateway
351
+ * forever. On a gateway the host reuses that is measurable — three
352
+ * sequential runs left three live subscriptions, each still holding its
353
+ * run's handles — and the listener set only grows. Ownership stops a
354
+ * retained listener from DELIVERING another run's work; closing is what
355
+ * stops it existing.
356
+ */
183
357
  close(): void {
184
358
  this.detach?.()
185
359
  this.detach = undefined
360
+ this.gateway = undefined
186
361
  this.unheard.clear()
187
362
  this.outstanding.clear()
363
+ this.ours.clear()
364
+ this.claimed.clear()
365
+ this.unowned.clear()
188
366
  // Release anyone still waiting. A closed inbox would otherwise hold
189
367
  // them to their own deadline for a completion that can no longer come.
190
368
  for (const wake of [...this.arrivals]) wake()
@@ -195,6 +373,30 @@ export class CompletionInbox {
195
373
  /** How much of a worker's output rides in the notification itself. */
196
374
  const NOTIFICATION_OUTPUT_LIMIT = 4_000
197
375
 
376
+ const NOTIFICATION_DELIMITER = /task-notification/gi
377
+
378
+ /**
379
+ * Defang this file's own delimiter inside a worker's text.
380
+ *
381
+ * Without it a worker whose output contains `</task-notification>` closes the
382
+ * block early, and everything it wrote after that sits OUTSIDE the boundary —
383
+ * reading as ordinary transcript rather than as a delegate's material.
384
+ * Measured before the fix: two closing tags in one notification, with
385
+ * attacker-controlled text between them.
386
+ *
387
+ * The replacement swaps the hyphen for an underscore rather than appending a
388
+ * suffix, for the reason `neutralizeEnvelopeDelimiter` records: a replacement
389
+ * that still CONTAINS the token is found again by a second pass or by any
390
+ * looser matcher downstream. `task_notification` shares no substring with the
391
+ * real delimiter while staying legible.
392
+ *
393
+ * The nested `<namzu-untrusted>` block defangs its own delimiter; these two
394
+ * patterns are disjoint, so the order they run in does not matter.
395
+ */
396
+ function neutralizeNotificationDelimiter(content: string): string {
397
+ return content.replace(NOTIFICATION_DELIMITER, 'task_notification')
398
+ }
399
+
198
400
  /**
199
401
  * The message a supervisor reads when a worker it stopped waiting for
200
402
  * finishes.
@@ -212,14 +414,32 @@ export function formatCompletionNotification(handles: readonly TaskHandle[]): st
212
414
  const blocks = handles.map((handle) => {
213
415
  const durationMs = handle.completedAt ? handle.completedAt - handle.createdAt : undefined
214
416
  const output = handle.result?.result ?? handle.result?.lastError ?? ''
215
- const truncated =
216
- output.length > NOTIFICATION_OUTPUT_LIMIT
217
- ? // `wait_for_task`, not `agent_task_list` — the listing takes only a
218
- // state filter, so an instruction to call it "with task_id" named
219
- // a parameter that does not exist and could not be followed. On an
220
- // already-finished task the wait returns immediately.
221
- `${output.slice(0, NOTIFICATION_OUTPUT_LIMIT)}\n… truncated. Call wait_for_task with task_id "${handle.taskId}" for the full output.`
222
- : output
417
+ const overLimit = output.length > NOTIFICATION_OUTPUT_LIMIT
418
+ const shown = overLimit ? output.slice(0, NOTIFICATION_OUTPUT_LIMIT) : output
419
+
420
+ // Framed for the same reason the blocking `create_task` frames its
421
+ // return value, and this path is the one that had nothing. A delegated
422
+ // worker is the component most likely to have consumed material nobody
423
+ // in this run authored it was told to read and report, and it ran
424
+ // `read`, `grep`, `fetch` over whatever it found — and its text lands
425
+ // in a parent that typically holds the broader tool grant. The same
426
+ // bytes were being wrapped on one path and pasted bare on this one.
427
+ //
428
+ // The metadata above stays OUTSIDE the envelope: the task id, the agent
429
+ // and the state are this kernel's own statements, and framing them as
430
+ // untrusted material would tell the model to discount the only part of
431
+ // the message it can rely on.
432
+ const body =
433
+ shown.length > 0
434
+ ? wrapUntrusted(
435
+ {
436
+ kind: 'agent-result',
437
+ attributes: { agent: handle.agentId, task: handle.taskId },
438
+ provenance: `This is the output of the delegated agent "${handle.agentId}", not this agent's own work.`,
439
+ },
440
+ neutralizeNotificationDelimiter(shown),
441
+ )
442
+ : '(the task produced no output)'
223
443
 
224
444
  const lines = [
225
445
  `task_id: ${handle.taskId}`,
@@ -227,7 +447,19 @@ export function formatCompletionNotification(handles: readonly TaskHandle[]): st
227
447
  `state: ${handle.state}`,
228
448
  ...(durationMs !== undefined ? [`duration_ms: ${durationMs}`] : []),
229
449
  '',
230
- truncated.length > 0 ? truncated : '(the task produced no output)',
450
+ body,
451
+ // Outside the envelope, deliberately: this sentence is an
452
+ // instruction from the kernel about how to get the rest, and inside
453
+ // the envelope the model has just been told not to treat the
454
+ // contents as instructions.
455
+ //
456
+ // `wait_for_task`, not `agent_task_list` — the listing takes only a
457
+ // state filter, so an instruction to call it "with task_id" named a
458
+ // parameter that does not exist and could not be followed. On an
459
+ // already-finished task the wait returns immediately.
460
+ ...(overLimit
461
+ ? [`… truncated. Call wait_for_task with task_id "${handle.taskId}" for the full output.`]
462
+ : []),
231
463
  ]
232
464
  return `<task-notification>\n${lines.join('\n')}\n</task-notification>`
233
465
  })
@@ -1,3 +1,4 @@
1
+ import { taskFailed } from '../tools/coordinator/outcome.js'
1
2
  import type { AgentInput } from '../types/agent/base.js'
2
3
  import type {
3
4
  CreateTaskOptions,
@@ -75,10 +76,25 @@ export class LocalTaskGateway implements TaskGateway {
75
76
  tenantId: this.taskContext.tenantId,
76
77
  projectId: this.taskContext.projectId,
77
78
  parentActor: this.taskContext.parentActor,
78
- // Hang the child run off the span the caller supplied, so a
79
+ // The caller's overrides, plus the span the caller supplied so a
79
80
  // delegated run joins the trace it belongs to instead of
80
81
  // starting its own root.
81
- ...(options.parentSpan ? { configOverrides: { parentSpan: options.parentSpan } } : {}),
82
+ //
83
+ // `options.configOverrides` used to be dropped here: this built
84
+ // a fresh object from `parentSpan` and never looked at the
85
+ // field, so a caller pinning a child to a cheaper model got the
86
+ // agent's default and no sign anything had been ignored. The
87
+ // dedicated `parentSpan` option is applied last because it is
88
+ // the specific field for that job — a caller who sets both is
89
+ // saying the same thing twice, and the named one is the answer.
90
+ ...(options.configOverrides || options.parentSpan
91
+ ? {
92
+ configOverrides: {
93
+ ...options.configOverrides,
94
+ ...(options.parentSpan ? { parentSpan: options.parentSpan } : {}),
95
+ },
96
+ }
97
+ : {}),
82
98
  },
83
99
  // The budget tracker is SHARED on purpose and must not be cloned.
84
100
  // `AgentManager.spawn` debits it (`remaining -= allocatedTokens`)
@@ -146,7 +162,7 @@ export class LocalTaskGateway implements TaskGateway {
146
162
  */
147
163
  private applySiblingPolicy(finished: TaskHandle): void {
148
164
  if (this.siblingFailurePolicy !== 'cancel-siblings') return
149
- if (!hasFailed(finished)) return
165
+ if (!taskFailed(finished)) return
150
166
 
151
167
  const cancelled: TaskId[] = []
152
168
  for (const taskId of this.trackedTaskIds) {
@@ -251,10 +267,6 @@ export class LocalTaskGateway implements TaskGateway {
251
267
  * would therefore miss the ordinary case — an agent that tried and could
252
268
  * not — and catch only the exceptional one.
253
269
  */
254
- function hasFailed(handle: TaskHandle): boolean {
255
- return handle.state === 'failed' || handle.result?.status === 'failed'
256
- }
257
-
258
270
  function toHandle(task: import('../types/agent/task.js').AgentTask): TaskHandle {
259
271
  return {
260
272
  taskId: task.taskId,
@@ -768,3 +768,77 @@ describe('LocalTaskGateway — what a failed child means for its siblings', () =
768
768
  releaseSlow()
769
769
  })
770
770
  })
771
+
772
+ describe('a concurrent fan-out shares one budget', () => {
773
+ /**
774
+ * Siblings launched from one assistant turn were each allocated a fraction
775
+ * of the SAME undebited number.
776
+ *
777
+ * The allocation is read at the top of `sendMessage`; the debit lands
778
+ * after `await provisionSpawn`, which is the only critical section. So N
779
+ * siblings all enter, all read an untouched `remaining`, and each takes
780
+ * its fraction of it. `create_task`'s own description instructs exactly
781
+ * this shape: "'fan out 8 specialists' is one assistant message with 8
782
+ * create_task blocks."
783
+ *
784
+ * **The children must not be allowed to finish.** A child that settles
785
+ * refunds its unspent budget, and the refund restores the tracker to a
786
+ * plausible number — so a test that measures after settle sees a healthy
787
+ * total and reports nothing. The over-commitment is real and transient,
788
+ * and transient is enough: every allocation decision taken during the
789
+ * window reads a tracker that is already wrong.
790
+ *
791
+ * The first version of this test did settle its children, passed, and
792
+ * would have certified the bug as fixed.
793
+ */
794
+ it('never allocates more than the parent has, while the children are still running', async () => {
795
+ // What each child was actually HANDED. Asserting on the tracker was the
796
+ // first attempt and it measured the wrong thing twice over: a settled
797
+ // child refunds, which restores a plausible number, and the harm is not
798
+ // the bookkeeping anyway — it is that four children each believe they
799
+ // may spend half a pool that only has one half to give.
800
+ const allocations: number[] = []
801
+ let release: (() => void) | undefined
802
+ const held = new Promise<void>((resolve) => {
803
+ release = resolve
804
+ })
805
+
806
+ // The harness's own manager, because a hand-built one here silently
807
+ // fails to provision and the children never run — which looks exactly
808
+ // like a passing test.
809
+ const harness = await buildHarness(
810
+ makeAgent('child-1', async (_input, config) => {
811
+ allocations.push(config.tokenBudget)
812
+ await held
813
+ return successResult()
814
+ }),
815
+ )
816
+
817
+ // ONE tracker, shared, as a real parent's context is.
818
+ const shared = { total: 100_000, remaining: 100_000 }
819
+ const context = {
820
+ ...buildContext(harness.parentSession.id, harness.projectId, harness.threadId),
821
+ budgetTracker: shared,
822
+ }
823
+
824
+ await Promise.allSettled(
825
+ Array.from({ length: 4 }, () =>
826
+ harness.manager.sendMessage(
827
+ buildOptions('child-1', harness.parentSession.id, harness.projectId),
828
+ context,
829
+ ),
830
+ ),
831
+ )
832
+
833
+ // Let the children record what they were handed before any settles.
834
+ await new Promise((r) => setTimeout(r, 20))
835
+ const handedOut = allocations.reduce((a, b) => a + b, 0)
836
+ release?.()
837
+
838
+ expect(allocations.length, 'every sibling should have started').toBe(4)
839
+ expect(
840
+ handedOut,
841
+ `four siblings were handed ${allocations.join(' + ')} from a pool of ${shared.total}`,
842
+ ).toBeLessThanOrEqual(shared.total)
843
+ })
844
+ })
@@ -127,44 +127,29 @@ export class AgentManager {
127
127
 
128
128
  const childAbortController = createChildAbortController(context.parentAbortController)
129
129
 
130
- const maxAllocation = Math.floor(
131
- context.budgetTracker.remaining * this.config.maxBudgetFraction,
132
- )
133
- const allocatedTokens = Math.min(
134
- options.budgetAllocation?.tokenBudget ?? maxAllocation,
135
- maxAllocation,
136
- )
137
-
138
- // Budget exhaustion must not INVERT into no budget at all. Downstream,
139
- // `tokenBudget: 0` means "uncapped" (`LimitChecker`: `tokenBudget > 0
140
- // && total >= tokenBudget`), and `maxAllocation` floors to 0 as soon as
141
- // the parent's remaining drops below `1 / maxBudgetFraction`. So the
142
- // most depleted parent in the tree was the one that spawned an
143
- // unlimited child. Refuse instead: a caller that wants an uncapped
144
- // child can say so explicitly with its own `budgetAllocation`.
145
- if (allocatedTokens <= 0) {
146
- throw new NamzuError({
147
- code: 'invalid_config',
148
- message: `Cannot spawn "${options.agentId}": the parent has ${context.budgetTracker.remaining} tokens remaining, which allocates 0 to the child — and a token budget of 0 means UNLIMITED downstream.`,
149
- details: {
150
- agentId: options.agentId,
151
- parentRemaining: context.budgetTracker.remaining,
152
- maxBudgetFraction: this.config.maxBudgetFraction,
153
- },
154
- })
155
- }
130
+ // The allocation is computed INSIDE the spawn lock, not here. Reading
131
+ // the parent's remaining budget at this point and debiting it after
132
+ // `provisionSpawn` put the two halves of a read-modify-write on either
133
+ // side of an await — so N siblings launched from one turn all read the
134
+ // same undebited number and each took a fraction of it. Measured: four
135
+ // concurrent children were handed 50 000 + 50 000 + 50 000 + 50 000
136
+ // from a pool of 100 000.
137
+ //
138
+ // `create_task`'s own description instructs exactly this shape ("'fan
139
+ // out 8 specialists' is one assistant message with 8 create_task
140
+ // blocks"), so the documented usage was the reproduction.
141
+ //
142
+ // Nothing pinned it because the only concurrent test built a fresh
143
+ // context per call each spawn got its own tracker, which measures
144
+ // width and not budget.
156
145
 
157
146
  // Phase 6: SubSession + child Session + WorkspaceRef triple. Happens
158
147
  // before taskId minting so a capacity failure short-circuits cleanly
159
148
  // with no observable state change.
160
149
  //
161
- // The budget debit follows it for the same reason. It used to come
162
- // first, so a spawn this call rejected still burned its allocation
163
- // from a pool nobody credited back the one state change the
164
- // comment above promised there would not be.
165
- const spawnRecord = await this.provisionSpawn(options, context)
166
-
167
- context.budgetTracker.remaining -= allocatedTokens
150
+ // The allocation now travels with it, because the read and the debit
151
+ // have to be on the same side of every await to mean anything.
152
+ const { spawnRecord, allocatedTokens } = await this.provisionSpawn(options, context)
168
153
 
169
154
  const taskId = generateTaskId()
170
155
 
@@ -449,7 +434,7 @@ export class AgentManager {
449
434
  private async provisionSpawn(
450
435
  options: SendMessageOptions,
451
436
  context: AgentTaskContext,
452
- ): Promise<ChildSpawnRecord> {
437
+ ): Promise<{ spawnRecord: ChildSpawnRecord; allocatedTokens: number }> {
453
438
  const key = options.parentSessionId
454
439
  const queued = (this.spawnLocks.get(key) ?? Promise.resolve()).then(
455
440
  () => this.provisionSpawnUnlocked(options, context),
@@ -475,7 +460,42 @@ export class AgentManager {
475
460
  private async provisionSpawnUnlocked(
476
461
  options: SendMessageOptions,
477
462
  context: AgentTaskContext,
478
- ): Promise<ChildSpawnRecord> {
463
+ ): Promise<{ spawnRecord: ChildSpawnRecord; allocatedTokens: number }> {
464
+ // Read the parent's remaining budget HERE, inside the lock, so that
465
+ // concurrent siblings queue behind one another rather than all reading
466
+ // the same untouched number. The debit at the end of this method closes
467
+ // the pair: read and write are now on the same side of every await.
468
+ const maxAllocation = Math.floor(
469
+ context.budgetTracker.remaining * this.config.maxBudgetFraction,
470
+ )
471
+ const allocatedTokens = Math.min(
472
+ options.budgetAllocation?.tokenBudget ?? maxAllocation,
473
+ maxAllocation,
474
+ )
475
+
476
+ // Budget exhaustion must not INVERT into no budget at all. Downstream,
477
+ // `tokenBudget: 0` means "uncapped" (`LimitChecker`: `tokenBudget > 0
478
+ // && total >= tokenBudget`), and `maxAllocation` floors to 0 as soon as
479
+ // the parent's remaining drops below `1 / maxBudgetFraction`. So the
480
+ // most depleted parent in the tree was the one that spawned an
481
+ // unlimited child. Refuse instead: a caller that wants an uncapped
482
+ // child can say so explicitly with its own `budgetAllocation`.
483
+ //
484
+ // Refusing before any provisioning work also preserves the property the
485
+ // debit's placement was chosen for: a spawn this call rejects makes no
486
+ // state change at all, and burns no allocation.
487
+ if (allocatedTokens <= 0) {
488
+ throw new NamzuError({
489
+ code: 'invalid_config',
490
+ message: `Cannot spawn "${options.agentId}": the parent has ${context.budgetTracker.remaining} tokens remaining, which allocates 0 to the child — and a token budget of 0 means UNLIMITED downstream.`,
491
+ details: {
492
+ agentId: options.agentId,
493
+ parentRemaining: context.budgetTracker.remaining,
494
+ maxBudgetFraction: this.config.maxBudgetFraction,
495
+ },
496
+ })
497
+ }
498
+
479
499
  // Phase 9: deps are unconditional required. Every spawn produces a
480
500
  // SubSession + Session + WorkspaceRef triple (Convention #0: no
481
501
  // partial/legacy path).
@@ -620,14 +640,24 @@ export class AgentManager {
620
640
  throw err
621
641
  }
622
642
 
643
+ // Debited only now, with the provisioning committed. Every path that
644
+ // could still have thrown is behind us, so a rejected spawn leaves the
645
+ // parent's budget untouched — the property the debit's original
646
+ // placement was chosen for, kept while closing the race that placement
647
+ // opened.
648
+ context.budgetTracker.remaining -= allocatedTokens
649
+
623
650
  return {
624
- subSessionId: subSession.id,
625
- childSessionId: childSession.id,
626
- tenantId: context.tenantId,
627
- parentSessionId: options.parentSessionId,
628
- rootSessionId,
629
- childDepth,
630
- workspaceRef,
651
+ spawnRecord: {
652
+ subSessionId: subSession.id,
653
+ childSessionId: childSession.id,
654
+ tenantId: context.tenantId,
655
+ parentSessionId: options.parentSessionId,
656
+ rootSessionId,
657
+ childDepth,
658
+ workspaceRef,
659
+ },
660
+ allocatedTokens,
631
661
  }
632
662
  }
633
663