fix(knowledge): take created ids from writes, not source diffs (#4431) - #4443
Conversation
GPT 5.6 Review — ✅ human override acceptedHuman judgment by @bolichen97 overrides the GPT 5.6 finding for This comment is updated in place on each push. The model was not re-run because an authorized human decision supersedes it. False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — 🟡 CONCERNSAdvisory premise-level review of Both data files read; I've verified the claims against the repository (the First-Principles-Verdict: CONCERNS Sound fix at cause level — but its own fail-safe rationale indicts an untouched sibling callback, and one baseline prune rides along undeclared. What this change shipsIntent: stop an ingest from deleting or claiming knowledge a concurrent import committed, by taking created ids from the writes themselves — a FIX.
Watch
[FIRST-PRINCIPLES-REVIEWED] 2342533 |
Design Review (Fable 5) — ✅ PASSAdvisory design-level review of Design-Verdict: PASS Root-cause fix on the established seam: ids collected at the write (the existing Watch
[DESIGN-REVIEWED] 2342533 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsI've independently verified the three conversion sites and the cross-file
No path where No findings. [OPUS-REVIEWED] 2342533 Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
8e51d91 to
2522af3
Compare
2522af3 to
e65736c
Compare
e65736c to
bc76f6f
Compare
|
Adjudication for the partial-failure rollback span (ingestion.py
Resolution: round 3 is correct and is now final. The rolled-back items are chunks of an INCOMPLETE write. A concurrent identical ingest whose duplicate gate attached to them recorded the whole-document Disposition: the round-2 prescription is REJECTED as harmful; the round-3 prescription is ADOPTED. Both rollback calls stay unscoped, with the rationale recorded in code comments at both sites. Please treat this span as adjudicated: further findings prescribing owner-scoping here should reference and rebut the truncated-document argument above rather than re-raising the round-2 form. |
bc76f6f to
24addd2
Compare
|
Adjudication for the round-5 findings on head 24addd2 (operator decision, option b from the escalation comment): Both round-5 findings (artifact ownership persist vs concurrent dedup collapse; partial-rollback delete vs cascade reassignment) are adjudicated as a pre-existing residual race class, deferred to follow-up #4457 (filed, cross-linked with #4441 since both change the transactional shape of the same hops):
Disposition: REBUT-AS-OUT-OF-SCOPE with tracked follow-up #4457. Findings in this class on subsequent heads of this PR should be reported as advisory referencing #4457, not BLOCKING, unless the finding shows the window is NEWLY INTRODUCED or WIDENED by this diff. |
b10712a to
2342533
Compare
|
/ai-review override gpt 2342533: residual pre-existing race class predating this PR, adjudicated and tracked in follow-up #4457 Context: the remaining finding (fallback |
Human judgment recorded@bolichen97 marked the gpt AI finding as false positive, not applicable, or explicitly accepted for
This decision applies only to this commit. A new push requires a new judgment. |
iamwhatever
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: fix (4 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean, security checklist all-NO, AI reviewers green. Category: fix -- sources knowledge-ingest created ids from write responses instead of source diffs, fixing id tracking.
Open PR relationship auditThis is a consolidated, point-in-time code-level audit note. It compares complete merge-base diffs and current/merged code; it does not treat a shared topic as duplication or partial coverage as completion. Relationship findings
No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit. |
Problem / Motivation
Three knowledge-ingestion sites still derive "what this call wrote" by snapshotting the source's full item-id set before the work and diffing a second snapshot afterwards. Each snapshot is a
SELECT id FROM items WHERE source_id = ?materializing one row per item in the SOURCE (~20k rows, over a second of blocking SQLite on a large library), and the "before" read runs ON the asyncio event loop, once per ingested file — the same per-file loop stall open PR #3397 removes from the folder-scan caller:ingestion.py::_ingest_file_body—_before_idscaptured on the loop, consumed only by the partial-failure branch of_finalize.ingestion.py::ingest_text— same shape, and this function did not yet collect the ids it writes.artifact_ingest.py::ingest_artifact— full before/after diff feeding_set_state, while the pipeline it calls already reports created ids throughon_committed(the contractagent_source.pyconsumes today).Why it matters
Beyond the loop stall, the diff is wrong, not just slow:
import_bundlewrites into the same aggregate source in its own transaction under no shared lock, so anything it commits while an ingest is awaiting gets attributed to that ingest — handing a document delete authority over knowledge it never created. The comment at the site 1 collection already records this; sites 2 and 3 still had the defect.What changed (motivation → approach → change)
Ids are now collected at the write, never inferred from a source snapshot — the same conversion #3397 applies to the folder-scan caller, extended to the remaining three sites:
_ingest_file_body): deleted the_before_idscapture; the partial-failure branch passeslist(created_item_ids)(already collected immediately afteradd_item, beforeadd_source_location/_store_entities/_embed_item, so a chunk that raises partway is still captured) instead ofafter_ids - _before_ids, dropping theafter_idsre-read too.ingest_text): addedcreated_item_idscollection mirroring site 1's ordering exactly; deleted the_before_idscapture and theafter_idsre-read.ingest_artifact): deleted both snapshot reads; the ids come frompipeline.ingest_file(..., on_committed=_record_ownership), which fires inside the finalize hop only on theprocessed == totalbranch — the same branch that reports job statuscompleted, which is the only path that consumesnew_ids. The callback also persists_set_statethere (GPT round-1 finding): the awaits between the pipeline's commit and the caller's own state write (temp-file cleanup, job-status read) are cancellation points, and a shutdown landing there would orphan the committed group; the write is fail-safe (a swallowed error defers to the caller's idempotent post-ingest write). The duplicate and partial/failed early returns are untouched.Strictly out of scope (deliberately not widened): the
_old_item_idsreads iningest_file/ingest_textshare the query text but mean "the pre-existing item group this call must delete" — no callback can supply them, and moving them off the loop changes the transactional shape of the delete. Filed as follow-up #4441. Also untouched:folder_watcher.pyandtest/test_knowledge_ingest_scan_off_loop.py(owned by open PR #3397 — this diff has zero file overlap with it), theprocessed == totalsuccess path and the duplicate gate. The partial-failure rollback calls deliberately do NOT passowner_source_id(adjudicated across GPT rounds 2-3, which prescribed opposite fixes for this span): the rolled-back items are chunks of an INCOMPLETE write, and detaching them to a concurrent duplicate-gate attacher would leave that attacher holding a truncated document whose recorded whole-document hash reads as satisfied, so no rescan ever repairs it. Destroying them outright leaves the attacher's claim naming an empty group, which the doc-state recovery paths already treat as re-attempt. This matches the pre-existing rollback semantics (the oldafter_ids - before_idsdelete was also unscoped). The success path's ownowner_source_idusage is untouched.Tests
New
test/test_knowledge_ingest_created_ids.py(all six fail on main before this change, pass after):extract_batch, i.e. after the point the old "before" snapshot was taken — theimport_bundlemisattribution case). Asserts the rollback deletes exactly what this call created: both foreign items survive,sync_statusgoeserror. On main the mid-ingest foreign item is destroyed.SELECT id FROM items WHERE source_idliteral per function (nested_finalizeclosures included, so re-adding the read on- or off-loop trips it):_ingest_file_body= 0,ingest_artifact= 0,ingest_text= exactly 2 (the out-of-scope_old_item_idsreads tracked by knowledge: _old_item_ids full-source reads still run on the event loop (ingest_file/ingest_text) #4441 — the message tells the future fixer to repoint the ratchet). Counting occurrences, not timing, per the shape fix(knowledge): take ingested item ids from the commit callback #3397 established.on_committedand asserts_set_statestored exactly the reported ids, non-empty on thecompletedpath (an empty list would silently lose the slug's claim/ownership record), and assertsingest_artifactstill passeson_committedat all.Local gates:
isort/flake8/mypyclean; fullpytest56640 passed, 296 skipped, 6 xfailed.Manual verification
N/A — unit coverage sufficient: the converted paths are exercised end-to-end through the real
IngestionPipelineand SQLite store in the new tests, including the concurrent-writer interleaving.Related Issues
Closes #4431
Follow-up for the out-of-scope on-loop reads: #4441
Checklist
docs/system-specs/modules/knowledge.mdalready documents the collected-at-the-write contract this PR extends; no documented behavior changed