@namzu/sdk 8.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 (95) hide show
  1. package/CHANGELOG.md +215 -0
  2. package/dist/agents/SupervisorAgent.d.ts.map +1 -1
  3. package/dist/agents/SupervisorAgent.js +14 -1
  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/gateway/__tests__/a-knob-nobody-can-turn.test.d.ts +2 -0
  10. package/dist/gateway/__tests__/a-knob-nobody-can-turn.test.d.ts.map +1 -0
  11. package/dist/gateway/__tests__/a-knob-nobody-can-turn.test.js +114 -0
  12. package/dist/gateway/__tests__/a-knob-nobody-can-turn.test.js.map +1 -0
  13. package/dist/gateway/local.d.ts.map +1 -1
  14. package/dist/gateway/local.js +19 -6
  15. package/dist/gateway/local.js.map +1 -1
  16. package/dist/manager/agent/__tests__/lifecycle.test.js +56 -0
  17. package/dist/manager/agent/__tests__/lifecycle.test.js.map +1 -1
  18. package/dist/manager/agent/lifecycle.d.ts.map +1 -1
  19. package/dist/manager/agent/lifecycle.js +62 -33
  20. package/dist/manager/agent/lifecycle.js.map +1 -1
  21. package/dist/manager/plan/__tests__/a-plan-that-succeeded-is-not-failed.test.d.ts +2 -0
  22. package/dist/manager/plan/__tests__/a-plan-that-succeeded-is-not-failed.test.d.ts.map +1 -0
  23. package/dist/manager/plan/__tests__/a-plan-that-succeeded-is-not-failed.test.js +73 -0
  24. package/dist/manager/plan/__tests__/a-plan-that-succeeded-is-not-failed.test.js.map +1 -0
  25. package/dist/manager/plan/lifecycle.d.ts +48 -0
  26. package/dist/manager/plan/lifecycle.d.ts.map +1 -1
  27. package/dist/manager/plan/lifecycle.js +56 -0
  28. package/dist/manager/plan/lifecycle.js.map +1 -1
  29. package/dist/runtime/query/__tests__/the-plan-graph-reaches-a-host.test.d.ts +2 -0
  30. package/dist/runtime/query/__tests__/the-plan-graph-reaches-a-host.test.d.ts.map +1 -0
  31. package/dist/runtime/query/__tests__/the-plan-graph-reaches-a-host.test.js +62 -0
  32. package/dist/runtime/query/__tests__/the-plan-graph-reaches-a-host.test.js.map +1 -0
  33. package/dist/runtime/query/events.d.ts.map +1 -1
  34. package/dist/runtime/query/events.js +5 -0
  35. package/dist/runtime/query/events.js.map +1 -1
  36. package/dist/store/task/disk.d.ts.map +1 -1
  37. package/dist/store/task/disk.js +14 -0
  38. package/dist/store/task/disk.js.map +1 -1
  39. package/dist/store/task/memory.d.ts.map +1 -1
  40. package/dist/store/task/memory.js +14 -0
  41. package/dist/store/task/memory.js.map +1 -1
  42. package/dist/tools/coordinator/__tests__/a-failed-worker-is-not-an-answer.test.d.ts +2 -0
  43. package/dist/tools/coordinator/__tests__/a-failed-worker-is-not-an-answer.test.d.ts.map +1 -0
  44. package/dist/tools/coordinator/__tests__/a-failed-worker-is-not-an-answer.test.js +101 -0
  45. package/dist/tools/coordinator/__tests__/a-failed-worker-is-not-an-answer.test.js.map +1 -0
  46. package/dist/tools/coordinator/__tests__/the-plan-a-human-approves.test.d.ts +2 -0
  47. package/dist/tools/coordinator/__tests__/the-plan-a-human-approves.test.d.ts.map +1 -0
  48. package/dist/tools/coordinator/__tests__/the-plan-a-human-approves.test.js +99 -0
  49. package/dist/tools/coordinator/__tests__/the-plan-a-human-approves.test.js.map +1 -0
  50. package/dist/tools/coordinator/agent.d.ts.map +1 -1
  51. package/dist/tools/coordinator/agent.js +17 -23
  52. package/dist/tools/coordinator/agent.js.map +1 -1
  53. package/dist/tools/coordinator/index.d.ts.map +1 -1
  54. package/dist/tools/coordinator/index.js +33 -3
  55. package/dist/tools/coordinator/index.js.map +1 -1
  56. package/dist/tools/coordinator/outcome.d.ts +60 -0
  57. package/dist/tools/coordinator/outcome.d.ts.map +1 -0
  58. package/dist/tools/coordinator/outcome.js +68 -0
  59. package/dist/tools/coordinator/outcome.js.map +1 -0
  60. package/dist/types/agent/gateway.d.ts +19 -2
  61. package/dist/types/agent/gateway.d.ts.map +1 -1
  62. package/dist/types/agent/supervisor.d.ts +38 -1
  63. package/dist/types/agent/supervisor.d.ts.map +1 -1
  64. package/dist/types/plan/index.d.ts +26 -0
  65. package/dist/types/plan/index.d.ts.map +1 -1
  66. package/dist/types/run/events.d.ts +17 -0
  67. package/dist/types/run/events.d.ts.map +1 -1
  68. package/dist/types/run/events.js.map +1 -1
  69. package/dist/types/task/index.d.ts +19 -1
  70. package/dist/types/task/index.d.ts.map +1 -1
  71. package/dist/types/task/index.js +10 -1
  72. package/dist/types/task/index.js.map +1 -1
  73. package/package.json +1 -1
  74. package/src/agents/SupervisorAgent.ts +14 -1
  75. package/src/agents/__tests__/a-policy-nobody-can-select.test.ts +202 -0
  76. package/src/gateway/__tests__/a-knob-nobody-can-turn.test.ts +139 -0
  77. package/src/gateway/local.ts +19 -7
  78. package/src/manager/agent/__tests__/lifecycle.test.ts +74 -0
  79. package/src/manager/agent/lifecycle.ts +72 -42
  80. package/src/manager/plan/__tests__/a-plan-that-succeeded-is-not-failed.test.ts +88 -0
  81. package/src/manager/plan/lifecycle.ts +61 -0
  82. package/src/runtime/query/__tests__/the-plan-graph-reaches-a-host.test.ts +79 -0
  83. package/src/runtime/query/events.ts +5 -0
  84. package/src/store/task/disk.ts +15 -0
  85. package/src/store/task/memory.ts +15 -0
  86. package/src/tools/coordinator/__tests__/a-failed-worker-is-not-an-answer.test.ts +133 -0
  87. package/src/tools/coordinator/__tests__/the-plan-a-human-approves.test.ts +121 -0
  88. package/src/tools/coordinator/agent.ts +17 -25
  89. package/src/tools/coordinator/index.ts +33 -3
  90. package/src/tools/coordinator/outcome.ts +70 -0
  91. package/src/types/agent/gateway.ts +19 -2
  92. package/src/types/agent/supervisor.ts +40 -1
  93. package/src/types/plan/index.ts +29 -0
  94. package/src/types/run/events.ts +17 -0
  95. package/src/types/task/index.ts +22 -3
