Skip to content

fix(sync): verify stored messages before trusting history_complete - #184

Open
rnavarro wants to merge 4 commits into
openclaw:mainfrom
rnavarro:fix/verify-history-complete
Open

fix(sync): verify stored messages before trusting history_complete#184
rnavarro wants to merge 4 commits into
openclaw:mainfrom
rnavarro:fix/verify-history-complete

Conversation

@rnavarro

Copy link
Copy Markdown

What I saw

I keep a personal archive of 13 guilds (about 1.1M messages, 3.9 GB SQLite) synced hourly. While tracking down forum threads that were present in channels but empty in messages, I found 24 channels carrying a channel:<id>:history_complete marker with zero stored message rows. Those channels are skipped on every run and stay skipped, including under --full. The only way I could get them to fetch was deleting their sync_state rows by hand.

select count(*) from channels c
where c.kind like 'thread%'
  and not exists (select 1 from messages m where m.channel_id = c.id)
  and exists (select 1 from sync_state s where s.scope = 'channel:' || c.id || ':history_complete');
-- 24

Mechanism

shouldSkipChannelSync decides to skip from two inputs, both read out of sync_state:

  • state.BackfillComplete, set by the history_complete marker
  • the stored cursor, compared against channel.LastMessageID

Neither input looks at messages. So a channel that has the marker but not the rows satisfies the skip condition, and satisfies it again on every later run. --full reaches the same check through syncChannelMessages, so it does not recover the channel either.

What this changes

Detection:

  • channelSyncState gains HasMessages, populated by a new ChannelHasMessages query.
  • needsHistoryVerification returns true when a channel is marked complete, holds no local rows, and has a non-empty LastMessageID (Discord still reports content).
  • When it returns true, the in-memory cursors are dropped and the channel is crawled from scratch. That is safe because AdvanceChannelLatestMessageID only moves the stored pointer forward, so a verification pass cannot rewind it.

Two guards stop that from re-crawling forever:

  • A channel whose messages were all deleted upstream would otherwise be re-checked every run. When a verification completes and still stores nothing, it writes channel:<id>:verified_empty and stops re-checking. --full clears that marker, so an explicit full run always re-checks.
  • verified_empty is never written when --since is set. filterMessagesSince can drop every fetched message before it is persisted, so an empty result inside a window says nothing about whether the channel is empty.

Cost

The probe is select exists(select 1 from messages where channel_id = ? limit 1), which uses the existing channel_id index. On my archive explain query plan reports SEARCH messages USING COVERING INDEX idx_messages_channel_id.

loadChannelSyncState returns early for channels that are not marked complete. The extra lookup therefore runs only for channels already carrying history_complete, and the second only for those that turn out to hold nothing.

Changes to existing tests

Four existing tests build their fixture out of exactly the state this change stops trusting: a history_complete marker plus a cursor, with no message rows. They fail against the fix for that reason alone, not because of what they assert.

I added the missing message row to each of those four fixtures, so the setup now describes a channel that genuinely finished backfilling. Every assertion is unchanged.

What I could not determine

I do not have a trace of how these 24 channels reached that state. I can confirm the state exists in a real archive, and that nothing in the current code path recovers from it. A crawl that records the marker before its rows land, an interrupted run, or a restore that loses message rows would each produce it. This change is defensive rather than a fix for an identified write path.

Testing

go test ./... passes. internal/syncer/history_verification_test.go covers six cases: marked-complete-with-messages still skips, marked-complete-without-messages re-fetches, an empty LastMessageID behaves sanely, a zero-result verification does not re-fetch on the next run, --full re-checks a channel marked verified_empty, and --since does not write that marker.

@clawsweeper

clawsweeper Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

🦞👀
ClawSweeper picked this up.

Pull request received. I will update this pull request when review starts.

@clawsweeper clawsweeper Bot added merge-risk: 🚨 session-state 🚨 Merging this PR could lose, corrupt, stale, or mis-associate session or agent state. P1 Urgent regression or broken agent/channel workflow affecting real users now. rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. labels Aug 20, 2026
@clawsweeper

clawsweeper Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Codex review: needs changes before merge. Reviewed August 27, 2026, 1:46 AM ET / 05:46 UTC.

ClawSweeper review

What this changes

