fix(knowledge): take ingested item ids from the commit callback - #3397
Conversation
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: |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsI've traced the full contract independently: the pipeline's The candidate list produced no candidates, and my own Step 2 pass finds nothing groundable to the required bar. No findings. [OPUS-REVIEWED] acba1fd Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
Design Review (Fable 5) — ✅ PASSAdvisory design-level review of Design-Verdict: PASS Root-cause fix on a pre-existing pipeline seam ( [DESIGN-REVIEWED] acba1fd |
307263f to
dcc7fac
Compare
Disposition for GPT 5.6 findings on
|
|
🤖 Kiro Crew [operator: bolichen97]: This PR has been inactive for 7+ days with failing CI. I've assessed the blockers and they appear resolvable — I'll push fixes directly to this branch as a co-author. Assessment: GPT 5.6 BLOCKING on folder_watcher.py:920 — recorder does not persist the committed group inside the callback (shutdown-race can orphan ingested items). Fix: update the existing folder_file_state row with committed IDs and terminal status inside _record_committed, closing the CancelledError window. If you'd prefer I don't touch this PR, add the |
dcc7fac to
9a57af9
Compare
|
🤖 Kiro Crew [operator: bolichen97] Pushed 1. GPT 5.6 BLOCKING (folder_watcher.py 2. Six CI test failures on the branch — fixed
3. New test: Local gates: isort ✅ flake8 ✅ mypy ✅ (42 pre-existing errors identical before/after) black baseline gate ✅ full pytest: 56623 passed / 0 failed. No design change: the callback-based approach is kept exactly as the author built it; the fix only makes the callback's effect durable. |
First Principles Review (Fable 5) — 🟡 CONCERNSAdvisory premise-level review of Now I have everything needed. Verifying the sibling count once more before writing the review: on-loop First-Principles-Verdict: CONCERNS The crash-loop fix is cause-level, but a second What this change shipsIntent: stop a large folder source from stall-crashing the gateway on every boot scan — a FIX.
Watch
[FIRST-PRINCIPLES-REVIEWED] acba1fd |
`_ingest_file` learned which items a file produced by reading the source's entire item-id set before and after handing the file to the pipeline, and diffing the two. Both reads ran synchronously on the event loop. `idx_items_source_id` keeps each read an index scan rather than a table scan, but it still materializes one row per item in the SOURCE: about 20k rows on a large folder source, measured at over a second per call, twice for every file in the scan. That is far past the loop-stall watchdog's budget, so the watchdog exits the process, the supervisor respawns it, the boot scan re-enters the same reads, and the gateway crash-loops -- four exits inside 17 minutes on a 2709-file source. Neither read is necessary. The pipeline already reports the ids it created through its `on_committed` callback, which it invokes inside its own `run_to_completion` finalize hop -- the same uncancellable unit that commits them. Passing a recorder there removes both reads outright instead of moving them to a worker thread, and removes the `sources.sync_status` read as well: `on_committed` fires only on the branch that actually commits a group, so an unset recorder IS the silent-rollback signal, per call rather than per source. Eliminating the reads matters beyond cost. Offloading them would have added await points AFTER the pipeline commits, and the caller writes the `folder_file_state` row naming the new items only once `_ingest_file` returns. A shutdown cancelling at such an await would leave committed items that no state row names: the next scan re-ingests the file and duplicates them while the first group stays untracked and undeletable. Sourcing the ids from the callback means there is no post-commit await to cancel. The recorder also PERSISTS the committed group from inside the finalize hop: the pipeline awaits again after that hop (`generate_source_summary`), so a shutdown cancelling there would otherwise leave a committed group that only the closure remembered -- the caller's state write never runs, the 'scanning' marker survives, and the next sweep re-ingests the file alongside the untracked first group. The write is a targeted UPDATE onto the 'scanning' marker the scan wrote before the call, and it is fail-safe: a writer-lock timeout is swallowed (the memory path and the caller's own 'done' write still stand) because a raise inside the finalize hop after the commit would poison the whole ingest and cause the very duplication it prevents. Both properties are ratcheted: the coroutine body may contain no synchronous sqlite call, and no await other than the pipeline call itself. Each ratchet ships with negative controls in both directions, so neither can pass vacuously. This is the site that survived #2175, #2336 and #2507 -- each moved a different call off the loop (`dedup_document`, `delete_items_batch`, the duplicate-skip gate) and none touched this one, which is why crash loops continued on builds carrying all three. Original approach and branch by CrysisDeu (Zezhen Xu); callback persistence, test-contract repairs and cancellation-window tests by Kiro Crew. Co-authored-by: Kiro Crew <noreply@kirodotdev.github.io>
9a57af9 to
acba1fd
Compare
|
🤖 Kiro Crew [operator: bolichen97] Pushed 1. GPT round-2 BLOCKING (callback persistence can raise past busy_timeout) — finding adopted, prescription adapted The prescribed fix ('revert the hunk until it can share the pipeline finalizer's transaction') is not implementable as stated: the finalize hop's worker connection is autocommit ( Fix applied instead: the persistence is now fail-safe — any exception is swallowed and logged, the in-memory path still delivers the ids, and the caller's own 2. PR Hygiene (single commit) — squashed Local gates re-run: flake8/isort/black-gate green, mypy identical to baseline, both affected test files 76/76 pass. |
|
🤖 Kiro Crew [operator: bolichen97] Response to First Principles CONCERNS on 1. Item 3 (in-callback persistence) undeclared in the description — adopted. The PR description now has a dedicated section ('The callback also persists, fail-safe') declaring the second writer, its named harm (cancellation during 2. 3. CI is fully green on |
|
🤖 Kiro Crew [operator: bolichen97] ✅ Review-ready at Drive summary (2 fix iterations, single squashed commit, original authorship preserved):
Design unchanged throughout: the author's callback-based |
bolichen97
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: fix (3 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean (Semgrep + CodeQL), security checklist all-NO, AI reviewers green. Category: fix ≤5 files with clear root cause — ingested item IDs sourced from commit callback instead of wrong location.
iamwhatever
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: fix (3 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean (Semgrep+CodeQL), security checklist all-NO, AI reviewers green. Category: fix removes double item-id-set reads from event loop in folder_watcher by using the pipeline on_committed callback, eliminating crash-loop on large sources.
bolichen97
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: fix (3 files, clear root cause). 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: replaces O(n) before/after ID query with on_committed callback to fix event-loop stall in folder watcher.
…dotdev#3397) `_ingest_file` learned which items a file produced by reading the source's entire item-id set before and after handing the file to the pipeline, and diffing the two. Both reads ran synchronously on the event loop. `idx_items_source_id` keeps each read an index scan rather than a table scan, but it still materializes one row per item in the SOURCE: about 20k rows on a large folder source, measured at over a second per call, twice for every file in the scan. That is far past the loop-stall watchdog's budget, so the watchdog exits the process, the supervisor respawns it, the boot scan re-enters the same reads, and the gateway crash-loops -- four exits inside 17 minutes on a 2709-file source. Neither read is necessary. The pipeline already reports the ids it created through its `on_committed` callback, which it invokes inside its own `run_to_completion` finalize hop -- the same uncancellable unit that commits them. Passing a recorder there removes both reads outright instead of moving them to a worker thread, and removes the `sources.sync_status` read as well: `on_committed` fires only on the branch that actually commits a group, so an unset recorder IS the silent-rollback signal, per call rather than per source. Eliminating the reads matters beyond cost. Offloading them would have added await points AFTER the pipeline commits, and the caller writes the `folder_file_state` row naming the new items only once `_ingest_file` returns. A shutdown cancelling at such an await would leave committed items that no state row names: the next scan re-ingests the file and duplicates them while the first group stays untracked and undeletable. Sourcing the ids from the callback means there is no post-commit await to cancel. The recorder also PERSISTS the committed group from inside the finalize hop: the pipeline awaits again after that hop (`generate_source_summary`), so a shutdown cancelling there would otherwise leave a committed group that only the closure remembered -- the caller's state write never runs, the 'scanning' marker survives, and the next sweep re-ingests the file alongside the untracked first group. The write is a targeted UPDATE onto the 'scanning' marker the scan wrote before the call, and it is fail-safe: a writer-lock timeout is swallowed (the memory path and the caller's own 'done' write still stand) because a raise inside the finalize hop after the commit would poison the whole ingest and cause the very duplication it prevents. Both properties are ratcheted: the coroutine body may contain no synchronous sqlite call, and no await other than the pipeline call itself. Each ratchet ships with negative controls in both directions, so neither can pass vacuously. This is the site that survived kirodotdev#2175, kirodotdev#2336 and kirodotdev#2507 -- each moved a different call off the loop (`dedup_document`, `delete_items_batch`, the duplicate-skip gate) and none touched this one, which is why crash loops continued on builds carrying all three. Original approach and branch by CrysisDeu (Zezhen Xu); callback persistence, test-contract repairs and cancellation-window tests by Kiro Crew. Co-authored-by: Zezhen Xu <zezhexu@amazon.com> Co-authored-by: Kiro Crew <noreply@kirodotdev.github.io>
Problem
A folder source large enough to matter crash-loops the gateway. On a 2709-file
source this reproduced as four loop-stall watchdog exits inside 17 minutes,
with the surviving process still reporting 7-9s of event-loop lag between them:
Dumps land in
~/.kiro/crew/logs/crash-dumps/loopstall-*.txt; the last one in aloop is usually truncated because the watchdog loses the race to the supervisor's
kill.
Why it matters
Every exit takes the whole gateway with it — live chat sessions, crons and
channels all die — and the respawned process re-enters the identical boot scan,
so the loop is self-sustaining rather than self-healing. Anyone whose Knowledge
Library holds one large folder source is affected, and the auto-registered
project-docs source made that the default rather than the exception.
Fix (symptoms → root cause → change)
_ingest_filelearned which items a file produced by reading the source'sentire item-id set before and after handing the file to the pipeline, and
diffing the two. Both reads ran synchronously on the event loop.
idx_items_source_idkeeps each read an index scan, so this is not amissing-index bug. The cost is result size: one row per item in the source
— ~20k rows on a large source, measured at >1s per call — twice for every file in
the scan. That is well past the watchdog's budget.
Neither read is necessary. The pipeline already reports the ids it created
through its
on_committedcallback, which it invokes inside its ownrun_to_completionfinalize hop (ingestion.py) — the same uncancellable unitthat commits them. Passing a recorder there removes both reads outright, and
removes the
sources.sync_statusread as well:on_committedfires only on thebranch that actually commits a group, so an unset recorder is the
silent-rollback signal — evaluated per call, rather than reading a column a
concurrent ingest on the same source can flip.
Why eliminate rather than offload
The first revision of this PR moved the reads to a worker thread with
asyncio.to_thread. The GPT reviewer correctly blocked that on SHA307263fac,and the finding was legitimate: offloading adds
awaitpoints after thepipeline commits, and the caller (
_do_scan) writes thefolder_file_staterowthat NAMES the new items only once
_ingest_filereturns. A shutdown cancellingat such an await leaves committed items that no state row names — the next scan
re-ingests the file and duplicates them, while the first group stays untracked
and undeletable.
Sourcing the ids from the callback means there is no post-commit await to cancel.
The cost problem and the atomicity problem are closed by the same change instead
of being traded against each other.
The callback also persists, fail-safe
Capturing the ids in memory alone left one window open: the pipeline awaits
again after its finalize hop (
generate_source_summary), and the caller'sstate write only runs once
_ingest_filereturns. A shutdown cancelling in thatgap strands a committed group nothing names. So the recorder also writes the
folder_file_staterow from inside the finalize hop itself — a targeted UPDATEonto the
scanningmarker the scan wrote before the call (committed ids,terminal
done, derivedtext_hash, cleared retry budget). The caller's owndonewrite still lands with the same values on the uncancelled path, so thetwo writers are order-independent; the caller's
_write_state_rowremains theauthoritative spelling and the callback's UPDATE must stay field-consistent
with it (
attempts=0,error_message=NULL,text_hashderivation).The write is fail-safe, never fail-closed: it runs after the group has
committed and the superseded items are deleted, so an escaping exception there
(writer-lock contention past
busy_timeout, e.g. a large concurrentimport_bundle) would poison the finalize hop and convert a successful ingestinto a terminal
failedrow — causing the exact duplication it exists toprevent. Errors are swallowed and logged; the memory path and the caller's
write still stand, and the exposure shrinks back to the cancellation window,
never past pre-fix behavior.
Why this wasn't already fixed
This is the site that survived #2175, #2336 and #2507. Each of those moved a
different call off the loop —
dedup_document,delete_items_batch, theduplicate-skip gate — and none touched this one, which is why crash loops kept
being reported on builds carrying all three.
Tests
test/test_knowledge_ingest_scan_off_loop.py, following the two-kinds shape ofthe existing
test_knowledge_delete_off_loop.py. Both invariants are ratcheted,because the fix for the first defect is what created the second:
test_ingest_file_issues_no_sqlite_call_on_the_event_loopexecute/fetchall/fetchonecalled directly from the coroutine body (nested scopes excluded — those are thread targets)test_ingest_file_never_awaits_after_the_pipeline_commitsto_threadhop added in good faith to get a query off the looptest_ingest_file_takes_its_item_ids_from_the_commit_callbacktest_ingest_file_reports_the_committed_ids_without_touching_sqliteset_trace_callback(store.dbis per-thread), and asserts the reported ids equal what the pipeline createdtest_ingest_file_reports_failure_when_the_pipeline_never_commitssync_status; losing this would record a partial failure asdoneand never retry ittest_ingest_file_still_reports_a_refused_duplicate_as_dedupedon_committedunfiredtest_ingest_file_lets_cancellation_through_without_a_failed_state_rowCancelledErroris aBaseException, so a shutdown keeps the retryablescanningmarker instead of a terminalfailedrowtest_commit_callback_persists_the_state_row_before_returning_ingest_filecan run any post-await codetest_a_failed_callback_persistence_does_not_poison_the_ingest(ids, 'done')through the memory path instead of rolling a committed group up as a partial failuretest_scan_records_the_committed_group_on_the_state_rowscan_source: a callback wired up but dropped on the way out would satisfy every unit assertion above and still strand every fileManual verification
Static gates run locally on the rebased tree:
flake8andisort --check-onlyclean;
mypyreports nothing infolder_watcher.py(the 3hooks.pyxattrerrors are pre-existing on
mainand Linux-only). AST invariants confirmeddirectly against the file: zero sqlite calls in the coroutine body, one await
(
ingest_file).The suite itself was deliberately not run locally by the original author (see rationale below); the drive-to-green pass later ran the full suite on a separate host: 56623 passed, 0 failed.
Original rationale: A local
pytest -n autoon this host saturates the CPU and starves the very gateway event loop this PR is
about, which is one of the documented ways it gets killed. CI is the gate for the
tests — and the previous revision's run confirms the shape works there: all four
Backend Tests (3.12, N)shards passed, 53 lanes green, with GPT the only realfailure.
Runtime confirmation is left to CI plus observation: the reproducing condition is
a >2000-file source, and the observable is the absence of new
loopstall-*.txtdumps across a boot scan.
Why no screenshot: backend-only change under
src/kiro_crew/knowledge/; nofrontend path is touched and nothing renders differently.
no issue closed: the crash loop was diagnosed from local gateway logs across
several sessions and never filed as a tracked issue.