@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
@@ -0,0 +1,122 @@
1
+ import { mkdtemp, rm } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { afterEach, describe, expect, it } from 'vitest'
5
+ import { MockLLMProvider } from '../../../provider/mock.js'
6
+ import { ToolRegistry } from '../../../registry/tool/execute.js'
7
+ import { createUserMessage } from '../../../types/message/index.js'
8
+ import type { RunEvent } from '../../../types/run/index.js'
9
+ import { drainQuery } from '../index.js'
10
+
11
+ /**
12
+ * Cumulative spend and current context size are different quantities, and a
13
+ * host divided the first by a context window and shipped it.
14
+ *
15
+ * That indicator climbed toward full on any long run no matter how much room
16
+ * the conversation actually had — most wrong exactly when someone needed it —
17
+ * because `usage` is summed over every turn and never falls, while the context
18
+ * is what is being sent right now and falls whenever a compaction sheds.
19
+ *
20
+ * The host had no better option: the correct numerator needs internals it
21
+ * cannot see, and the correct denominator was a two-branch guess on a model
22
+ * name. So both numbers are on the event, named so that reaching for the wrong
23
+ * one is a visible mistake rather than a plausible guess.
24
+ */
25
+
26
+ let workdirs: string[] = []
27
+
28
+ afterEach(async () => {
29
+ await Promise.all(workdirs.map((d) => rm(d, { recursive: true, force: true })))
30
+ workdirs = []
31
+ })
32
+
33
+ type UsageEvent = Extract<RunEvent, { type: 'token_usage_updated' }>
34
+
35
+ async function run(
36
+ compaction: Record<string, unknown> | undefined,
37
+ turns: unknown[],
38
+ ): Promise<UsageEvent[]> {
39
+ const seen: UsageEvent[] = []
40
+ const dir = await mkdtemp(join(tmpdir(), 'namzu-ctxsize-'))
41
+ workdirs.push(dir)
42
+
43
+ await drainQuery(
44
+ {
45
+ provider: new MockLLMProvider({ turns: turns as never }),
46
+ tools: new ToolRegistry(),
47
+ runConfig: { model: 'mock-model', timeoutMs: 30_000, tokenBudget: 100_000, maxIterations: 3 },
48
+ ...(compaction ? { compactionConfig: compaction } : {}),
49
+ agentId: 'agent_ctx',
50
+ agentName: 'Context Agent',
51
+ workingDirectory: dir,
52
+ sessionId: 'ses_ctx',
53
+ threadId: 'thd_ctx',
54
+ projectId: 'prj_ctx',
55
+ tenantId: 'tnt_ctx',
56
+ messages: [createUserMessage('go')],
57
+ } as never,
58
+ (event: RunEvent) => {
59
+ if (event.type === 'token_usage_updated') seen.push(event)
60
+ },
61
+ )
62
+
63
+ return seen
64
+ }
65
+
66
+ const COMPACTION = {
67
+ strategy: 'sliding-window',
68
+ triggerThreshold: 0.9,
69
+ contextWindowTokens: 50_000,
70
+ keepRecentMessages: 4,
71
+ }
72
+
73
+ describe('a run reports how much room its context has', () => {
74
+ it('carries the context size and the window together', async () => {
75
+ const events = await run(COMPACTION, [{ text: 'done' }])
76
+
77
+ expect(events.length).toBeGreaterThan(0)
78
+ const event = events[0] as UsageEvent
79
+ expect(event.contextTokens).toBeGreaterThan(0)
80
+ expect(event.contextWindowTokens).toBe(50_000)
81
+ })
82
+
83
+ it('says whether each number was measured or guessed', async () => {
84
+ // A fraction is only as honest as the weaker of its two terms, so a
85
+ // surface rendering these owes a reader the distinction. It cannot
86
+ // pass it on if it never receives it.
87
+ const event = (await run(COMPACTION, [{ text: 'done' }]))[0] as UsageEvent
88
+
89
+ expect(['provider', 'estimate']).toContain(event.contextMeasuredBy)
90
+ // The window was stated in config here, so its provenance is not a guess.
91
+ expect(event.windowSource).toBe('config')
92
+ })
93
+
94
+ it('keeps the context size distinct from cumulative spend', async () => {
95
+ // The whole defect in one assertion, and this driver demonstrates it
96
+ // more sharply than a real one would: it reports no usage at all, so
97
+ // cumulative spend stays at zero while the context genuinely fills.
98
+ //
99
+ // A surface dividing spend by a window would show 0% here, for a
100
+ // conversation that is really there. The same surface on a long run
101
+ // shows 100% for a conversation that was compacted down to nothing.
102
+ // Both directions, same category error — which is why the two numbers
103
+ // are reported separately rather than left to be inferred from each
104
+ // other.
105
+ const events = await run(COMPACTION, [{ text: 'one' }, { text: 'two' }])
106
+ const last = events[events.length - 1] as UsageEvent
107
+
108
+ expect(last.contextTokens).toBeGreaterThan(0)
109
+ expect(last.contextTokens).not.toBe(last.usage.totalTokens)
110
+ })
111
+
112
+ it('reports nothing rather than a guess when no window is configured', async () => {
113
+ // Without a compaction config nothing resolves a window, and inventing
114
+ // one would be the guess this replaces. Absent is a fact a surface can
115
+ // act on; a fabricated default is not.
116
+ const event = (await run(undefined, [{ text: 'done' }]))[0] as UsageEvent
117
+
118
+ expect(event.contextTokens).toBeUndefined()
119
+ expect(event.contextWindowTokens).toBeUndefined()
120
+ expect(event.windowSource).toBeUndefined()
121
+ })
122
+ })
@@ -0,0 +1,79 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { InMemoryTaskStore } from '../../../store/task/memory.js'
4
+ import type { RunId } from '../../../types/ids/index.js'
5
+ import type { RunEvent } from '../../../types/run/index.js'
6
+ import { EventTranslator } from '../events.js'
7
+
8
+ /**
9
+ * The task store maintains a full dependency graph — `blocks` and `blockedBy`
10
+ * mirrored on both ends, written under a lock, and deadlock-avoided — and none
11
+ * of it reached the wire.
12
+ *
13
+ * So a host could draw a flat list of units and nothing about their order,
14
+ * while the model was already maintaining the order. Two optional fields is the
15
+ * smallest change that lets a host draw the plan the model has in mind.
16
+ */
17
+
18
+ const RUN = 'run_graph' as RunId
19
+
20
+ /**
21
+ * Only what the emitter touches — and it touches more than the id.
22
+ *
23
+ * `emitEvent` appends to the run store, so a fake without one produces an
24
+ * unhandled rejection AFTER the assertions have passed: every test reports
25
+ * green and the process exits non-zero. Worth stating because that is the
26
+ * failure shape this session has been unpicking all day, arriving here in a
27
+ * test fixture.
28
+ */
29
+ const runMgr = {
30
+ id: RUN,
31
+ getRunStore: () => ({ appendEvent: async () => undefined }),
32
+ } as never
33
+
34
+ async function capture(body: (store: InMemoryTaskStore) => Promise<void>): Promise<RunEvent[]> {
35
+ const store = new InMemoryTaskStore()
36
+ const emitter = new EventTranslator(runMgr)
37
+ const stop = emitter.wireTaskStore(store, RUN)
38
+
39
+ await body(store)
40
+ // The store's listeners are async; let them settle before draining.
41
+ await new Promise((resolve) => setTimeout(resolve, 20))
42
+ stop()
43
+
44
+ return [...emitter.drainPending()]
45
+ }
46
+
47
+ type Created = Extract<RunEvent, { type: 'task_created' }>
48
+ type Updated = Extract<RunEvent, { type: 'task_updated' }>
49
+
50
+ describe('a host can see what a unit waits on', () => {
51
+ it('carries the edges once a dependency exists', async () => {
52
+ const events = await capture(async (store) => {
53
+ const gather = await store.create({ runId: RUN, subject: 'gather' })
54
+ const summarise = await store.create({ runId: RUN, subject: 'summarise' })
55
+ await store.block(gather.id, summarise.id)
56
+ })
57
+
58
+ const withEdges = events
59
+ .filter((e): e is Updated => e.type === 'task_updated')
60
+ .find((e) => e.blockedBy !== undefined)
61
+
62
+ expect(withEdges, 'the dependency the store recorded never reached the wire').toBeDefined()
63
+ expect(withEdges?.blockedBy).toHaveLength(1)
64
+ })
65
+
66
+ it('says nothing rather than empty when a unit depends on nothing', async () => {
67
+ // Absent and empty are different claims. A reader must be able to tell
68
+ // "this unit has no dependencies" from "this emitter predates the
69
+ // field" — an empty array asserts the first about both.
70
+ const events = await capture(async (store) => {
71
+ await store.create({ runId: RUN, subject: 'standalone' })
72
+ })
73
+
74
+ const created = events.find((e): e is Created => e.type === 'task_created')
75
+
76
+ expect(created).toBeDefined()
77
+ expect(created && 'blockedBy' in created).toBe(false)
78
+ })
79
+ })
@@ -121,6 +121,10 @@ export class EventTranslator {
121
121
  taskId: task.id,
122
122
  subject: task.subject,
123
123
  status: task.status,
124
+ // Absent rather than empty: a reader must be able to tell
125
+ // "depends on nothing" from an emitter that predates these.
126
+ ...(task.blockedBy.length > 0 ? { blockedBy: task.blockedBy } : {}),
127
+ ...(task.owner !== undefined ? { owner: task.owner } : {}),
124
128
  })