The PR verifies persisted Discord history-complete state against SQLite message rows and fully re-crawls channels whose local history is missing.

Regression provenance

Possible regression — probable (reproduction; reviewed change). No predecessor PR is attributed.

Merge readiness

⚠️ Needs maintainer review before merge - 3 items remain

Keep open: the recovery is needed because current main still trusts completion state without checking stored rows, but failed-crawl restoration can wait indefinitely after the channel deadline.

Priority: P1
Reviewed head: 93ea12271c17f9886818a33cd3d7ecd9325fa694

Review scores

Measure Result What it means
Overall readiness 🦐 gold shrimp (3/6) The archive-level proof is strong, but the unbounded failed-crawl cleanup is a concrete merge blocker.
Proof confidence 🦞 diamond lobster (5/6) Sufficient (terminal): The contributor supplied a before/after run against a byte-identical copy of a real archive showing a formerly skipped channel recover 991 rows; redact private archive details in any future artifacts.
Patch quality 🦐 gold shrimp (3/6) 1 actionable review finding remain.

Verification

Check Result Evidence
Real behavior Verified Sufficient (terminal): The contributor supplied a before/after run against a byte-identical copy of a real archive showing a formerly skipped channel recover 991 rows; redact private archive details in any future artifacts.
Evidence reviewed 7 items Current main still has the reported gap: Current main's skip decision consults only the completion marker and stored cursor; it has no stored-message existence check or recovery path.
Detached restoration is unbounded: After a crawl error, the branch passes context.WithoutCancel(ctx) directly to SQLite restoration. That context has no deadline, so a pool wait or database lock can outlast the configured per-channel timeout.
Existing reliability precedent bounds detached writes: The adjacent failure ledger deliberately uses context.WithoutCancel only as the base for a five-second context.WithTimeout, showing the established bounded-cleanup pattern.
Findings 1 actionable finding [P2] Bound detached marker restoration
Security None None.

Live Verification

Command: go test ./internal/syncer -run 'TestHistoryVerification(RecoversStrandedChannel|RestoresMarkerAfterContextDeadline)' -count=1

Result: FAIL (failed) — execution before step 1 expect_output: sh -lc pnpm install --ignore-scripts --frozen-lockfile failed: ! Corepack is about to download https://registry.npmjs.org/pnpm/-/pnpm-11.24.0.tgz

sh -lc pnpm install --ignore-scripts --frozen-lockfile failed: ! Corepack is about to download https://registry.npmjs.org/pnpm/-/pnpm-11.24.0.tgz

Assertions:

  • FAIL expect_output: PASS

How this fits together

DisCrawl syncs Discord channel history into SQLite and uses stored cursors and completion markers to decide whether a channel can be skipped. This change compares that stored state with actual message rows before sending a channel to either the normal sync path or a full recovery crawl.

flowchart LR
A[Discord channel metadata] --> C[Sync decision]
B[SQLite sync markers] --> C
D[SQLite message rows] --> C
C -->|complete and rows exist| E[Skip unchanged channel]
C -->|marker without rows| F[Full history recovery]
F --> D
Loading

Before merge

  • Bound detached marker restoration (P2) - context.WithoutCancel removes the channel deadline, so these SQLite calls can wait indefinitely for a pool slot or database lock after a failed crawl. Use a short independent timeout, as the adjacent failure-ledger helper does, and cover contention.
  • Resolve merge risk (P2) - A SQLite lock or exhausted connection pool can make failed-verification marker restoration exceed the configured per-channel timeout, delaying sync completion.
  • Complete next step (P2) - A narrow code-and-test repair can bound the detached cleanup without changing the proposed recovery contract.

Findings

  • [P2] Bound detached marker restoration — internal/syncer/message_sync.go:281
Agent review details

Security

None.

Review metrics

Metric Value Why it matters
Patch size 8 files; production +201, tests +551/-5 The recovery mechanism changes persisted sync-state handling and adds focused behavior coverage.

Merge-risk options

Maintainer options:

  1. Bound failed-crawl restoration (recommended)
    Wrap the detached restoration in a short independent timeout and add a contention test so a locked SQLite store cannot stall a channel worker indefinitely.
Copy recommended automerge instruction
@clawsweeper automerge

Special instructions:
Use a short independent timeout for SQLite marker restoration after a failed verification crawl, with focused contention coverage.

