Skip to content

fix(knowledge): run the duplicate-skip gate off the event loop - #2507

Merged
iamwhatever merged 1 commit into
mainfrom
fix/dupe-skip-off-loop
Aug 13, 2026
Merged

fix(knowledge): run the duplicate-skip gate off the event loop#2507
iamwhatever merged 1 commit into
mainfrom
fix/dupe-skip-off-loop

Conversation

@CrysisDeu

Copy link
Copy Markdown
Collaborator

Problem

The gateway crash-loops every 6–8 minutes while the knowledge folder watcher
scans. Ten loop-stall dumps landed in one hour on a build that already carries
the earlier off-loop fixes, every one with delete_items_batch on the main
thread. The user-visible symptom is the app dying and relaunching repeatedly,
each relaunch re-paying full startup.

Why it matters

A stalled event loop freezes every task on it — the user's chat turn and the
liveness heartbeat alike — so the watchdog kills the process and the supervisor
respawns it into the same scan. That is a self-sustaining loop, not a one-off
hitch: the app is never reliably usable while a source with duplicate content
is being scanned.

Fix (symptom → root cause → change)

The earlier pass wrapped the five delete_items_batch sites reachable from the
ingest body, but _skip_as_duplicate was missed. It is a sync method called
bare from two async defs (ingest_file, ingest_text), and it deletes the
superseded items at ingestion.py:217 directly on the loop.

Why that delete is expensive: delete_items_batch does not just delete. After
the per-item cascade it re-runs the orphan-entity sweep — three NOT IN
subqueries over the mention and relation tables — and then calls _load_graph(),
a full rebuild of the entity graph. On a large library that is seconds of
blocking SQLite, paid per duplicate file the scan meets.

The change routes the gate through the existing run_to_completion helper, so
the delete, the source-location attach and the terminal job row travel as one
hop. That grouping is deliberate and load-bearing: splitting the committed delete
from the row that records it strands data, because the next scan then re-ingests
alongside the orphaned items — the same hazard the helper was introduced for.

run_to_completion is widened to forward its callable's return value
(Callable[[], _T] -> _T). The gate reports a job id its callers branch on;
with the old -> None signature that value would have to be read after the
await, splitting the very unit the hop exists to keep whole. Cancellation
semantics are unchanged — the work is still drained and the CancelledError
still wins; only the value is dropped. Backward-compatible for the two existing
None-returning finalizers.

Scope note, stated plainly: this makes the gateway survive the scan. It does
not make the work cheap — a full graph rebuild after deleting a handful of items
is a separate defect, and each duplicate file still burns that cost on a worker
thread. Incremental graph maintenance touches every mutation path and belongs in
its own change, not smuggled into a crash-loop fix.

Tests

Two added to test/test_knowledge_delete_off_loop.py, alongside the existing
AST ratchet and off-thread assertions:

  • test_duplicate_skip_runs_the_delete_off_the_loop_thread — records the thread
    ident inside a wrapped delete_items_batch and asserts it is not the loop
    thread. It also asserts the gate was actually reached, so the test cannot pass
    vacuously if the duplicate path is skipped.
  • test_run_to_completion_forwards_the_return_value — pins the widened
    signature, which is what lets the mutation and its reported value stay in one
    hop.

Manual verification

N/A — unit coverage is sufficient. The defect is "which thread runs this call",
which the off-thread assertion checks directly and the AST ratchet prevents
regressing. Reproducing the crash loop itself needs a multi-thousand-file corpus
with duplicate content and a live watchdog, which no unit test should carry.

Screenshots

N/A — no user-visible UI change.

@CrysisDeu
CrysisDeu requested a review from a team as a code owner August 10, 2026 08:06
@github-actions github-actions Bot added the readiness: checking Automated validation is still running label Aug 10, 2026
@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5) — 🟡 CONCERNS

Advisory design-level review of 794bf1eabb49a778fee57c1b0bee1334696ae820 — updated in place on each push; does not block merge.

Design-Verdict: CONCERNS

Sound root-cause fix following the codebase's own derive-under-lock idiom, but the new callback-inside-an-open-transaction seam is a contract enforced only by docstring.

Watch

  • on_duplicate runs caller code inside the gate's open BEGIN IMMEDIATE ("the gate invokes it before its own COMMIT"). The contract "takes no lock and no transaction of its own" is documented but not enforced: a future caller passing a normal set_state-style finalizer (which calls db.commit()) would silently end the transaction mid-gate — the connection is autocommit, so exactly the early-commit hazard add_source_location_in_txn exists to avoid — reopening the window with no test failing, since the atomicity test only covers today's three finalizers.
  • The PR description covers only the off-loop move and the run_to_completion return-value widening ("Two added to test_knowledge_delete_off_loop.py"); the majority of the diff — the BEGIN IMMEDIATE gate redesign, on_duplicate injection, surviving_group_in_txn, and the three-table terminal-write derivation — is documented only in the commit message. Reviewers reading the description will materially under-scope the change; lift the commit-message content into the PR body.

