Skip to content

Fix Gemini thinking blocks, resync reliability, and startup UX - #58

Merged
wesm merged 45 commits into
mainfrom
nicer-gemini-thinking
Feb 26, 2026
Merged

Fix Gemini thinking blocks, resync reliability, and startup UX#58
wesm merged 45 commits into
mainfrom
nicer-gemini-thinking

Conversation

@wesm

@wesm wesm commented Feb 26, 2026

Copy link
Copy Markdown
Member

Summary

Gemini thinking blocks

  • Two-pass thinking regex: Split THINKING_RE into THINKING_MARKED_RE (for [/Thinking]-delimited blocks, matched first) and THINKING_LEGACY_RE (fallback). Prevents fallback delimiters from truncating marked blocks.
  • Thinking end markers: All parsers (Claude, Gemini, OpenCode) emit [/Thinking] end markers.
  • Gemini thinking format: Parts joined with \n\n, ordered chronologically (thinking, content, tools).
  • Merge consecutive thinking blocks: Multiple Gemini thoughts collapse into a single collapsible block.
  • Thinking-only filter fix: Messages with thinking + response text are no longer hidden by the showThinking toggle.

Session count consistency

  • Aligned counts across views: Session list, analytics summary, and status bar all use the same root-session filter (message_count > 0 AND relationship_type NOT IN ('subagent', 'fork')).
  • Full numbers in dashboard: Removed K/M abbreviation, always shows full number with comma separators.
  • Default date range: Analytics defaults to all-time instead of 1 year.
  • Go HTTP integration test: TestSessionCountConsistency seeds mixed session types and asserts all three endpoints agree.
  • E2e Playwright test: session-count-consistency.spec.ts verifies the same invariant through the full UI stack, asserting exact expected count.

Resync reliability

  • Fresh database build: ResyncAll builds a new DB from scratch and swaps atomically, instead of in-place mutation.
  • Atomic DB connections: atomic.Pointer[sql.DB] for reader/writer so HTTP handlers don't race with connection swaps.
  • Windows-safe swap: Close connections before rename to avoid mandatory file locking failures.
  • Empty-discovery guard: Abort resync when file discovery returns zero but old DB has file-backed sessions. Uses FileBackedSessionCount (excludes OpenCode) so OpenCode-only datasets aren't blocked, while mixed-source data loss is prevented.
  • Insights preservation: Abort swap when CopyInsightsFrom fails instead of silently dropping insights.
  • Failure tracking: Abort when failures exceed successful syncs.
  • FTS rebuild: Drop and rebuild FTS index during resync.
  • Sync serialization: SyncPaths and SyncSingleSession serialized with syncMu.

E2e test isolation

  • All agent directories (Claude, Codex, Copilot, Gemini, OpenCode) point to empty dirs in e2e-server.sh, preventing real session data from leaking into tests.
  • Test fixture seeds subagent, fork, and empty sessions alongside root sessions.

Other

  • Session ID for all agents: Gemini, OpenCode, and Copilot sessions show the copyable session ID.
  • Clean startup logging: log.Printf goes to ~/.agentsview/debug.log only. Missing directory warnings stay on stderr.
  • Debug log management: Truncate debug.log on startup when >10MB.
  • Windows test fix: Close log file before TempDir cleanup to avoid mandatory file locking.

Fixes #56

🤖 Generated with Claude Code

wesm and others added 2 commits February 26, 2026 07:05
Change thinking format from [Thinking: subject] to [Thinking] with
subject as the first line of content. This matches the frontend
regex that renders collapsible thinking blocks, instead of showing
thinking as plain inline text.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
writeMessages() assumed session files are append-only and only
inserted messages with ordinals greater than the current max. During
a full resync (triggered by parser changes), existing messages were
never updated because ordinals hadn't grown. This meant parser fixes
(like the thinking block format change) had no effect on already-synced
sessions even after a full resync.

Thread a forceReplace flag from ResyncAll through to writeBatch so it
uses writeSessionFull (delete+reinsert) instead of the incremental
append path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@roborev-ci

roborev-ci Bot commented Feb 26, 2026

Copy link
Copy Markdown

roborev: Combined Review (db5588c)

Verdict: All reviewers agree the code is clean.

No medium, high, or critical severity issues were found by any
of the reviewing agents.


Synthesized from 4 reviews (agents: codex, gemini | types: default, security)

writeSessionFull does DELETE+INSERT on both the messages table and the
FTS5 index for every session. With 10K+ sessions this makes ResyncAll
effectively hang. Add a cheap content comparison (message count + total
content length) that skips the expensive replace when content hasn't
changed. Only sessions actually affected by a parser change get the
full FTS5 rewrite.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@roborev-ci

roborev-ci Bot commented Feb 26, 2026

Copy link
Copy Markdown

roborev: Combined Review (eaf7d9d)

Review Summary: The ResyncAll optimization contains logic flaws in its change detection mechanism that risk leaving stale data in the
database and failing on non-ASCII text.

