fix(knowledge): make the sync_status column the single source of truth - #7435
Conversation
Design Review (Fable 5) — ✅ PASSDesign-level review of All source hunks read and cross-checked against the description; the frontend badge path renders the column value generically, so Design-Verdict: PASS Root-cause fix at the correct seam — the store's own write path — with the migration's irreversibility explicitly bounded and every concurrency window compare-and-set. [DESIGN-REVIEWED] 9c1fd20 |
First Principles Review (Fable 5) — ✅ PASSPremise-level review of All verification complete. First-Principles-Verdict: PASS Two stores for one state was the cause of both reported symptoms; this deletes the second store at the write seam, with 0 blob readers/writers left behind. What this change shipsIntent: stop a source's sync state diverging (stale "synced" badge on a deleted file, paused folders still walked) by making the
Sibling count: grepped [FIRST-PRINCIPLES-REVIEWED] 9c1fd20 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
028faaf to
491154b
Compare
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
|
Both advisory reviews landed the same two findings against 1. Export/import round trip lost the status for post-fix bundles -- FIXED ( The reviewers are right, and the finding is sharper than the description I shipped: Pinned by This also answers the First Principles note that 2. The watcher bypassed the seam the PR declares canonical -- FIXED ( Also correct, and it undercut the PR's own thesis. I had matched the raw Scope note so the claim is not overstated: the raw column writes in The PR body was updated before the push, so the description the lanes read matches the diff. It also carries a second pattern-harvest rule this exchange earned: when a change empties a storage channel, every reader of that channel has to move in the same commit -- after removing a key from a persisted structure, grep for remaining readers of that key. |
491154b to
5da4a43
Compare
|
Round 2 dispositions against 1. GPT (BLOCKING) + First Principles + Design: the every-open migration is itself a reader of the blob copy -- FIXED ( All three lanes converged on this from different directions and they were all right, including against my own comment from round 1 that called the leftover blob key "inert (no reader consults it)". The migration's error-lift IS a reader, and it runs on every open, so:
Fixed at both ends. Pinned by 2. GPT (BLOCKING): the recovery write marked a failed ingestion as synced -- FIXED ( Correct. I had cleared Pinned by That test then surfaced a latent bug on main, in the same loop's own error handler: 3. GPT (BLOCKING): synchronous SQLite write on the event loop -- FIXED ( Accepted and applied, after checking the remedy was safe rather than taking it on faith: Scope note, so nothing is overclaimed: the two SELECTs at the top of 4. First Principles, accepted-and-deferred: 16 raw column writes remain elsewhere. Agreed with your own disposition on it. They write the authoritative store, so there is no divergence and nothing to fix for this defect; converting them is a mechanical refactor that belongs on its own. The PR body was updated before this push, so the description the lanes read matches the diff, and its pattern-harvest section now carries the rule this round earned: a repair pass that runs on every open and reads a field it does not also clear is not a migration, it is a permanent second reader. |
5da4a43 to
979b67c
Compare
|
Round 3 dispositions against GPT (BLOCKING) Correct, and it is the sharpest finding of the round. My Pinned by GPT (BLOCKING) Correct, same class one layer up. Where the guard is applied is a deliberate line rather than a blanket: a write whose value comes from an earlier SNAPSHOT is guarded, because it can be wrong by the time it lands; a write recording the outcome of something that just happened in this sweep (the Pinned by GPT (BLOCKING) Real, and cheap, so accepted without argument even though it needs an extreme blob. Pinned by GPT (BLOCKING) The prescribed fix is "revert the added every-open migration scans", which would reinstate the standing-reader defect that Design Review and First Principles both required fixing last round, so it needs evidence rather than compliance. Three measurements:
On the anchor itself: First Principles subtraction: drop the four This is the direct opposite of GPT's round-2 BLOCKING finding on the same lines, which cited the repo's own First Principles subtraction: fold the legacy-error lift into the retire loop -- DONE ( Good catch, and it made the code better in a second way: the two passes shared the First Principles, previously accepted-and-deferred: 16 raw column writes remain in |
979b67c to
acbf6c9
Compare
|
Round 4, now at First Principles subtraction: fold the pre-existing Taken, and it is a better shrink than it first looks. Every candidate for either kind of convergence is a row whose blob holds a status, so one SELECT covers both: the initial-state repair (blob status onto a column still at its The side effect is worth naming because it settles the last open argument on this PR: the store now performs ONE open-time scan of It also changed a test outcome, in a direction that pins something real. CI red: Backend Lint & Type Check (3.10) -- FIXED (
GPT's run against 558 tests pass across the affected files; black, flake8 and isort clean on every touched file. |
acbf6c9 to
5608205
Compare
|
Round 5, now at First Principles: the Your dichotomy is the right frame: either loop-blocking matters, in which case three unfixed siblings are the problem, or it does not, in which case the wrappers are surface. I cannot resolve it by deleting them -- that is precisely the line GPT blocked on in round 2, citing the repo's own
The named harm, since you asked for one rather than a caveat: the sweep is a background coroutine on the gateway's loop, Scope boundary, stated rather than implied, because your grep stopped at 606 tests pass across the affected files; flake8, black and isort clean. On GPT's lane: its run against |
5608205 to
c48c2c1
Compare
8a0f2c9 to
1e78cb2
Compare
|
Round 7 disposition against GPT (BLOCKING) Correct, and I verified the premise rather than taking it on faith. JSON permits escapes inside a KEY, so a blob stored as Reachability checked, because it decides whether this is a real row or a thought experiment: Fixed by taking your remedy rather than patching the filter: the pass no longer prefilters on blob text at all and decides membership from the parsed value. There is no substring that can cover the escaped forms -- every character in the key can be escaped independently -- so the only correct predicate is the decoded one. The prefilter was an optimization, and a cheap one to drop: Pinned by Everything else re-verified on the new head: 559 tests pass across the affected suites, and flake8, black, isort and |
1e78cb2 to
2c6dbc6
Compare
|
Round 8 disposition -- head GPT 5.6, BLOCKING, The finding is real and I confirmed it reachable before fixing it. The clear ran behind the mtime gate, and deletion is precisely the event that invalidates that gate: a restore preserving the archived mtime ( Fixed by removing the special case rather than adding a second hash site: the change gate now fires for Two existing tests in this file had seeded a mismatched New test Gates on No override used. Design Review, First Principles and Opus 4.8 all passed on |
A knowledge source's sync state was stored twice -- the sources.sync_status
column and a sync_status key inside the properties JSON blob -- with writers
and readers split across the two, so each side could act on a state the other
had never written. Converge on the column: it is what the dashboard, the
watcher's pre-scan skip and SyncScheduler.sync_all read.
The properties blob keeps one role, the INSERT-time channel a caller uses to
state an initial status, held to an allowlist. It is no longer persisted next to
the column, and no row keeps a second answer after the store has opened once.
After insert the column is written explicitly or not at all: a status found in a
properties write is dropped rather than applied, because a blob read off a
legacy row is stale by definition.
- store: _without_sync_status strips the key on all three insert paths and on
every update. One migration pass repairs the column where it was never written
and then RETIRES the copy, so its own read is a one-time repair rather than a
standing reader of a value that goes stale the moment a column-only writer
moves the row. Membership is decided on the PARSED blob, not a substring of
its text: JSON escapes are legal in a key, so {"sync_\u0073tatus": ...} holds
the key without containing it literally.
- store: a LIFECYCLE value in the blob is never promoted, not even 'error'. It
cannot be ordered against the column, so promoting would mark a recovered
source errored. Not promoting costs at most one sync attempt, because
_record_failure already carries that row's failure count.
- store: update_source takes if_sync_status, a compare-and-set on the status
column, and the migration's repair binds the column it read as well as the
blob. Every live writer transitions the column WITHOUT touching properties, so
a blob-only precondition still matched and could stamp a stale status over a
transition that had just landed.
- store: import_bundle restores each source's status from the column export_all
ships, falling back to a legacy bundle's blob copy, so a restored pause is not
silently resumed; 'paused' joins the durable initial states the shared
allowlist admits. The migration's JSON parses also catch RecursionError, which
the ValueError guard missed and which runs on every open.
- watcher: the folder pre-scan skip reads the column, and a vanished local_file
is marked 'missing' in the column instead of the blob. That marker can be left
again when the file returns -- but only once processing has shown the stored
copy is current, since 'synced' is a claim about content. Both writes derive
from the sweep's snapshot, so both are compare-and-set, and every store call in
the sweep now runs off the event loop.
- watcher: the sweep's own error handler called .get() on a sqlite3.Row, so any
failure in the single-file loop raised AttributeError from inside the handler
and abandoned every remaining source for that sweep.
- sync: _record_failure writes the column only, and sync_all reads it only, off
the event loop.
- handlers: confirm/pause/resume stop double-writing the blob.
2c6dbc6 to
9c1fd20
Compare
|
Round 9 disposition -- head BLOCKING, Verified by enumerating every exit of Fixed by deleting the guard and the flag, which leaves the CAS as the only discriminator -- and it is the right one. An ingestion that ran has already moved the row off Two tests pin it.
Correct: a legacy blob-
Correct, and the CODE is right while the comment overstated it. The repair predicates on both (
Round 6 removed the lift and the migration now refuses to promote Gates on |
bolichen97
left a comment
There was a problem hiding this comment.
Reviewed via parallel subagent audit: diff matches description, CI fully green, no blocking findings, no unresolved threads.
Problem / Motivation
A knowledge source's sync state is stored twice: the
sources.sync_statusCOLUMN and a
sync_statuskey inside thepropertiesJSON blob. Writers andreaders are split across the two stores, so each side can act on a state the
other never wrote.
Two symptoms a user can see today:
Library ("synced"). The watcher marks it missing in the properties blob only,
and the Library renders the column. The blob key is also never cleared when
the file comes back, so the two stores stay divergent for the life of the row.
in the column alone still walks and delete-reconciles the whole folder every
sweep. Today that is avoided only because three handlers remember to write
both stores by hand; the next transition that forgets re-opens the bug.
Restoring a bundle also landed every source at the
pendingdefault, becauseimport_bundle-- the third insert path -- never wrote the column at all.Why it matters
Sync state is what the Library shows and what the sweep obeys, so a divergence
is both a wrong badge and wrong work: a paused folder that is still walked
spends the scan budget the user paused to stop, and an errored source that only
one reader can see is retried forever. It has already been patched twice
(seeding the column on insert, then reading either store in
sync_all), eachtime by teaching one more site about both copies. That is the pattern that keeps
the defect alive: every new transition has to remember a rule nothing enforces.
What changed (motivation -> approach -> change)
Symptom: state read from one store, written to the other. Root cause: two
stores, with no seam that makes one of them authoritative. Fix: converge on the
column, and put the convergence at the store's own write seam rather than in a
convention writers must remember.
The blob keeps exactly one role -- the INSERT-time channel a caller uses to
STATE an initial status, held to an allowlist. It is no longer persisted next to
the column, and no row keeps a second answer once the store has opened. After
insert the column is written explicitly or not at all: a status found in a
properties write is DROPPED, not applied. That direction is deliberate -- a blob
read off a legacy row is stale by definition, and applying it would let the
watcher stamp
missingback onto a file it had just re-ingested (pinned by atest).
store.py:_without_sync_statusstrips the key on all three insert paths andon every
update_source, so no caller can mint a second copy.store.py: ONE migration pass over the rows that still carry a copy does allthe convergence -- it absorbs the pre-existing initial-state repair, repairs the
column where it was never written, and then RETIRES the copy. Every candidate is
a row whose blob holds a status. Membership is decided on the PARSED blob
rather than a substring of its text: JSON escapes are legal inside a key, so a
blob stored as
{"sync_\u0073tatus": "paused"}holds the key this passconverges without ever containing it literally, and a raw-text filter would
skip the row -- leaving the column at its default and the watcher walking a
folder the user had paused.
import_bundleused to store a bundle's propertiesverbatim, so such a row can exist. The retirement is what keeps the repair
honest: the migration runs on every open, so leaving the key would make it a
standing reader of a value that goes stale the moment a column-only writer moves
the row.
store.py: a LIFECYCLE value in the blob is deliberately NOT promoted, not even'error'. It cannot be ordered against the column -- a pre-column_record_failurewrote'error'to the blob alone, and a later successfulre-ingest wrote
'synced'to the column alone, so neither copy carries evidenceof which happened last. Promoting would mark a RECOVERED source errored, with
the copy retired in the same pass so nothing could correct it. Not promoting
costs at most ONE sync attempt:
_record_failurereadsconsecutive_failuresfrom the blob, which such a row already carries at or above its threshold, so the
first attempt that fails writes the column and quiesces the source for good --
while an attempt that SUCCEEDS is the right outcome for a source that had
recovered. The column is authoritative; a value that cannot be ordered against
it does not get to overrule it.
store.py:import_bundlerestores each source's status from the COLUMN thatexport_allships, falling back to a legacy bundle's blob copy, so a restoredpause is not silently resumed -- then strips the blob like every other insert
path, so a status the allowlist REFUSED cannot be read back by the migration
one reopen later.
'paused'joins the durable initial states, which everyinsert path now reads through one shared allowlist; a bundle is untrusted
input, so an outcome state like
errororsyncingstill cannot be assertedabout work that never ran.
store.py:update_sourcegainsif_sync_status, a compare-and-set on thestatus column, and the migration's repair binds the column it read as well as
the blob. Every live writer transitions the column WITHOUT touching properties, so
a blob-only precondition still matched: the repair could stamp the blob's initial
state over a transition that had just landed, and the sweep could stamp
'synced'over an'error'a manual sync recorded while it ran. A caller that derives astatus from a snapshot passes the value it saw; a caller writing the outcome of
something that just happened has current information and does not.
store.py: the migration's JSON parses also catchRecursionError-- aRuntimeError, so theValueError/TypeErrorguard missed it.json.loadsrecurses per nesting level and this runs on EVERY open, so one pathologically
nested legacy blob would abort every store construction rather than skipping
one row.
watcher.py: the folder pre-scan skip reads the column; a vanishedlocal_fileis markedmissingin the column, and can LEAVE that state whenthe file returns. A returning file's content is READ, not assumed: deletion is
the event that breaks the mtime heuristic, because a restore preserving the
archived mtime (
cp -p,rsync -t,tar -x) can put different content on diskunder an mtime that never advanced, so the change gate now also fires for a row
that reads
missing. The clear runs LAST, after the read, and itscompare-and-set on
missingis what decides whether it is the write that takesthe marker off: an ingestion that ran has already written the column (
syncedwhen it stored the document,
erroron a partial write) and this no-ops, whilethe one outcome that writes NO status -- the pre-ingest duplicate gate, which
refuses the write because a holder already holds this exact document -- is the
one it clears. A failed ingest raises, so the clear is never reached with the
file unread. Both writes derive from the sweep's snapshot, so both are
compare-and-set. Status writes go through
update_source(one spelling in the file, including the one that was already there).
watcher.py: every store call in the sweep's own body -- the two sourcequeries, the post-ingest re-read, and all four writes -- now runs off the event
loop. The sweep is a background coroutine and sqlite reads are synchronous, so
a contended database could hold one for as long as
busy_timeoutand stallevery other task;
KnowledgeStorekeeps a connection per thread, so a workergets its own. Scope boundary, stated rather than implied: the re-embed job the
sweep delegates to at the end still makes synchronous store calls. Those are
pre-existing, belong to the embedding-job machinery rather than to source
state, and are untouched here.
.github/sync-io-in-async-baseline.txtrecordsthe shrink the repo's own gate asks for:
knowledge/sync.py1 -> 0 andknowledge/watcher.py6 -> 2.watcher.py: the sweep's own error handler called.get()on asqlite3.Row,which has no such method -- so any failure in the single-file loop raised
AttributeErrorfrom inside the handler, replacing the real error andabandoning every remaining source for that sweep. Found by the test for the
failed-re-ingest case above.
sync.py:_record_failurewrites the column only;sync_allreads it only,off the event loop.
handlers/knowledge.py: confirm / pause / resume stop double-writing the blob.No frontend change: the Library already reads the top-level
sync_statusfield,which is the column.
Tests
New
test/test_knowledge_sync_status_column.py-- the watcher contract:local_fileis markedmissingin the column, and writes nosecond copy
missing; a present file at another status isuntouched; a returning file whose re-ingest FAILS stays
missingrather thanreporting content it never ingested; and the recovery write loses to a status
that moved mid-sweep
case where the mtime gate alone would have reported freshness about a file it
never opened
cleared, so a present, accounted-for file does not go on reading
missing;and the clear loses to a status the ingestion itself wrote
test/test_knowledge.py-- the store contract, including a JSON-escaped statuskey that converges and is retired: insert stores no second copy and
does not mutate the caller's dict; an outcome state in properties still never
seeds the column, while
pausedis accepted;update_sourcedrops a blob-bornestatus and honours
if_sync_status; a stale blob cannot move the column; themigration retires the copy without promoting a lifecycle value, does not
re-error a source that has since synced (whether it recovered before or after the
first open), loses its repair to a concurrent column write, and survives a blob
too deeply nested to parse; an export/import round trip restores
pausedandpending_confirmationwhile refusingerror; a legacy blob-onlybundle still restores; and a refused bundle status does not come back at the next
open.
Rewritten rather than deleted, so the behaviour they guarded is still guarded:
the
sync_alllegacy-error test now builds a real pre-column row and reopens thestore, so it proves the honest contract: the row is attempted once, and the first
failure writes the column and quiesces it for good; the
mid-pass race test still proves a concurrent properties write beats the snapshot,
and now also pins the self-heal -- every write for the raced row is refused, so
nothing is lost, and the next open converges it; the
pause / project-docs / autosource assertions read the column; the bundle
JSON-well-formedness test keeps its subject on a non-status key.
Every new or changed case was checked RED against the code it fixes -- 11 of 15
against unmodified
src/in round 1, and every round-2 and round-3 case againstthe revision it corrects. 558 tests pass across the affected files; flake8 and
isort clean on every touched file.
Manual verification
N/A -- unit coverage sufficient. Both symptoms are store/watcher state
transitions with no UI of their own, and each is now driven end to end through
the real
KnowledgeWatcher._scan()and a real reopenedKnowledgeStore,including the concurrent-writer windows.
Related Issues
No GitHub issue: this comes from the local review backlog as
f-20260815-02(recorded 2026-08-15, held for a ruling on which store should win; the ruling is
the column, which is what both prior patches had been converging on anyway).
Pattern harvest
Rule candidate:
review-prompt/agents-md.Pattern: "one fact persisted in two places, converged by asking every writer to
update both". The tell is a comment that explains why a write is duplicated
("keep the JSON copy in sync with the column") or a read that ORs two stores
("errored in EITHER store") -- both appeared here, added by earlier fixes to
this same defect. Each such patch narrows the window instead of closing it,
because correctness then rests on every future writer remembering. The
generalizable move is the one taken here: pick the authoritative store, and
enforce it at the single write seam so the other copy cannot be created, rather
than teaching one more site to write both.
Three narrower rules this PR earned the hard way, all worth a review prompt:
move in the same commit. Revision 1 stripped the blob but left
import_bundlereading it, which would have resumed a paused folder on every restore.
Mechanical check: after removing a key from a persisted structure, grep for
remaining readers of that key.
place is not a migration, it is a permanent second reader. Revision 2 had a
lift that would re-apply a stale
errorover a freshsyncedat every start.Mechanical check: a repair pass that reads a field it does not also clear will
re-apply it forever.
the one the reader happened to look at. Revision 2's lift bound the blob only
-- and after this change every live writer moves the COLUMN and leaves the blob
alone, so the precondition matched exactly when it should not have. Mechanical
check: when converging on one field, audit each CAS whose precondition names a
different field from the one being written.
Revision note (
c48c2c1e4). An earlier revision of this PR DID promote a blob'error'onto the column. Opus showed that a source which recovered before thefirst upgrade open would be re-marked errored and then have its copy retired, so
nothing could correct it -- the third finding against that one mechanism across
three rounds. Adding a fourth guard was the wrong answer: the promotion is not
load-bearing, because the scheduler's own failure count re-quiesces a still-failing
row on its first failed attempt. It is gone, and the pattern harvest below records
that.