Technical review

Best possible solution:

Preserve the recovery design, but give its post-failure SQLite restoration a short independent deadline and prove that a blocked store returns control promptly while retaining the intended marker state.

Do we have a high-confidence way to reproduce the issue?

Yes: current main directly represents the reported marker-plus-cursor state and skips it without inspecting message rows; the branch's focused tests model that state. I did not execute the test suite because this is a read-only review.

Is this the best way to solve the issue?

No: the full-recovery approach addresses the reported state, but its detached failure cleanup needs an independent timeout before it is safe to merge.

Full review comments:

  • [P2] Bound detached marker restoration — internal/syncer/message_sync.go:281
    context.WithoutCancel removes the channel deadline, so these SQLite calls can wait indefinitely for a pool slot or database lock after a failed crawl. Use a short independent timeout, as the adjacent failure-ledger helper does, and cover contention.
    Confidence: 0.99

Overall correctness: patch is incorrect
Overall confidence: 0.98

AGENTS.md: not found in the target repository.

Codex review notes: model internal, reasoning high; reviewed against 5ab5d11351b9.

Labels

Label justifications:

  • P1: The defect can permanently suppress recovery of channel history during ordinary recurring syncs.
  • merge-risk: 🚨 availability: The new detached cleanup can wait beyond the configured per-channel timeout under SQLite contention.
  • merge-risk: 🚨 session-state: The patch changes persistent completion and verification markers that control later sync decisions.
  • rating: 🦐 gold shrimp: Overall readiness is 🦐 gold shrimp; proof is 🦞 diamond lobster and patch quality is 🦐 gold shrimp.
  • status: ⏳ waiting on author: ClawSweeper has contributor-facing work open and is waiting for author action. Sufficient (terminal): The contributor supplied a before/after run against a byte-identical copy of a real archive showing a formerly skipped channel recover 991 rows; redact private archive details in any future artifacts.
  • proof: sufficient: Contributor real behavior proof is sufficient. The contributor supplied a before/after run against a byte-identical copy of a real archive showing a formerly skipped channel recover 991 rows; redact private archive details in any future artifacts.

Evidence

Acceptance criteria:

  • [P1] go test ./internal/syncer -run 'TestHistoryVerification' -count=1.
  • [P1] go test ./...

What I checked:

  • Current main still has the reported gap: Current main's skip decision consults only the completion marker and stored cursor; it has no stored-message existence check or recovery path. (internal/syncer/message_sync.go:260, 5ab5d11351b9)
  • Detached restoration is unbounded: After a crawl error, the branch passes context.WithoutCancel(ctx) directly to SQLite restoration. That context has no deadline, so a pool wait or database lock can outlast the configured per-channel timeout. (internal/syncer/message_sync.go:281, 93ea12271c17)
  • Existing reliability precedent bounds detached writes: The adjacent failure ledger deliberately uses context.WithoutCancel only as the base for a five-second context.WithTimeout, showing the established bounded-cleanup pattern. (internal/syncer/failures.go:58, 5ab5d11351b9)
  • Focused deadline coverage does not cover a locked database: The branch test blocks the fake Discord client until the crawl deadline, then restores against an unlocked SQLite store; it does not establish that detached restoration returns under database contention. (internal/syncer/history_verification_test.go:473, 93ea12271c17)
  • Prior blocker remains unchanged: The current head is identical to the prior reviewed head for the affected file, so the previously reported bounded-restoration blocker remains unresolved. (internal/syncer/message_sync.go:281, 93ea12271c17)
  • Real archive proof: The contributor's before/after transcript reports the same stranded text channel changing from zero stored rows to 991 after the recovery path, followed by a zero-write repeat run. (93ea12271c17)

Likely related people:

  • Ayaan Zaidi: Recent main commits added the adjacent latest-only and archived-thread synchronization behavior that this PR extends. (role: recent sync-path contributor; confidence: high; commits: 5a0dc6f611f8, 938b6b279dfc; files: internal/syncer/message_sync.go)
  • Peter Steinberger: History shows sustained ownership of the SQLC store surface and the adjacent bounded failure-ledger cleanup pattern. (role: storage and reliability contributor; confidence: medium; commits: 13e828a8e861, 5b5afe96dcb9; files: internal/store/query.go, internal/syncer/failures.go)