Suggestions

  • Before the gate's COMMIT, assert self.store.db.in_transaction after on_duplicate() returns, so a committing finalizer fails loudly instead of silently splitting the atomic unit.

[DESIGN-REVIEWED] 794bf1e

@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Opus 4.8 Review — ✅ no blocking findings

Reviewed 794bf1eabb49a778fee57c1b0bee1334696ae820 — this comment is updated in place on each push.

Review details

The candidate file contains no findings — the discovery pass concluded "No candidates." I may not extend it with findings of my own (Step 2). There is nothing to validate.

No findings.

[OPUS-REVIEWED] 794bf1e

Verdict parsed from the review's SHA-scoped output markers for commit 794bf1eabb49a778fee57c1b0bee1334696ae820.

False positive or not applicable? A repository writer can comment:
/ai-review override fable 794bf1eabb49a778fee57c1b0bee1334696ae820: <one-sentence reason>

@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review — ✅ no blocking findings

GPT 5.6 completed its review of 794bf1eabb49a778fee57c1b0bee1334696ae820 and found no blocking issues.

This comment is updated in place on each push.

Review details

No findings.
[GPT-REVIEWED] 794bf1e

False positive or not applicable? A repository writer can comment:
/ai-review override gpt 794bf1eabb49a778fee57c1b0bee1334696ae820: <one-sentence reason>

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 10, 2026
@CrysisDeu
CrysisDeu force-pushed the fix/dupe-skip-off-loop branch from 1c4a84c to d4fe1a4 Compare August 10, 2026 08:45
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 10, 2026
@CrysisDeu

Copy link
Copy Markdown
Collaborator Author

Disposition — GPT 5.6 finding on 1c4a84cca

BLOCKING — ingestion.py:436 — "Duplicate gate races holder deletion" → FIXED in d4fe1a461.

The finding is correct, and it is worth stating precisely why, because the
mechanism is subtle. While _skip_as_duplicate ran on the event loop it had no
await in it, so it was atomic against every other coroutine by construction
a user's delete_source_cascade could only land before or after it, never inside.
Moving it to a worker thread removed that accidental guarantee. The dashboard
already runs delete_source_cascade off-loop (handlers/knowledge.py:1015), so
after the offload two threads on two connections genuinely interleave, and the
gate's read-then-write across three separate transactions became a real TOCTOU.
Consequence as reported: the target gets a terminal skipped_duplicate row while
the copy it attached to is cascaded away, and the content is unrecoverable.

What was NOT done, and why. The suggested remedy — "revert both offloads until
the gate is atomic with source deletion" — was not taken. Reverting restores the
loop-stall crash loop this PR exists to fix, which trades data loss for a gateway
that dies every 6–8 minutes. Instead the gate was made atomic, which satisfies the
same precondition without giving up the fix.

The fix. The gate now runs as ONE BEGIN IMMEDIATE transaction that re-reads
the holder under the write lock and declines to dedupe when it is gone, falling
through to a normal ingest. This is the idiom already used in this codebase for
exactly this hazard — delete_source_cascade (store.py:954) and
start_rebuild_job (ingestion.py:983, whose comment says a concurrent writer
"can't both observe" the pre-state). A concurrent delete_source_cascade now
waits on the lock instead of racing. A cheap unlocked probe runs first so the
common not-a-duplicate answer does not serialize every ingest behind the lock.

Two seams were needed to make that possible without touching existing callers:

  • delete_items_batch keeps its own transaction and graph reload, but delegates
    its body to a new delete_items_batch_in_txn, so a caller already holding a
    write transaction can include the delete in it. The five sites from fix(knowledge): run the ingest-path item deletes off the event loop #2336 are
    unchanged.
  • add_source_location grows an _in_txn variant. This one is load-bearing and
    easy to miss: the connection is in autocommit mode, so its trailing
    db.commit() would have ended the caller's BEGIN IMMEDIATE early and
    reopened the very window the lock closes.

Regression test, proven red pre-fix.
test_duplicate_gate_reingests_when_the_holder_vanishes_before_the_lock makes the
authoritative in-transaction lookup miss after the probe has hit, and asserts the
target is not recorded as a duplicate and keeps items of its own. Stripping the
in-transaction revalidation makes it fail with
the gate consulted the holder only once, so the holder is still read outside the write lock — so it cannot pass vacuously. The pre-existing off-thread test was
retargeted to the _in_txn seam the gate now calls; its own non-vacuous guard
caught that retarget being necessary.

Verified locally: 264 passed across the knowledge suite, isort / flake8 /
mypy clean, rebased onto current main.


Also on this PR, for the record

Backend Tests (Windows) (2) — unrelated, not fixed here. It fails on
test_lesson_contradiction.py::test_a_sharp_s_case_variant_inserts_rather_than_enriching
(stored spelling lost: ['STRASSE …']), a German ß→SS casefold issue in lesson
storage. That test is main's own (from #2166) and untouched by this branch, its
only "knowledge" reference is a category string, and neither vector_memory.py
nor lessons.py imports knowledge.ingestion — so no code path connects it to
this diff. main's Windows shard also fails independently (3 of its last 8 CI runs
red, most recently on a different test entirely).

