Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/cli/src/ui/commands/workflowsCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,14 @@ function entry(overrides: Partial<WorkflowTask> = {}): WorkflowTask {
isBackgrounded: true,
abortController: new AbortController(),
currentPhase: null,
currentPhaseVisitId: null,
phases: [],
phaseVisits: [],
dispatches: [],
agentsDispatched: 0,
agentsCompleted: 0,
recentLogs: [],
events: [],
tokensSpent: 0,
tokenBudgetTotal: null,
perPhaseTokens: new Map<string | null, number>(),
Expand Down
6 changes: 5 additions & 1 deletion packages/cli/src/ui/commands/workflowsCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,14 @@ function snapshotToTask(s: WorkflowSnapshot): WorkflowTask {
meta: s.meta,
status: s.status,
currentPhase: null,
currentPhaseVisitId: null,
phases: s.phases ?? [],
phaseVisits: s.phaseVisits ?? [],
dispatches: s.dispatches ?? [],
agentsDispatched: s.agentsDispatched ?? 0,
agentsCompleted: s.agentsCompleted ?? 0,
recentLogs: s.recentLogs ?? [],
events: s.events ?? [],
tokensSpent: s.tokensSpent ?? 0,
tokenBudgetTotal: s.tokenBudgetTotal ?? null,
perPhaseTokens: new Map(s.perPhaseTokens ?? []),
Expand All @@ -48,7 +52,7 @@ function snapshotToTask(s: WorkflowSnapshot): WorkflowTask {
outputOffset: 0,
notified: true,
abortController: new AbortController(),
} as WorkflowTask;
};
}

/**
Expand Down
17 changes: 17 additions & 0 deletions packages/core/src/agents/runtime/workflow-journal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,23 @@ describe('WorkflowJournal', () => {
expect(replay.started.get('k1')).toHaveLength(1);
});

it('drain waits for fire-and-forget appends', async () => {
const j = new WorkflowJournal(path.join(dir, 'sub', 'journal.jsonl'));
void j.append({ type: 'started', key: 'k1', agentId: '1' });
void j.append({
type: 'result',
key: 'k1',
agentId: '1',
result: 'done',
});

await j.drain();

const replay = await j.load();
expect(replay.started.get('k1')).toHaveLength(1);
expect(replay.results.get('k1')?.result).toBe('done');
});

it('load on a missing file returns empty maps', async () => {
const j = new WorkflowJournal(path.join(dir, 'nope.jsonl'));
const replay = await j.load();
Expand Down
11 changes: 10 additions & 1 deletion packages/core/src/agents/runtime/workflow-journal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,8 @@ export function buildReplay(entries: JournalEntry[]): JournalReplay {
* failure must not fail the dispatch).
*/
export class WorkflowJournal {
private pending = Promise.resolve();

constructor(readonly path: string) {}

/** Load + parse all entries into replay maps. Empty maps if no file. */
Expand All @@ -183,6 +185,13 @@ export class WorkflowJournal {

/** Append one entry. Rejects only on I/O error (callers `.catch`). */
append(entry: JournalEntry): Promise<void> {
return writeLine(this.path, entry);
const operation = this.pending.then(() => writeLine(this.path, entry));
this.pending = operation.catch(() => undefined);
Comment on lines +199 to +200

Copy link
Copy Markdown
Collaborator

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 (mockRejectedValueOnce on fs.promises.appendFile), asserting the failing append rejects for its caller while the second append still writes and drain() resolves.

中文说明

延后至下一轮。仍计划:新增一个 journal 测试令首次 append 被拒绝(对 fs.promises.appendFile 使用 mockRejectedValueOnce),断言失败的 append 对调用方拒绝、第二次 append 仍写入、drain() 正常 resolve。

return operation;
}

/** Wait until every append issued so far has settled. */
drain(): Promise<void> {
return this.pending;
}
}
171 changes: 171 additions & 0 deletions packages/core/src/agents/runtime/workflow-orchestrator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,170 @@ describe('WorkflowOrchestrator', () => {
});
});

it('records dependency tails across sequential, parallel, and pipeline dispatches', async () => {

Copy link
Copy Markdown
Collaborator

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: an orchestrator test running workflow('child') (child issues one sequential and one in-parallel agent()), collecting dispatchQueued/dispatchStarted/dispatchSettled, asserting unique stable ids, correct dependsOn tails relative to pre-nesting dispatches, and settle events.

中文说明

延后至下一轮。仍计划:新增 orchestrator 测试,父脚本调用 workflow('child')(子级发出一个顺序与一个 parallel 内的 agent()),收集 dispatchQueued/dispatchStarted/dispatchSettled,断言唯一稳定 id、相对嵌套前 dispatch 的正确 dependsOn tails 与落定事件。

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}`,
Expand Down Expand Up @@ -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
Expand All @@ -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 () => {
Expand Down
Loading
Loading