Rank-up moves

Optional improvements that raise the rating; they are not merge blockers.

  • Use a short independent restoration timeout and add a SQLite-contention regression test.

Rating scale

Score Internal tier Crab rank Meaning
6/6 S 🦀 challenger crab Exceptional readiness
5/6 A 🦞 diamond lobster Very strong readiness
4/6 B 🐚 platinum hermit Good normal PR; ordinary maintainer review
3/6 C 🦐 gold shrimp Useful, but confidence is limited
2/6 D 🦪 silver shellfish Proof or implementation needs work
1/6 F 🧂 unranked krab Not merge-ready
N/A NA 🌊 off-meta tidepool Rating does not apply

Overall follows the weaker of proof and patch quality.
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics.

Workflow

  • ClawSweeper keeps one durable marker-backed review comment per issue or PR.
  • Re-runs edit this comment so the latest verdict, findings, and automation markers stay together instead of adding duplicate bot comments.
  • A fresh review can be triggered by eligible @clawsweeper re-review comments, exact-item GitHub events, scheduled/background review runs, or manual workflow dispatch.
  • PR/issue authors and users with repository write access can comment @clawsweeper re-review or @clawsweeper re-run on an open PR or issue to request a fresh review only.
  • Maintainers can also comment @clawsweeper review to request a fresh review only.
  • Fresh-review commands do not start repair, autofix, rebase, CI repair, or automerge.
  • Maintainer-only repair and merge flows require explicit commands such as @clawsweeper autofix, @clawsweeper automerge, @clawsweeper fix ci, or @clawsweeper address review.
  • Maintainers can comment @clawsweeper explain to ask for more context, or @clawsweeper stop to stop active automation.

History

Review history (9 earlier review cycles; latest 8 shown)
  • reviewed 2026-08-20T18:33:19.235Z sha f123ed4 :: needs changes before merge. :: [P2] Delete the persisted verified-empty marker on recheck
  • reviewed 2026-08-20T19:39:30.065Z sha 93ea122 :: needs maintainer review before merge. :: none
  • reviewed 2026-08-22T02:17:28.342Z sha 93ea122 :: needs changes before merge. :: [P2] Bound the detached marker restoration
  • reviewed 2026-08-23T01:15:52.651Z sha 93ea122 :: needs changes before merge. :: [P2] Bound the detached marker restoration
  • reviewed 2026-08-23T22:09:00.981Z sha 93ea122 :: needs changes before merge. :: [P2] Bound the detached marker restoration
  • reviewed 2026-08-24T22:01:21.064Z sha 93ea122 :: needs changes before merge. :: [P2] Bound the detached marker restoration
  • reviewed 2026-08-25T22:01:46.563Z sha 93ea122 :: needs changes before merge. :: [P2] Bound the detached marker restoration
  • reviewed 2026-08-26T11:03:57.491Z sha 93ea122 :: needs changes before merge. :: [P2] Bound the detached marker restoration

A non-thread channel marked history_complete with no stored rows was left
with a single page of history in latest-only mode. Verification zeroed the
in-memory cursor, so syncChannelHistory took the syncLatestChannelHistory
branch, stored one page, and the untouched history_complete marker made
every later run skip the channel.

Route verification through syncFullChannelHistory directly so it always
crawls the whole channel, and clear history_complete for the duration of
the crawl so a crawl that fails partway leaves a resumable backfill rather
than a partial history marked complete. The marker is restored when the
crawl failed without storing anything, so the channel stays verifiable.

Skip verification entirely when since is set: a windowed crawl can only
reach back to the window, and completing there would lock the channel into
a fraction of its history. needsHistoryVerification now holds that rule, so
recordVerifiedEmptyChannel no longer needs its own since guard.
A verification crawl now covers every page instead of one, so the
per-channel deadline in messageChannelContext is a realistic way for it to
fail. On a cancelled context both queries in the restore fail and the
history_complete marker the crawl removed stays removed, which leaves the
channel with a latest cursor, no rows and no marker: latest-only runs skip
it and only a full run plus one more run unstick it.

Run the restore on context.WithoutCancel so it outlives the crawl.
@rnavarro

Copy link
Copy Markdown
Author

The P1 is real. Fixed, with the before/after you asked for.

The finding