@@ -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
 
@@ -0,0 +1,88 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import type { RunId } from '../../../types/ids/index.js'
4
+ import { PlanManager } from '../lifecycle.js'
5
+
6
+ /**
7
+ * `completePlan` scored an unreported step as a failure.
8
+ *
9
+ * The test was "is every step completed or skipped", and everything else fell
10
+ * to the same branch — so a step still `pending` produced `failed`. Since
11
+ * `addStep` defaults every step to `pending`, a caller that added steps, did
12
+ * the work, and settled the plan without reporting each one got `failed` for a
13
+ * plan that had fully succeeded. That is the path of least effort, not an
14
+ * unusual one.
15
+ *
16
+ * A step that FAILED is an outcome. A step nobody reported on is not — it says
17
+ * the caller and the plan disagree about whether the work is over, and
18
+ * answering "failed" settles that by inventing a result.
19
+ */
20
+
21
+ const RUN = 'run_plan_outcome' as RunId
22
+
23
+ function planWithSteps(count: number): PlanManager {
24
+ const manager = new PlanManager(RUN)
25
+ manager.startGenerating('a plan')
26
+ for (let i = 0; i < count; i += 1) {
27
+ manager.addStep({
28
+ id: `step-${i + 1}`,
29
+ description: `step ${i + 1}`,
30
+ dependsOn: [],
31
+ order: i,
32
+ })
33
+ }
34
+ manager.markReady()
35
+ return manager
36
+ }
37
+
38
+ describe('a plan settles on what its steps actually reported', () => {
39
+ it('completes when every step reported success', () => {
40
+ const manager = planWithSteps(2)
41
+ for (const step of manager.active?.steps ?? []) {
42
+ manager.updateStepStatus(step.id, 'completed')
43
+ }
44
+
45
+ expect(manager.completePlan()?.status).toBe('completed')
46
+ })
47
+
48
+ it('counts a skipped step as settled, not as a failure', () => {
49
+ const manager = planWithSteps(2)
50
+ const steps = manager.active?.steps ?? []
51
+ manager.updateStepStatus(steps[0]?.id as string, 'completed')
52
+ manager.updateStepStatus(steps[1]?.id as string, 'skipped')
53
+
54
+ expect(manager.completePlan()?.status).toBe('completed')
55
+ })
56
+
57
+ it('fails when a step actually failed', () => {
58
+ const manager = planWithSteps(2)
59
+ const steps = manager.active?.steps ?? []
60
+ manager.updateStepStatus(steps[0]?.id as string, 'completed')
61
+ manager.updateStepStatus(steps[1]?.id as string, 'failed')
62
+
63
+ expect(manager.completePlan()?.status).toBe('failed')
64
+ })
65
+
66
+ it('refuses rather than scoring a step nobody reported on', () => {
67
+ // The defect, in the shape a caller reaches it: steps added, work done,
68
+ // nothing reported. Answering `failed` here is the invented result.
69
+ const manager = planWithSteps(2)
70
+ manager.updateStepStatus(manager.active?.steps[0]?.id as string, 'completed')
71
+
72
+ expect(() => manager.completePlan()).toThrow(/have not reported an outcome/)
73
+ })
74
+
75
+ it('names the way out rather than only the refusal', () => {
76
+ // A caller in this position either forgot to report progress or called
77
+ // too early, and only they know which — so the message has to carry
78
+ // both moves, not just the complaint.
79
+ const manager = planWithSteps(1)
80
+
81
+ expect(() => manager.completePlan()).toThrow(/updateStepStatus/)
82
+ expect(() => manager.completePlan()).toThrow(/failPlan/)
83
+ })
84
+
85
+ it('still returns null when there is no plan at all', () => {
86
+ expect(new PlanManager(RUN).completePlan()).toBeNull()
87
+ })
88
+ })
@@ -26,6 +26,33 @@ export type PlanEventListener = (event: PlanEvent) => void
26
26
 
