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
6 changes: 6 additions & 0 deletions src/conductor/engine/workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -1152,6 +1152,12 @@ async def _check_interrupt(self, current_agent_name: str) -> InterruptResult | N
if self._interrupt_event is None or not self._interrupt_event.is_set():
return None

# Clearing here is safe because is_set() above is the only consumer
# within this method; the unwind path raised below does NOT re-check
# the event before reaching the parent engine. If a future change
# adds a between-agent recheck after the InterruptError catch, this
# clear must move to AFTER the raise to preserve interrupt visibility.
# Issue #145 (S2).
self._interrupt_event.clear()

# In web mode, the interrupt was already handled at the provider level
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { AgentDetail } from './AgentDetail';
import { ScriptDetail } from './ScriptDetail';
import { GateDetail } from './GateDetail';
import { GroupDetail } from './GroupDetail';
import { DialogDetail } from './DialogDetail';
import { DialogEngagementPrompt } from './DialogEngagementPrompt';
import { SubworkflowDetail } from './SubworkflowDetail';
import { cn } from '@/lib/utils';
Expand Down
49 changes: 42 additions & 7 deletions src/conductor/web/frontend/src/components/detail/GroupDetail.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { useState } from 'react';
import { ChevronDown, ChevronRight, Loader2 } from 'lucide-react';
import { ChevronDown, ChevronRight, Loader2, Layers } from 'lucide-react';
import { MetadataGrid } from './MetadataGrid';
import { OutputViewer } from './OutputViewer';
import { ActivityStream } from './ActivityStream';
import type { NodeData, ForEachItemData } from '@/stores/workflow-store';
import { NODE_STATUS_HEX } from '@/lib/constants';
import { formatElapsed, formatCost, formatTokens } from '@/lib/utils';
import { useViewedGroupProgress } from '@/hooks/use-viewed-context';
import { useViewedGroupProgress, useViewedSubworkflowContexts } from '@/hooks/use-viewed-context';
import { useWorkflowStore } from '@/stores/workflow-store';
import type { NodeStatus } from '@/lib/constants';