High

Lossy equality check can miss real message changes
Files: internal/sync/engine.go:954-967, internal/db/messages.go:325-342
Description:
replaceIfChanged() treats a session as unchanged when only the message count and total content length match. This is a highly collision-prone heuristic. If a session file is modified such that the text changes but the total character count remains exactly the same (e.g., replacing "hte" with "the"), or if there
are metadata-only parser changes, the engine will mistakenly assume the content is unchanged. This causes ResyncAll to silently skip updating the database, resulting in stale data persisting.
Suggested Remediation: Replace the length-based comparison with a robust change detection method. Use a stronger fingerprint (e.g.,
a fast hash like SHA-256 or xxHash of the concatenated message contents/fields) or perform a direct string equality check before deciding to skip the update.

Medium

Length-unit mismatch causes false "changed" detection for multi-byte characters
Files: internal/db/ messages.go:333, internal/sync/engine.go:954-963
Description: The database query uses SQLite's LENGTH(content) function, which returns the number of characters for TEXT. However, the Go code compares this against len(string ), which returns the number of bytes. For any session containing multi-byte UTF-8 characters (like emojis or non-English text), these length values will always mismatch even if the content is identical. This defeats the optimization entirely, silently falling back to a full FTS5 delete+reinsert.
Suggested
Remediation:
Compare like-for-like units. Cast the column to a BLOB in SQLite to correctly count bytes: COALESCE(SUM(LENGTH(CAST(content AS BLOB))), 0).


Synthesized from 4 reviews (agents: codex, gemini | types: default, security)

wesm and others added 7 commits February 26, 2026 07:23
Replace the replaceIfChanged content-length heuristic with a cleaner
approach: drop the FTS triggers and table before the resync, do all
message replacements without per-row FTS overhead, then rebuild the
index in one pass from the content table afterward.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The unconditional log.Printf in syncOneOpenCode printed with Go's
default timestamp directly after the carriage-return progress line,
producing garbled output. The same information is already logged
behind the verbose flag in syncAllLocked.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
SyncPaths (called by file watcher) and SyncSingleSession (called by
API handlers) were not holding syncMu, so they could run concurrently
with ResyncAll. This caused DB writer contention and, with the FTS
drop+rebuild approach, could insert messages while triggers were
absent. Both now acquire syncMu to serialize with all other sync
operations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…esync

Add Warnings field to SyncStats so ResyncAll can report FTS rebuild
failures to the caller instead of silently leaving search broken.
Add FTS search verification to the resync integration test.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… tools)

Thoughts happen before the response text, so extract them first.
Previously thinking blocks appeared after the assistant's response
which was confusing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Update syncMu comment to reflect it now serializes all sync operations.
Re-store lastSyncStats after ResyncAll appends warnings so
/api/v1/sync/status includes them.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
writeSessionFull called ReplaceSessionMessages per session, each a
separate transaction. With batchSize=100, that's 100 transactions
of DELETE+INSERT before progress updates, causing the resync to
appear hung.

Replace with a single-transaction bulk delete of all messages in the
batch, then use the normal writeMessages INSERT path (which sees
maxOrd=-1 and inserts all). This makes each batch: 1 bulk delete tx
+ N fast insert txs instead of N full replace txs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@roborev-ci

roborev-ci Bot commented Feb 26, 2026

Copy link
Copy Markdown

roborev: Combined Review (affb2ae)

Summary Verdict: The code changes look generally solid, but there is
one high-severity issue regarding potential data loss during partial failures in the new force-replace sync path.

High

Non-atomic force-replace path can lose session messages on partial failures

  • Files: [engine.go](/home/roborev/.roborev/clones/
    wesm/agentsview/internal/sync/engine.go#L910), messages.go
  • Description: Resync All with forceReplace=true bulk-deletes messages for the batch, commits that delete, then writes sessions/messages one-by-one. If UpsertSession or InsertMessages fails for any session, old rows are already gone and the code only logs/continues. This can
    leave sessions empty/partial, and unchanged files may then be skipped on later syncs.
  • Suggested fix: Keep per-session delete+insert atomic (e.g., use ReplaceSessionMessages per session while FTS triggers are dropped), or perform delete+reinsert in one transaction per session and
    retry/report failures.

Synthesized from 4 reviews (agents: codex, gemini | types: default, security)

wesm and others added 3 commits February 26, 2026 07:42
Write all log output to both stderr and ~/.agentsview/debug.log via
io.MultiWriter so there is a persistent record for diagnosing resync
issues.

Add timing instrumentation to:
- writeBatch (bulk delete phase, per-session write phase)
- ResyncAll (FTS drop and rebuild)
- InsertMessages, DeleteMessagesForSessions, ReplaceSessionMessages
  (logged when >100ms to avoid flooding)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ormal sync

Address review findings from #7451:
- If bulk DeleteMessagesForSessions fails, fall back to per-session
  writeSessionFull (atomic delete+insert) instead of continuing with
  the incremental path against stale data
- Only emit "write batch" timing logs during forceReplace (resync),
  not during normal initial sync where they spam stdout

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@roborev-ci

roborev-ci Bot commented Feb 26, 2026

Copy link
Copy Markdown

roborev: Combined Review (6c715b5)

Verdict: The proposed changes introduce performance improvements and bug fixes, but there are a few significant issues with the new bulk deletion and synchronization logic that need addressing.

High

  • Bulk delete before per-session writes can cause message loss on partial failures
    • File:
      internal/sync/engine.go#L917
      , [internal/sync/engine.go#L944](/home/roborev
      /.roborev/clones/wesm/agentsview/internal/sync/engine.go#L944)
    • Description: writeBatch(..., forceReplace=true) deletes messages for the full batch first, then writes sessions one-by-one. If Upsert Session fails (or downstream message write fails), the old messages are already gone and that session is left empty/stale.
    • Suggested Remediation: Make replacement atomic per session (delete+insert in one tx, e.g. via ReplaceSessionMessages), or abort the batch on first write error
      and surface it in SyncStats.Warnings.

Medium

  • Failed bulk delete is ignored, so “replace” semantics can silently fail
    • File: [internal/sync/engine.go#L931](/home/roborev/.roborev/cl
      ones/wesm/agentsview/internal/sync/engine.go#L931)
    • Description: If DeleteMessagesForSessions fails, the code only logs and continues. The subsequent incremental path may append nothing (unchanged ordinals), leaving old parsed content in place while
      resync appears successful.
    • Suggested Remediation: Treat bulk-delete failure as a hard error for the batch, or fallback to per-session transactional replace and record warning/error in returned stats.

Synthesized from 4 reviews (agents: codex, gemini | types: default, security)

wesm and others added 10 commits February 26, 2026 07:49
Address review findings:
- Truncate debug.log at startup if it exceeds 10MB to prevent
  unbounded disk growth from the append-only log file
- Add tests for setupLogFile (file creation, dual output, open
  failure fallback) and truncateLogFile (over/under limit, missing)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace the slow ResyncAll approach (bulk-delete + reinsert 400K
messages with per-row trigger overhead) with a fresh-DB-and-swap
strategy:

1. Open a new empty DB at a temp path
2. Point the engine at the new DB and run normal sync (pure inserts)
3. Copy insights from old DB via SQLite ATTACH
4. Close temp DB, rename over original, reopen original handle

This reduces resync from 2+ minutes to seconds for large datasets
by avoiding trigger overhead entirely.

Changes:
- Add DB.Path(), DB.Reopen() for file-swap support
- Add DB.CopyInsightsFrom() using ATTACH/DETACH
- Rewrite ResyncAll to use fresh-DB strategy
- Remove forceReplace from writeBatch/collectAndBatch/syncAllLocked
- Remove DeleteMessagesForSessions and DeleteSessionMessages
- Clean up stale temp DB files on startup

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Address review #7456 findings:
- Use os.Lstat in truncateLogFile to detect and skip symlinks,
  preventing accidental truncation of symlink targets
- Replace hardcoded /nonexistent/path in TestTruncateLogFileMissing
  with t.TempDir()-based path for CI portability
- Add TestTruncateLogFileSymlink to verify symlinks are preserved

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Address review #7459 findings:
- Abort swap when sync discovers sessions but syncs zero (prevents
  replacing a populated DB with an empty one)
- Open new DB handles before closing old ones in reopenLocked so
  the struct never points at closed handles on failure
- Add TestResyncAllPreservesInsights to verify insights survive
  the fresh-DB-and-swap flow
- Add TestResyncAllAbortsOnZeroSynced to verify the safety guard

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Address review #7463: os.WriteFile and os.Symlink errors were
ignored, allowing the test to pass vacuously if symlink creation
fails. Now fatals on write failure and skips with a message when
symlinks are unsupported.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Address review #7464 findings:
- Add Failed counter to SyncStats, incremented on hard parse/stat
  errors in collectAndBatch
- Abort resync swap when Failed > Synced (not just when Synced == 0),
  preventing partial DB replacement when bulk failures occur
- Replace non-deterministic abort test with chmod-based approach
  that guarantees a permission error, removing the t.Skip escape
  hatch

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Address review #7465: the broad t.Skip on any os.Symlink error
could mask real setup bugs. Now only skips for EPERM/EACCES
(environments where symlinks are unsupported) and fatals for
unexpected errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Address review findings from #7465, #7466, #7468:

- Add unexported filesOK counter (file-level) so abort guard
  compares Failed vs filesOK instead of Failed vs Synced
  (session-level), fixing a unit mismatch when fork detection
  produces multiple sessions per file
- Skip chmod-based abort test on Windows and when running as root
- Add ENOTSUP and ENOSYS to symlink test skip condition for
  filesystems that report unsupported symlinks with those errnos

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Address review #7471 findings:
- Add TestResyncAllAbortsWithForkAndFailures exercising the exact
  unit-mismatch scenario: 1 fork file (Synced=2) + 2 failed files
  (Failed=2) aborts because Failed > filesOK(1), even though
  Failed == Synced
- Fix SyncStats comment to reflect that TotalSessions includes
  OpenCode sessions, not just discovered files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Stop sending log.Printf output to both stderr and the log file.
Diagnostic logs now go to debug.log only, keeping the terminal clean
during initial sync (no interleaved timing/HTTP logs with the progress
bar).

warnMissingDirs uses fmt.Fprintf(os.Stderr) directly since missing
directory warnings are user-facing.

Simplify initial sync summary to "N sessions synced" (plus failed count
when >0), dropping the confusing total/skipped breakdown.

Simplify resync modal to show sessions synced + failed (when >0),
dropping skipped (always 0 during resync) and total (includes
non-interactive files that produce no session).

Add failed field to frontend SyncStats type to match Go struct.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@wesm wesm changed the title Fix Gemini thinking blocks and full resync content replacement Fix Gemini thinking, resync reliability, and startup UX Feb 26, 2026
After setupLogFile redirects log output to the debug file, log.Fatalf
in mustOpenDB and ListenAndServe would silently exit. Replace with a
fatal() helper that prints to stderr and exits.

Distinguish os.ErrNotExist from other stat errors in warnMissingDirs
to avoid printing "not found" for permission or IO errors.

Add failed:0 to all SyncStats test fixtures (sync.test.ts MOCK_STATS
and client.test.ts SSE done-event JSON) to match the new required field.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@roborev-ci

roborev-ci Bot commented Feb 26, 2026

Copy link
Copy Markdown

roborev: Combined Review (fd75c9a)

The PR introduces structural improvements for database resyncs and logging, but contains high-severity concurrency and file-locking issues during the database swap phase that must be addressed.

High Severity

1. Data Corruption
& File Locking Risk from Open SQLite Database During Swap

  • File: internal/sync/engine.go (Inside ResyncAll, lines 401-402)
  • Description: The origDB database connections are not closed before calling os.Rename(tempPath, origPath) and forcibly deleting the original database's -wal and -shm files. This causes two major issues:
    1. Deleting WAL/SHM files while a SQLite database is open can lead to undefined behavior and irreversible corruption of the newly swapped database when SQLite attempts to checkpoint.

    2. On Windows, attempting to rename or overwrite an open file fails with a sharing violation/access denied error, causing ResyncAll to consistently fail.

  • Suggested Remediation: Call origDB.Close() completely before the file rename and WAL deletion operations. Rely solely on
    os.Rename for the main .db file, and ensure origDB.Reopen() is called to re-establish connections regardless of whether the rename succeeds or fails.

2. Concurrent Handle Swap Race Condition

  • File: internal/db/db.go:341

  • Description: Reopen() mutates db.reader and db.writer and closes old handles while many read paths access db.reader without the same lock. This creates a race window that can surface intermittent database is closed or stale-handle behavior during a resync.

  • Suggested Remediation: Make connection-handle access synchronized for both reads and writes (e.g., a shared RW lock around handle access/lifecycle), or swap to a new immutable DB instance at a higher layer and switch pointers under a lock that all DB consumers honor.

Medium Severity

1. Resync Swap
-Failure Path Drops Stats Update

  • File: internal/sync/engine.go:311
  • Description: In ResyncAll, if os.Rename(tempPath, origPath) fails, the function returns early after appending a warning but does not persist that into
    e.lastSyncStats. UI/state consumers can miss the failure context and see stale stats.
  • Suggested Remediation: Persist lastSyncStats on all exit paths (e.g., using a common defer or helper).

2. Unsafe Connection Reuse with ATTACH
DATABASE

  • File: internal/db/insights.go (Inside CopyInsightsFrom)
  • Description: Executing ATTACH DATABASE and the subsequent INSERT using db.writer.Exec relies on database/sql reusing the same underlying connection. While Max OpenConns(1) makes this likely, it is not strictly guaranteed by connection pool semantics, which could cause the query to fail if a connection reconnects between statements.
  • Suggested Remediation: Use conn, err := db.writer.Conn(context.Background()) to run connection-scoped
    statements (like ATTACH and the INSERT) on a guaranteed single connection, and defer conn.Close().

Synthesized from 4 reviews (agents: codex, gemini | types: default, security)

wesm and others added 9 commits February 26, 2026 10:06
Add TestResyncPreservesDataThroughSwap to verify sessions, messages,
and projects survive the close-rename-reopen sequence end-to-end via
the HTTP API. Add TestResyncConcurrentReads to verify concurrent
readers don't panic or deadlock during resync. Add
TestCloseRecoveryOnRenameFail to verify service recovery when rename
fails. These tests run on both Linux and Windows in CI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Address review findings:
- Abort swap if CloseConnections fails (restore service via Reopen)
- Fix TestConcurrentReadsWhileReopen: use WaitGroup.Go instead of
  manually managing Add/Done with channel close race
- Add engine-level TestResyncAllPostReopenAvailability: verify
  reads and writes work after close-rename-reopen cycle
- Add engine-level TestResyncAllConcurrentReads with reader barrier
- Tighten TestResyncConcurrentReads: verify resync SSE done stats,
  add reader-start barrier, assert post-resync data integrity

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Fix && to || in post-resync SyncAll assertion so partial regressions
are caught. Move readyCount.Done() after first successful GetSession
call so the reader barrier ensures actual read overlap with resync.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Close origDB connections before copying insights so no writes land
in the old DB after the copy. Persist lastSyncStats on all early
return paths so /api/v1/sync/status reflects failure state. Gate
FTS assertion in TestResyncAllReplacesMessageContent with HasFTS()
so the test passes without the fts5 build tag.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Close newDB before removeTempDB on CloseConnections failure to
prevent leaked handles and Windows file locks. Capture hadFTS
before ResyncAll so FTS regressions are detected even when
HasFTS() returns false post-resync.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
All three views now exclude sub-agent/fork sessions and empty sessions:
- Analytics buildWhere: add relationship_type NOT IN filter
- GetStats: count root sessions directly instead of trigger counter
- Default analytics date range: "All" instead of 1 year

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
TestSessionCountConsistency seeds root, subagent, fork, empty, and
continuation sessions, then asserts that /api/v1/sessions,
/api/v1/stats, and /api/v1/analytics/summary all report the same
count (only root + continuation sessions with messages).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add session-count-consistency.spec.ts: verifies session list,
  analytics summary card, and status bar all show the same count
- Add subagent, fork, and empty sessions to test fixture to
  ensure they are excluded from all three counts
- Fix e2e server isolation: set COPILOT_DIR and OPENCODE_DIR
  to empty dir to prevent discovering real host sessions
- Stop abbreviating session counts in dashboard (show full
  number with commas instead of "3.0K")

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Assert each view shows exactly 8 (the known root session count)
instead of only checking equality. Catches regressions where all
three views silently include subagent/fork/empty sessions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@roborev-ci

roborev-ci Bot commented Feb 26, 2026

Copy link
Copy Markdown

roborev: Combined Review (9e61860)

Verdict: Changes requested to address a high-severity data loss risk during resync, and medium-severity issues with concurrency and SQL filtering.

High

Resync can still drop insights when copy
fails

  • File: internal/sync/engine.go:390
  • Description: In ResyncAll, newDB.Copy InsightsFrom(origPath) failure only appends a warning, then the swap continues. That replaces the original DB with a new DB that may have no insights, causing data loss.
  • Suggested remediation: Abort swap on insight-copy failure (close newDB, reopen origDB, return warning
    ), or make insight copy transactional/mandatory before rename. Add an integration test for copy-failure behavior.

Medium

Data race on e.db reassignment

  • File: internal/sync/engine.go (ResyncAll and FindSourceFile)

Description:** e.db is reassigned (e.db = newDB) during ResyncAll without holding a lock. Concurrent calls to FindSourceFile(), which reads e.db without a mutex, will trigger a Go data race.

  • Suggested remediation: Protect
    e.db reads and writes using the existing e.mu (RLock/Lock), or convert e.db to an atomic.Pointer[db.DB].

NOT IN filter excludes NULL relationship types

  • Files: internal/db
    /analytics.go:96
    , [internal/db/stats.go:19](/home/roborev/.roborev/clones/wes
    m/agentsview/internal/db/stats.go:19)
  • Description: relationship_type NOT IN ('subagent', 'fork') evaluates to unknown for NULL, so legacy rows with NULL are excluded unintentionally. This can undercount sessions in stats/analytics.

Suggested remediation: Use COALESCE(relationship_type, '') NOT IN ('subagent', 'fork') (or relationship_type IS NULL OR ...). Add tests covering rows with NULL relationship_type.


Synthesized from 4 reviews (agents: codex, gemini | types: default, security)

wesm and others added 5 commits February 26, 2026 13:16
If session directories are temporarily inaccessible or
misconfigured, discovery may return zero files. Previously this
would proceed with the swap, replacing a populated DB with an
empty one. Now ResyncAll snapshots the old session count and
aborts if the new sync found zero files but the old DB had data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Three fixes for ResyncAll:

1. Abort swap when CopyInsightsFrom fails instead of continuing
   and losing all insights. Follows the same recovery pattern as
   CloseConnections and rename failures.

2. Use filesDiscovered (file-only count) instead of TotalSessions
   in the empty-discovery guard so OpenCode sessions don't mask
   missing file-based sessions.

3. Handle oldStats error explicitly: fail closed by assuming the
   old DB has data worth protecting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
TestSetupLogFile: close the log file before TempDir cleanup runs.
On Windows, open file handles prevent directory deletion. Register
cleanup after TempDir (LIFO ordering ensures file closes first).

thinking block e2e: use .first() and scope child locators to the
block, matching the pattern used by tool-block tests. The fixture
session has multiple thinking blocks, causing strict mode violation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The emptyDiscovery check aborted ResyncAll when filesDiscovered==0,
even if OpenCode sessions synced successfully. This blocked resync
for OpenCode-only datasets.

Add stats.Synced==0 to the condition so the guard only fires when
no sessions were synced from any source. Add integration test for
the OpenCode-only ResyncAll path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The previous fix (stats.Synced == 0) prevented OpenCode-only abort
but reintroduced the mixed-source data-loss risk: when file dirs
are inaccessible but OpenCode syncs succeed, file-backed sessions
would be silently dropped.

Root fix: add DB.FileBackedSessionCount() that queries sessions
where agent != 'opencode'. The emptyDiscovery guard now compares
file discovery against the old file-backed count, correctly
handling both cases:
- OpenCode-only: oldFileSessions=0 → guard inactive → resync OK
- Mixed source, files missing: oldFileSessions>0 → guard fires →
  abort protects file-backed sessions

Add TestResyncAllAbortsMixedSourceEmptyFiles integration test.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@roborev-ci

roborev-ci Bot commented Feb 26, 2026

Copy link
Copy Markdown

roborev: Combined Review (2ca8d30)

Summary Verdict: The PR introduces solid improvements to database swapping and sync logic, but requires fixes for a high-severity logic flaw in the sync failure guard, alongside medium-severity issues related to file TOCTOU, SQL NULL filtering, and database connection pooling.

High Severity

  • Failure guard can be bypassed by "successful" files that produced no sessions
    • Files: [internal/sync/engine.go:745](/home/roborev/.roborev/clones/wesm/agentsview/internal/sync/engine.go#L
      745), internal/sync/engine.go:372, [internal/sync/progress.go:35](/home/rob
      orev/.roborev/clones/wesm/agentsview/internal/sync/progress.go#L35)
    • Details: filesOK is documented as “files that produced at least one session”, but it is incremented for every non-error file before checking whether that file
      produced any sessions. ResyncAll then uses Failed > filesOK to decide abort/swap. This can under-trigger aborts and permit swapping to a degraded DB when many files parse to zero sessions (e.g., non-interactive files).
    • Remediation: Increment
      filesOK only when len(r.results) > 0 (or equivalent “produced session” signal), and add a resync abort test with zero-session files plus real failures.

Medium Severity

  • TOCTOU Vulnerability in Log Truncation
    *
    File: cmd/agentsview/main.go:252

    • Details: truncateLogFile performs os .Lstat(path) and then later os.Truncate(path, 0) on the same path. This is a classic Time-of-Check to Time-of-Use (TOCTOU) pattern: an attacker who can modify the path between checks could swap in a symlink and cause truncation of an
      unintended file.
    • Remediation: Avoid path-based check-then-use. Open the file once with no-follow semantics (O_NOFOLLOW where supported), fstat the opened FD, verify regular file, then ftruncate that same FD.
  • Potential NULL-filter regression in root session counting (legacy DB compatibility)

    • Files: internal/db/stats.go:19,
      internal/db/analytics.go:96
    • Details: The new filter uses relationship_type NOT IN ('subagent', 'fork'). If legacy rows contain NULL in relationship_type, SQL NOT IN returns unknown and excludes those rows. That can skew stats/analytics counts and weaken the empty-discovery safety check (via FileBackedSessionCount).
    • Remediation: Use COALESCE (relationship_type, '') NOT IN ('subagent', 'fork') (or enforce/backfill non-NULL via migration), and add a test with NULL relationship_type rows.
  • Connection Pool Issue with ATTACH DATABASE

    • File: internal/ db/insights.go:146 (in CopyInsightsFrom)
    • Details: w.Exec("ATTACH DATABASE ...") and the subsequent w.Exec("INSERT ...") rely on the *sql.DB connection pool. Even with MaxOpenCon ns=1, database/sql does not strictly guarantee the exact same underlying connection is reused across separate Exec calls (e.g., if an idle connection is closed and a new one is dialed). If the second Exec runs on a newly dialed connection, it will lack the attached database and
      the insert will fail.
    • Remediation: Use conn, err := w.Conn(context.Background()) to explicitly acquire a single connection, and execute the ATTACH, INSERT, and DETACH statements on conn.

Synthesized from 4 reviews (agents: codex, gemini | types: default, security)

wesm and others added 2 commits February 26, 2026 14:04
database/sql's pool doesn't guarantee the same underlying connection
across separate Exec calls. ATTACH DATABASE is connection-scoped, so
the subsequent INSERT could run on a different connection that lacks
the attached database. Use Conn(ctx) to pin a single connection for
the ATTACH/INSERT/DETACH sequence.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add three new review guidelines (11-13) to suppress recurring false
positives:
- TOCTOU on local user-owned files (not exploitable)
- NOT NULL schema constraints (NULL cannot exist in relationship_type)
- Verify control flow before flagging logic errors

Use TOML triple-quoted multi-line string for readability.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@roborev-ci

roborev-ci Bot commented Feb 26, 2026

Copy link
Copy Markdown

roborev: Combined Review (a3d5c0b)

Review Summary:
The changes introduce no security vulnerabilities, but contain medium-severity bugs related to missing UI warnings for database resyncs and regex parsing edge cases for legacy blocks.

Medium Severity

  • Resync warnings are generated by backend but not surfaced in UI

    • Files: sync.ts: 14, [ResyncModal.svelte:117](/home/roborev/.roborev/clones
      /wesm/agentsview/frontend/src/lib/components/modals/ResyncModal.svelte:117), [progress.go:41](/home/roborev/.roborev/clones/wesm/agentsview/internal/sync/progress.go:
      41), engine.go:380
    • Description: The backend now emits warnings in SyncStats (
      e.g., aborted swap / copy failure), but the frontend SyncStats type and modal rendering only show synced/failed. Users can miss important failure reasons.
    • Suggested Fix: Add warnings?: string[] to frontend sync types, persist through store state, and render warnings in the
      resync done/error view.
  • Legacy thinking regex can truncate old thinking blocks at first blank line

    • Files: [content-parser.ts:23](/home/roborev/.roborev/clones/wesm/agentsview/frontend/src
      /lib/utils/content-parser.ts:23), export.go:581
    • Description: The legacy
      pattern now ends on \n\n ((?=\n\[|\n\n|$)), so unmarked historical thinking blocks containing paragraph breaks may be cut early and misclassified as visible response text.
    • Suggested Fix: Tighten fallback boundary logic (for example \n\n(? =\[) or a more explicit two-pass heuristic) and add tests for legacy unmarked thinking with internal blank lines.

Synthesized from 4 reviews (agents: codex, gemini | types: default, security)

@roborev-ci

roborev-ci Bot commented Feb 26, 2026

Copy link
Copy Markdown

roborev: Combined Review (8e1e3ef)

Verdict: The code is generally secure and robust, but there are two medium-severity issues regarding legacy thinking block parsing and database reopen error handling during resync.

Medium: Legacy thinking parsing truncates at first blank line

Files:

  • /home/roborev/.rob orev/clones/wesm/agentsview/frontend/src/lib/utils/content-parser.ts:23
  • /home/roborev/.roborev/clones/wesm/agentsview/frontend/src/lib/components/content/MessageList. svelte:44
  • /home/roborev/.roborev/clones/wesm/agentsview/internal/server/export.go:581

Description:
THINKING_LEGACY_RE now uses (?=\n\[|\n\ n|$), so legacy blocks without [/Thinking] stop at any blank line. Multi-paragraph legacy thinking becomes split into thinking + normal text, which changes rendering and “hide thinking” behavior.

Suggested Fix:
For legacy fallback, terminate on explicit next block markers (or next [Tool| Read|.../[Thinking]) rather than any blank line; keep the stricter behavior only for marked blocks. Add regression tests for multi-paragraph legacy thinking.

Medium: Resync can report completion even if DB reopen fails after swap

File:

  • /home/roborev/.rob orev/clones/wesm/agentsview/internal/sync/engine.go:500

Description:
After a successful rename, origDB.Reopen() failure is only appended to warnings and execution returns normal stats. At that point, connections were previously closed, so read
/write endpoints can fail until recovery/restart, leaving the app in a broken state with closed DB handles.

Suggested Fix:
Treat reopen failure as a hard resync failure path (set failed status and return error response semantics), optionally retry reopen, and ensure the UI surfaces this as fatal rather than “done
”.


Synthesized from 4 reviews (agents: codex, gemini | types: default, security)

The virtual list scroll clamp test was flaky on CI because it
checked scrollTop immediately after filtering without waiting for
the filtered results to render. Add a wait for the session list
header to show the filtered count before asserting scroll position.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@wesm
wesm force-pushed the nicer-gemini-thinking branch from 8e1e3ef to 0c2f4a7 Compare February 26, 2026 20:34
@roborev-ci

roborev-ci Bot commented Feb 26, 2026

Copy link
Copy Markdown

roborev: Combined Review (0c2f4a7)

Summary Verdict: The codebase is generally secure and structurally sound, but there are a few medium-severity issues related to database
connection handling during resync and legacy parsing logic that need to be addressed.

Medium

Resync can return “success with warning” while DB remains unavailable

  • File: internal/sync/engine.go (ResyncAll, post-rename origDB.Reopen() path
    )
  • Problem: If origDB.Reopen() fails after the swap, the code only appends a warning and continues. That can leave closed DB handles in service while the sync response still looks mostly successful.
  • Suggested fix: Treat reopen failure as a hard failure for resync (early return with
    a failed status), and retry/recover before returning (or explicitly put the engine into a degraded/fatal state).

Legacy thinking parsing now truncates multi-paragraph legacy blocks

  • Files:
    • frontend/src/lib/utils/content-parser.ts (THINKING_ LEGACY_RE)
    • frontend/src/lib/components/content/MessageList.svelte (thinking-strip regex)
    • internal/server/export.go (thinkingLegacyRe)
  • Problem: The legacy regex now ends on \n\n,
    so old [Thinking] content with internal blank lines gets split, and trailing thought text can be misclassified/rendered as normal response text.
  • Suggested fix: Keep the legacy fallback from stopping on generic blank lines, or add a stricter delimiter heuristic (e.g., explicit marker first, otherwise boundary only
    at known block starts), then keep the new behavior for marked [/Thinking] blocks.

Synthesized from 4 reviews (agents: codex, gemini | types: default, security)

@wesm

wesm commented Feb 26, 2026

Copy link
Copy Markdown
Member Author

"Finding 1 (Medium - Reopen failure): Not concerning. If Reopen() fails after a successful rename, the new DB
file is already in place. The next startup or resync will reopen it. Making this a "hard failure" doesn't help
— the swap already succeeded, and there's nothing to roll back to. The warning is the correct behavior.

Finding 2 (Medium - Legacy thinking truncation): This is by design. The two-pass regex was the entire point of
this branch — THINKING_MARKED_RE handles [/Thinking]-delimited blocks correctly, and THINKING_LEGACY_RE is a
conservative fallback that intentionally stops at \n\n to avoid consuming response text. Old blocks with
internal blank lines would need a resync to get [/Thinking] end markers (already noted in the PR). The
alternative (greedy legacy regex) was the original bug that swallowed response text."

@wesm
wesm merged commit 25239ea into main Feb 26, 2026
6 checks passed
cursor Bot pushed a commit to diazMelgarejo/periscope that referenced this pull request Jun 1, 2026
…io#58)

## Summary

### Gemini thinking blocks
- **Two-pass thinking regex**: Split `THINKING_RE` into
`THINKING_MARKED_RE` (for `[/Thinking]`-delimited blocks, matched first)
and `THINKING_LEGACY_RE` (fallback). Prevents fallback delimiters from
truncating marked blocks.
- **Thinking end markers**: All parsers (Claude, Gemini, OpenCode) emit
`[/Thinking]` end markers.
- **Gemini thinking format**: Parts joined with `\n\n`, ordered
chronologically (thinking, content, tools).
- **Merge consecutive thinking blocks**: Multiple Gemini thoughts
collapse into a single collapsible block.
- **Thinking-only filter fix**: Messages with thinking + response text
are no longer hidden by the `showThinking` toggle.

### Session count consistency
- **Aligned counts across views**: Session list, analytics summary, and
status bar all use the same root-session filter (`message_count > 0 AND
relationship_type NOT IN ('subagent', 'fork')`).
- **Full numbers in dashboard**: Removed K/M abbreviation, always shows
full number with comma separators.
- **Default date range**: Analytics defaults to all-time instead of 1
year.
- **Go HTTP integration test**: `TestSessionCountConsistency` seeds
mixed session types and asserts all three endpoints agree.
- **E2e Playwright test**: `session-count-consistency.spec.ts` verifies
the same invariant through the full UI stack, asserting exact expected
count.

### Resync reliability
- **Fresh database build**: `ResyncAll` builds a new DB from scratch and
swaps atomically, instead of in-place mutation.
- **Atomic DB connections**: `atomic.Pointer[sql.DB]` for reader/writer
so HTTP handlers don't race with connection swaps.
- **Windows-safe swap**: Close connections before rename to avoid
mandatory file locking failures.
- **Empty-discovery guard**: Abort resync when file discovery returns
zero but old DB has file-backed sessions. Uses `FileBackedSessionCount`
(excludes OpenCode) so OpenCode-only datasets aren't blocked, while
mixed-source data loss is prevented.
- **Insights preservation**: Abort swap when `CopyInsightsFrom` fails
instead of silently dropping insights.
- **Failure tracking**: Abort when failures exceed successful syncs.
- **FTS rebuild**: Drop and rebuild FTS index during resync.
- **Sync serialization**: `SyncPaths` and `SyncSingleSession` serialized
with `syncMu`.

### E2e test isolation
- All agent directories (Claude, Codex, Copilot, Gemini, OpenCode) point
to empty dirs in e2e-server.sh, preventing real session data from
leaking into tests.
- Test fixture seeds subagent, fork, and empty sessions alongside root
sessions.

### Other
- **Session ID for all agents**: Gemini, OpenCode, and Copilot sessions
show the copyable session ID.
- **Clean startup logging**: `log.Printf` goes to
`~/.agentsview/debug.log` only. Missing directory warnings stay on
stderr.
- **Debug log management**: Truncate `debug.log` on startup when >10MB.
- **Windows test fix**: Close log file before TempDir cleanup to avoid
mandatory file locking.

Fixes kenn-io#56

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
@wesm
wesm deleted the nicer-gemini-thinking branch June 25, 2026 12:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

Nicer formatting for Gemini thinking blocks

1 participant