Verification zeroed the in-memory state but passed latestOnly through. For a non-thread channel isThreadChannel is false and the cleared state.Latest sent it into syncLatestChannelHistory, which fetches one page. history_complete was only ever cleared in memory, so the next run saw stored rows, declined to verify, and skipped the channel for good. My testing was all on threads, which take the syncFullChannelHistory branch, so I missed it.

Verification now calls syncFullChannelHistory directly and always crawls the whole channel, matching what the code already does for an incomplete thread. It declines to verify entirely when --since is set, since a windowed crawl cannot finish a recovery and completing there would lock in a fraction of the history.

It also clears history_complete for the duration of the crawl. Without that, a crawl failing partway leaves partial rows under an intact marker, which no later run can get past, not even --full. The marker is restored only if the failed crawl stored nothing, on a context detached from the crawl so a per-channel deadline cannot swallow the restore.

Before/after

Two builds of the same tree differing only by this change (both carry a one-line auth patch so a user token authenticates; upstream main cannot otherwise run against my archive). Scratch copy of a real guild. A text channel, not a thread. Both runs start from a byte-identical database file and use the same command with no flags, which is the latest-only path.

$ sqlite3 demo.db "select count(*) from messages where channel_id='1492404278076112997';"
0

$ discrawl-before --config ... sync --guild 1489492316832923658
... messages_written=0 elapsed=0s
0 rows.

$ discrawl-after --config ... sync --guild 1489492316832923658
... messages_written=991 elapsed=5s
991 rows, oldest_id=1492406740556185722, history_complete=1

That was a full crawl, not one page: a page is 100 messages, oldest_id matches the first message the original backfill reached, and history_complete is set again, which only syncBackfillPages does on reaching the start of the channel. Re-running writes 0 and leaves 991.

The channel held 995 rows before I stranded it. Four ids from June to August are no longer served by Discord, and nothing new appeared, so the count carries that drift; the oldest_id match is the load-bearing part.

Two notes on the review itself: my original description said 24 channels, it is 27 today (4 text, 23 threads), both just what the query returned on the day. And the Live Verification step failed in pnpm install / corepack before reaching go run . sync --help, which looks like harness setup rather than this branch.

@clawsweeper clawsweeper Bot added proof: sufficient Contributor real behavior proof is sufficient. rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. and removed rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. labels Aug 20, 2026
Nothing deleted channel:<id>:verified_empty. A channel marked empty that
later gained messages kept the marker, so if those rows were lost again
loadChannelSyncState reloaded VerifiedEmpty, needsHistoryVerification
returned false, and the channel was skipped permanently. That is the state
this branch exists to prevent.

Reconcile the marker at load, in syncChannelMessages, whenever rows are
present. That is the only point the marker is read, so no caller can act on
one that was not checked against the rows first, and it covers every writer
including the gateway tail and the ordinary incremental path, neither of
which passes through verification. loadChannelSyncState now reads the
marker for every completed channel rather than only for empty ones, which
is one extra point read on the sync_state primary key.

recordVerifiedEmptyChannel also deletes the marker when its crawl found
rows, so a --full recheck that recovers a channel retires it in the same
run rather than leaving a stale one behind.
@rnavarro

Copy link
Copy Markdown
Author

A channel marked verified_empty that later gained messages kept the marker, so if those rows were lost again needsHistoryVerification returned false and the channel was skipped permanently. Nothing ever deleted the marker.

I now clear it in syncChannelMessages whenever rows are present. That is the only place the marker is read, so it covers every writer rather than only the verification path: an ordinary incremental sync and the gateway tail both store rows without going near recordVerifiedEmptyChannel. That function also clears it when its own crawl found rows, so a --full recheck retires the marker in the same run instead of the next one.

@clawsweeper clawsweeper Bot added rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. merge-risk: 🚨 availability 🚨 Merging this PR could cause crashes, hangs, restart loops, stalls, or process outages. rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. and removed status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. labels Aug 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

merge-risk: 🚨 availability 🚨 Merging this PR could cause crashes, hangs, restart loops, stalls, or process outages. merge-risk: 🚨 session-state 🚨 Merging this PR could lose, corrupt, stale, or mis-associate session or agent state. P1 Urgent regression or broken agent/channel workflow affecting real users now. proof: sufficient Contributor real behavior proof is sufficient. rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant