Skip to content

fix(587): align raw_records to observe-filtered records in FILE mode#794

Closed
Muizzkolapo wants to merge 4 commits into
mainfrom
fix/file-mode-observe-drop-desync
Closed

fix(587): align raw_records to observe-filtered records in FILE mode#794
Muizzkolapo wants to merge 4 commits into
mainfrom
fix/file-mode-observe-drop-desync

Conversation

@Muizzkolapo

Copy link
Copy Markdown
Owner

Summary

  • FILE-mode tool/HITL processing passed the full pre-observe data as raw_records while the observe-filtered records (filtered) were fewer — because context_scope.observe drops records missing an observed upstream namespace.
  • UnifiedProcessor forwards raw_records to prefilter_by_guard, which indexes original_data[idx] positionally and asserts len(original_data) == len(data). The mismatch raised RuntimeError("prefilter_by_guard received N original_data for M input records — length mismatch"), failing the action and cascade-SKIPping every dependent.
  • Real hit: ql_code_centered dedup_by_concept merges tag_code_concept (guard-filtered to 34) with dedup_code_blocks (52) → 18 observe-dropped → 34 vs 52 → crash → code_usage_scenario, generate_code_caveats, … all SKIP.
  • Fix (agent_actions/workflow/pipeline.py): drop the observe-skipped source_guids from raw_records so it stays positionally 1:1 with filtered. Mirrors the existing cascade-quarantine lockstep filter (UnifiedProcessor, unified.py:172-182, also guid-based).

Spec deviation

Spec 587 recommended the "robust" fix (change apply_context_scope_for_records to return the aligned raw subset). Deviated to the minimal guid-filter at the caller: the arity change breaks ~58 unpack sites across 9 files, and source_guid is a hard upstream invariant here — write_source raises DataValidationError on any record lacking one (sqlite_backend.py:524-529) and UNIQUE(relative_path, source_guid) blocks duplicates — so the source_guid=None/dup gap the robust fix would close is structurally unreachable. Minimal fix = far smaller blast radius, same correctness.

Verification

  • RED: tests/unit/workflow/test_file_mode_observe_drop_raw_records.py drives the real FILE-mode _process_by_strategy branch with 5 records where 2 lack the observed namespace, and captures the raw_records the pipeline passes. Pre-fix it is the full 5-record data (≠ the 3 survivors) → fails; post-fix it equals the 3 survivors.
  • GREEN: full suite 8090 passed; ruff format --check, ruff check, mypy agent_actions clean.
  • Review: one blind fresh-context reviewer (diff + bug statement only) → YES. Confirmed alignment ordering holds, no-drop path unchanged, test proves the bug, and the null/duplicate-guid edge is unreachable (enforced upstream).
  • Smoke: skipped — LOW risk, no chokepoint or registry/config surface touched (2 files, 81 lines).
  • Blast radius: pipeline.py is the only prod caller passing raw_records; RECORD mode doesn't pass it; no batch equivalent (FILE-mode tool/HITL always run online). All FILE-mode sibling suites stay green.

A FILE-mode tool/HITL action whose context_scope.observe drops records for a
missing upstream namespace passes the full pre-observe data as raw_records while
the observe-filtered survivors are fewer. prefilter_by_guard then asserts
len(original_data) == len(data) and crashes with a length-mismatch RuntimeError,
failing the action and cascade-SKIPping every downstream action.
FILE-mode tool/HITL processing passed the full pre-observe data as raw_records
while the observe-filtered records were fewer (observe drops records missing an
upstream namespace — the shape of a merge between a guard-filtered source and an
unfiltered one). prefilter_by_guard indexes original_data positionally and asserts
len(original_data) == len(data), so the mismatch raised a length-mismatch
RuntimeError, failing the action and cascade-SKIPping all dependents.

Drop the observe-skipped source_guids from raw_records so it stays 1:1 with the
filtered records. Mirrors the existing cascade-quarantine lockstep filter in
UnifiedProcessor. source_guid is unique per FILE-mode downstream record.
…servability

Code review of PR #794 found the guid set-diff alignment is fragile:
merged/version records can carry source_guid=None (loop.py warns and
produces them — it does not raise), so a None guid is filtered out of the
skip set, its record stays in raw_records while it is absent from filtered,
and prefilter_by_guard raises the same length-mismatch RuntimeError the PR
was written to prevent.