Cost, acknowledged and deferred. Off-loading makes the gateway survive the
scan; it does not make the work cheap. delete_items_batch still runs a full
_load_graph() rebuild plus the orphan sweep per call, now on a worker thread.
Incremental graph maintenance touches every mutation path and is deliberately not
in this PR.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention merge conflict Branch has merge conflicts with its base — author must resolve before merge and removed readiness: checking Automated validation is still running labels Aug 10, 2026
@CrysisDeu

Copy link
Copy Markdown
Collaborator Author

Parking this PR — the approach is wrong for this site

Converting to draft. Not because GPT's findings are wrong (they are both correct), but
because the strategy cannot succeed, and the evidence for that is empirical rather than
argumentative.

The decisive fact. #2336 is merged and shipped, this PR's site is the last one it
missed — and the gateway still wrote a loop-stall dump two minutes before this comment.
The crash moved to folder_watcher.py:830, an unrelated SELECT id FROM items WHERE source_id = ? that returns 20,003 rows in ~1.1s per file on the affected corpus. At a
15s watchdog that is ~13 files to death. So even merged and green, this PR does not stop
the crash loop it was written for.

Why site-by-site off-loading cannot win here. On a 20k-item source, every
synchronous sqlite call on the ingest path is a bomb, and there are many. Worse, each
off-load has a cost the first ones hid: _skip_as_duplicate was atomic against every
other coroutine purely because it contained no await. That guarantee was free, it was
never designed, and moving the function to a thread spends it. GPT found the resulting
race twice, at two different boundaries:

  1. holder deleted between the probe and the attach → fixed here with BEGIN IMMEDIATE
    plus revalidation;
  2. the caller's folder_file_state finalization sits outside that serialized unit →
    would require the transaction to span ingest_file's return.

Round 2 is not a defect in the round-1 fix. It is the next step that was also implicitly
atomic. There is no reason to expect it is the last one, and both times the reviewer's
own remedy was "revert the offload" — which trades data loss for a crash loop.

What actually fixes it. Two things, neither of them this PR:

  • Make the work cheap instead of moving it. delete_items_batch ends in a full
    _load_graph() — a complete rebuild of a 175k-node / 250k-edge graph after deleting a
    handful of items — and re-runs the orphan sweep that _init_schema already runs. Make
    the graph update incremental and gate the sweep behind an EXISTS probe, and the
    ~2.3s becomes milliseconds. Cheap work does not need to leave the loop, so all the
    free atomicity is preserved and none of these races exist.
  • Stop the corpus from being created. feat(knowledge): add documents automatically, and dedup per document #1380 auto-registers chat-slot project
    directories with a max_files cap but no chunk-count budget and no worktree dedup. A
    directory holding 73 git worktrees produced a single 20,356-item source without the
    user doing anything. A file-count cap does not bound chunk count, which is the
    quantity that actually hurts.

What is worth keeping from this branch. delete_items_batch_in_txn and
add_source_location_in_txn are the seams any atomic version of this path needs, and the
second one documents a real trap: on this autocommit connection, add_source_location's
trailing db.commit() silently ends an enclosing BEGIN IMMEDIATE. Also
run_to_completion forwarding its return value. Whoever picks up the incremental-graph
work should lift those rather than rediscover them.

No override requested. GPT is flagging real data loss, and "accept stranding items to
stop a crash loop" is a bad trade when making the delete cheap avoids both.

@CrysisDeu
CrysisDeu marked this pull request as draft August 10, 2026 17:48
@CrysisDeu
CrysisDeu force-pushed the fix/dupe-skip-off-loop branch from d4fe1a4 to 36e31d4 Compare August 12, 2026 20:46
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 12, 2026
@CrysisDeu
CrysisDeu marked this pull request as ready for review August 12, 2026 20:47
@CrysisDeu

Copy link
Copy Markdown
Collaborator Author

Round 2 disposition, and why this is out of draft again.

BLOCKING — ingestion.py:265 — "the holder lock is released before duplicate state finalization" → FIXED in 36e31d4b5.

The finding is correct, and the mechanism is worth stating precisely. The gate commits its own BEGIN IMMEDIATE before returning, so a user deleting the HOLDER lands in the window between that commit and the scan's terminal state write. That cascade is not destructive on its own — it sees this folder's source_locations row, reassigns the surviving item to this source, and adopts it into this very folder_file_state row. What destroyed the document was the write that came next: item_ids='[]', which is what "the gate refused, so this file owns nothing" predicts. That erased the adoption and left the only remaining copy owned by the source but named by no state row — unreachable by the deleted-file path and undeletable, exactly the strand the ownership model exists to prevent.

What was NOT done, and why. The suggested remedy — revert the off-loop gate hunk — was not taken, for the same reason as round 1: reverting restores the loop-stall crash loop this PR exists to fix, trading data loss for a gateway that dies every 6–8 minutes. Nor was the alternative I had written off in my own parking note taken: making the transaction span ingest_file's return. That would hold the write lock across an LLM extraction call.

