Skip to content

Commit 1a6b989

Browse files
committed
fix(log-stream): prevent duplicate log lines and optimize status handling
- Add deduplication logic for new log lines to avoid duplicates - Optimize status change handling to avoid unnecessary re-initialization - Use raw line count for incremental state consistency
1 parent 6648924 commit 1a6b989

1 file changed

Lines changed: 57 additions & 17 deletions

File tree

src/cli/tui/routes/workflow/hooks/useLogStream.ts

Lines changed: 57 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
* - Windowed line storage (memory efficient)
99
*/
1010

11-
import { createSignal, createEffect, onCleanup } from "solid-js"
11+
import { createSignal, createEffect, onCleanup, on } from "solid-js"
1212
import { AgentMonitorService } from "../../../../../agents/monitoring/monitor.js"
1313
import type { AgentRecord } from "../../../../../agents/monitoring/types.js"
1414
import { existsSync, statSync, openSync, readSync, closeSync, readFileSync } from "fs"
@@ -224,6 +224,38 @@ interface WindowedLinesState {
224224
startOffset: number
225225
}
226226

227+
/**
228+
* Deduplicate new lines by checking for overlap with current lines
229+
* Returns the portion of newLines that don't already exist at the end of current
230+
*/
231+
function deduplicateNewLines(currentLines: string[], newLines: string[]): string[] {
232+
if (currentLines.length === 0 || newLines.length === 0) {
233+
return newLines
234+
}
235+
236+
// Check for overlap: find if any prefix of newLines matches the suffix of currentLines
237+
// Start with larger potential overlaps and work down
238+
const maxOverlap = Math.min(currentLines.length, newLines.length)
239+
240+
for (let overlapSize = maxOverlap; overlapSize > 0; overlapSize--) {
241+
// Check if last 'overlapSize' lines of current match first 'overlapSize' of new
242+
let isMatch = true
243+
for (let i = 0; i < overlapSize; i++) {
244+
const currentIdx = currentLines.length - overlapSize + i
245+
if (currentLines[currentIdx] !== newLines[i]) {
246+
isMatch = false
247+
break
248+
}
249+
}
250+
if (isMatch) {
251+
logDebug('deduplicateNewLines: found overlap of %d lines, removing duplicates', overlapSize)
252+
return newLines.slice(overlapSize)
253+
}
254+
}
255+
256+
return newLines
257+
}
258+
227259
/**
228260
* Update windowed lines with new data, trimming from front if needed
229261
* @param skipTrim - If true, don't trim lines (used when user has scrolled away)
@@ -246,8 +278,16 @@ function updateWindowedLines(
246278
}
247279
}
248280

249-
// Append new lines
250-
const allLines = [...current.lines, ...newLines]
281+
// Deduplicate new lines before appending
282+
const dedupedNewLines = deduplicateNewLines(current.lines, newLines)
283+
284+
if (dedupedNewLines.length === 0) {
285+
// All new lines were duplicates
286+
return current
287+
}
288+
289+
// Append deduplicated new lines
290+
const allLines = [...current.lines, ...dedupedNewLines]
251291

252292
// Trim from the front if exceeding window (unless skipTrim is true)
253293
if (!skipTrim && allLines.length > maxWindow) {
@@ -388,11 +428,12 @@ export function useLogStream(
388428
}
389429
}
390430

391-
createEffect(() => {
392-
const agentId = opts.monitoringAgentId()
393-
const agentStatus = opts.agentStatus?.()
394-
395-
logDebug('Effect triggered, agentId=%s status=%s', agentId, agentStatus)
431+
// Use on() to explicitly track ONLY agentId changes, not status changes
432+
// Status changes are handled within the polling loop to avoid full re-initialization
433+
createEffect(on(
434+
() => opts.monitoringAgentId(),
435+
(agentId) => {
436+
logDebug('Effect triggered, agentId=%s (status changes handled in polling loop)', agentId)
396437

397438
if (agentId === undefined) {
398439
logDebug('No agentId, resetting state')
@@ -498,9 +539,10 @@ export function useLogStream(
498539
const thinking = extractLatestThinking(fileLines)
499540
const currentFileSize = getFileSize(logPath)
500541

501-
// Update incremental state
542+
// Update incremental state - use RAW line count for consistency
543+
// The incremental read function uses raw line counts to detect new lines
502544
incrementalState.lastFileSize = currentFileSize
503-
incrementalState.lastLineCount = filteredLines.length
545+
incrementalState.lastLineCount = fileLines.length
504546

505547
// Update windowed state
506548
const trimCount = Math.max(0, filteredLines.length - maxWindow)
@@ -722,12 +764,10 @@ export function useLogStream(
722764
// Check if we need to adjust polling interval
723765
const newStatus = opts.agentStatus?.()
724766
if (newStatus !== lastStatus) {
725-
// Immediate poll when transitioning TO running (from slow polling state)
726-
if ((newStatus === 'running' || newStatus === 'retrying') &&
727-
(lastStatus === 'paused' || lastStatus === 'awaiting' || lastStatus === 'pending' || lastStatus === 'delegated')) {
728-
logDebug('Status changed to running, triggering immediate poll')
729-
updateLogs(logPath)
730-
}
767+
// Status changed - just adjust the polling interval
768+
// Don't do an extra updateLogs here since we just did one above
769+
// The new polling interval will naturally handle faster updates
770+
logDebug('Status changed from %s to %s, adjusting poll interval', lastStatus, newStatus)
731771
lastStatus = newStatus
732772
updatePollingInterval(logPath, newStatus)
733773
}
@@ -751,7 +791,7 @@ export function useLogStream(
751791
clearTimeout(graceTimeout)
752792
}
753793
})
754-
})
794+
}))
755795

756796
return {
757797
get lines() {

0 commit comments

Comments
 (0)