@namzu/sdk 11.0.0 → 12.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +75 -0
- package/dist/bridge/a2a/mapper.d.ts.map +1 -1
- package/dist/bridge/a2a/mapper.js +2 -0
- package/dist/bridge/a2a/mapper.js.map +1 -1
- package/dist/bridge/sse/mapper.d.ts.map +1 -1
- package/dist/bridge/sse/mapper.js +12 -0
- package/dist/bridge/sse/mapper.js.map +1 -1
- package/dist/contracts/api.d.ts +1 -1
- package/dist/contracts/api.d.ts.map +1 -1
- package/dist/gateway/__tests__/a-progress-tee-cannot-name-a-task-that-does-not-exist.test.d.ts +2 -0
- package/dist/gateway/__tests__/a-progress-tee-cannot-name-a-task-that-does-not-exist.test.d.ts.map +1 -0
- package/dist/gateway/__tests__/a-progress-tee-cannot-name-a-task-that-does-not-exist.test.js +137 -0
- package/dist/gateway/__tests__/a-progress-tee-cannot-name-a-task-that-does-not-exist.test.js.map +1 -0
- package/dist/gateway/local.d.ts.map +1 -1
- package/dist/gateway/local.js +25 -1
- package/dist/gateway/local.js.map +1 -1
- package/dist/manager/plan/lifecycle.d.ts +1 -1
- package/dist/manager/plan/lifecycle.d.ts.map +1 -1
- package/dist/manager/plan/lifecycle.js +6 -1
- package/dist/manager/plan/lifecycle.js.map +1 -1
- package/dist/run/reporter.d.ts.map +1 -1
- package/dist/run/reporter.js +2 -0
- package/dist/run/reporter.js.map +1 -1
- package/dist/runtime/query/__tests__/a-settled-plan-reaches-the-host.test.d.ts +2 -0
- package/dist/runtime/query/__tests__/a-settled-plan-reaches-the-host.test.d.ts.map +1 -0
- package/dist/runtime/query/__tests__/a-settled-plan-reaches-the-host.test.js +97 -0
- package/dist/runtime/query/__tests__/a-settled-plan-reaches-the-host.test.js.map +1 -0
- package/dist/runtime/query/events.d.ts.map +1 -1
- package/dist/runtime/query/events.js +20 -2
- package/dist/runtime/query/events.js.map +1 -1
- package/dist/runtime/query/index.d.ts.map +1 -1
- package/dist/runtime/query/index.js +14 -1
- package/dist/runtime/query/index.js.map +1 -1
- package/dist/types/plan/index.d.ts +13 -0
- package/dist/types/plan/index.d.ts.map +1 -1
- package/dist/types/run/events.d.ts +33 -0
- package/dist/types/run/events.d.ts.map +1 -1
- package/dist/types/run/events.js.map +1 -1
- package/package.json +1 -1
- package/src/bridge/a2a/mapper.ts +2 -0
- package/src/bridge/sse/mapper.ts +14 -0
- package/src/contracts/api.ts +5 -0
- package/src/gateway/__tests__/a-progress-tee-cannot-name-a-task-that-does-not-exist.test.ts +175 -0
- package/src/gateway/local.ts +25 -1
- package/src/manager/plan/lifecycle.ts +6 -1
- package/src/run/reporter.ts +2 -0
- package/src/runtime/query/__tests__/a-settled-plan-reaches-the-host.test.ts +122 -0
- package/src/runtime/query/events.ts +20 -2
- package/src/runtime/query/index.ts +15 -2
- package/src/types/plan/index.ts +14 -0
- package/src/types/run/events.ts +24 -0
package/src/contracts/api.ts
CHANGED
|
@@ -206,6 +206,11 @@ export type StreamEventType =
|
|
|
206
206
|
| 'plan.approved'
|
|
207
207
|
| 'plan.rejected'
|
|
208
208
|
| 'plan.step_updated'
|
|
209
|
+
// The outcome. Without these the plan stream stopped one event short of
|
|
210
|
+
// saying how it went, so a client could render a plan as in-flight
|
|
211
|
+
// indefinitely — it learned the plan was approved and never that it closed.
|
|
212
|
+
| 'plan.completed'
|
|
213
|
+
| 'plan.failed'
|
|
209
214
|
| 'agent.pending'
|
|
210
215
|
| 'agent.completed'
|
|
211
216
|
| 'agent.failed'
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import type { Agent } from '../../types/agent/core.js'
|
|
4
|
+
import type { AgentManagerContract } from '../../types/agent/manager.js'
|
|
5
|
+
import type {
|
|
6
|
+
AgentTask,
|
|
7
|
+
AgentTaskContext,
|
|
8
|
+
AgentTaskState,
|
|
9
|
+
SendMessageOptions,
|
|
10
|
+
} from '../../types/agent/task.js'
|
|
11
|
+
import type { AgentId, RunId, SessionId, TaskId, TenantId } from '../../types/ids/index.js'
|
|
12
|
+
import type { RunEventListener } from '../../types/run/events.js'
|
|
13
|
+
import type { ProjectId, ThreadId } from '../../types/session/ids.js'
|
|
14
|
+
import { LocalTaskGateway } from '../local.js'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A child that spoke before its own spawn resolved killed the launch.
|
|
18
|
+
*
|
|
19
|
+
* The progress tee handed to `sendMessage` read `task.taskId` — the `const`
|
|
20
|
+
* that the very same `await` assigns. So any run event emitted by the child
|
|
21
|
+
* before `sendMessage` returned reached that line inside the temporal dead
|
|
22
|
+
* zone and threw `Cannot access 'task' before initialization`, taking the whole
|
|
23
|
+
* `create_task` down with it.
|
|
24
|
+
*
|
|
25
|
+
* It survived review and a full unit suite because a single sequential launch
|
|
26
|
+
* usually resolves before the child says anything. A CONCURRENT fan-out does
|
|
27
|
+
* not — and that is the shape `create_task`'s own description tells the model
|
|
28
|
+
* to use. Observed live on the published package: four launches from one turn,
|
|
29
|
+
* three dead.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const RUN = 'run_tee' as RunId
|
|
33
|
+
|
|
34
|
+
/** Emits a run event DURING the spawn, before it resolves. */
|
|
35
|
+
class TalksDuringSpawn implements AgentManagerContract {
|
|
36
|
+
/** Kept so a test can make the child speak AFTER the spawn resolved too. */
|
|
37
|
+
private lastListener?: RunEventListener
|
|
38
|
+
|
|
39
|
+
constructor(private readonly emitsBeforeResolve: number) {}
|
|
40
|
+
|
|
41
|
+
/** The child says something once the caller holds its handle. */
|
|
42
|
+
speakNow(): void {
|
|
43
|
+
this.lastListener?.({ type: 'iteration_started', runId: RUN, iteration: 99 } as never)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async sendMessage(
|
|
47
|
+
options: SendMessageOptions,
|
|
48
|
+
_context: AgentTaskContext,
|
|
49
|
+
listener?: RunEventListener,
|
|
50
|
+
): Promise<AgentTask> {
|
|
51
|
+
this.lastListener = listener
|
|
52
|
+
// The child is alive and streaming before the caller holds its handle.
|
|
53
|
+
for (let i = 0; i < this.emitsBeforeResolve; i += 1) {
|
|
54
|
+
listener?.({ type: 'iteration_started', runId: RUN, iteration: i } as never)
|
|
55
|
+
}
|
|
56
|
+
await Promise.resolve()
|
|
57
|
+
return {
|
|
58
|
+
taskId: 'task_spoke_early' as TaskId,
|
|
59
|
+
agentId: options.agentId,
|
|
60
|
+
agent: {} as Agent<never, never>,
|
|
61
|
+
childAbortController: new AbortController(),
|
|
62
|
+
context: {} as AgentTaskContext,
|
|
63
|
+
state: 'completed' as AgentTaskState,
|
|
64
|
+
pendingMessages: [],
|
|
65
|
+
createdAt: 1,
|
|
66
|
+
} as AgentTask
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
cancel(): void {}
|
|
70
|
+
cancelAll(): void {}
|
|
71
|
+
async continueTask(): Promise<void> {}
|
|
72
|
+
queueMessage(): void {}
|
|
73
|
+
drainMessages() {
|
|
74
|
+
return []
|
|
75
|
+
}
|
|
76
|
+
async waitForCompletion(): Promise<void> {}
|
|
77
|
+
getInstance(): AgentTask | undefined {
|
|
78
|
+
return undefined
|
|
79
|
+
}
|
|
80
|
+
listByParent(): AgentTask[] {
|
|
81
|
+
return []
|
|
82
|
+
}
|
|
83
|
+
listActive(): AgentTask[] {
|
|
84
|
+
return []
|
|
85
|
+
}
|
|
86
|
+
getState(): AgentTaskState | undefined {
|
|
87
|
+
return undefined
|
|
88
|
+
}
|
|
89
|
+
on(): void {}
|
|
90
|
+
off(): void {}
|
|
91
|
+
cleanup(): void {}
|
|
92
|
+
dispose(): void {}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function context(): AgentTaskContext {
|
|
96
|
+
return {
|
|
97
|
+
parentRunId: RUN,
|
|
98
|
+
parentAgentId: 'supervisor',
|
|
99
|
+
parentAbortController: new AbortController(),
|
|
100
|
+
depth: 0,
|
|
101
|
+
budgetTracker: { total: 100_000, remaining: 100_000 },
|
|
102
|
+
tenantId: 'tnt_t' as TenantId,
|
|
103
|
+
threadId: 'thd_t' as ThreadId,
|
|
104
|
+
sessionId: 'ses_t' as SessionId,
|
|
105
|
+
projectId: 'prj_t' as ProjectId,
|
|
106
|
+
parentActor: { kind: 'agent', agentId: 'supervisor' as AgentId, tenantId: 'tnt_t' as TenantId },
|
|
107
|
+
} as AgentTaskContext
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
describe('a launch survives a child that speaks before the spawn resolves', () => {
|
|
111
|
+
it('does not throw when an event arrives mid-spawn', async () => {
|
|
112
|
+
const gateway = new LocalTaskGateway(new TalksDuringSpawn(3), context())
|
|
113
|
+
// The listener is what makes this reproduce, and its absence is what
|
|
114
|
+
// made the bug invisible for so long: with no progress subscriber the
|
|
115
|
+
// loop body never runs, so `task.taskId` is never evaluated and the
|
|
116
|
+
// dead zone is never entered. `create_task` attaches one for the idle
|
|
117
|
+
// bound on every blocking launch, which is why it bit in production and
|
|
118
|
+
// not in any test.
|
|
119
|
+
gateway.onTaskProgress?.(() => {})
|
|
120
|
+
|
|
121
|
+
await expect(
|
|
122
|
+
gateway.createTask({ agentId: 'worker', prompt: 'go', workingDirectory: '/tmp' }),
|
|
123
|
+
).resolves.toMatchObject({ taskId: 'task_spoke_early' })
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('still forwards those events to the host listener', async () => {
|
|
127
|
+
// The tee is what broke, not the forwarding. A host watching the child
|
|
128
|
+
// must not lose its early events to this fix.
|
|
129
|
+
const seen: string[] = []
|
|
130
|
+
const gateway = new LocalTaskGateway(new TalksDuringSpawn(3), context(), (e) => {
|
|
131
|
+
seen.push(e.type)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
await gateway.createTask({ agentId: 'worker', prompt: 'go', workingDirectory: '/tmp' })
|
|
135
|
+
|
|
136
|
+
expect(seen).toEqual(['iteration_started', 'iteration_started', 'iteration_started'])
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('reports progress once the task has an id to report it against', async () => {
|
|
140
|
+
// Before the id exists nothing is waiting on the task — the caller does
|
|
141
|
+
// not hold the handle yet — so silence there is correct. What must work
|
|
142
|
+
// is everything after.
|
|
143
|
+
const manager = new TalksDuringSpawn(1)
|
|
144
|
+
const gateway = new LocalTaskGateway(manager, context())
|
|
145
|
+
const progressed: TaskId[] = []
|
|
146
|
+
gateway.onTaskProgress?.((id) => progressed.push(id))
|
|
147
|
+
|
|
148
|
+
await gateway.createTask({ agentId: 'worker', prompt: 'go', workingDirectory: '/tmp' })
|
|
149
|
+
// The one emitted mid-spawn is not attributed — there was no id yet.
|
|
150
|
+
expect(progressed).toEqual([])
|
|
151
|
+
|
|
152
|
+
// Now the child speaks with the handle already in the caller's hands,
|
|
153
|
+
// which is the case an idle bound is actually measuring.
|
|
154
|
+
manager.speakNow()
|
|
155
|
+
|
|
156
|
+
expect(progressed).toEqual(['task_spoke_early' as TaskId])
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('survives a concurrent fan-out, which is how this was found', async () => {
|
|
160
|
+
const gateway = new LocalTaskGateway(new TalksDuringSpawn(2), context())
|
|
161
|
+
// Same reason as above: the live failure came through the idle bound's
|
|
162
|
+
// subscriber, so a fan-out test without one would not reproduce the
|
|
163
|
+
// thing it is named after.
|
|
164
|
+
gateway.onTaskProgress?.(() => {})
|
|
165
|
+
|
|
166
|
+
const launched = await Promise.all(
|
|
167
|
+
[1, 2, 3, 4].map(() =>
|
|
168
|
+
gateway.createTask({ agentId: 'worker', prompt: 'go', workingDirectory: '/tmp' }),
|
|
169
|
+
),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
expect(launched).toHaveLength(4)
|
|
173
|
+
expect(launched.every((h) => h.taskId === 'task_spoke_early')).toBe(true)
|
|
174
|
+
})
|
|
175
|
+
})
|
package/src/gateway/local.ts
CHANGED
|
@@ -60,6 +60,12 @@ export class LocalTaskGateway implements TaskGateway {
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
async createTask(options: CreateTaskOptions): Promise<TaskHandle> {
|
|
63
|
+
// Filled once the spawn resolves. A box rather than a bare binding
|
|
64
|
+
// because the assignment happens AFTER the `await` that the reader is
|
|
65
|
+
// passed into — see the progress tee below for why it cannot simply
|
|
66
|
+
// read the `task` const it is declared beside.
|
|
67
|
+
const launched: { id?: TaskId } = {}
|
|
68
|
+
|
|
63
69
|
const task = await this.agentManager.sendMessage(
|
|
64
70
|
{
|
|
65
71
|
agentId: options.agentId,
|
|
@@ -109,12 +115,30 @@ export class LocalTaskGateway implements TaskGateway {
|
|
|
109
115
|
// idle bound measures. The event itself is not forwarded — a
|
|
110
116
|
// progress signal that carried the child's output would be a
|
|
111
117
|
// second, undocumented way to read a worker's work.
|
|
118
|
+
//
|
|
119
|
+
// The id comes from `launched.id`, NOT from the `task` const
|
|
120
|
+
// below. This callback is handed to the very `await` that assigns
|
|
121
|
+
// `task`, so a child that emits anything before `sendMessage`
|
|
122
|
+
// resolves reached it inside the temporal dead zone and threw
|
|
123
|
+
// `Cannot access 'task' before initialization` — killing the launch
|
|
124
|
+
// outright.
|
|
125
|
+
//
|
|
126
|
+
// It survived because a single sequential launch usually resolves
|
|
127
|
+
// before the child says anything. A concurrent fan-out does not:
|
|
128
|
+
// with four `create_task` calls from one turn — the shape this
|
|
129
|
+
// tool's own description tells the model to use — the event loop
|
|
130
|
+
// interleaves and three of the four died. Found by running one.
|
|
112
131
|
(event) => {
|
|
113
132
|
this.listener?.(event)
|
|
114
|
-
|
|
133
|
+
// No id yet means nothing is waiting on this task: the caller
|
|
134
|
+
// does not hold the handle, so an idle bound cannot be running
|
|
135
|
+
// against it. There is no progress to report to anyone.
|
|
136
|
+
if (launched.id === undefined) return
|
|
137
|
+
for (const notify of this.progressListeners) notify(launched.id)
|
|
115
138
|
},
|
|
116
139
|
)
|
|
117
140
|
|
|
141
|
+
launched.id = task.taskId
|
|
118
142
|
this.trackedTaskIds.add(task.taskId)
|
|
119
143
|
|
|
120
144
|
this.agentManager
|
|
@@ -276,11 +276,16 @@ export class PlanManager {
|
|
|
276
276
|
return this.currentPlan
|
|
277
277
|
}
|
|
278
278
|
|
|
279
|
-
failPlan(
|
|
279
|
+
failPlan(error: string): Plan | null {
|
|
280
280
|
if (!this.currentPlan) return null
|
|
281
281
|
|
|
282
282
|
this.currentPlan.status = 'failed'
|
|
283
283
|
this.currentPlan.completedAt = Date.now()
|
|
284
|
+
// Recorded rather than discarded. This argument was named `_error`
|
|
285
|
+
// because nothing read it, so a failed plan carried no account of what
|
|
286
|
+
// went wrong — and the event that now reports the failure would have
|
|
287
|
+
// had nothing to say beyond the word.
|
|
288
|
+
this.currentPlan.failureReason = error
|
|
284
289
|
|
|
285
290
|
for (const step of this.currentPlan.steps) {
|
|
286
291
|
if (step.status === 'pending' || step.status === 'running') {
|
package/src/run/reporter.ts
CHANGED
|
@@ -93,6 +93,8 @@ export function createRunReporter(parentLogger?: Logger): RunReporter {
|
|
|
93
93
|
case 'plan_approved':
|
|
94
94
|
case 'plan_rejected':
|
|
95
95
|
case 'plan_step_updated':
|
|
96
|
+
case 'plan_completed':
|
|
97
|
+
case 'plan_failed':
|
|
96
98
|
case 'tool_review_requested':
|
|
97
99
|
case 'tool_review_completed':
|
|
98
100
|
case 'checkpoint_created':
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import type { PlanManager } from '../../../manager/plan/lifecycle.js'
|
|
4
|
+
import { MockLLMProvider, registerMock } from '../../../provider/index.js'
|
|
5
|
+
import { ToolRegistry } from '../../../registry/index.js'
|
|
6
|
+
import type { RunEvent } from '../../../types/run/index.js'
|
|
7
|
+
import {
|
|
8
|
+
generateProjectId,
|
|
9
|
+
generateSessionId,
|
|
10
|
+
generateTenantId,
|
|
11
|
+
generateThreadId,
|
|
12
|
+
} from '../../../utils/id.js'
|
|
13
|
+
import { drainQuery } from '../index.js'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The plan stream stopped one event short of the outcome.
|
|
17
|
+
*
|
|
18
|
+
* `plan_ready`, `plan_approved`, `plan_rejected` and `plan_step_updated` all
|
|
19
|
+
* reached the wire; `plan.completed` and `plan.failed` were folded into a bare
|
|
20
|
+
* `break` in the translator and emitted nothing. So a host watching the stream
|
|
21
|
+
* saw the steps report and then silence — it could learn a plan had been
|
|
22
|
+
* approved and never that it closed, which leaves a plan rendered as in-flight
|
|
23
|
+
* indefinitely.
|
|
24
|
+
*
|
|
25
|
+
* **This was found by a live end-to-end run, not by a test, and that is the
|
|
26
|
+
* point worth keeping.** The settlement tests read the outcome off
|
|
27
|
+
* `PlanManager` through `onContextCreated`, so they proved the plan settled
|
|
28
|
+
* without ever asking whether a consumer of the EVENT STREAM could see it. A
|
|
29
|
+
* verification can be entirely sound about a thing that is no longer the thing
|
|
30
|
+
* you need to know.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
registerMock()
|
|
34
|
+
|
|
35
|
+
async function runWithPlan(seed: (pm: PlanManager) => void): Promise<RunEvent[]> {
|
|
36
|
+
const events: RunEvent[] = []
|
|
37
|
+
|
|
38
|
+
await drainQuery(
|
|
39
|
+
{
|
|
40
|
+
provider: new MockLLMProvider({ responses: [{ content: 'done' }] } as never),
|
|
41
|
+
tools: new ToolRegistry(),
|
|
42
|
+
agentId: 'a',
|
|
43
|
+
agentName: 'A',
|
|
44
|
+
messages: [{ role: 'user', content: 'go' }],
|
|
45
|
+
workingDirectory: process.cwd(),
|
|
46
|
+
runConfig: { model: 'mock', tokenBudget: 100_000, timeoutMs: 30_000, maxIterations: 4 },
|
|
47
|
+
projectId: generateProjectId(),
|
|
48
|
+
sessionId: generateSessionId(),
|
|
49
|
+
threadId: generateThreadId(),
|
|
50
|
+
tenantId: generateTenantId(),
|
|
51
|
+
onContextCreated: ({ planManager }: { planManager: PlanManager }) => seed(planManager),
|
|
52
|
+
} as never,
|
|
53
|
+
(event: RunEvent) => {
|
|
54
|
+
events.push(event)
|
|
55
|
+
},
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
return events
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function twoStepPlan(pm: PlanManager): void {
|
|
62
|
+
pm.startGenerating('the work')
|
|
63
|
+
pm.addStep({ id: 'step_1', description: 'first', dependsOn: [], order: 1 })
|
|
64
|
+
pm.addStep({ id: 'step_2', description: 'second', dependsOn: [], order: 2 })
|
|
65
|
+
pm.markReady()
|
|
66
|
+
pm.approve()
|
|
67
|
+
pm.startExecution()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const typesOf = (events: RunEvent[]) => events.map((e) => e.type)
|
|
71
|
+
|
|
72
|
+
describe('a settled plan says so on the run stream', () => {
|
|
73
|
+
it('emits plan_completed when the run settles a successful plan', async () => {
|
|
74
|
+
const events = await runWithPlan((p) => {
|
|
75
|
+
twoStepPlan(p)
|
|
76
|
+
p.updateStepStatus('step_1', 'completed')
|
|
77
|
+
p.updateStepStatus('step_2', 'skipped')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
expect(typesOf(events)).toContain('plan_completed')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('emits plan_failed, carrying the reason failPlan was given', async () => {
|
|
84
|
+
// `failPlan` took this argument and discarded it — the parameter was
|
|
85
|
+
// spelled `_error`. An event that says "failed" without saying why puts
|
|
86
|
+
// the reader back where the missing event did.
|
|
87
|
+
const events = await runWithPlan((p) => {
|
|
88
|
+
twoStepPlan(p)
|
|
89
|
+
p.failPlan('the provider refused the request')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
const failed = events.find((e) => e.type === 'plan_failed')
|
|
93
|
+
expect(failed).toBeDefined()
|
|
94
|
+
expect((failed as { reason?: string }).reason).toBe('the provider refused the request')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('emits plan_failed when a step actually failed', async () => {
|
|
98
|
+
const events = await runWithPlan((p) => {
|
|
99
|
+
twoStepPlan(p)
|
|
100
|
+
p.updateStepStatus('step_1', 'completed')
|
|
101
|
+
p.updateStepStatus('step_2', 'failed')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
expect(typesOf(events)).toContain('plan_failed')
|
|
105
|
+
expect(typesOf(events)).not.toContain('plan_completed')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('says nothing terminal while a step has not reported', async () => {
|
|
109
|
+
// The plan is genuinely unsettled, so the silence here is correct — it
|
|
110
|
+
// is the silence AFTER settlement that was the defect.
|
|
111
|
+
const events = await runWithPlan((p) => {
|
|
112
|
+
twoStepPlan(p)
|
|
113
|
+
p.updateStepStatus('step_1', 'completed')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
expect(typesOf(events)).not.toContain('plan_completed')
|
|
117
|
+
expect(typesOf(events)).not.toContain('plan_failed')
|
|
118
|
+
// ...and the step that DID report is still announced, so this is not a
|
|
119
|
+
// stream that has simply gone quiet.
|
|
120
|
+
expect(typesOf(events)).toContain('plan_step_updated')
|
|
121
|
+
})
|
|
122
|
+
})
|
|
@@ -193,10 +193,28 @@ export class EventTranslator {
|
|
|
193
193
|
})
|
|
194
194
|
}
|
|
195
195
|
break
|
|
196
|
-
case 'plan.generating':
|
|
197
|
-
case 'plan.executing':
|
|
198
196
|
case 'plan.completed':
|
|
197
|
+
await this.emitEvent({
|
|
198
|
+
type: 'plan_completed',
|
|
199
|
+
runId,
|
|
200
|
+
planId: plan.id,
|
|
201
|
+
})
|
|
202
|
+
break
|
|
199
203
|
case 'plan.failed':
|
|
204
|
+
await this.emitEvent({
|
|
205
|
+
type: 'plan_failed',
|
|
206
|
+
runId,
|
|
207
|
+
planId: plan.id,
|
|
208
|
+
...(plan.failureReason ? { reason: plan.failureReason } : {}),
|
|
209
|
+
})
|
|
210
|
+
break
|
|
211
|
+
// Deliberately silent, and not for the same reason the terminal
|
|
212
|
+
// pair used to be. `plan.generating` and `plan.executing` are
|
|
213
|
+
// already bracketed by `plan_ready` and `plan_approved` — a
|
|
214
|
+
// consumer learns both facts from events it already gets, so an
|
|
215
|
+
// event here would carry nothing a reader did not have.
|
|
216
|
+
case 'plan.generating':
|
|
217
|
+
case 'plan.executing':
|
|
200
218
|
break
|
|
201
219
|
default: {
|
|
202
220
|
// `PlanEvent.type` is scoped to plan-manager events; sub-session
|
|
@@ -552,8 +552,6 @@ export async function* query(params: QueryParams): AsyncGenerator<RunEvent, Run>
|
|
|
552
552
|
return { approved: false, feedback: `Action: ${decision.action}` }
|
|
553
553
|
})
|
|
554
554
|
|
|
555
|
-
params.onContextCreated?.({ planManager: ctx.planManager })
|
|
556
|
-
|
|
557
555
|
const eventTranslator = new EventTranslator(ctx.runMgr)
|
|
558
556
|
eventTranslator.wireActivityStore(ctx.activityStore, ctx.runId)
|
|
559
557
|
eventTranslator.wirePlanManager(ctx.planManager, ctx.runId)
|
|
@@ -977,6 +975,21 @@ export async function* query(params: QueryParams): AsyncGenerator<RunEvent, Run>
|
|
|
977
975
|
try {
|
|
978
976
|
await ctx.runMgr.init()
|
|
979
977
|
|
|
978
|
+
// Handed over here, and the position is load-bearing in BOTH
|
|
979
|
+
// directions. It has to follow `wirePlanManager`, or a host that
|
|
980
|
+
// builds its plan in this callback — which is what the callback is
|
|
981
|
+
// for — does it into silence: `plan_ready`, `plan_approved` and
|
|
982
|
+
// every `plan_step_updated` are emitted with nothing subscribed,
|
|
983
|
+
// and the host then watches a stream that never mentions the plan
|
|
984
|
+
// it just created. It also has to follow `runMgr.init()`, because
|
|
985
|
+
// emitting appends to the run store and an uninitialised store
|
|
986
|
+
// throws — moving it up to the wiring alone traded a silent drop
|
|
987
|
+
// for 25 unhandled rejections.
|
|
988
|
+
//
|
|
989
|
+
// Still before the iteration loop, which is the guarantee the
|
|
990
|
+
// callback actually makes.
|
|
991
|
+
params.onContextCreated?.({ planManager: ctx.planManager })
|
|
992
|
+
|
|
980
993
|
ctx.log.info('Starting query', {
|
|
981
994
|
runId: ctx.runMgr.id,
|
|
982
995
|
agent: params.agentName,
|
package/src/types/plan/index.ts
CHANGED
|
@@ -68,6 +68,20 @@ export interface Plan {
|
|
|
68
68
|
rejectedAt?: number
|
|
69
69
|
completedAt?: number
|
|
70
70
|
rejectionReason?: string
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Why the plan failed, when it did.
|
|
74
|
+
*
|
|
75
|
+
* `failPlan` has always taken this and thrown it away — the parameter was
|
|
76
|
+
* spelled `_error` because nothing read it. So a plan settled as `failed`
|
|
77
|
+
* carried no account of what went wrong, and the `plan_failed` event that
|
|
78
|
+
* reports it would have said "failed" and nothing else, which puts a reader
|
|
79
|
+
* exactly where the silence did.
|
|
80
|
+
*
|
|
81
|
+
* Distinct from {@link rejectionReason}: that is a human declining a plan
|
|
82
|
+
* before it ran, this is a plan that ran and did not finish.
|
|
83
|
+
*/
|
|
84
|
+
failureReason?: string
|
|
71
85
|
}
|
|
72
86
|
|
|
73
87
|
export interface PlanApprovalRequest {
|
package/src/types/run/events.ts
CHANGED
|
@@ -434,6 +434,30 @@ type CoreRunEvent =
|
|
|
434
434
|
stepId: string
|
|
435
435
|
status: PlanStep['status']
|
|
436
436
|
}
|
|
437
|
+
/**
|
|
438
|
+
* The plan is over, and it went the way it was supposed to.
|
|
439
|
+
*
|
|
440
|
+
* The plan events used to stop before the outcome: `plan_ready`,
|
|
441
|
+
* `plan_approved`, `plan_rejected` and `plan_step_updated` all reached the
|
|
442
|
+
* wire, and the two terminal ones were folded into a bare `break` in the
|
|
443
|
+
* translator. So a host watching the stream saw the steps report and then
|
|
444
|
+
* silence — it could tell a plan had been approved and never that it
|
|
445
|
+
* closed, which leaves a plan rendered as in-flight forever.
|
|
446
|
+
*
|
|
447
|
+
* Found by the first live end-to-end run rather than by a test, and the
|
|
448
|
+
* reason is worth keeping: the tests read the outcome off `PlanManager`
|
|
449
|
+
* through `onContextCreated`, so they proved the plan settled without ever
|
|
450
|
+
* asking whether a consumer of the EVENT STREAM could see it.
|
|
451
|
+
*/
|
|
452
|
+
| { type: 'plan_completed'; runId: RunId; planId: PlanId }
|
|
453
|
+
/**
|
|
454
|
+
* The plan is over and it did not finish.
|
|
455
|
+
*
|
|
456
|
+
* `reason` is the text handed to `failPlan`, which used to be discarded —
|
|
457
|
+
* an event that says "failed" without saying why puts the reader back
|
|
458
|
+
* where the missing event did.
|
|
459
|
+
*/
|
|
460
|
+
| { type: 'plan_failed'; runId: RunId; planId: PlanId; reason?: string }
|
|
437
461
|
| {
|
|
438
462
|
type: 'agent_pending'
|
|
439
463
|
runId: RunId
|