Skip to content

Commit 51addc5

Browse files
authored
fix(terminal): use wall-clock duration for loop iterations with concurrent children (#4443)
1 parent 1166d82 commit 51addc5

2 files changed

Lines changed: 308 additions & 6 deletions

File tree

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.test.ts

Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,312 @@ describe('groupEntriesByExecution', () => {
482482
})
483483
})
484484

485+
describe('duration computation', () => {
486+
/**
487+
* Regression guard for the 18m → 20m → 22m bug.
488+
*
489+
* When a loop iteration contains a parallel block, the iteration's displayed
490+
* duration must be wall-clock (max(endedAt) − min(startedAt)), not the sum of
491+
* child durationMs. Summing over concurrent parallel branches over-counts time
492+
* and causes the displayed iteration duration to climb rapidly as each branch
493+
* resolves.
494+
*/
495+
it('loop iteration with concurrent parallel branches uses wall-clock duration', () => {
496+
const branches = 5
497+
const branchDurationMs = 110_000
498+
const loopIterStartMs = Date.UTC(2025, 0, 1, 0, 0, 0)
499+
const loopIterEndMs = loopIterStartMs + branchDurationMs
500+
501+
const entries: ConsoleEntry[] = []
502+
for (let branch = 0; branch < branches; branch++) {
503+
entries.push(
504+
makeEntry({
505+
blockId: 'function-1',
506+
blockName: 'Function 1',
507+
executionOrder: branch + 1,
508+
startedAt: new Date(loopIterStartMs).toISOString(),
509+
endedAt: new Date(loopIterEndMs).toISOString(),
510+
durationMs: branchDurationMs,
511+
iterationType: 'parallel',
512+
iterationCurrent: branch,
513+
iterationTotal: branches,
514+
iterationContainerId: 'parallel-1',
515+
parentIterations: [
516+
{
517+
iterationType: 'loop',
518+
iterationCurrent: 0,
519+
iterationTotal: 1,
520+
iterationContainerId: 'loop-1',
521+
},
522+
],
523+
})
524+
)
525+
}
526+
527+
const tree = buildEntryTree(entries)
528+
const loopSubflow = tree.find((n) => n.entry.blockType === 'loop')
529+
expect(loopSubflow).toBeDefined()
530+
531+
const iteration = loopSubflow!.children[0]
532+
expect(iteration.nodeType).toBe('iteration')
533+
expect(iteration.entry.durationMs).toBe(branchDurationMs)
534+
expect(iteration.entry.durationMs).toBeLessThan(branches * branchDurationMs)
535+
})
536+
537+
it('subflow container with concurrent children uses wall-clock duration', () => {
538+
const branches = 4
539+
const branchDurationMs = 60_000
540+
const startMs = Date.UTC(2025, 0, 1, 0, 0, 0)
541+
const endMs = startMs + branchDurationMs
542+
543+
const entries: ConsoleEntry[] = []
544+
for (let branch = 0; branch < branches; branch++) {
545+
entries.push(
546+
makeEntry({
547+
blockId: 'function-1',
548+
executionOrder: branch + 1,
549+
startedAt: new Date(startMs).toISOString(),
550+
endedAt: new Date(endMs).toISOString(),
551+
durationMs: branchDurationMs,
552+
iterationType: 'parallel',
553+
iterationCurrent: branch,
554+
iterationTotal: branches,
555+
iterationContainerId: 'parallel-1',
556+
})
557+
)
558+
}
559+
560+
const tree = buildEntryTree(entries)
561+
const subflow = tree.find((n) => n.entry.blockType === 'parallel')
562+
expect(subflow).toBeDefined()
563+
expect(subflow!.entry.durationMs).toBe(branchDurationMs)
564+
expect(subflow!.entry.durationMs).toBeLessThan(branches * branchDurationMs)
565+
})
566+
567+
it('sequential loop iteration uses wall-clock duration', () => {
568+
const blockStart = Date.UTC(2025, 0, 1, 0, 0, 0)
569+
const blockEnd = blockStart + 5_000
570+
571+
const entries: ConsoleEntry[] = [
572+
makeEntry({
573+
blockId: 'function-1',
574+
executionOrder: 1,
575+
startedAt: new Date(blockStart).toISOString(),
576+
endedAt: new Date(blockEnd).toISOString(),
577+
durationMs: 5_000,
578+
iterationType: 'loop',
579+
iterationCurrent: 0,
580+
iterationTotal: 1,
581+
iterationContainerId: 'loop-1',
582+
}),
583+
]
584+
585+
const tree = buildEntryTree(entries)
586+
const loop = tree.find((n) => n.entry.blockType === 'loop')
587+
expect(loop).toBeDefined()
588+
expect(loop!.children[0].entry.durationMs).toBe(5_000)
589+
})
590+
591+
it('parallel iteration uses wall-clock duration', () => {
592+
const start = Date.UTC(2025, 0, 1, 0, 0, 0)
593+
const end = start + 7_500
594+
595+
const entries: ConsoleEntry[] = [
596+
makeEntry({
597+
blockId: 'function-1',
598+
executionOrder: 1,
599+
startedAt: new Date(start).toISOString(),
600+
endedAt: new Date(end).toISOString(),
601+
durationMs: 7_500,
602+
iterationType: 'parallel',
603+
iterationCurrent: 0,
604+
iterationTotal: 1,
605+
iterationContainerId: 'parallel-1',
606+
}),
607+
]
608+
609+
const tree = buildEntryTree(entries)
610+
const parallel = tree.find((n) => n.entry.blockType === 'parallel')
611+
expect(parallel).toBeDefined()
612+
expect(parallel!.children[0].entry.durationMs).toBe(7_500)
613+
})
614+
615+
it('sequential loop with gaps between iterations: each iteration is wall-clock of its own children', () => {
616+
const entries: ConsoleEntry[] = []
617+
const iterStarts = [0, 10_000, 30_000]
618+
const blockDuration = 1_000
619+
const base = Date.UTC(2025, 0, 1, 0, 0, 0)
620+
621+
for (let i = 0; i < iterStarts.length; i++) {
622+
entries.push(
623+
makeEntry({
624+
blockId: 'function-1',
625+
executionOrder: i + 1,
626+
startedAt: new Date(base + iterStarts[i]).toISOString(),
627+
endedAt: new Date(base + iterStarts[i] + blockDuration).toISOString(),
628+
durationMs: blockDuration,
629+
iterationType: 'loop',
630+
iterationCurrent: i,
631+
iterationTotal: 3,
632+
iterationContainerId: 'loop-1',
633+
})
634+
)
635+
}
636+
637+
const tree = buildEntryTree(entries)
638+
const loop = tree.find((n) => n.entry.blockType === 'loop')!
639+
for (let i = 0; i < 3; i++) {
640+
expect(loop.children[i].entry.durationMs).toBe(blockDuration)
641+
}
642+
expect(loop.entry.durationMs).toBe(iterStarts[2] + blockDuration - iterStarts[0])
643+
})
644+
645+
it('loop-in-loop: outer iteration duration spans all inner iterations wall-clock', () => {
646+
const entries: ConsoleEntry[] = []
647+
const base = Date.UTC(2025, 0, 1, 0, 0, 0)
648+
const innerDuration = 2_000
649+
const innerCount = 3
650+
651+
for (let inner = 0; inner < innerCount; inner++) {
652+
const start = base + inner * innerDuration
653+
entries.push(
654+
makeEntry({
655+
blockId: 'function-1',
656+
executionOrder: inner + 1,
657+
startedAt: new Date(start).toISOString(),
658+
endedAt: new Date(start + innerDuration).toISOString(),
659+
durationMs: innerDuration,
660+
iterationType: 'loop',
661+
iterationCurrent: inner,
662+
iterationTotal: innerCount,
663+
iterationContainerId: 'inner-loop',
664+
parentIterations: [
665+
{
666+
iterationType: 'loop',
667+
iterationCurrent: 0,
668+
iterationTotal: 1,
669+
iterationContainerId: 'outer-loop',
670+
},
671+
],
672+
})
673+
)
674+
}
675+
676+
const tree = buildEntryTree(entries)
677+
const outerLoop = tree.find((n) => n.entry.blockType === 'loop')!
678+
const outerIter = outerLoop.children[0]
679+
expect(outerIter.entry.durationMs).toBe(innerCount * innerDuration)
680+
})
681+
682+
it('loop-in-parallel: each branch duration reflects its own loop wall-clock', () => {
683+
const entries: ConsoleEntry[] = []
684+
const base = Date.UTC(2025, 0, 1, 0, 0, 0)
685+
const innerDuration = 1_500
686+
const innerCount = 2
687+
const branches = 3
688+
689+
for (let branch = 0; branch < branches; branch++) {
690+
for (let inner = 0; inner < innerCount; inner++) {
691+
const start = base + inner * innerDuration
692+
entries.push(
693+
makeEntry({
694+
blockId: 'function-1',
695+
executionOrder: branch * innerCount + inner + 1,
696+
startedAt: new Date(start).toISOString(),
697+
endedAt: new Date(start + innerDuration).toISOString(),
698+
durationMs: innerDuration,
699+
iterationType: 'loop',
700+
iterationCurrent: inner,
701+
iterationTotal: innerCount,
702+
iterationContainerId: 'inner-loop',
703+
parentIterations: [
704+
{
705+
iterationType: 'parallel',
706+
iterationCurrent: branch,
707+
iterationTotal: branches,
708+
iterationContainerId: 'parallel-1',
709+
},
710+
],
711+
})
712+
)
713+
}
714+
}
715+
716+
const tree = buildEntryTree(entries)
717+
const parallelSubflow = tree.find((n) => n.entry.blockType === 'parallel')!
718+
expect(parallelSubflow.children).toHaveLength(branches)
719+
for (let branch = 0; branch < branches; branch++) {
720+
const branchNode = parallelSubflow.children[branch]
721+
expect(branchNode.entry.durationMs).toBe(innerCount * innerDuration)
722+
}
723+
expect(parallelSubflow.entry.durationMs).toBe(innerCount * innerDuration)
724+
})
725+
726+
it('single-block iteration: duration equals the block durationMs', () => {
727+
const start = Date.UTC(2025, 0, 1, 0, 0, 0)
728+
const blockDuration = 3_141
729+
730+
const entries: ConsoleEntry[] = [
731+
makeEntry({
732+
blockId: 'function-1',
733+
executionOrder: 1,
734+
startedAt: new Date(start).toISOString(),
735+
endedAt: new Date(start + blockDuration).toISOString(),
736+
durationMs: blockDuration,
737+
iterationType: 'loop',
738+
iterationCurrent: 0,
739+
iterationTotal: 1,
740+
iterationContainerId: 'loop-1',
741+
}),
742+
]
743+
744+
const tree = buildEntryTree(entries)
745+
const loop = tree.find((n) => n.entry.blockType === 'loop')!
746+
expect(loop.children[0].entry.durationMs).toBe(blockDuration)
747+
expect(loop.entry.durationMs).toBe(blockDuration)
748+
})
749+
750+
it('does not sum concurrent branch durations into iteration duration', () => {
751+
const branches = 20
752+
const branchDurationMs = 100_000
753+
const start = Date.UTC(2025, 0, 1, 0, 0, 0)
754+
755+
const entries: ConsoleEntry[] = []
756+
for (let branch = 0; branch < branches; branch++) {
757+
const branchStart = start + branch * 5
758+
entries.push(
759+
makeEntry({
760+
blockId: 'function-1',
761+
executionOrder: branch + 1,
762+
startedAt: new Date(branchStart).toISOString(),
763+
endedAt: new Date(branchStart + branchDurationMs).toISOString(),
764+
durationMs: branchDurationMs,
765+
iterationType: 'parallel',
766+
iterationCurrent: branch,
767+
iterationTotal: branches,
768+
iterationContainerId: 'parallel-1',
769+
parentIterations: [
770+
{
771+
iterationType: 'loop',
772+
iterationCurrent: 0,
773+
iterationTotal: 1,
774+
iterationContainerId: 'loop-1',
775+
},
776+
],
777+
})
778+
)
779+
}
780+
781+
const tree = buildEntryTree(entries)
782+
const loopSubflow = tree.find((n) => n.entry.blockType === 'loop')!
783+
const iteration = loopSubflow.children[0]
784+
785+
const wallClock = branchDurationMs + (branches - 1) * 5
786+
expect(iteration.entry.durationMs).toBe(wallClock)
787+
expect(iteration.entry.durationMs).toBeLessThan(branches * branchDurationMs)
788+
})
789+
})
790+
485791
describe('flattenVisibleExecutionRows', () => {
486792
it('only includes children for expanded nodes', () => {
487793
const childBlock = makeEntry({

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -393,9 +393,7 @@ export function buildEntryTree(entries: ConsoleEntry[], idPrefix = ''): EntryNod
393393
const subflowEndMs = Math.max(
394394
...allRelevantBlocks.map((b) => new Date(b.endedAt || b.timestamp).getTime())
395395
)
396-
const totalDuration = allRelevantBlocks.reduce((sum, b) => sum + (b.durationMs || 0), 0)
397-
const subflowDuration =
398-
iterationType === 'parallel' ? subflowEndMs - subflowStartMs : totalDuration
396+
const subflowDuration = subflowEndMs - subflowStartMs
399397

400398
const subflowExecutionOrder = Math.min(...allRelevantBlocks.map((b) => b.executionOrder))
401399
const metadataSource = allRelevantBlocks[0]
@@ -449,9 +447,7 @@ export function buildEntryTree(entries: ConsoleEntry[], idPrefix = ''): EntryNod
449447
const iterEndMs = Math.max(
450448
...allIterEntries.map((b) => new Date(b.endedAt || b.timestamp).getTime())
451449
)
452-
const iterDuration = allIterEntries.reduce((sum, b) => sum + (b.durationMs || 0), 0)
453-
const iterDisplayDuration =
454-
iterationType === 'parallel' ? iterEndMs - iterStartMs : iterDuration
450+
const iterDisplayDuration = iterEndMs - iterStartMs
455451

456452
const iterExecutionOrder = Math.min(...allIterEntries.map((b) => b.executionOrder))
457453
const iterMetadataSource = allIterEntries[0]

0 commit comments

Comments
 (0)