interface GroupDetailProps {
Expand Down Expand Up @@ -92,7 +93,7 @@ export function GroupDetail({ node }: GroupDetailProps) {
{showItems && (
<div className="space-y-1">
{forEachItems.map((item) => (
<ForEachItemRow key={`${item.key}-${item.index}`} item={item} />
<ForEachItemRow key={`${item.key}-${item.index}`} groupName={node.name} item={item} />
))}
</div>
)}
Expand All @@ -103,14 +104,25 @@ export function GroupDetail({ node }: GroupDetailProps) {
}

const ITEM_STATUS_COLORS: Record<ForEachItemData['status'], string> = {
running: NODE_STATUS_HEX.running,
completed: NODE_STATUS_HEX.completed,
failed: NODE_STATUS_HEX.failed,
running: NODE_STATUS_HEX.running!,
completed: NODE_STATUS_HEX.completed!,
failed: NODE_STATUS_HEX.failed!,
};

function ForEachItemRow({ item }: { item: ForEachItemData }) {
function ForEachItemRow({ groupName, item }: { groupName: string; item: ForEachItemData }) {
const [expanded, setExpanded] = useState(item.status === 'running');
const color = ITEM_STATUS_COLORS[item.status];
const subworkflowContexts = useViewedSubworkflowContexts();
const navigateIntoSubworkflow = useWorkflowStore((s) => s.navigateIntoSubworkflow);

// For-each iterations of a workflow-type agent get their own
// SubworkflowContext, keyed by `${groupName}[${item.key}]`. When one
// exists, surface a "Dive In" affordance so the iteration's nested
// workflow is reachable from the group detail panel (parity with the
// single-iteration WorkflowNode dive-in).
const iterationSlotKey = `${groupName}[${item.key}]`;
const iterationContext = subworkflowContexts.find((c) => c.slotKey === iterationSlotKey);
const canDiveIn = !!iterationContext;

const hasDetails = !!(
item.prompt ||
Expand Down Expand Up @@ -172,6 +184,29 @@ function ForEachItemRow({ item }: { item: ForEachItemData }) {
>
{item.status}
</span>

{/* Dive-in button: navigate into this iteration's sub-workflow context */}
{canDiveIn && (
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
navigateIntoSubworkflow(iterationSlotKey);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.stopPropagation();
e.preventDefault();
navigateIntoSubworkflow(iterationSlotKey);
}
}}
title={`Dive into ${iterationContext?.workflowName ?? iterationSlotKey}`}
className="flex-shrink-0 p-1 rounded hover:bg-[var(--accent)]/20 hover:text-[var(--accent)] transition-colors text-[var(--text-muted)] cursor-pointer"
>
<Layers className="w-3 h-3" />
</span>
)}
</button>

{/* Expanded detail panel */}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { Handle, Position, type NodeProps } from '@xyflow/react';
import { ShieldCheck } from 'lucide-react';
import { cn } from '@/lib/utils';
import { NODE_STATUS_HEX } from '@/lib/constants';
import { useWorkflowStore } from '@/stores/workflow-store';
import { useViewedNodes } from '@/hooks/use-viewed-context';
import { NodeTooltip } from './NodeTooltip';
import type { GraphNodeData } from './graph-layout';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,9 +196,9 @@ export function WorkflowGraph() {
}, [selectNode]);

// Minimap node color
const minimapNodeColor = useCallback((node: Node) => {
const minimapNodeColor = useCallback((node: Node): string => {
const status = ((node.data as GraphNodeData)?.status || 'pending') as NodeStatus;
return NODE_STATUS_HEX[status] || NODE_STATUS_HEX.pending;
return NODE_STATUS_HEX[status] ?? NODE_STATUS_HEX.pending ?? '#6b7280';
}, []);

// Update selected state on nodes
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Layers, ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { NODE_STATUS_HEX } from '@/lib/constants';
import { useWorkflowStore } from '@/stores/workflow-store';
import { useViewedNodes, useViewedSubworkflowContexts } from '@/hooks/use-viewed-context';
import { useViewedSubworkflowContexts } from '@/hooks/use-viewed-context';
import { NodeTooltip } from './NodeTooltip';
import type { GraphNodeData } from './graph-layout';
import type { NodeStatus } from '@/lib/constants';
Expand Down
23 changes: 22 additions & 1 deletion src/conductor/web/frontend/src/components/graph/graph-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,13 +192,34 @@ export function buildGraphElements(
if (node.parentId) childToParent.set(node.id, node.parentId);
}

// Dedupe edges by (from, to). YAML route lists frequently combine a
// conditional route with a catch-all to the same target (e.g. an "if X
// then $end" plus a bare "to: $end" fallback). The engine evaluates
// routes in order and the first match wins, so multiple entries between
// the same pair represent ONE visual transition, not parallel edges.
// Without deduping, dagre lays them as two overlapping/diverging edges
// which render as phantom strands going off-canvas.
// When collapsing routes with different `when` conditions, the label is
// cleared to avoid implying only one condition applies.
const seenPairs = new Map<string, { when: string | undefined; idx: number }>();
for (const r of routes) {
const from = childToParent.get(r.from) ?? r.from;
const to = childToParent.get(r.to) ?? r.to;
if (!nodeIds.has(from) || !nodeIds.has(to)) continue;
// Skip self-loops created by remapping (e.g. group member → group member)
if (from === to) continue;
const edgeId = `${from}->${to}${r.when ? `[${r.when}]` : ''}`;
const pairKey = `${from}->${to}`;
const existing = seenPairs.get(pairKey);
if (existing) {
// Multiple distinct conditions collapse — drop the label.
if (existing.when !== r.when) {
flowEdges[existing.idx]!.data = { when: undefined };
}
continue;
}
const idx = flowEdges.length;
seenPairs.set(pairKey, { when: r.when, idx });
const edgeId = `${pairKey}${r.when ? `[${r.when}]` : ''}`;

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.

Nit/Suggestion — Edge dedupe is lossy on when metadata.

The surviving edge keeps data: { when: r.when } from the first route only. For the documented repro (conditional + bare catch-all) that's fine — the second route has no when. But for two-conditional routes to the same target (e.g. when: success then when: error), the visual edge will display only [success] even though the actual graph means "go to $end on either condition."

Consider clearing when (or marking it '*' / 'multiple') when collapsing routes with non-equivalent conditions, e.g.:

const existing = seenPairs.get(pairKey);
if (existing && existing !== r.when) {
  // Multiple distinct conditions collapse — drop the label.
  flowEdges[existing.idx].data = { when: undefined };
  continue;
}

Minor UX nit, not blocking.

flowEdges.push({
id: edgeId,
source: from,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { JSX } from 'react';
import { useEffect, useMemo, useState, useCallback } from 'react';
import { X, ChevronRight, ChevronDown } from 'lucide-react';

Expand Down Expand Up @@ -46,7 +47,7 @@ function highlightLine(line: string): JSX.Element | string {
{indent}{dash ?? ''}
<span className="text-sky-400">{key}</span>
<span className="text-[var(--text-muted)]">{colon}</span>
{formatValue(value)}
{formatValue(value ?? '')}
</span>
);
}
Expand All @@ -57,7 +58,7 @@ function highlightLine(line: string): JSX.Element | string {
const [, indent, dash, value] = listMatch;
return (
<span>
{indent}<span className="text-[var(--text-muted)]">{dash}</span>{formatValue(value)}
{indent}<span className="text-[var(--text-muted)]">{dash}</span>{formatValue(value ?? '')}
</span>
);
}
Expand Down
2 changes: 1 addition & 1 deletion src/conductor/web/frontend/src/hooks/use-viewed-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*/
import { useMemo } from 'react';
import { useWorkflowStore } from '@/stores/workflow-store';
import type { NodeData, GroupProgress, HighlightedEdge, SubworkflowContext, WorkflowAgent, RouteEdge, ParallelGroup, ForEachGroup } from '@/stores/workflow-store';
import type { NodeData, GroupProgress, HighlightedEdge, SubworkflowContext } from '@/stores/workflow-store';

/** Resolve a SubworkflowContext from a path of indices. */
function resolveCtx(contexts: SubworkflowContext[], path: number[]): SubworkflowContext | null {
Expand Down
6 changes: 6 additions & 0 deletions src/conductor/web/frontend/src/stores/workflow-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,12 @@ function resolveContext(contexts: SubworkflowContext[], path: number[]): Subwork
* Walk the subworkflow context tree by slot keys, returning the index path
* (numeric, for use with resolveContext) and the resolved context.
*
* For each slot, matches the newest matching context to support re-runs /
* iteration loops where the same slot key appears multiple times. Note the
* consequence: older iterations of the same slot become unreachable via this
* path resolver — late-arriving events targeting them must use the index
* path captured at the time the iteration was active. Issue #145 (S3).
*
* Returns null if any segment cannot be matched.
*/
function resolveSlotPath(
Expand Down
2 changes: 1 addition & 1 deletion src/conductor/web/frontend/tsconfig.tsbuildinfo
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/components/detail/activitystream.tsx","./src/components/detail/agentdetail.tsx","./src/components/detail/detailpanel.tsx","./src/components/detail/gatedetail.tsx","./src/components/detail/groupdetail.tsx","./src/components/detail/metadatagrid.tsx","./src/components/detail/outputviewer.tsx","./src/components/detail/scriptdetail.tsx","./src/components/detail/subworkflowdetail.tsx","./src/components/graph/agentnode.tsx","./src/components/graph/animatededge.tsx","./src/components/graph/egressnode.tsx","./src/components/graph/endnode.tsx","./src/components/graph/gatenode.tsx","./src/components/graph/groupnode.tsx","./src/components/graph/ingressnode.tsx","./src/components/graph/nodetooltip.tsx","./src/components/graph/scriptnode.tsx","./src/components/graph/startnode.tsx","./src/components/graph/workflowgraph.tsx","./src/components/graph/workflownode.tsx","./src/components/graph/graph-layout.ts","./src/components/layout/breadcrumbbar.tsx","./src/components/layout/errorbanner.tsx","./src/components/layout/header.tsx","./src/components/layout/outputpane.tsx","./src/components/layout/replaybar.tsx","./src/components/layout/resizablelayout.tsx","./src/components/layout/statusbar.tsx","./src/components/layout/yamlviewer.tsx","./src/hooks/use-deep-link.ts","./src/hooks/use-elapsed-timer.ts","./src/hooks/use-replay.ts","./src/hooks/use-viewed-context.ts","./src/hooks/use-websocket.ts","./src/lib/constants.ts","./src/lib/utils.ts","./src/stores/workflow-store.ts","./src/types/events.ts"],"errors":true,"version":"5.9.3"}
{"root":["./src/app.tsx","./src/main.tsx","./src/components/detail/activitystream.tsx","./src/components/detail/agentdetail.tsx","./src/components/detail/detailpanel.tsx","./src/components/detail/dialogdetail.tsx","./src/components/detail/dialogengagementprompt.tsx","./src/components/detail/dialogoverlay.tsx","./src/components/detail/gatedetail.tsx","./src/components/detail/groupdetail.tsx","./src/components/detail/metadatagrid.tsx","./src/components/detail/outputviewer.tsx","./src/components/detail/scriptdetail.tsx","./src/components/detail/subworkflowdetail.tsx","./src/components/graph/agentnode.tsx","./src/components/graph/animatededge.tsx","./src/components/graph/egressnode.tsx","./src/components/graph/endnode.tsx","./src/components/graph/gatenode.tsx","./src/components/graph/groupnode.tsx","./src/components/graph/ingressnode.tsx","./src/components/graph/nodetooltip.tsx","./src/components/graph/scriptnode.tsx","./src/components/graph/startnode.tsx","./src/components/graph/workflowgraph.tsx","./src/components/graph/workflownode.tsx","./src/components/graph/graph-layout.ts","./src/components/layout/breadcrumbbar.tsx","./src/components/layout/errorbanner.tsx","./src/components/layout/header.tsx","./src/components/layout/outputpane.tsx","./src/components/layout/replaybar.tsx","./src/components/layout/resizablelayout.tsx","./src/components/layout/statusbar.tsx","./src/components/layout/yamlviewer.tsx","./src/hooks/use-deep-link.ts","./src/hooks/use-elapsed-timer.ts","./src/hooks/use-replay.ts","./src/hooks/use-viewed-context.ts","./src/hooks/use-websocket.ts","./src/lib/constants.ts","./src/lib/utils.ts","./src/stores/workflow-store.ts","./src/types/events.ts"],"version":"5.9.3"}
27 changes: 15 additions & 12 deletions src/conductor/web/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -443,24 +443,25 @@ def _is_proactor_shutdown_race(self, context: dict[str, Any]) -> bool:
Returns True only when all of:
- The exception is ``AssertionError``
- The uvicorn server is in shutdown state (``should_exit`` is set)
- The traceback (if available) originates from asyncio internals
- The traceback is present and the deepest frame originates from
asyncio internals
"""
exc = context.get("exception")
if not isinstance(exc, AssertionError):
return False
if self._server is None or not getattr(self._server, "should_exit", False):
return False
# Extra safety: check traceback originates from asyncio, not user code
# Require an asyncio traceback frame so unrelated AssertionErrors
# raised during shutdown (e.g., from a workflow callback finishing
# late) propagate to the default handler instead of being silently
# swallowed. Issue #145 (I3).
import traceback as tb_mod

tb = exc.__traceback__
if tb is not None:
frames = tb_mod.extract_tb(tb)
if frames and "asyncio" in frames[-1].filename:
return True
# If no traceback but server is shutting down, still suppress —
# the only known source of AssertionError during shutdown is this race.
return True
if tb is None:
return False
frames = tb_mod.extract_tb(tb)
return bool(frames) and "asyncio" in frames[-1].filename

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.

_guarded_serve retains the same permissive AssertionError handling that was just tightened here.

Issue #145 calls out both silent-swallow sites — _loop_exception_handler and _guarded_serve (lines 443-458 below). The wrapper still catches any AssertionError whenever should_exit is set, with no traceback inspection:

except AssertionError:
    if self._server is not None and getattr(self._server, "should_exit", False):
        logger.debug(...)  # Suppressed silently
    else:
        raise

Suggested options:

  • Apply the same asyncio-frame check inline in the except, or
  • Refactor: synthesize a context dict from the exception and call self._is_proactor_shutdown_race(ctx) so both sites share one gate.


def _loop_exception_handler(
self, loop: asyncio.AbstractEventLoop, context: dict[str, Any]
Expand All @@ -483,12 +484,14 @@ async def _guarded_serve(self) -> None:

If ``serve()`` itself raises ``AssertionError`` during shutdown
(rather than the exception surfacing through a callback), this
wrapper suppresses it.
wrapper applies the same asyncio-frame gate used in
``_loop_exception_handler`` to avoid swallowing unrelated errors.
"""
try:
await self._server.serve()
except AssertionError:
if self._server is not None and getattr(self._server, "should_exit", False):
except AssertionError as exc:
ctx: dict[str, Any] = {"exception": exc}
if self._is_proactor_shutdown_race(ctx):
logger.debug(
"Suppressed proactor accept-loop AssertionError during server shutdown"
)
Expand Down
1 change: 0 additions & 1 deletion src/conductor/web/static/assets/index-Bj4GrM3A.css

This file was deleted.

1 change: 1 addition & 0 deletions src/conductor/web/static/assets/index-RVgkHYYd.css

Large diffs are not rendered by default.

Loading
Loading