The fix. FolderWatcher._record_deduped_state takes the same BEGIN IMMEDIATE and reads the file's item group instead of assuming it empty, writing the healthy status when items are there. That is enough, because it removes the ordering assumption rather than trying to eliminate the ordering:

  • cascade lands before this read → its adoption is visible, and preserved;
  • cascade lands after → the row now exists with the matching hash, so the cascade's own _adopt_reassigned_item step fills the group in (the path already designed for this);
  • cascade lands during → serialized by the lock.

There is no interleaving left in which the terminal write can contradict the database. Two short transactions do the job; one long one is not needed.

Mechanically the change also splits _update_state into _write_state_row (no transaction control, reusable inside a lock) plus the commit, and lifts the refused-row text-hash derivation into _deduped_text_hash so both paths share it. No behavior change on the done / failed / scanning writes.

Regression test, proven red pre-fix. test_deduped_state_write_keeps_an_adoption_that_landed_after_the_gate reproduces the interleaving deterministically with no threads: it wraps pipeline.ingest_file and cascades the holder away the instant the gate returns its terminal job, then asserts the state row names the items the source now owns. Replacing the in-lock read with a predicted [] fails it with the terminal 'deduped' write overwrote the adoption: this source owns ['11c9f139-…'] but its state row names []. Two non-vacuity guards sit in front of that assertion — one that the gate actually refused the file, one that the cascade actually left this source owning something — so it cannot pass for the wrong reason.

Verified locally after rebasing onto 0f1a4d55a (one additive conflict in ingestion.py, main's EmbedRateLimiter alongside this branch's _T): 1174 tests pass across the knowledge, folder, dedup and ingest suites; isort, flake8, mypy, docs-lint all exit 0. docs/system-specs/modules/knowledge.md is updated in the same commit — its pre-ingest-gate bullet said the state row "is then recorded with an empty group", which is no longer unconditionally true.

On the parking note above. I wrote that this strategy could not win because round 2 would not be the last implicitly-atomic step. I still think the two things named there are the real fixes — make delete_items_batch cheap so the work never has to leave the loop, and stop a 20k-item source from being created in the first place — and this PR is still not a fix for the crash loop that has since moved to folder_watcher.py:830. What changed my mind about this PR is the shape of the remedy: rounds 1 and 2 were both closed by the same rule, take the write lock and derive the state rather than predict it, and applying that rule leaves the gate with no predicted state left to be wrong about. That is a closed set, not an open series.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running merge conflict Branch has merge conflicts with its base — author must resolve before merge labels Aug 12, 2026
@CrysisDeu
CrysisDeu force-pushed the fix/dupe-skip-off-loop branch from 36e31d4 to d8f6e9c Compare August 12, 2026 21:08
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 12, 2026
@CrysisDeu

Copy link
Copy Markdown
Collaborator Author

Round 3 disposition.

BLOCKING — folder_watcher.py:513 — "duplicate-state transaction blocks the event loop" → FIXED in d8f6e9cfd.

Correct, and it is a regression I introduced in the round-2 fix rather than a pre-existing property. _record_deduped_state was called synchronously from _do_scan, which is a coroutine, so its BEGIN IMMEDIATE acquired the write lock on the loop thread — and acquiring a write lock is a blocking wait, up to this connection's 30s timeout, on any concurrent writer. That is the exact stall class this PR exists to remove, reintroduced one line further along than the one it removed. Closing a race by taking a lock is only safe if the lock is taken somewhere it is allowed to wait.

The fix is the one suggested: the call now goes through await run_to_completion(lambda: self._record_deduped_state(...)). run_to_completion rather than a bare asyncio.to_thread for the reason this PR added the helper — a cancellation arriving while the work item is still queued in the executor drops the callable entirely, and dropping this callable leaves the file's row on its scanning marker, which is re-ingested at full cost on every later sweep. Same guarantee the gate's own delete already has.

Two notes on why this is safe rather than merely quieter:

  • The store hands out one connection per thread, so the worker gets its own. That is what makes the lock do its job: it now serializes against the dashboard's off-loop delete_source_cascade instead of contending with it from the loop thread. The connection is in autocommit mode, so the loop thread holds no open transaction that the worker could deadlock against.
  • The surrounding scan's other state writes (done / failed / scanning, last_seen) still run on the loop connection. With autocommit each is its own committed statement, so there is no cross-connection transaction to interleave — only this one write needs the lock, and only it leaves the loop.

Regression guard. Rather than add a second near-duplicate scan test, the round-2 test now also records which thread the terminal write lands on, and is renamed test_deduped_state_write_is_off_loop_and_keeps_a_late_adoption — both properties belong to that single write. Reverting the offload fails it with the terminal 'deduped' write ran on the event-loop thread; it takes the write lock, so it must travel through run_to_completion, and it carries its own "was this branch even reached" guard so it cannot pass vacuously.

Verified: 1174 tests pass across the knowledge, folder, dedup and ingest suites; isort, flake8, mypy all exit 0.

The other reds on this PR, for the record — neither is from this diff

  • Inclusive Language — infrastructure, not content: the job died in Install woke with curl: (56) Connection died, tried 5 times before giving up fetching the pinned woke release. It never scanned anything. Cleared by the re-run this push triggers.
  • Electron Shell Testsmochi/test/petOverlays.test.js, 850 pass / 1 fail. That file is the only test in its directory that does not stub require('electron') via a Module._load intercept — its six siblings all do — and the job sets ELECTRON_SKIP_BINARY_DOWNLOAD=1, so it passes or fails on runner cache state. This diff touches no file under website/. It wants the one-line intercept copied from a sibling, in a PR that owns that directory rather than this one.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 12, 2026
@CrysisDeu

Copy link
Copy Markdown
Collaborator Author

Round 8 disposition — accepted, and it simplifies the change. Pushed as c27ded556.

BLOCKING — ingestion.py:337 — "New aggregate documents can lose ownership across the commit gap" → FIXED

Correct. Round 7 put the caller's record inside the gate's hop but still after its COMMIT, in its own BEGIN IMMEDIATE. For a FIRST-TIME artifact or agent document that is not enough: at commit time the state row does not exist yet, so a delete_source_cascade acquiring the lock in the gap reassigns the surviving item to this source and has nothing to adopt it into — _adopt_reassigned_item matches on (source_id, content_hash), finds no row, and returns without logging. The record that follows then reports an empty group while the source owns a searchable item, which is the same strand one ordering earlier.

The fix is the suggested one, and it makes the code smaller. on_duplicate() now runs before the gate's COMMIT, and the three finalizers drop their own BEGIN IMMEDIATE / COMMIT / ROLLBACK blocks entirely — they are called from inside the gate's transaction, on the gate's worker thread, so they take no lock of their own. The delete of the previous group, the location claim on the holder's items, the terminal job row and the caller's record are one atomic unit. A cascade now sees either no claim at all or a claim WITH the row that names it, and there is no gap left to land in.

Two properties come free from that ordering rather than needing their own guards:

  • A failure in the record rolls the whole refusal back. Previously a raising finalizer left the delete and the claim committed.
  • The transaction still does not span ingest_file's return. It stays inside _skip_as_duplicate; only the callback is injected. That is the distinction that makes this different from the "hold the write lock across an LLM extraction" shape I rejected earlier — the callback is one row read plus one INSERT OR REPLACE, bounded and I/O-free.

Test. test_duplicate_gate_and_terminal_state_are_one_transaction asserts atomicity directly: it makes the finalizer raise and requires the gate's delete, its claim on the holder's items, and its skipped_duplicate job row to all be absent afterwards. Moving on_duplicate() back after COMMIT fails it. I replaced the round-7 cancellation test with this one — it covers the same guarantee more strongly (atomic pairing rather than "the finalizer ran"), and it does so without holding a worker thread on an event, which is a real hazard: the earlier version's blocked thread perturbed an unrelated test_subagent_reap_race cancellation test under -n auto while passing 3/3 alone.

The _record_deduped_state-on-the-loop ratchet stays, with its rationale updated: a direct call from a coroutine body would now both take the write lock on the loop AND sit outside the gate's transaction.

The remaining red on this PR is a main-side breakage, not this diff

Backend Tests shard 3 fails on 3.10, 3.12 and Windows alike — deterministic, not a flake — on test/test_mcp_core_more_coverage.py:

AttributeError: <module 'kiro_crew.mcp_core'> does not have the attribute 'is_tool_cancelled'
AttributeError: <module 'kiro_crew.mcp_core'> does not have the attribute 'resolve_max_subagents'

Those symbols now live in mcp_shared.py:127 and subagent.py:813, and the test still monkeypatch.setattrs them on mcp_core. Checked out origin/main's own copies of test_mcp_core_more_coverage.py and mcp_core.py into a clean tree and ran them: 15 failed, 96 passed. So this arrived with the mcp_core split and this branch only inherits it by rebasing onto that main. Coverage Gate and PR Readiness are downstream of it.

Nothing in this diff touches mcp_core, mcp_shared or subagent, so it is not fixable here without unrelated scope. It needs its own change — pointing those monkeypatches at the modules that now own the symbols.

Verified after rebasing onto 017a78e9c: test_knowledge_delete_off_loop.py 16 pass / 1 xfail; isort, flake8, mypy, docs-lint all exit 0; no inclusive-language violations in the added .py/.md lines.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 12, 2026
@CrysisDeu
CrysisDeu force-pushed the fix/dupe-skip-off-loop branch from c27ded5 to c29101e Compare August 13, 2026 00:10
@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Aug 13, 2026
`delete_items_batch` rebuilds the whole entity graph inside its own
transaction, so reaching it from a coroutine body blocks the gateway loop
past the stall watchdog: the watchdog exits, the supervisor respawns, and
the fresh process runs the same scan. The duplicate gate was the last site
still calling it on the loop.

Moving the gate to a worker thread spends a guarantee it never declared.
While `_skip_as_duplicate` ran on the loop it contained no `await`, so it
was atomic against every other coroutine by construction -- a user's
`delete_source_cascade` could only land before or after it, never inside.
The dashboard already runs that cascade off-loop, so after the offload two
threads on two connections genuinely interleave, and the gate's
read-then-write became a real time-of-check window at two boundaries.