Root-cause fix: align by input INDEX instead of source_guid.
apply_context_scope_for_records now tags each skipped record with its
position (a field on the dict it already returns — no return-arity change,
one production call site), and the pipeline drops exactly those indices from
raw_records. Immune to None and duplicate guids.

Also from the review:
- raise_if_terminal_failure now receives the observe-filtered survivors, so
  the terminal-failure count reflects records actually processed, not the
  inflated pre-observe total.
- All-observe-skipped now writes a node-level SKIPPED explicitly (the
  survivors path no longer routes through raise_if_terminal_failure's
  branch-1, which cannot fire on an empty list) so downstream still
  cascade-skips.
- Observe-skips without a source_guid are logged instead of silently
  dropped from the disposition batch.
- ARCHITECTURE.md updated to show raw_records=aligned_raw.

Tests: None-guid alignment; real (non-mocked) prefilter_by_guard invariant;
accurate failure count; node-level skip preserved; unkeyed-skip warning;
no-drop fast path; HITL branch coverage.
@Muizzkolapo

Copy link
Copy Markdown
Owner Author

Addressed all 7 findings from /code-review max (commit 652cba2)

The review disproved the invariant this PR originally rested on. Fixes below.

HIGH — source_guid=None recreates the crash. Correct. loop.py _create_merged_record warns and produces merged records with source_guid=None (it does not raise), so the guid set-diff dropped nothing for them and re-desynced raw_records. Also, the "~58 unpack sites" that had justified the minimal guid fix was a miscount — apply_context_scope_for_records has one production call site. Root-cause fix: align by input index, not guid. apply_context_scope_for_records now tags each skipped record with its position (field on the dict it already returns — no arity change), and the pipeline drops exactly those indices. Immune to None and duplicate guids.

MEDIUM — inflated active_input_count. raise_if_terminal_failure now receives the observe-filtered survivors, so the count reflects records actually processed, not the pre-observe total.

MEDIUM — silent disposition drop for guid-less skips. Now logged (N observe-skipped record(s) had no source_guid …).

LOW — stale ARCHITECTURE.md. Updated to raw_records=aligned_raw.

LOW ×3 — test gaps. Added: no-drop fast path; HITL branch; and a non-mocked test that runs the real prefilter_by_guard on aligned vs. full arrays (proves the actual RuntimeError boundary, so the mock can't hide a regression). Plus a None-guid alignment RED and a node-level-skip preservation guard.

Also verified: preserved the all-observe-skipped → node-level SKIPPED cascade (executor _has_blocking_disposition keys off NODE_LEVEL_RECORD_ID), now written explicitly since the empty-filtered path can't route through raise_if_terminal_failure branch 1.

Verification: 8097 passed (+7 tests); ruff check + ruff format --check clean (1032 files); mypy clean (453 files).

@Muizzkolapo

Copy link
Copy Markdown
Owner Author

Closing in favor of the root fix (spec 588).

#794 fixed the symptom — the raw_records misalignment that crashed prefilter_by_guard when observe drops records. But the reason records were being dropped here is the real bug: guard filter is not authoritative across the DAG. Per guards/ARCHITECTURE.md, filter means a record is "excluded entirely — cannot be carried forward," yet 18 syllabus-rejected blocks resurrected at dedup_by_concept through the unfiltered dedup_code_blocks sibling (fan-in unions by source_guid; filter leaves no per-record marker to suppress them).

Shipping #794 alone would have papered over the crack — the framework would keep resurrecting dead records, re-deriving a filter decision it already made upstream, and wasting a merge + a processing pass.

588 fixes the cause: a per-record FILTERED marker written on the filter path, subtracted at fan-in (lineage-scoped). After 588, dedup_by_concept receives 34, nothing resurrects, and this crash path isn't reached.

The alignment invariant #794 hardened will be re-evaluated inside 588's blast-radius work and folded in properly if a legitimate observe-drop path can still reach the crash — not shipped as a standalone patch. Branch fix/file-mode-observe-drop-desync retained for reference.

@github-actions github-actions Bot locked and limited conversation to collaborators Jul 15, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant