fix(knowledge): reconcile artifacts on every start, not on source-row creation - #2452
Conversation
Design Review (Fable 5) — 🟡 CONCERNSAdvisory design-level review of Design-Verdict: CONCERNS Sound root-cause fix, but the repair loop is restart-gated: a deferred backlog on a long-running gateway silently stays incomplete for weeks. WatchThe budget "defers the remainder … drains across successive starts" — but reconcile is only ever armed from Suggestions
[DESIGN-REVIEWED] 7c18b88 |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsFINDING -- src/kiro_crew/knowledge/artifact_ingest.py:752 -- extractor failures hit False positive or not applicable? A repository writer can comment: |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsBoth candidates are self-rated low confidence and framed as scalability/accuracy concerns. I verified the mechanisms against the actual code. Candidate 1 (failed extraction consumes budget → starves older artifacts): The mechanism is real — a non-raising failed/partial ingest returns a truthy Candidate 2 (O(N) Neither survives falsification at ≥80. No findings. [OPUS-REVIEWED] 7c18b88 Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
c97545f to
09cbb77
Compare
|
All three findings on 1. GPT + Opus (both) — BLOCKING,
Now Worth flagging for maintainers: this is the same defect class as #2175 and #2336, and the existing ratchet did not catch it. New test: 2. GPT — BLOCKING,
New test: 3. GPT — BLOCKING,
New test: Verification on The Windows shard-2 failure on the previous SHA was |
09cbb77 to
85711b0
Compare
|
Both findings on 1. GPT — BLOCKING, Confirmed the mechanism in code before fixing. On a Reconcile now reads the job status and New test: 2. GPT — BLOCKING,
New test: On the Windows shard-2 failure — not from this diff, and
The test makes a file unreadable with It was added today in #1676 ( |
85711b0 to
7476652
Compare
|
Accepted GPT 5.6's blocking finding on Two unbudgeted repair passes now run inside
3 new tests, each verified failing on the unmodified source: kind-change-to-unsupported reaps, kind drift reaps under an empty allowlist, and rename-while-off refreshes the label without re-ingesting (asserts item ids unchanged and Also rebased onto current main (was 50+ commits behind). |
7476652 to
4ee8e09
Compare
|
Accepted GPT 5.6's round-2 finding (
So reconcile now decides from the kind recorded at ingest instead. Tests: 6 reconcile tests now cover kind change to unsupported, kind change into an excluded kind, narrowing-alone-never-reaps across repeated starts, NULL legacy row left alone, drift reaped under an empty allowlist, and rename-while-off refreshing the label without re-ingesting. Suite: 47 passed in the file, 1793 passed across all knowledge/artifact tests. flake8, isort, mypy clean. Spec doc updated with the recorded-kind rationale. On Fable's advisory CONCERNS (in-process retry for a >50 deferred backlog): leaving as-is for this PR, since it changes when billed extraction runs rather than fixing a correctness gap, and the PR is already a follow-up to a merged change. Happy to take it as a separate issue. |
38ad7f4 to
1d87792
Compare
|
Both round-7 findings addressed in Nonempty fallback snapshots overwrite newer content ( Startup retry duplicates committed items (
The probe test is kept as a regression test: it deletes the state row post-ingest and asserts exactly one copy of the body survives the next reconcile (it fails without the sweep, showing 2). Suite: 56 passed in the file, 1802 across all knowledge/artifact tests. flake8, isort, mypy clean. Spec doc updated with both rules. |
1d87792 to
3ec7ddd
Compare
|
Round-8 finding is correct, and I have removed the orphan sweep in That leaves the round-7 duplication unfixed, which I want to be explicit about rather than paper over. The two findings are in direct tension: reconstructing ownership requires treating unowned items as residue, and that is only safe once imported items arrive owned. The root cause is that Scope call: the duplication window is reachable from the live listener path too (crash, then any upsert event for that artifact), so it predates this PR and removing the sweep restores the status quo rather than regressing it. Filed #2670 with both halves, the reproduction, and the suggested direction (carry the state tables in export/import, then fence the crash window, after which the sweep becomes safe and also repairs pre-existing residue). Everything else from rounds 1-7 stands. Suite: 55 passed in the file, 1801 across all knowledge/artifact tests. flake8, isort, mypy clean. |
UX Review (Fable 5) — ⏭️ skippedRevision |
… creation The catch-up pass was gated on ensure_artifact_source returning created=True. That row outlives the feature being switched off, so on any install that ever had auto-ingest on, a later opt-in got created=False and never repaired the drift from the off-window.
3ec7ddd to
7c18b88
Compare
|
Accepted the round-9 finding, fixed in There is now an unbudgeted pre-pass before the ingest loop that removes tracked artifacts whose live body is blank. Scoping:
Two tests: with Suite: 57 passed in the file, 1803 across all knowledge/artifact tests. flake8, isort, mypy clean. Spec doc updated. For context on where this stands: all 48 CI checks were green on the previous head, with this review the only blocker. |
… creation (kirodotdev#2452) The catch-up pass was gated on ensure_artifact_source returning created=True. That row outlives the feature being switched off, so on any install that ever had auto-ingest on, a later opt-in got created=False and never repaired the drift from the off-window.
Follow-up to #2448, fixing the blocking finding GPT 5.6 raised on it. #2448 was merged while CI was still running, so the finding was never acted on; dispositions are recorded here.
Problem
Artifacts silently stop appearing in the Knowledge Library, permanently, with the toggle reading ON.
ArtifactKnowledgeSync.start()gated its catch-up pass on whether it had just inserted the aggregate source row:That row outlives the feature being switched off. So on any install that ever had auto-ingest on — which, before #2448, was every install, since it shipped on by default:
knowledge.auto_ingest_artifactsgoes off (an upgrade past fix(knowledge): make the three auto-ingest paths opt-in #2448, or the user flips it) →_start_artifact_ingest_asyncreturns early → the change-listener is never registeredensure_artifact_sourcefinds the row →created=False→ the catch-up never runsThe only recovery was to delete the "Artifacts" source by hand so the row would be re-inserted on the next boot — which nobody would guess.
The
createdflag was documented as a deliberate design choice ("the row's existence is the idempotency marker … no separate flag needed"), justified by "nothing writes the store while the gateway is down, and there is no out-of-process writer, so no recurring reconcile is needed". Making the feature opt-in invalidated that premise: the gateway now runs, and writes artifacts, with this listener switched off.Why it matters
It is silent and it is not self-healing. The switch says ON, the artifact exists, the Library does not have it, and nothing in the UI or the logs tells the user which of those to distrust. Search then returns confidently incomplete results — worse than an obvious failure, because the user has no reason to doubt it.
Fix (symptoms → root cause → change)
Root cause: the catch-up pass keyed off a proxy (row insertion) for the thing it actually needed to know (does the Library match the store?). The proxy was only ever valid while the feature could not be off.
Change: replace it with a real comparison,
reconcile_artifacts, and run it on everystart():artifact_item_statelacks or disagrees withcreatedis now reported for logging only, andensure_artifact_source's docstring says so, so the trap is not re-set laterThis repairs drift from any cause — the feature having been off, a crash mid-ingest, a restore from backup — not just this one. That is why it was chosen over the alternative (a config write-back migration that preserves the old effective value): the migration fixes the one path that produced this bug, while reconcile fixes the class.
Three properties that make running it on every start safe:
ingest_artifactalready returnsNonefor unchanged content, so the steady state spends zero extraction calls and logs at debug rather than writing a line every boot.auto_ingest_artifact_kindsmakes an artifact ineligible, not absent — reaping on that basis would delete content the user never deleted. Removals run first and are unbudgeted, since they are pure state deletes with no token cost.RECONCILE_INGEST_BUDGET(a module constant — no new config key, no i18n or settings surface).ArtifactStore.listis newest-first, so a backlog from a long off-window drains across successive starts with the most recent artifacts landing first, rather than arriving as one unbounded burst of billed extraction calls. This also closes the second item I disclosed on fix(knowledge): make the three auto-ingest paths opt-in #2448 ("the first-enable artifact backfill is unbounded"). Unchanged artifacts never consume budget, so a converged store never defers.Both the filesystem walk and the state read are
asyncio.to_thread-offloaded, as the previous pass already was.Tests
test/test_knowledge_artifact_ingest.py—TestBackfillbecomesTestReconcile, plus the regression:test_start_still_reconciles_when_the_source_row_already_exists— the regression this PR exists for. Pre-creates the row (as an upgrade has), adds content while "off", and assertsstart()still reconciles. Proven red against the pre-fix gate:test_a_converged_store_costs_nothing— second pass over identical content returns(0, 0, 0). This is the property that makes every-start safe; without it the fix would trade a correctness bug for a cost bug.test_reconcile_drops_state_for_artifacts_deleted_while_off— a delete during the off-window leaves searchable text with no artifact behind it; reconcile must drop it.test_an_ineligible_kind_is_not_treated_as_deleted— narrowingkindsmust not reap.test_budget_defers_the_remainder_and_a_later_run_finishes_it— the budget defers rather than drops, and the next start finishes the backlog.test_reconcile_ingests_eligible_only/test_reconcile_empty_kinds_noop— carried over fromTestBackfill.Manual verification
pytest(full backend)isort --check-only/flake8mypy src/kiro_crew/hooks.pyscripts/docs-lint.shThe 17 failures are the same pre-existing macOS-host set documented on #2448 (Seatbelt sandbox spawn, nested-pytest harness, a macOS temp-path redaction artifact, a
__CF_USER_TEXT_ENCODINGenv leak) — verified against a cleanorigin/maincheckout there, and byte-identical node IDs here. Passing count rose 39814 → 39915 with the new tests; no new failure appeared.No frontend gates run: this change has no
website/diff.Screenshots
N/A — no user-visible UI change. The fix is entirely in the gateway's startup reconcile path; the Settings toggle and its copy are untouched by this PR (they were shipped in #2448).
Review round 2 (commit
09cbb7764)Three blocking findings from the first round, all legitimate, all fixed — see the disposition comment for the full reasoning:
remove_artifactran on the event loop (GPT + Opus).remove_artifact->delete_items_batch->_load_graphis a graph rebuild in a SQLite transaction; the sibling live-delete in_handlealready offloads the identical call. Nowawait asyncio.to_thread(...). This is the same defect class as fix(knowledge): run per-file dedup off the event loop #2175 / fix(knowledge): run the ingest-path item deletes off the event loop #2336 — and the existingtest_knowledge_delete_off_loop.pyratchet missed it, because it scans for the literaldelete_items_batchinsideasync defbodies and my call reached it viaremove_artifact.if not kinds: returnsat above the removal loop, so an empty allowlist left deleted content searchable. Removals now run first, unconditionally.meta.jsonwas treated as a deletion.ArtifactStore.listomits such an artifact, so a transient read error would have deleted a live artifact's indexed content._artifact_is_really_gonenow confirms per slug and removes only onArtifactNotFoundError.Three new tests, one per finding; the off-loop one asserts the thread and is proven red against the pre-fix code.
Still open from #2448
Not fixed here, and deliberately: the artifacts toggle still needs a gateway restart to take effect.
_start_artifact_ingest_asyncreadsauto_ingest_artifactsonce at startup, while its two neighbours in the same Settings card re-read config every watcher sweep and apply immediately. This PR makes the restart repair the drift correctly, but the user still has to know to restart. Fixing that means either a live config re-read on this path or an explicit hint on the toggle — a separate change, and the UX reviewer flagged it on #2448 too.