Both are closed here by taking the write lock and DERIVING state rather
than predicting it -- the idiom this codebase already uses for the same
hazard in `delete_source_cascade` and `start_rebuild_job` -- and both of the
resulting locked sections run OFF the loop, because acquiring a write lock
is itself a blocking wait.

1. The gate itself is now one `BEGIN IMMEDIATE` unit that re-reads the
   holder under the lock and falls through to a normal ingest when it is
   gone. A cheap unlocked probe runs first, so the overwhelmingly common
   not-a-duplicate answer does not serialize every ingest behind the lock.
2. The terminal `deduped` write takes the same lock and READS the document's
   item group instead of assuming it empty. The gate commits before
   returning, so a cascade can land in between; that cascade is benign by
   design -- it sees this source's location row, reassigns the surviving
   item here, and adopts it into this very state row. Writing `[]`
   afterwards erased the adoption and left the only remaining copy owned by
   the source but named by no row: unreachable by the delete path and
   undeletable. Deriving the group closes the window in both orders instead
   of one. It travels through `run_to_completion` for the same reason the
   gate does -- `BEGIN IMMEDIATE` waits on any concurrent writer up to the
   connection's busy timeout (10s: `PRAGMA busy_timeout=10000` overrides the
   30s connect timeout), and that wait on the loop thread would be the
   original stall reintroduced one line further along.

**All three doc-state tables have that window, and all three are fixed.**
`folder_file_state` was the first one found; `artifact_item_state` and
`agent_item_state` reached the same terminal write through
`artifact_ingest.ingest_artifact` and `agent_source._add_agent_document`, both
`async def`, both writing a hardcoded empty group after the gate committed. The
aggregate case is the more reachable one: `_OWNERSHIP_HASH_COL` maps those
tables to `content_hash`, already the text-hash domain, so
`_adopt_reassigned_item` matches and adopts rather than silently missing the
way it does for a transformed file.

**And the record is written INSIDE the gate's transaction, not after its commit.**
Leaving it to the caller is not merely riskier, it is unsound in two ways.
`run_to_completion` guarantees the gate FINISHES and then re-raises a cancellation,
so a shutdown lands with the delete, the location claim and the terminal job row
all durable and the caller's write never reached; the row then keeps its
pre-ingest marker, and because a `scanning` row has no `text_hash`,
`detach_source_location_by_hash` short-circuits and nothing can release the claim.
And even without a cancellation, a FIRST-TIME aggregate document has no state row
yet at commit time, so a `delete_source_cascade` landing in the gap reassigns the
surviving item here and has nothing to adopt it into --
`_adopt_reassigned_item` matches on `(source_id, hash)`, finds no row, and returns
without logging.

So each caller passes its record in as `on_duplicate`, the duplicate-branch sibling
of the `on_committed` finalizer this pipeline already accepts for the success
branch and for the identical stated reason, and the gate invokes it before its own
`COMMIT`. The delete, the claim, the job row and the record are one atomic unit:
nothing can observe a claim without the row that names it, and a failure in the
record rolls the whole refusal back. The transaction does not span
`ingest_file`'s return -- it stays inside the gate; only the callback is injected.

The derivation lives once, in `KnowledgeStore.surviving_group_in_txn(table,
source_id, key)`, with `_DOC_STATE_KEY_COL` naming the column that identifies
one document per table -- an allowlist, because those identifiers are
interpolated into SQL. It is row-scoped, never `(source_id, content_hash)`:
two documents in one source may legitimately hold identical text, so a
hash-scoped read names one physical item into both rows and destroys it on the
first delete of either -- the cross-wire `_adopt_reassigned_item` already
refuses with its own ambiguity guard. An aggregate row that ends up owning
items is written `active`, not `deduped`, because `find_document_by_hash` only
matches `active` and a row owning content while reporting `deduped` would let
the same text in again under a second slug.

Two seams were needed to make the gate atomic without touching existing
callers. `delete_items_batch_in_txn` carries the body so a caller already
holding a write transaction can include the delete in it, while
`delete_items_batch` keeps its own transaction and graph reload. And
`add_source_location` grows an `_in_txn` variant -- this one is
load-bearing and easy to miss: the connection is in autocommit mode, so its
trailing `commit()` would have ENDED the caller's `BEGIN IMMEDIATE` early
and reopened the very window the lock closes. `run_to_completion` also now
forwards its return value, which is what lets the whole gate travel through
the hop as one unit.

Both races have a regression test proven red before the fix, each with its
own non-vacuity guard: stripping the in-transaction revalidation fails with
"the gate consulted the holder only once", predicting an empty group fails
with "the terminal 'deduped' write overwrote the adoption" for folders and
"the terminal write erased the adoption" for both aggregates, and calling any
of the three terminal writes synchronously fails the
`_record_deduped_state`-on-the-loop ratchet, which names the offending file and
line. The AST ratchet that pins every `delete_items_batch` call site was taught
the indirection so it still sees the hop. And
`test_duplicate_gate_and_terminal_state_are_one_transaction` makes the finalizer
raise and requires the gate's delete, its claim on the holder's items and its
terminal job row to be absent afterwards -- it fails if the finalizer runs after
`COMMIT`.