27
27
  export type PlanApprovalHandler = (request: PlanApprovalRequest) => Promise<PlanApprovalResponse>
28
28
 
29
+ /**
30
+ * The plan a run declares, and the gate a host approves it through.
31
+ *
32
+ * **The kernel deliberately drives only part of this class.** It builds a plan
33
+ * (`approve_plan` calls `startGenerating` / `addStep` / `markReady`), gates it
34
+ * (`iteration/phases/context.ts` calls `approve` and `startExecution`),
35
+ * translates its events onto the run stream (`EventTranslator.wirePlanManager`),
36
+ * and settles it on failure (`runtime/query/result.ts` calls `failPlan`). It
37
+ * never reports a step outcome and never settles a plan that succeeded.
38
+ *
39
+ * That is a split, not an omission — `drainQuery` hands the manager to the host
40
+ * through `onContextCreated({ planManager })` BEFORE the iteration loop starts,
41
+ * precisely so a host can drive the half the kernel does not. So a grep for
42
+ * callers of `updateStepStatus` or `completePlan` inside this package finds
43
+ * none, and that is not evidence the methods are dead: the callers are hosts,
44
+ * and they are outside the repository. `PlanManager` is exported from
45
+ * `public-runtime.ts` for this reason.
46
+ *
47
+ * Recorded here because the absence has already been read once as a dead layer
48
+ * and proposed for deletion. What it would have deleted is a working
49
+ * human-in-the-loop approval gate.
50
+ *
51
+ * The one genuine gap in the split is tracked separately: nothing settles a
52
+ * plan that SUCCEEDED, so its status can reach `failed` or stay `executing`
53
+ * but never `completed`. Fixing that needs a decision about what a
54
+ * kernel-built plan's steps mean, not a guessed status — see `completePlan`.
55
+ */
29
56
  export class PlanManager {
30
57
  private currentPlan: Plan | null = null
31
58
  private runId: RunId
@@ -185,9 +212,43 @@ export class PlanManager {
185
212
  return step
186
213
  }
187
214
 
215
+ /**
216
+ * Settle the plan, computing its outcome from its steps.
217
+ *
218
+ * A step that is still `pending` or `running` used to land here as
219
+ * **`failed`**, because the test was "is every step completed or skipped"
220
+ * and anything else fell to the same branch. So a caller that added steps,
221
+ * did the work, and settled the plan without reporting each step got
222
+ * `failed` for a plan that fully succeeded — and `addStep` defaults every
223
+ * step to `pending`, so that is the path of least effort, not an unusual
224
+ * one.
225
+ *
226
+ * The two cases are different facts and want different responses. A step
227
+ * that FAILED is an outcome: the plan failed, report it. A step nobody
228
+ * reported on is not an outcome at all — it says the caller and this plan
229
+ * disagree about whether the work is over, and answering "failed" resolves
230
+ * that disagreement by inventing a result.
231
+ *
232
+ * So an unfinished step is refused rather than scored. The message names
233
+ * the steps and the two ways out, because a caller in this position either
234
+ * forgot to report progress or called too early, and only they know which.
235
+ */
188
236
  completePlan(): Plan | null {
189
237
  if (!this.currentPlan) return null
190
238
 
239
+ const unfinished = this.currentPlan.steps.filter(
240
+ (s) => s.status === 'pending' || s.status === 'running',
241
+ )
242
+ if (unfinished.length > 0) {
243
+ const named = unfinished.slice(0, 3).map((s) => s.description)
244
+ const rest = unfinished.length - named.length
245
+ const listed = rest > 0 ? `${named.join('; ')}, and ${rest} more` : named.join('; ')
246
+ const counted = `${unfinished.length} of ${this.currentPlan.steps.length} steps`
247
+ throw new Error(
248
+ `Cannot complete plan "${this.currentPlan.title}": ${counted} have not reported an outcome (${listed}). Report each step with updateStepStatus — 'skipped' is a valid outcome — or call failPlan if the plan is being abandoned. Scoring an unreported step as a failure would report a plan that succeeded as one that did not.`,
249
+ )
250
+ }
251
+
191
252
  const allDone = this.currentPlan.steps.every(
192
253
  (s) => s.status === 'completed' || s.status === 'skipped',
193
254
  )
@@ -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: {
@@ -35,10 +35,16 @@ export interface DiskTaskStoreConfig {
35
35
  logger?: Logger
36
36
  }
37
37
 
38
+ // `failed` ranks alongside `completed` rather than after it: both are
39
+ // terminal, and neither may transition to the other. Ranking it higher would
40
+ // admit completed -> failed, which would let a settled unit be reopened as a
41
+ // failure; ranking it lower would forbid in_progress -> failed, which is the
42
+ // transition this status exists for.
38
43
  const STATUS_ORDER: Record<TaskStatus, number> = {
39
44
  pending: 0,
40
45
  in_progress: 1,
41
46
  completed: 2,
47
+ failed: 2,
42
48
  }
43
49
 
44
50
  function isForwardTransition(from: TaskStatus, to: TaskStatus): boolean {
@@ -393,7 +399,16 @@ export class DiskTaskStore implements TaskStore {
393
399
  }
394
400
  if (!mutated) {
395
401
  this.log.debug('block(): edge already exists', { blockerId, blockedId })
402
+ return
396
403
  }
404
+
405
+ // Announce BOTH ends, and only when something actually changed. The
406
+ // edge was written and nothing said so, so the graph was observable
407
+ // only by polling — a listener saw a unit created and never learned
408
+ // that something now waits on it.
409
+ const now = Date.now()
410
+ this.emit({ type: 'task.updated', taskId: blockerId, task: blocker, timestamp: now })
411
+ this.emit({ type: 'task.updated', taskId: blockedId, task: blocked, timestamp: now })
397
412
  })
398
413
  }
399
414
 
@@ -10,10 +10,16 @@ import type {
10
10
  } from '../../types/task/index.js'
11
11
  import { generateTaskId } from '../../utils/id.js'
12
12
 
13
+ // `failed` ranks alongside `completed` rather than after it: both are
14
+ // terminal, and neither may transition to the other. Ranking it higher would
15
+ // admit completed -> failed, which would let a settled unit be reopened as a
16
+ // failure; ranking it lower would forbid in_progress -> failed, which is the
17
+ // transition this status exists for.
13
18
  const STATUS_ORDER: Record<TaskStatus, number> = {
14
19
  pending: 0,
15
20
  in_progress: 1,
16
21
  completed: 2,
22
+ failed: 2,
17
23
  }
18
24
 
19
25
  function isForwardTransition(from: TaskStatus, to: TaskStatus): boolean {
@@ -177,6 +183,15 @@ export class InMemoryTaskStore implements TaskStore {
177
183
  if (!blocked.blockedBy.includes(blockerId)) {
178
184
  blocked.blockedBy.push(blockerId)
179
185
  }
186
+
187
+ // Announce BOTH ends. The edge was written and nothing said so, which
188
+ // left the graph observable only by polling: a listener saw a unit
189
+ // created and never learned that something now waits on it. Both sides
190
+ // changed, so both are announced — a host tracking only one would draw
191
+ // half the edge.
192
+ const now = Date.now()
193
+ this.emit({ type: 'task.updated', taskId: blockerId, task: blocker, timestamp: now })
194
+ this.emit({ type: 'task.updated', taskId: blockedId, task: blocked, timestamp: now })
180
195
  }
181
196
 
182
197
  async reset(): Promise<void> {