Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
125 changes: 125 additions & 0 deletions src/conductor/web/frontend/src/components/graph/graph-layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,91 @@ function seedNestedSubworkflows(): void {
);
}

/**
* Reproduces a loop-back re-invocation of the same sequential subworkflow
* (issue #361): `sub_agent` runs to completion once, then a route sends
* execution back through it a second time. This produces two sibling
* `SubworkflowContext`s sharing `slotKey: 'sub_agent'` — index 0 (completed)
* and index 1 (running) — so slot-key resolution must pick the newest
* (index 1), not the first, match.
*/
function seedRootWithLoopedBackSubworkflow(): void {
const { processEvent } = useWorkflowStore.getState();

processEvent(
event('workflow_started', {
name: 'root',
agents: [{ name: 'planner' }, { name: 'sub_agent', type: 'workflow' }],
routes: [
{ from: 'planner', to: 'sub_agent' },
{ from: 'sub_agent', to: '$end' },
],
parallel_groups: [],
for_each_groups: [],
entry_point: 'planner',
}),
);

// First invocation: starts, runs, completes.
processEvent(
event('subworkflow_started', {
agent_name: 'sub_agent',
workflow: 'sub.yaml',
iteration: 1,
slot_key: 'sub_agent',
parent_path: [],
}),
);
processEvent(
event('workflow_started', {
name: 'child-workflow',
agents: [{ name: 'childA' }],
routes: [{ from: 'childA', to: '$end' }],
parallel_groups: [],
for_each_groups: [],
entry_point: 'childA',
subworkflow_path: ['sub_agent'],
}),
);
processEvent(
event('workflow_completed', {
output: {},
subworkflow_path: ['sub_agent'],
}),
);
processEvent(
event('subworkflow_completed', {
agent_name: 'sub_agent',
elapsed: 1.0,
parent_path: [],
}),
);

// Loop-back: a route sends execution through `sub_agent` a second time,
// creating a new sibling context with the same slotKey.
processEvent(
event('subworkflow_started', {
agent_name: 'sub_agent',
workflow: 'sub.yaml',
iteration: 2,
slot_key: 'sub_agent',
parent_path: [],
}),
);
processEvent(
event('workflow_started', {
name: 'child-workflow',
agents: [{ name: 'childA' }],
routes: [{ from: 'childA', to: '$end' }],
parallel_groups: [],
for_each_groups: [],
entry_point: 'childA',
subworkflow_path: ['sub_agent'],
}),
);
processEvent(event('agent_started', { agent_name: 'childA', iteration: 1, subworkflow_path: ['sub_agent'] }));
}

/**
* Dispatch a root workflow with a `for_each`-of-workflow group (`batch`) that
* has fanned out into `count` started iterations, each its own child
Expand Down Expand Up @@ -508,6 +593,36 @@ describe('buildGraphElements — inline subworkflow expansion', () => {
expect(ingress?.type).toBe('ingressNode');
expect(ingress?.data.parentAgent).toBe('sub_agent');
});

it('tracks the newest invocation after a loop-back re-invocation (issue #361)', () => {
seedRootWithLoopedBackSubworkflow();
const s = useWorkflowStore.getState();
expect(s.subworkflowContexts).toHaveLength(2);
expect(s.subworkflowContexts[0]!.status).toBe('completed');
expect(s.subworkflowContexts[1]!.status).toBe('running');

// Collapsed: childContextKey must point at the live (index 1) context,
// not the stale completed (index 0) one. The pill's own status (sourced
// from ctx.nodes['sub_agent'], separate from childContextKey resolution)
// should agree that the subworkflow is live.
const collapsed = buildGraphElements(rootBase(), [], new Set());
const wfCollapsed = collapsed.nodes.find((n) => n.id === nodeKey([], 'sub_agent'));
expect(wfCollapsed!.data.childContextKey).toBe(contextKey([1]));
expect(wfCollapsed!.data.status).toBe('running');

// Expanded: the inline child DAG embeds the newest (running) context's
// agents, not the stale first invocation.
const expanded = new Set([contextKey([1])]);
const { nodes } = buildGraphElements(rootBase(), [], expanded);
const container = nodes.find((n) => n.id === nodeKey([], 'sub_agent'));
expect(container!.data.expanded).toBe(true);
const childA = nodes.find((n) => n.id === nodeKey([1], 'childA'));
expect(childA).toBeDefined();
expect(childA!.data.contextPath).toEqual([1]);
expect(childA!.data.status).toBe('running');
// The stale first invocation's nodes are not rendered inline.
expect(nodes.some((n) => n.id === nodeKey([0], 'childA'))).toBe(false);
});
});

describe('collectExpandableContextKeys', () => {
Expand Down Expand Up @@ -578,6 +693,16 @@ describe('collectExpandableContextKeys', () => {
contextKey([0, 0]),
]);
});

it('resolves to the newest context after a loop-back re-invocation (issue #361)', () => {
seedRootWithLoopedBackSubworkflow();
const s = useWorkflowStore.getState();
expect(s.subworkflowContexts).toHaveLength(2);
// Must key off index 1 (the live re-invocation), not index 0 (stale).
expect(collectExpandableContextKeys(s.agents, s.subworkflowContexts, [])).toEqual([
contextKey([1]),
]);
});
});

describe('expansionKeysForContextPath', () => {
Expand Down
24 changes: 21 additions & 3 deletions src/conductor/web/frontend/src/components/graph/graph-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,15 @@ export function collectExpandableContextKeys(
): void => {
for (const a of ctxAgents) {
if ((a.type || 'agent') !== 'workflow') continue;
const childIdx = ctxChildren.findIndex((c) => c.slotKey === a.name);
// Match newest-first: a loop-back route can re-invoke the same
// subworkflow, appending another child with the same slotKey (issue #361).
let childIdx = -1;
for (let i = ctxChildren.length - 1; i >= 0; i--) {
if (ctxChildren[i]!.slotKey === a.name) {
childIdx = i;
break;
}
}
if (childIdx < 0) continue;
const child = ctxChildren[childIdx]!;
if (child.agents.length === 0) continue;
Expand Down Expand Up @@ -489,8 +497,18 @@ function layoutContext(
if (nodeType === 'workflow') {
// Sequential subworkflow: slotKey === agent name. Locate its child
// context so the node can advertise a stable expansion key and, when
// expanded, render the child DAG inline as a container.
const childIdx = ctx.children.findIndex((c) => c.slotKey === a.name);
// expanded, render the child DAG inline as a container. Match
// newest-first: a loop-back route can re-invoke the same subworkflow,
// appending a sibling child with the same slotKey (issue #361) — same
// precedent as findChildContext/resolveSlotPath (workflow-store.ts) and
// resolveSubworkflowPath (use-deep-link.ts), issue #145.
let childIdx = -1;
for (let i = ctx.children.length - 1; i >= 0; i--) {
if (ctx.children[i]!.slotKey === a.name) {
childIdx = i;
break;
}
}
const child = childIdx >= 0 ? ctx.children[childIdx] : undefined;
const childKey = childIdx >= 0 ? contextKey([...absPath, childIdx]) : undefined;
const canExpand = !!child && child.agents.length > 0;
Expand Down

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/conductor/web/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Conductor Dashboard</title>
<script type="module" crossorigin src="/assets/index-DT3gmBmg.js"></script>
<script type="module" crossorigin src="/assets/index-CZ10hUnS.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CBt0mC9n.css">
</head>
<body>
Expand Down
Loading