A package worker's refusal is silently promoted into the active generation (#78 criterion 7) #115
Labels
No labels
code-review
correctness
dos
performance
security
severity/high
severity/low
severity/medium
tech-debt
Kind/Breaking
Kind/Bug
Kind/Documentation
Kind/Enhancement
Kind/Feature
Kind/Security
Kind/Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
h-dv/code-index#115
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
A package that traps, hangs or refuses on a subset of a project's files still produces a generation that replaces the last good one. The refused files' facts — symbols, refs, imports — are dropped on all three channels, and nothing anywhere says so. There is no roll-up from "this dispatch was refused" to "this generation failed", and no per-file disclosure that a promoted generation is partial.
This violates #78 criterion 7 (generation-safe activation, promotion and rollback).
The chain, verified link by link on the working tree
crates/indexer/src/index.rs:8279-8286—extract_for'sParse::Errarm maps a refusal to an emptyextraction_diagnostics, with a comment claiming "The refusal itself rides infiles.parse_error".The claim is false on the generation-build path.
writer::write_pending_contribution(crates/indexer/src/writer.rs:445-529) writes zeroparse_error— onlywrite_upsertdoes (writer.rs:268-291), andwrite_upsertis the ACTIVE path a generation build must never take.write_pending_contributionwrites thefile_contributionsrow unconditionally viainsert_contribution_measured, then gates symbols/refs/imports behindif let ParseResult::Ok(extract). A refused file therefore leaves a contribution row that is indistinguishable from a legitimately fact-free one.It cannot simply write
files.parse_erroreither:filesis single-valued and query-visible, and touching it is the "no query-visible state changes before step 11" constraint the function exists to honour.build::extract_one(build.rs:1199-1250) returnsResponse::Accepted { outcome, hash }regardless ofParseResult::Error—run_taskhands back aWriterMsg::Upsertcarrying the error and the acceptance check is only the stat-identity high-water protocol.dispatch_round(build.rs:1004, called at:684and:794) then settles the queue rowSTATE_DONE.The build gates count contributions, not errors. Gate 5 (
build.rs:1444) asserts everyreparsefile that settleddoneproduced exactly one contribution — and a refusal wrote one at step 2, so the count looks healthy. Gate 6's projection check is contributions-based too.crates/cli/src/activation.rs:740(and the resume path at:905) promotesOutcome::Readyunconditionally.Net effect: promotion supersedes the active contribution for that file (the partition excludes re-extracted files from the carry) and installs an empty one in its place. The facts are gone,
Ready's counts look healthy, and the operator is toldgeneration N ACTIVE.The tree already expects the fix
crates/mcp-server/tests/reason_code_registry.rs:819-836registersworker_timeoutandworker_crashasno_producerin these words:Scope beyond packages
ParseResult::Erroris also produced by the builtin arm (parse_with_plugin:set_languagefailure,parser.parsereturningNone, andcatch_unwindcatching a plugin panic). The defect and its fix are generic over both producers — a refusal is a refusal whoever refused.What a fix has to do
Recovery::Revalidatepath re-runs the gates over an already-settled queue with no extraction at all, so an in-memory count would read zero.Found by two independent audits from opposite ends of the chain, converging on the same defect; verified end to end.
Refs #78 (criterion 7).
Response::Unclaimedis the same silent-loss door as #115, still open: three causes collapse into one count that reads as the designed case #123Fixed. The gate asks about the loss, not the refusal — which is why it fails neither way.
Policy: regression-gated, not a threshold
The discriminator is structural: does promoting this generation delete facts the active one is serving for that file?
gate_failed, naming the paths. The last good generation keeps answering, which is #78's criterion 4.Both alternatives were considered and rejected with reasons, written into
build.rs:The predicate avoids both because it asks about the consequence rather than the event.
One detail that matters: the denominator is facts that exist, not the active row's own
refusalcolumn. Reading that column would treat a pre-migrationNULLas "did not refuse", collapsing two states the schema is being changed to separate.The chain — all five links held, with three corrections
Verified with
search_symbols/find_callers/read_code, not text grep. None of the corrections changes the substance:index.rs:8279claims the refusal "rides infiles.parse_error".write_upsertreally does write it (:269/275/287) — so the claim is correct for the ordinary path and false for the build path sitting next to it. And it cannot be fixed there:filesis single-valued and query-visible, and not touching it is the entire reasonwrite_pending_contributionexists.Response::Acceptedis returned byextract_oneatbuild.rs:1249, not bydispatch_round.dispatch_roundconsumes it (find_callersconfirms exactly the two call sites).build.rs:1386infinishis the only production transition toready.promotion::resumeandrollbackonly promote already-ready/supersededgenerations. So this is a single chokepoint — there is no second door.What was built
m0059 adds
file_contributions.refusal TEXT(m0058 was taken by another lane;CURRENT_VERSION58→59). Three states:NULL= not measured,''= measured and did not refuse, otherwise the producer's own words.No backfill, deliberately: unlike m0057 there is no provable population, and deriving one from
files.parse_erroris a cross-table correlation that would keep answering confidently after the shape moved.It is durable rather than in-memory because
Recovery::Revalidatere-runs the gates with no extraction at all — an in-memory count reads zero there, and the gate would pass on a resumed build.ProducerVerdict { diagnostics, refusal }carries it (a struct, not an eighth argument — that tripsclippy::too_many_arguments). Gate 5b inbuild.rs;Readygainsrefusals(complete) plusrefused_files(8-row evidence).Mutations — all RUN, all RED, against the shipped code
must FAIL, not promote: Ready { … refusals: 1, refused_files: [RefusedFile { path: "loses.rs", refusal: "plugin panicked during extract" }] }— the defect printing itselfrefusals: 0, refused_files: vec![]unconditionallyif refusals > 0(the rejected policy)expected a ready generation, got Failed { reason: "gate_failed" }refusals = len + 1refusals: 1, refused_files: []: a count contradicted by its own evidenceAND refusal <> ''files.parse_errorleft: Some("plugin panicked…") right: NoneTEXT NOT NULL DEFAULT ''left: Some("") right: NoneA mutation found a real defect in the fix itself. The count and the evidence list were two statements over "the same" predicate; dropping
<> ''made them disagree —refusals: 7, refused_files: [], which reads as truncated. They are now one query with the discriminator on the same row, andrefusalsis that vector's length.Two honest records rather than re-aimed tests: the rejected-policy mutation survived at the positive control, and the positive control (
if true) is recorded as not isolated — a control asserting "ordinary builds still promote" necessarily shares that assertion with every test that wants aReady.No test seam: a
LanguagePluginthat panics produces a genuineParseResult::Errorthroughparse_with_plugin's realcatch_unwind, and the tests drivebuild::build→gate→promotion::promotethroughactivate's own match arm.What an operator sees
Promoted-but-partial:
refusals=is unconditional, so0is a printed measurement, not an absence.Fact-losing:
activation FAILED (gate_failed), withgeneration_failures.detail= "1 of 1 refused file(s) would have LOST facts the active generation is serving: app/models/user.rb".Corrected in passing
The
worker_timeout/worker_crashprose inreason_code_registry.rswas stale: the roll-up now exists, but those codes stay unwritten becauseextract_forflattens the structuredReasoninto a string — so the gate cannot honestly claim "timeout" over "crash". Said plainly rather than left implying coverage.Split out rather than absorbed
#123 —
Response::Unclaimedis the same silent-loss door: three causes (routes-to-nobody, vanished file, non-UTF-8/read failure) collapse into one count that reads as the designed case. Code-verified, not driven end to end, so it was not widened into this change.Also recorded and not fixed here:
files.parse_erroris not restamped by promotion (m0054 names that omission deliberately), so it can disagree withfile_contributions.refusaluntil the next ordinary pass; andplugin_activationhas no MCP surface for refusals.Gates: fmt, clippy
-D warnings, rustdoc,cargo test --workspace2891/0 twice, daemon leg 571/0,precision_gate7/7,baseline.jsonmd5 unmoved.archive_refusedreaches nocoverage_reasonscode, so an agent's answer is qualified by nothing #124Response::Unclaimedis the same silent-loss door as #115, still open: three causes collapse into one count that reads as the designed case #123code-index://docs/reason-codesis at 3,979 of its 4,000-token cap, so the next reason code this project mints cannot be documented #184