ingest: bounded parallel ingest (--jobs N), on a context-local wiki redirect - #129
Conversation
The 2026-07 audit's finding 1.2.5 (the blocker under backlog #11, `--jobs N`): ingest's per-source staging redirect ASSIGNED config.WIKI_DIR/INDEX_PATH/ LOG_PATH/MANIFEST_PATH and os.environ["CITADEL_WIKI_DIR"] in place, so one process could only ever be inside one redirect — two sources could never be staged at once. The wiki path is now read through accessors (config.wiki_dir()/index_path()/ sources_index_path()/log_path()/manifest_path()/failures_path()) backed by a ContextVar. A ContextVar is per-thread by construction, so N workers can each hold their own staging redirect while the main thread still sees the live wiki. Unset — every read path, every CLI command, the MCP server — the accessors return the module attributes verbatim, so the process-wide layout (and every test that monkeypatches it) behaves exactly as before. The child processes a session spawns get the staging wiki through an explicit per-spawn env (config.child_env()) rather than a global os.environ assignment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GrYKfDw5w6qsFZJR7ssSMt
Backlog #11 of the 2026-07 audit, unblocked by the context-local wiki redirect: N sources are folded in concurrently, each still against its own staging copy and its own all-or-nothing promote. What is shared is serialized, not raced: * one lock guards every touch of the LIVE wiki — the clone (taken together with a hash snapshot of what was cloned) and the promote. Sessions, the minutes-long part, run entirely outside it. * the promote is base-aware under --jobs: it prunes only what its OWN base had and its staging lacks (so a concurrent source's new page is never deleted as "the agent removed it"), and refuses — before writing a byte — if any path it would touch has moved since the clone. * a refused promote is not a failure: the source is re-run serially at the end of its group, where it sees the winner's page and merges into it, the result a serial run would have produced. Reported as `raced`, since it is the one place parallelism costs a session a serial run would not have. * report/manifest/failures bookkeeping stays on the main thread, so those writes are as single-threaded as before and the manifest is still saved per completed source. An interrupt cancels queued sources and still records the work already promoted. Default is 1 — unchanged serial behavior, line for line. The trade-off is cross-linking (concurrent sessions cannot see each other's new pages), so the knob is documented as a throughput trade, not a free win. Also fixes a defect the parallel path exposed: the promote's leftover-temp sweep deleted HIDDEN *.citadeltmp files, i.e. another writer's in-flight atomic manifest/failures save; and atomic_write_text's temp name is now unique per thread, not just per pid. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GrYKfDw5w6qsFZJR7ssSMt
Self-review fix on top of --jobs N: which pages a source touched is decided against its OWN clone, not against the current live wiki. A staging copy also holds untouched copies of every page the source did not write, so judging by "differs from live" made a page a concurrent source had just rewritten look like this source's change — safe (the base check refused the promote) but wrong: it forced a needless serial re-run, and the intent behind it was to copy a stale page over newer work. Pinned by a test where one source rewrites a pre-existing page while another only writes its own. Docs: CITADEL_JOBS in docs/configuration.md + the .env template, a "working off a big backlog" recipe in docs/recipes.md, the CHANGELOG entry, the CLAUDE.md architecture + convention notes (the wiki path is read through config.wiki_dir(), never config.WIKI_DIR), and the audit itself — backlog #11 ticked off with what shipped and what moved during implementation, plus finding 1.2.5, the § 1.5 "strictly serial" gap and the § 3.3 parallelism bullet annotated, and a state-of-the-backlog note recording that all 15 ranked items are now done and where the still-open work lives. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GrYKfDw5w6qsFZJR7ssSMt
There was a problem hiding this comment.
Pull request overview
This PR adds bounded parallelism to citadel ingest via --jobs N / CITADEL_JOBS, enabled by refactoring the per-source staging redirect from process-global mutation (config.WIKI_DIR / os.environ) to a context-local (ContextVar) accessor model (config.wiki_dir() and related path accessors). This preserves ingest’s staging/promote safety guarantees while allowing multiple sources to be folded concurrently in one process.
Changes:
- Introduces
citadel ingest --jobs N(andCITADEL_JOBS) to run multiple source sessions concurrently, with serialized, base-aware promotion and serial re-runs on detected conflicts. - Reworks wiki-path consumption across the codebase to go through redirect-aware accessors (
config.wiki_dir(),index_path(), etc.) and passes the staging wiki path to child processes viaconfig.child_env(). - Adds a comprehensive offline concurrency test suite and updates docs/config/templates/changelog to describe the new knob and its trade-offs.
Reviewed changes
Copilot reviewed 40 out of 40 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| tests/test_usage_accounting.py | Updates test to use config.wiki_dir() for redirect-aware wiki path access. |
| tests/test_runlock.py | Updates test to use config.wiki_dir() when referencing the live wiki. |
| tests/test_pagecache.py | Updates tests to use config.wiki_dir() and adjusts docstrings to match redirect-aware semantics. |
| tests/test_netdrive.py | Uses config.wiki_dir() in netdrive layout tests to respect redirects/out-of-repo layouts. |
| tests/test_ingest_staging.py | Updates staging assertions to use config.wiki_dir() and reflect redirect behavior. |
| tests/test_ingest_resume.py | Updates resume tests to write/remove pages under config.wiki_dir(). |
| tests/test_ingest_reconcile_delete.py | Updates deletion reconciliation test to use config.wiki_dir(). |
| tests/test_ingest_progress.py | Extends progress event expectations to include the jobs field. |
| tests/test_ingest_parallel.py | New: offline tests that prove true concurrency (Barrier) and pin safety properties under --jobs. |
| tests/test_ingest_core.py | Updates core ingest tests to use redirect-aware wiki path access. |
| tests/test_ingest_chunking.py | Updates chunking tests to assert against config.wiki_dir() in staging. |
| tests/test_curate.py | Updates curate test to read log via config.log_path(). |
| tests/conftest.py | Updates fixtures and helpers to read/write wiki paths via accessors at call time. |
| docs/recipes.md | Documents a “working off a big backlog” recipe using citadel ingest --jobs N. |
| docs/configuration.md | Adds CITADEL_JOBS documentation and explains safety vs. cross-linking trade-off. |
| docs/audit-2026-07.md | Marks audit backlog #11 complete and records shipped design details. |
| CLAUDE.md | Updates architecture/conventions to require wiki access via accessors and documents bounded parallelism. |
| citadel/wikigit.py | Switches from config.WIKI_DIR to config.wiki_dir() for redirect-aware wiki location. |
| citadel/viewer/init.py | Uses config.wiki_dir() for viewer naming, source-link calculation, and output path defaults. |
| citadel/transcribe.py | Derives transcript cache sibling directory from config.wiki_dir(). |
| citadel/templates/env.example | Adds commented CITADEL_JOBS section and throughput guidance. |
| citadel/store_core.py | Makes read/write paths redirect-aware (config.wiki_dir(), config.index_path(), etc.). |
| citadel/server.py | Uses config.wiki_dir() for safe on-disk presence checks. |
| citadel/runlock.py | Derives lock file location from config.wiki_dir() parent. |
| citadel/resume.py | Derives resume cache dir from config.wiki_dir() parent. |
| citadel/progress.py | Adds jobs to on_start and introduces on_source_retry for conflict-triggered serial re-runs. |
| citadel/pdftext.py | Derives PDF text cache dir from config.wiki_dir() parent. |
| citadel/pagecache.py | Updates documentation references to match redirect-aware wiki semantics. |
| citadel/manifest.py | Uses config.manifest_path() for redirect-aware manifest IO and warnings. |
| citadel/llm.py | Ensures LLM subprocesses receive the staging wiki via env=config.child_env() and uses config.wiki_dir() in prompt building. |
| citadel/linkgraph.py | Makes link rewrite and raw-reference rewrite write targets redirect-aware (config.wiki_dir()). |
| citadel/ingest.py | Implements --jobs concurrency, base-aware promotion, conflict detection + serial re-run, and thread-safe staging naming. |
| citadel/grammar.py | Uses config.wiki_dir() when resolving/normalizing internal link paths. |
| citadel/failures.py | Uses config.failures_path() for redirect-aware failures catalog IO. |
| citadel/doctor.py | Uses redirect-aware manifest/wiki paths (e.g., config.manifest_path(), config.wiki_dir()). |
| citadel/curate.py | Uses config.wiki_dir() when walking/reading wiki pages and selecting pages. |
| citadel/config.py | Introduces wiki accessors + ContextVar redirect, child_env(), CITADEL_JOBS, and thread-unique temp naming for atomic writes. |
| citadel/cli.py | Adds --jobs/-j flag, validates usage, and threads the value through to ingest.ingest(). |
| citadel/catalogs.py | Writes generated catalogs/indexes using redirect-aware wiki paths. |
| CHANGELOG.md | Records the new bounded parallel ingest feature and the context-local redirect change. |
Copilot review round 1. clone() held the live-wiki lock across BOTH the copytree and a full hash walk of the live wiki — two reads of the corpus and a serialization window twice as long as it needs to be, on the exact path --jobs N exists to speed up. The staging tree is a byte-exact copy of what was just cloned, so the base can be hashed off it, after the lock is released: identical hashes, one read of the wiki instead of two, and every other worker's clone/promote unblocked sooner. It still has to happen before anything mutates staging — a resume replay writes into it — which the comment now says explicitly. Pinned by a test that a clone hashes identically to what it copied, over the whole file set _content_files considers (nested folders and non-.md files included). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GrYKfDw5w6qsFZJR7ssSMt
Adversarial cross-review (second model) of the --jobs N concurrency found a real data-loss path where the new parallel machinery meets the existing resume checkpoints of a CHUNKED source. Both manifestations were reproduced offline before fixing, and the tests are committed. _checkpoint_delta computed "what promoting staging onto LIVE would do". Under --jobs the live wiki drifts between a source's clone and its checkpoint, so: * a page a CONCURRENT source created read as "in live, not in my staging" — banked as a deletion this source had made. A checkpoint is durable, so a later run — even a strictly serial one — replayed it and pruned a fully-ingested source's page off the live wiki: no conflict, no error, no delete session, just a page gone. * a page a concurrent source rewrote read as this source's change, carrying the clone's stale bytes. resume.save compounded it by recording the base state at SAVE time (after the concurrent promote), so replay's guard verified "live unchanged since I saved" while the delta embodied "live as of my clone" — the wrong invariant, which a concurrent promote passes straight through. The delta and the recorded base state now both come from the source's own clone snapshot — exactly the rule the base-aware promote already uses. A page another source touched fails the guard and the checkpoint is dropped for a full restart. That also fixes the second manifestation: a raced chunked source whose checkpoint had completed == total was re-run with ZERO sessions and promoted its stale copy over the winner's page, silently violating the documented "re-run serially and merge into the winner" contract. Also locks llm's transcript sequence counter, whose own comment promised concurrent files never collide. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GrYKfDw5w6qsFZJR7ssSMt
The durable half of the previous commit: what the adversarial cross-review found, why a checkpoint measured against a moving live wiki outlives the run that created it, and the three root-cause fixes the parallel path exposed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GrYKfDw5w6qsFZJR7ssSMt
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GrYKfDw5w6qsFZJR7ssSMt
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 41 out of 41 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
citadel/ingest.py:2459
emit()can be called concurrently from worker threads under--jobs > 1(e.g.source_startis emitted inside_attempt_source). If theprogresscallback has any internal state (includingConsoleProgress’s_label/spinner fields), concurrent calls can race and produce incorrect output or intermittent errors (which are then silently swallowed). Serializing progress-callback invocations with a simple lock keeps behavior deterministic while still allowing the sessions themselves to run in parallel.
def emit(event: str, **data) -> None:
if progress is not None:
try:
progress(event, data)
except Exception: # noqa: BLE001 - progress must never break ingest
pass
…spend Copilot review round 2, both points genuine. emit() now fires from worker threads, so the callback — whatever a caller passed — is serialized behind a lock. "Your progress callback is never invoked concurrently" becomes a property of the API instead of something each caller has to discover; the console reporter was safe either way (locked writes, spinner off when jobs > 1), but the contract must not rest on that reasoning holding for every future callback. It costs nothing: emit fires a handful of times per source, around sessions that take minutes. The conflict path bypassed _record_source_run and so dropped the outcome's resumed_note, even though the checkpoint replay had already happened — the codebase's own rule is that a restored checkpoint is recorded whatever the verdict. Both facts that hold regardless of verdict (what an attempt cost, what it reused) now live in one owner, _record_spend, called from the normal path and from the conflict path, so the next branch cannot forget one of them again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GrYKfDw5w6qsFZJR7ssSMt
…a test fd
- _run_source_jobs_parallel's main loop now skips a cancelled future instead of
calling result() on it. The described failure (the run aborting with an
unexpected CancelledError instead of the original interrupt) cannot actually
happen — a future is only ever cancelled after an interrupt is recorded, and
the first interrupt wins — but the loop relied on that reasoning rather than
saying it. The drain loop already skipped cancelled futures; now both do.
- `citadel ingest --jobs` help text dropped a stray word ("Fold up to N sources
in CONCURRENTLY").
- The progress test opened a source file as its output stream and never closed
it; io.StringIO is what it wanted in the first place.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrYKfDw5w6qsFZJR7ssSMt
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 41 out of 41 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
tests/test_ingest_parallel.py:413
- This test opens a file handle for
ConsoleProgress(stream=...)without closing it. Using a context manager avoids leaking descriptors (and keeps pytest warning configurations from turning this into a failure).
refresh already honored CITADEL_JOBS — it drives ingest — but had no flag of its own, which was the weaker half of keeping the new CLI surface small: a refresh slice is ordered by LAST-CHECKED time rather than by topic, so its sources are typically unrelated, which is exactly the case --jobs exists for. Same knob, same trade-off, same refusal below 1 (exit 2, mirroring ingest); refresh only chooses which sources to hand ingest. Docs, help text and the CHANGELOG say so in one place each. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GrYKfDw5w6qsFZJR7ssSMt
Copilot round 4. The refactor moved five of these field comments to the accessor form and left sources_index_path on the old attribute name, so the dataclass documented two conventions at once. Made consistent, plus the sentence that makes the annotation mean something: the fixture WIRES the module attributes, the fields are annotated with the ACCESSOR because that is how code reads them — and inside a fake session only the accessor sees the staging copy the session is supposed to edit, while the attribute still names the live wiki. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GrYKfDw5w6qsFZJR7ssSMt
Copilot round 5, two comments; the first was factually wrong and the second right, but both pointed at wording worth sharpening in a concurrency PR. Measured rather than argued (CPython 3.12): a ThreadPoolExecutor worker does NOT inherit the submitting thread's context — it reads the ContextVar's default — and neither does a plain threading.Thread. Context propagation into threads is an asyncio-task property, not an executor one. But the same measurement surfaced the caveat neither comment named: a POOLED thread is reused, and a value one work item leaves set is still there for the next item on that thread. So wiki_redirect's `finally` reset is load-bearing — it is what stops a finished source's staging path being handed to the next source that lands on the same worker. The comment now says both. The redirect test really did check the main thread while calling it a sibling. It now checks both shapes while the redirect is held: a genuine sibling worker (neither thread started the other — the actual --jobs shape) and the main thread that started the holder. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GrYKfDw5w6qsFZJR7ssSMt
What
The 2026-07 audit's backlog #11 — the last open item in its ranked table — shipped in the two steps the audit itself scoped: first the unblock (finding 1.2.5), then the feature.
Unblock. The per-source staging redirect assigned
config.WIKI_DIR/INDEX_PATH/LOG_PATH/MANIFEST_PATHandos.environ["CITADEL_WIKI_DIR"]in place, so one process could only ever be inside one redirect. The wiki path is now read through accessors (config.wiki_dir(),index_path(),sources_index_path(),log_path(),manifest_path(),failures_path()) backed by aContextVar— per-thread by construction, so N workers each hold their own redirect while the main thread still sees the live wiki. Unset, the accessors return the module attributes verbatim, so every read path, every CLI command, the MCP server, and every test that monkeypatches the layout behave exactly as before. The session's child processes get their staging wiki through an explicit per-spawnenv(config.child_env()) instead of a global mutation.Feature.
citadel ingest --jobs N/-j(envCITADEL_JOBS, default 1 = the serial behavior, line for line) folds N sources in concurrently, each on its own staging copy — the isolation primitive was already there. Every guarantee is unmoved: one promote per source, all-or-nothing, nothing partial on the live wiki, the manifest still saved per completed source. What is shared is serialized rather than raced:_ConcurrentChange), and that source is re-run serially at the end of its group — where it sees the winner's page and merges into it, i.e. the result a serial run would have produced. Those re-runs are surfaced on the report (Re-run serially), because they are the one place parallelism costs a session serial ingest would not have spent.The default stays 1 on purpose: the real cost is cross-linking, not safety — concurrent sessions cannot see each other's new pages, so they link less richly and can duplicate a topic (
curateis the designed cleanup).curateitself remains serial by construction (its clusters share pages by definition, so nearly every pair would race);refreshinheritsCITADEL_JOBS, since it drivesingest.Three defects the parallel path exposed are fixed at the root: the promote's leftover-temp sweep deleted hidden
*.citadeltmpfiles — another writer's in-flight atomic manifest/failures save —config.atomic_write_text's temp name was unique per PID but not per thread, and the transcript log's sequence counter was bumped without a lock while its own comment promised concurrent files never collide.Docs:
CITADEL_JOBSindocs/configuration.md+ the.envtemplate, a "working off a big backlog" recipe indocs/recipes.md, CHANGELOG, CLAUDE.md (architecture + the read-the-wiki-through-the-accessor convention), and the audit itself — #11 ticked off with what shipped and what moved during implementation, finding 1.2.5 / § 1.5 "strictly serial" / § 3.3 annotated, plus a state-of-the-backlog note (all 15 ranked items done; where the still-open work lives).What review found (worth reading before merging)
An adversarial cross-review by a second model, run specifically against the concurrency, found the one path where this could corrupt a wiki — not in the conflict detection, but where the new parallelism meets the existing resume checkpoints of a chunked source. Both manifestations were reproduced offline before the fix and are now pinned (
tests/test_ingest_parallel_resume.py):_checkpoint_deltacomputed "what promoting staging onto live would do". Under--jobsthe live wiki drifts between a source's clone and its checkpoint, so a page a concurrent source had created read as a deletion this source made, and one it had rewritten read as this source's change carrying stale bytes. A checkpoint is durable: a later run — even a strictly serial one — replayed that delta and pruned a fully-ingested source's page off the live wiki, with no conflict, no error and no delete session.resume.savecompounded it by recording the base state at save time (after the concurrent promote), so replay's guard verified "live unchanged since I saved" while the delta embodied "live as of my clone" — the wrong invariant, which a concurrent promote passes straight through. A raced chunked source then re-ran with zero sessions and promoted its stale copy over the winner's page, silently violating the documented merge contract.Both now derive from the source's own clone snapshot — exactly the rule the base-aware promote uses — so a page another source touched fails the guard and the checkpoint is dropped for a full restart.
Copilot's rounds contributed the lock-window narrowing (hash the clone, not the live wiki), the serialized progress callback, the missing
resumed_noteon the conflict path (now with one owner,_record_spend, so a future branch cannot forget it), and two hygiene fixes (a dropped word in the--jobshelp text, a leaked file handle in a test). One Copilot finding was a false positive —future.result()on a cancelled future cannot abort the run, since a future is only cancelled after an interrupt is recorded and the first interrupt wins — but the loop now skips cancelled futures explicitly rather than relying on that reasoning.Testing
uv run pytest -q— 1216 passed, 1 skippeduv run ruff check ./uv run ruff format --check .— cleanCITADEL_WORKSPACE=corpora/beverages uv run python -m citadel lint— OKtests/test_ingest_parallel.py,tests/test_ingest_parallel_resume.py) drive the concurrency for real: the fake sessions synchronize on athreading.Barrier, so a test requiring two sources in flight at once cannot pass on a serial run. Counter-checked by forcing the driver serial — the concurrency tests fail exactly as intended. Each safety property is pinned to the failure it prevents (a concurrent source's pages surviving a promote, only-my-own-delta, the serial re-run and its failure path, the checkpoint that must not bank another source's page as a deletion, the raced chunked source that must really re-run, the interrupt keeping promoted work, the hidden-temp sweep, the never-concurrent progress callback).claudeCLI in this container is not authenticated (a live 4-source--jobs 4run failed every session withAuthentication error). That run did still exercise the parallel driver end-to-end with real subprocesses — four sessions in flight simultaneously, per-source failure recording, finalize. The rules tree is untouched (norules_versionchange) and the serial path is unchanged, so wiki quality for the default configuration cannot have moved; a corpus grade of a--jobs > 1run is worth doing on a machine with a logged-in CLI.🤖 Generated with Claude Code
https://claude.ai/code/session_01GrYKfDw5w6qsFZJR7ssSMt