Repository navigation
feat(core): expose workflow execution state #9034
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 7 commits
c34178f
82e02a0
a644d0e
f519913
53d317b
9778f9e
04a7091
15895b8
1878baf
c8ef022
87fe9e1
c678df9
4b544d4
52a1e38
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -417,6 +417,170 @@ describe('WorkflowOrchestrator', () => { | |
| }); | ||
| }); | ||
|
|
||
| it('records dependency tails across sequential, parallel, and pipeline dispatches', async () => { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Deferred to the next round. Still planned: an orchestrator test running 中文说明延后至下一轮。仍计划:新增 orchestrator 测试,父脚本调用 |
||
| const orchestrator = new WorkflowOrchestrator( | ||
| async (prompt) => `mock:${prompt}`, | ||
| ); | ||
| const queued: Array<{ | ||
| id: string; | ||
| label?: string; | ||
| dependsOn: string[]; | ||
| }> = []; | ||
|
|
||
| await orchestrator.run({ | ||
| script: ` | ||
| phase('Inspect'); | ||
| await agent('inspect', { label: 'inspect' }); | ||
| phase('Review'); | ||
| await parallel([ | ||
| () => agent('correctness', { label: 'correctness' }), | ||
| () => agent('architecture', { label: 'architecture' }), | ||
| ]); | ||
| phase('Fix'); | ||
| await pipeline( | ||
| ['a', 'b'], | ||
| (_prev, item) => agent('verify ' + item, { label: 'verify-' + item }), | ||
| (_prev, item) => agent('fix ' + item, { label: 'fix-' + item }), | ||
| ); | ||
| `, | ||
| args: undefined, | ||
| emitter: { | ||
| dispatchQueued: (event) => queued.push(event), | ||
| }, | ||
| }); | ||
|
|
||
| const ids = new Map(queued.map((event) => [event.label, event.id])); | ||
| const dependencies = (label: string) => | ||
| queued | ||
| .find((event) => event.label === label)! | ||
| .dependsOn.map((id) => queued.find((event) => event.id === id)!.label); | ||
|
|
||
| expect(dependencies('inspect')).toEqual([]); | ||
| expect(dependencies('correctness')).toEqual(['inspect']); | ||
| expect(dependencies('architecture')).toEqual(['inspect']); | ||
| expect(dependencies('verify-a')).toEqual(['correctness', 'architecture']); | ||
| expect(dependencies('verify-b')).toEqual(['correctness', 'architecture']); | ||
| expect(dependencies('fix-a')).toEqual(['verify-a']); | ||
| expect(dependencies('fix-b')).toEqual(['verify-b']); | ||
| expect(new Set(ids.values()).size).toBe(7); | ||
| }); | ||
|
|
||
| it.each(['parallel', 'pipeline'] as const)( | ||
| 'preserves newer parent dependencies when an un-awaited %s settles', | ||
| async (kind) => { | ||
| const queued: Array<{ | ||
| id: string; | ||
| label?: string; | ||
| dependsOn: string[]; | ||
| }> = []; | ||
| const orchestrator = new WorkflowOrchestrator( | ||
| async (prompt) => `${prompt}-done`, | ||
| ); | ||
| const fanout = | ||
| kind === 'parallel' | ||
| ? `parallel([() => agent('fanout', { label: 'fanout' })])` | ||
| : `pipeline([0], () => agent('fanout', { label: 'fanout' }))`; | ||
|
|
||
| await orchestrator.run({ | ||
| script: ` | ||
| const pending = ${fanout}; | ||
| await agent('parent', { label: 'parent' }); | ||
| await pending; | ||
| await agent('joined', { label: 'joined' }); | ||
| `, | ||
| args: undefined, | ||
| scheduler: new WorkflowDispatchScheduler(2), | ||
| emitter: { | ||
| dispatchQueued: (event) => queued.push(event), | ||
| }, | ||
| }); | ||
|
|
||
| const labelsById = new Map( | ||
| queued.map((event) => [event.id, event.label]), | ||
| ); | ||
| const joined = queued.find((event) => event.label === 'joined'); | ||
| expect(joined?.dependsOn.map((id) => labelsById.get(id)).sort()).toEqual([ | ||
| 'fanout', | ||
| 'parent', | ||
| ]); | ||
| }, | ||
| ); | ||
|
|
||
| it('emits queued, started, and settled lifecycle events for one dispatch', async () => { | ||
| const orchestrator = new WorkflowOrchestrator(async () => 'done'); | ||
| const events: string[] = []; | ||
|
|
||
| await orchestrator.run({ | ||
| script: `await agent('inspect', { label: 'scope' });`, | ||
| args: undefined, | ||
| emitter: { | ||
| dispatchQueued: ({ id, label }) => events.push(`queued:${id}:${label}`), | ||
| dispatchStarted: (id) => events.push(`started:${id}`), | ||
| dispatchSettled: (id, error) => | ||
| events.push(`settled:${id}:${error ?? 'ok'}`), | ||
| }, | ||
| }); | ||
|
|
||
| expect(events).toHaveLength(3); | ||
| const dispatchId = events[0]!.split(':')[1]; | ||
| expect(events).toEqual([ | ||
| `queued:${dispatchId}:scope`, | ||
| `started:${dispatchId}`, | ||
| `settled:${dispatchId}:ok`, | ||
| ]); | ||
| }); | ||
|
|
||
| it('passes the recorded dispatch id into the production dispatch boundary', async () => { | ||
| const receivedIds: Array<string | undefined> = []; | ||
| const orchestrator = new WorkflowOrchestrator( | ||
| async (_prompt, _opts, dispatchId) => { | ||
| receivedIds.push(dispatchId); | ||
| return 'done'; | ||
| }, | ||
| ); | ||
|
|
||
| await orchestrator.run({ | ||
| script: `await agent('inspect', { label: 'scope' });`, | ||
| args: undefined, | ||
| }); | ||
|
|
||
| expect(receivedIds).toEqual(['dispatch-1']); | ||
| }); | ||
|
|
||
| it('preserves the dependency tail across empty parallel helpers', async () => { | ||
| const orchestrator = new WorkflowOrchestrator(async () => 'done'); | ||
| const queued: Array<{ | ||
| id: string; | ||
| label?: string; | ||
| dependsOn: string[]; | ||
| }> = []; | ||
|
|
||
| await orchestrator.run({ | ||
| script: ` | ||
| await agent('before', { label: 'before' }); | ||
| await parallel([]); | ||
| await agent('after parallel', { label: 'after-parallel' }); | ||
| await pipeline([], () => agent('unused')); | ||
| await agent('after pipeline', { label: 'after-pipeline' }); | ||
| `, | ||
| args: undefined, | ||
| emitter: { | ||
| dispatchQueued: (event) => queued.push(event), | ||
| }, | ||
| }); | ||
|
|
||
| const labelById = new Map( | ||
| queued.map((event) => [event.id, event.label ?? event.id]), | ||
| ); | ||
| const dependsOn = (label: string) => | ||
| queued | ||
| .find((event) => event.label === label)! | ||
| .dependsOn.map((id) => labelById.get(id)); | ||
|
|
||
| expect(dependsOn('after-parallel')).toEqual(['before']); | ||
| expect(dependsOn('after-pipeline')).toEqual(['after-parallel']); | ||
| }); | ||
|
|
||
| it('emitter subscriber errors do not break the run (defensive try/catch)', async () => { | ||
| const orchestrator = new WorkflowOrchestrator( | ||
| async (prompt) => `mock:${prompt}`, | ||
|
|
@@ -813,9 +977,13 @@ describe('WorkflowOrchestrator', () => { | |
| const orchestrator = new WorkflowOrchestrator(() => | ||
| Promise.reject(new Error('nested-boom')), | ||
| ); | ||
| const appendedLogs: string[] = []; | ||
| const outcome = await orchestrator.run({ | ||
| script: `return 'parent:' + (await workflow('child'));`, | ||
| args: undefined, | ||
| emitter: { | ||
| logAppended: (line) => appendedLogs.push(line), | ||
| }, | ||
| resolveSavedWorkflow: async () => ({ | ||
| // The fire-and-forget dispatch fails but the nested script | ||
| // still completes — the only trace of the failure is the | ||
|
|
@@ -827,6 +995,9 @@ describe('WorkflowOrchestrator', () => { | |
| expect(outcome.logs).toContain( | ||
| 'dispatch failed (result not consumed): nested-boom', | ||
| ); | ||
| expect(appendedLogs).toEqual([ | ||
| 'dispatch failed (result not consumed): nested-boom', | ||
| ]); | ||
| }); | ||
|
|
||
| it('keeps a nested agent result behind the shared pause gate', async () => { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Deferred to the next round. Still planned: one journal test making the first append reject (
mockRejectedValueOnceonfs.promises.appendFile), asserting the failing append rejects for its caller while the second append still writes anddrain()resolves.中文说明
延后至下一轮。仍计划:新增一个 journal 测试令首次 append 被拒绝(对
fs.promises.appendFile使用mockRejectedValueOnce),断言失败的 append 对调用方拒绝、第二次 append 仍写入、drain()正常 resolve。