Not addressed here, and deliberately: off-loading makes the gateway survive
the scan, it does not make the work cheap. `delete_items_batch` still runs a
full graph rebuild plus the orphan sweep per call. Incremental graph
maintenance touches every mutation path and is its own change.

Also not addressed, and pre-existing: for a TRANSFORMED file (PDF, DOCX,
HTML) the cascade's adoption matches nothing at all, because
`_adopt_reassigned_item` keys a folder row on `COALESCE(text_hash,
content_hash)` while `items.content_hash` is over extracted text, and a
refused row derives its `text_hash` from a byte-identical sibling row that a
lone PDF does not have. `main` writes an unconditional empty group at this
site, so the row never named the item there either -- this change is
equivalent for that shape and strictly better for plaintext. Closing it needs
the incoming document's text hash carried out of the gate instead of guessed
from a sibling, which changes what the gate reports.
`test_deduped_state_write_recovers_a_transformed_files_reassigned_item` pins
it as a strict xfail with a live repro, so it fails the moment someone lands
that and the exemption goes stale.
@CrysisDeu
CrysisDeu force-pushed the fix/dupe-skip-off-loop branch from c29101e to 794bf1e Compare August 13, 2026 00:36
@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention readiness: passed Eligible automated validation passed for the current revision and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Aug 13, 2026

@bolichen97 bolichen97 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tier 1 auto-approve: fix (10 files). Criteria: no conflict, no requested changes, no security surface, AI reviewers green. Category: knowledge subsystem — move duplicate-skip gate off the event loop.

@iamwhatever
iamwhatever merged commit 5130cf3 into main Aug 13, 2026
88 of 89 checks passed
@iamwhatever
iamwhatever deleted the fix/dupe-skip-off-loop branch August 13, 2026 02:05

@iamwhatever iamwhatever left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tier 1 auto-approve: small-fix (10 files). Criteria: no conflict, no requested changes, no security surface, AI reviewers green. Category: moves a blocking duplicate-skip check off the event loop to prevent knowledge ingest stalls — pure async correctness fix, no behavioral change.

@github-actions github-actions Bot removed the readiness: passed Eligible automated validation passed for the current revision label Aug 13, 2026
CrysisDeu pushed a commit that referenced this pull request Aug 13, 2026
`_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.

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.
bolichen97 pushed a commit that referenced this pull request Aug 18, 2026
`_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.

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.
bolichen97 pushed a commit that referenced this pull request Aug 19, 2026
`_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>
bolichen97 pushed a commit that referenced this pull request Aug 19, 2026
`_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: Zezhen Xu <zezhexu@amazon.com>
Co-authored-by: Kiro Crew <noreply@kirodotdev.github.io>
encomjp pushed a commit to encomjp/kirocrew-customapi that referenced this pull request Aug 22, 2026
…irodotdev#2507)

`delete_items_batch` rebuilds the whole entity graph inside its own
transaction, so reaching it from a coroutine body blocks the gateway loop
past the stall watchdog: the watchdog exits, the supervisor respawns, and
the fresh process runs the same scan. The duplicate gate was the last site
still calling it on the loop.

Moving the gate to a worker thread spends a guarantee it never declared.
While `_skip_as_duplicate` ran on the loop it contained no `await`, so it
was atomic against every other coroutine by construction -- a user's
`delete_source_cascade` could only land before or after it, never inside.
The dashboard already runs that cascade off-loop, so after the offload two
threads on two connections genuinely interleave, and the gate's
read-then-write became a real time-of-check window at two boundaries.

Both are closed here by taking the write lock and DERIVING state rather
than predicting it -- the idiom this codebase already uses for the same
hazard in `delete_source_cascade` and `start_rebuild_job` -- and both of the
resulting locked sections run OFF the loop, because acquiring a write lock
is itself a blocking wait.

1. The gate itself is now one `BEGIN IMMEDIATE` unit that re-reads the
   holder under the lock and falls through to a normal ingest when it is
   gone. A cheap unlocked probe runs first, so the overwhelmingly common
   not-a-duplicate answer does not serialize every ingest behind the lock.
2. The terminal `deduped` write takes the same lock and READS the document's
   item group instead of assuming it empty. The gate commits before
   returning, so a cascade can land in between; that cascade is benign by
   design -- it sees this source's location row, reassigns the surviving
   item here, and adopts it into this very state row. Writing `[]`
   afterwards erased the adoption and left the only remaining copy owned by
   the source but named by no row: unreachable by the delete path and
   undeletable. Deriving the group closes the window in both orders instead
   of one. It travels through `run_to_completion` for the same reason the
   gate does -- `BEGIN IMMEDIATE` waits on any concurrent writer up to the
   connection's busy timeout (10s: `PRAGMA busy_timeout=10000` overrides the
   30s connect timeout), and that wait on the loop thread would be the
   original stall reintroduced one line further along.