125
129
  break
126
130
  case 'task.updated':
@@ -133,6 +137,7 @@ export class EventTranslator {
133
137
  subject: task.subject,
134
138
  status: task.status,
135
139
  owner: task.owner,
140
+ ...(task.blockedBy.length > 0 ? { blockedBy: task.blockedBy } : {}),
136
141
  })
137
142
  break
138
143
  default: {
@@ -55,6 +55,38 @@ export class GuardCoordinator {
55
55
  this.startTime = Date.now() - Math.max(0, elapsedMs)
56
56
  }
57
57
 
58
+ /**
59
+ * Wall-clock left before this run is asked to start finishing.
60
+ *
61
+ * NOT the time left before the deadline, and the difference is the whole
62
+ * point. The checks above run BETWEEN iterations, so anything that waits
63
+ * inside one cannot be stopped by them, and a caller sizing such a wait
64
+ * needs a number the run actually owns: a fixed two-minute hold measured
65
+ * against a run configured for twenty seconds kept it open for 120,267 ms.
66
+ *
67
+ * Measuring to the DEADLINE was the first attempt and it was wrong. The
68
+ * binding constraint is `budgetWarningThreshold`, the point at which this
69
+ * guard stops asking for more work and asks for a closing summary — that
70
+ * last slice exists so the run can produce an answer, and a wait sized
71
+ * against the deadline eats into it. Half of the time-to-deadline, started
72
+ * just under the threshold, ends at 95% of the budget: half the closing
73
+ * reserve spent waiting for a result the closing answer was supposed to
74
+ * use.
75
+ *
76
+ * Zero once the threshold has passed, which is also how a caller gets the
77
+ * re-evaluation it needs: `forceFinalize` is sampled at the top of an
78
+ * iteration and this is read when the wait is about to start, so a long
79
+ * iteration that crossed the line in between is told to wait for nothing.
80
+ *
81
+ * Reads through the same `startTime` the limit checks use, so a run
82
+ * resumed from a checkpoint (see `restoreElapsed`) reports the time left
83
+ * on the RUN rather than on the process now hosting it.
84
+ */
85
+ remainingBeforeFinalizeMs(): number {
86
+ const finalizeAt = this.limitConfig.timeoutMs * this.limitConfig.budgetWarningThreshold
87
+ return Math.max(0, finalizeAt - (Date.now() - this.startTime))
88
+ }
89
+
58
90
  beforeIteration(runMgr: RunPersistence, abortSignal: AbortSignal): GuardCheckResult {
59
91
  const limitState = {
60
92
  aborted: abortSignal.aborted,
@@ -0,0 +1,265 @@
1
+ import { mkdtemp, rm } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { afterEach, describe, expect, it } from 'vitest'
5
+
6
+ import { CompletionInbox } from '../../../../gateway/completion-inbox.js'
7
+ import { ToolRegistry } from '../../../../registry/tool/execute.js'
8
+ import { DELEGATION_TIMEOUT_MS } from '../../../../tools/coordinator/index.js'
9
+ import type { TaskHandle } from '../../../../types/agent/gateway.js'
10
+ import type { SessionId, TaskId, TenantId } from '../../../../types/ids/index.js'
11
+ import { createUserMessage } from '../../../../types/message/index.js'
12
+ import type { LLMProvider, StreamChunk } from '../../../../types/provider/index.js'
13
+ import type { ProjectId, ThreadId } from '../../../../types/session/ids.js'
14
+ import { GuardCoordinator } from '../../guard.js'
15
+ import { drainQuery } from '../../index.js'
16
+ import { settleGraceMs } from '../index.js'
17
+
18
+ /**
19
+ * How long a finishing run waits for a worker it launched.
20
+ *
21
+ * This used to be a constant — 120 seconds, unrelated to the run holding it.
22
+ * Measured before the change: a run configured `timeoutMs: 20_000` was held
23
+ * open for 120,267 ms, because the hold sits INSIDE an iteration and the guard
24
+ * only checks between them, so nothing could interrupt it. The same constant
25
+ * abandoned workers observed at 4m21s, 5m58s and 8m04s on runs that had hours
26
+ * left.
27
+ */
28
+
29
+ describe('the grace is a share of what the run has left', () => {
30
+ it('takes half, so the turn that reads the result still has time to happen', () => {
31
+ // The fraction is the whole argument. Spending everything remaining
32
+ // would deliver a notification into a run with no turn left to act on
33
+ // it — the failure this mechanism exists to prevent, in a new costume.
34
+ expect(settleGraceMs(60_000)).toBe(30_000)
35
+ })
36
+
37
+ it('holds for nothing when the run has nothing left', () => {
38
+ // A decision, not a rounding artefact: with no time left there is no
39
+ // turn in which to read a notification, so waiting can only delay a
40
+ // stop that is already due. Nothing is lost — `waitForArrival` returns
41
+ // before it looks at its timer when a completion is already in hand.
42
+ expect(settleGraceMs(0)).toBe(0)
43
+ })
44
+
45
+ it('cannot outlive the deadline it was derived from', () => {
46
+ // Bounded by construction, which is why the guard's inability to
47
+ // interrupt a hold needs no new interrupt seam.
48
+ for (const remaining of [1, 250, 30_000, 600_000, 3_600_000]) {
49
+ expect(settleGraceMs(remaining)).toBeLessThan(remaining)
50
+ }
51
+ })
52
+
53
+ it('stops at the longest this subsystem ever waits for a worker', () => {
54
+ // Only reachable for a host whose run timeout exceeds two hours.
55
+ expect(settleGraceMs(10 * 60 * 60 * 1000)).toBe(DELEGATION_TIMEOUT_MS)
56
+ })
57
+ })
58
+
59
+ describe('the guard reports time to the finalize point, not to the deadline', () => {
60
+ it('stops short of the closing reserve', () => {
61
+ // 90% of the budget, not 100%. The last tenth is what the guard keeps
62
+ // so a run can produce a closing answer; a wait sized against the
63
+ // deadline spends it on waiting instead.
64
+ const guard = new GuardCoordinator({ tokenBudget: 1_000, timeoutMs: 60_000 })
65
+
66
+ const remaining = guard.remainingBeforeFinalizeMs()
67
+
68
+ expect(remaining).toBeLessThanOrEqual(54_000)
69
+ expect(remaining).toBeGreaterThan(50_000)
70
+ })
71
+
72
+ it('subtracts the time a previous process already spent', () => {
73
+ // A run resumed from a checkpoint gets the remainder of ITS budget, not
74
+ // a fresh clock — otherwise N resumes buy N x timeoutMs, and a hold
75
+ // sized from the fresh clock would outlive the real deadline.
76
+ const guard = new GuardCoordinator({ tokenBudget: 1_000, timeoutMs: 60_000 })
77
+ guard.restoreElapsed(50_000)
78
+
79
+ expect(guard.remainingBeforeFinalizeMs()).toBeLessThanOrEqual(4_000)
80
+ expect(guard.remainingBeforeFinalizeMs()).toBeGreaterThan(1_000)
81
+ })
82
+
83
+ it('reports nothing once the run is already past the finalize point', () => {
84
+ // 95% elapsed: the guard is about to ask for a closing summary, and a
85
+ // hold opened here would be taken out of the answer's time.
86
+ const guard = new GuardCoordinator({ tokenBudget: 1_000, timeoutMs: 60_000 })
87
+ guard.restoreElapsed(57_000)
88
+
89
+ expect(guard.remainingBeforeFinalizeMs()).toBe(0)
90
+ })
91
+
92
+ it('never reports a negative remainder', () => {
93
+ const guard = new GuardCoordinator({ tokenBudget: 1_000, timeoutMs: 1_000 })
94
+ guard.restoreElapsed(500_000)
95
+
96
+ expect(guard.remainingBeforeFinalizeMs()).toBe(0)
97
+ })
98
+ })
99
+
100
+ describe('the hold cannot reach into the run closing reserve', () => {
101
+ it('ends before the finalize point however late it starts', () => {
102
+ // The failure the finalize-relative input exists for. Against the
103
+ // DEADLINE, a hold beginning at elapsed fraction e ends at
104
+ // 0.5 + 0.5e — so one starting just under the 0.9 threshold ends at
105
+ // 95% of the budget, with half the closing reserve gone. Against the
106
+ // finalize point it cannot cross 90% at all.
107
+ const timeoutMs = 60_000
108
+ const finalizeAt = timeoutMs * 0.9
109
+
110
+ for (const elapsed of [0, 30_000, 50_000, 53_000, 53_900]) {
111
+ const remainingBeforeFinalize = Math.max(0, finalizeAt - elapsed)
112
+ const endsAt = elapsed + settleGraceMs(remainingBeforeFinalize)
113
+
114
+ expect(
115
+ endsAt,
116
+ `a hold starting at ${elapsed}ms crossed the finalize point`,
117
+ ).toBeLessThanOrEqual(finalizeAt)
118
+ }
119
+ })
120
+ })
121
+
122
+ const ZERO_USAGE = {
123
+ promptTokens: 0,
124
+ completionTokens: 0,
125
+ totalTokens: 0,
126
+ cachedTokens: 0,
127
+ cacheWriteTokens: 0,
128
+ }
129
+
130
+ /** Answers straight away. The hold is the only thing that can delay this run. */
131
+ class AnswersImmediately implements LLMProvider {
132
+ readonly id = 'answers'
133
+ readonly name = 'Answers Immediately'
134
+ async *chatStream(): AsyncIterable<StreamChunk> {
135
+ yield { id: 'm1', delta: { content: 'Done.' } }
136
+ yield { id: 'm1', delta: {}, finishReason: 'stop', usage: ZERO_USAGE }
137
+ }
138
+ }
139
+
140
+ const workdirs: string[] = []
141
+ afterEach(async () => {
142
+ await Promise.all(workdirs.map((dir) => rm(dir, { recursive: true, force: true })))
143
+ workdirs.length = 0
144
+ })
145
+
146
+ /**
147
+ * The loop, not the helper.
148
+ *
149
+ * `settleGraceMs` has its own tests above and every one of them passes with
150
+ * the loop still calling a constant. Only a real run can say which number the
151
+ * hold actually used.
152
+ */
153
+ describe('the hold a run pays is the one its own budget allows', () => {
154
+ async function runHoldingFor(timeoutMs: number): Promise<number> {
155
+ const workingDirectory = await mkdtemp(join(tmpdir(), 'namzu-grace-'))
156
+ workdirs.push(workingDirectory)
157
+
158
+ const inbox = new CompletionInbox()
159
+ // `getTask` because the inbox asks the gateway about a task whose
160
+ // completion may have been announced before the launch was recorded.
161
+ // A double that omits a method the interface requires is not a smaller
162
+ // gateway, it is a broken one.
163
+ inbox.attach({ onTaskCompleted: () => () => {}, getTask: () => undefined } as never)
164
+ // Outstanding and never settling: the worst case the bound exists for.
165
+ // `expect` records the launch as well, so the inbox owns this task.
166
+ inbox.expect('tsk_never' as TaskId)
167
+
168
+ const startedAt = Date.now()
169
+ await drainQuery({
170
+ provider: new AnswersImmediately(),
171
+ tools: new ToolRegistry(),
172
+ completionInbox: inbox,
173
+ agentId: 'agent_test',
174
+ agentName: 'Test Agent',
175
+ messages: [createUserMessage('go')],
176
+ workingDirectory,
177
+ runConfig: {
178
+ model: 'mock-model',
179
+ timeoutMs,
180
+ tokenBudget: 100_000,
181
+ maxIterations: 2,
182
+ maxResponseTokens: 256,
183
+ },
184
+ sessionId: 'ses_grace' as SessionId,
185
+ threadId: 'thd_grace' as ThreadId,
186
+ projectId: 'prj_grace' as ProjectId,
187
+ tenantId: 'tnt_grace' as TenantId,
188
+ } as never)
189
+ return Date.now() - startedAt
190
+ }
191
+
192
+ it('a two-second run does not wait two minutes for a worker', async () => {
193
+ const elapsed = await runHoldingFor(2_000)
194
+
195
+ // Half of two seconds, plus whatever the turn itself costs. The number
196
+ // this replaced would have parked here for 120 s regardless.
197
+ expect(elapsed, 'the hold outlived the run budget that bounds it').toBeLessThan(5_000)
198
+ }, 200_000)
199
+
200
+ it('a run with no time left does not hold at all', async () => {
201
+ // The guard stops this run at the top of its first iteration, so no
202
+ // hold is even reached — which is the point: the last tenth of a run
203
+ // already never holds, so no artificial minimum has to defend it.
204
+ const elapsed = await runHoldingFor(1)
205
+
206
+ expect(elapsed).toBeLessThan(2_000)
207
+ }, 200_000)
208
+
209
+ it('still delivers a completion that arrives inside the grace', async () => {
210
+ // The bound must not become an excuse to drop what was nearly ready.
211
+ const workingDirectory = await mkdtemp(join(tmpdir(), 'namzu-grace-hit-'))
212
+ workdirs.push(workingDirectory)
213
+
214
+ const inbox = new CompletionInbox()
215
+ let announce: ((h: TaskHandle) => void) | undefined
216
+ inbox.attach({
217
+ onTaskCompleted: (cb: (h: TaskHandle) => void) => {
218
+ announce = cb
219
+ return () => {
220
+ announce = undefined
221
+ }
222
+ },
223
+ getTask: () => undefined,
224
+ } as never)
225
+ inbox.expect('tsk_soon' as TaskId)
226
+ const timer = setTimeout(() => {
227
+ announce?.({
228
+ taskId: 'tsk_soon' as TaskId,
229
+ agentId: 'reviewer',
230
+ state: 'completed',
231
+ createdAt: 0,
232
+ completedAt: 1,
233
+ result: { status: 'completed', result: 'ARRIVED INSIDE THE GRACE' },
234
+ } as TaskHandle)
235
+ }, 150)
236
+ timer.unref?.()
237
+
238
+ const run = await drainQuery({
239
+ provider: new AnswersImmediately(),
240
+ tools: new ToolRegistry(),
241
+ completionInbox: inbox,
242
+ agentId: 'agent_test',
243
+ agentName: 'Test Agent',
244
+ messages: [createUserMessage('go')],
245
+ workingDirectory,
246
+ runConfig: {
247
+ model: 'mock-model',
248
+ timeoutMs: 30_000,
249
+ tokenBudget: 100_000,
250
+ maxIterations: 3,
251
+ maxResponseTokens: 256,
252
+ },
253
+ sessionId: 'ses_grace' as SessionId,
254
+ threadId: 'thd_grace' as ThreadId,
255
+ projectId: 'prj_grace' as ProjectId,
256
+ tenantId: 'tnt_grace' as TenantId,
257
+ } as never)
258
+
259
+ const userText = (run.messages as { role: string; content: unknown }[])
260
+ .filter((m) => m.role === 'user')
261
+ .map((m) => (typeof m.content === 'string' ? m.content : ''))
262
+
263
+ expect(userText.some((m) => m.includes('ARRIVED INSIDE THE GRACE'))).toBe(true)
264
+ }, 200_000)
265
+ })