**All three doc-state tables have that window, and all three are fixed.**
`folder_file_state` was the first one found; `artifact_item_state` and
`agent_item_state` reached the same terminal write through
`artifact_ingest.ingest_artifact` and `agent_source._add_agent_document`, both
`async def`, both writing a hardcoded empty group after the gate committed. The
aggregate case is the more reachable one: `_OWNERSHIP_HASH_COL` maps those
tables to `content_hash`, already the text-hash domain, so
`_adopt_reassigned_item` matches and adopts rather than silently missing the
way it does for a transformed file.

**And the record is written INSIDE the gate's transaction, not after its commit.**
Leaving it to the caller is not merely riskier, it is unsound in two ways.
`run_to_completion` guarantees the gate FINISHES and then re-raises a cancellation,
so a shutdown lands with the delete, the location claim and the terminal job row
all durable and the caller's write never reached; the row then keeps its
pre-ingest marker, and because a `scanning` row has no `text_hash`,
`detach_source_location_by_hash` short-circuits and nothing can release the claim.
And even without a cancellation, a FIRST-TIME aggregate document has no state row
yet at commit time, so a `delete_source_cascade` landing in the gap reassigns the
surviving item here and has nothing to adopt it into --
`_adopt_reassigned_item` matches on `(source_id, hash)`, finds no row, and returns
without logging.

So each caller passes its record in as `on_duplicate`, the duplicate-branch sibling
of the `on_committed` finalizer this pipeline already accepts for the success
branch and for the identical stated reason, and the gate invokes it before its own
`COMMIT`. The delete, the claim, the job row and the record are one atomic unit:
nothing can observe a claim without the row that names it, and a failure in the
record rolls the whole refusal back. The transaction does not span
`ingest_file`'s return -- it stays inside the gate; only the callback is injected.

The derivation lives once, in `KnowledgeStore.surviving_group_in_txn(table,
source_id, key)`, with `_DOC_STATE_KEY_COL` naming the column that identifies
one document per table -- an allowlist, because those identifiers are
interpolated into SQL. It is row-scoped, never `(source_id, content_hash)`:
two documents in one source may legitimately hold identical text, so a
hash-scoped read names one physical item into both rows and destroys it on the
first delete of either -- the cross-wire `_adopt_reassigned_item` already
refuses with its own ambiguity guard. An aggregate row that ends up owning
items is written `active`, not `deduped`, because `find_document_by_hash` only
matches `active` and a row owning content while reporting `deduped` would let
the same text in again under a second slug.

Two seams were needed to make the gate atomic without touching existing
callers. `delete_items_batch_in_txn` carries the body so a caller already
holding a write transaction can include the delete in it, while
`delete_items_batch` keeps its own transaction and graph reload. And
`add_source_location` grows an `_in_txn` variant -- this one is
load-bearing and easy to miss: the connection is in autocommit mode, so its
trailing `commit()` would have ENDED the caller's `BEGIN IMMEDIATE` early
and reopened the very window the lock closes. `run_to_completion` also now
forwards its return value, which is what lets the whole gate travel through
the hop as one unit.

Both races have a regression test proven red before the fix, each with its
own non-vacuity guard: stripping the in-transaction revalidation fails with
"the gate consulted the holder only once", predicting an empty group fails
with "the terminal 'deduped' write overwrote the adoption" for folders and
"the terminal write erased the adoption" for both aggregates, and calling any
of the three terminal writes synchronously fails the
`_record_deduped_state`-on-the-loop ratchet, which names the offending file and
line. The AST ratchet that pins every `delete_items_batch` call site was taught
the indirection so it still sees the hop. And
`test_duplicate_gate_and_terminal_state_are_one_transaction` makes the finalizer
raise and requires the gate's delete, its claim on the holder's items and its
terminal job row to be absent afterwards -- it fails if the finalizer runs after
`COMMIT`.

Not addressed here, and deliberately: off-loading makes the gateway survive
the scan, it does not make the work cheap. `delete_items_batch` still runs a
full graph rebuild plus the orphan sweep per call. Incremental graph
maintenance touches every mutation path and is its own change.

Also not addressed, and pre-existing: for a TRANSFORMED file (PDF, DOCX,
HTML) the cascade's adoption matches nothing at all, because
`_adopt_reassigned_item` keys a folder row on `COALESCE(text_hash,
content_hash)` while `items.content_hash` is over extracted text, and a
refused row derives its `text_hash` from a byte-identical sibling row that a
lone PDF does not have. `main` writes an unconditional empty group at this
site, so the row never named the item there either -- this change is
equivalent for that shape and strictly better for plaintext. Closing it needs
the incoming document's text hash carried out of the gate instead of guessed
from a sibling, which changes what the gate reports.
`test_deduped_state_write_recovers_a_transformed_files_reassigned_item` pins
it as a strict xfail with a live repro, so it fails the moment someone lands
that and the exemption goes stale.

Co-authored-by: t <t@t>
encomjp pushed a commit to encomjp/kirocrew-customapi that referenced this pull request Aug 22, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants