Skip to content

select_tests: bare-name AST resolver, empty-diff skip, drop canary - #2262

Merged
jwbron merged 2 commits into
mainfrom
egg/select-tests-bare-name-resolver
Apr 29, 2026
Merged

select_tests: bare-name AST resolver, empty-diff skip, drop canary#2262
jwbron merged 2 commits into
mainfrom
egg/select-tests-bare-name-resolver

Conversation

@jwbron

@jwbron jwbron commented Apr 29, 2026

Copy link
Copy Markdown
Owner

Summary

Three changes to scripts/select_tests.py that fix changeset-aware narrowing for the inner loop:

  • Bare-name AST resolver — supplements grimp so source-code changes actually narrow. The codebase universally uses bare-name imports (406/407 tests, 33/33 sampled production files), but grimp registers production under fully-qualified names; without help it returns no downstream tests for almost every source change and the no downstream tests for changed module trigger widens to the full suite. The resolver AST-scans every module, maps bare-name imports to their FQ production targets via the same prefix rules grimp's sys.path injections imply, and unions the synthetic edges with grimp's reverse closure. End-to-end: editing orchestrator/action_guards.py now selects 106/278 tests instead of widening to 278.

  • Empty-diff short-circuitmake test on a clean tree used to widen to the full suite via an empty diff trigger (minutes of pointless runs). Now the selector emits empty stdout + selected 0 tests (skipping pytest) and the Makefile skips pytest entirely. Unresolvable-baseline / stale-LKG still widen for safety.

  • Drop the canary — every-10th-invocation full-suite tick was defense in depth against unknown static-analysis blind spots. With the AST resolver in place the analysis is much more thorough; remaining true blind spots (subprocess crossings, data-file loads, dynamic imports) are already covered by their own explicit triggers. Ground truth is make test-all before push (which CI runs anyway).

Verification

  • 302 selector tests pass (down from 323 — the delta is the 9 canary tests deleted plus 12 unrelated parametrize cases that referenced the canary trigger string; offset by 32 new tests in test_select_tests_bare_name.py).
  • Direct-Python probe against the real repo confirms bundle.bare_name_upstream builds 197 keys / 1306 edges, and _walk_upstream_combined reaches the expected test consumers for representative source modules.
  • End-to-end on a local temp commit (LKG=HEAD): perturbing orchestrator/action_guards.py produced select-tests: narrowed 106/278 tests in 3.29s (baseline=…, trigger=diff) and pytest ran the narrowed selection through to ~84% before I cancelled the timeout.
  • Empty-diff path: clean tree → select-tests: no changes since baseline …; selected 0 tests (skipping pytest), zero stdout.

Related issues surfaced during this work

Test plan

  • CI make test-all passes (the 80% coverage gate is unchanged — narrowing is local-inner-loop only).
  • Reviewer sanity-checks the bare-name index logic in build_bare_name_index against the prefix table (shared., orchestrator., sandbox.tools., sandbox.).
  • Reviewer confirms the BFS combination in _walk_upstream_combined is OK with grimp's find_downstream_modules semantics (transitive closure of importers).

Three changes to `scripts/select_tests.py` driven by inner-loop friction:

1. **Bare-name AST resolver.** The codebase universally uses bare-name
   imports (verified 406/407 test files and 33/33 sampled production
   files), but grimp registers production under fully-qualified names.
   Without help, grimp's `find_downstream_modules` returns the empty
   set for almost every source-code change and the
   `no downstream tests for changed module` widening trigger fires
   for nearly every diff — narrowing was effectively inert.

   The resolver AST-scans every module in the graph, maps each
   bare-name import target back to its fully-qualified production
   module via the same prefix-stripping rules grimp's sys.path
   injections imply (`shared.`, `orchestrator.`, `sandbox.`,
   `sandbox.tools.`), and stores a reverse-edge map on `GraphBundle`.
   `reverse_closure` now unions grimp's transitive closure with these
   synthetic edges, and the zero-downstream-offender check consults
   the union — so the trigger only fires on a true blind spot.

   End-to-end: editing `orchestrator/action_guards.py` now selects
   106/278 tests (the actual transitive consumers); previously this
   widened to all 278.

   Tracked separately:
   - #2256 — assess rewriting bare-name imports to package-prefix
     paths (alternative cleanup path; resolver is the contained
     internal fix).
   - #2259 — top-level `tests/` tree shadowed by `sandbox/tests/`
     during graph build, blocking narrowing for tests under that
     subtree (pre-existing issue, surfaced during verification).

2. **Empty-diff short-circuit.** An empty diff against a resolvable,
   current baseline now logs `selected 0 tests (skipping pytest)` and
   exits with empty stdout — the Makefile already keys off empty
   selector output to skip pytest entirely. Previously the selector
   widened to the full suite via an `empty diff` trigger, so
   `make test` on a clean tree spent minutes running every test for
   no reason. Unresolvable-baseline / stale-LKG still widen for
   safety.

3. **Drop the canary.** The every-10th-invocation full-suite tick was
   defense in depth against unknown static-analysis blind spots. With
   the bare-name resolver in place the analysis is much more
   thorough, and the remaining true blind spots (subprocess crossings,
   data-file loads, dynamic imports) are already covered by their own
   explicit triggers. The cost — every 10th `make test` blowing up to
   a full suite — is no longer worth the implicit insurance. Ground
   truth is `make test-all` before push (which CI runs anyway).

Test counts: 302 selector tests pass (was 323; the delta is the 9
canary tests deleted plus 12 unrelated parametrize cases removed
from the canary trigger row, replaced with the new resolver suite —
32 tests in `test_select_tests_bare_name.py`).

@egg-reviewer egg-reviewer Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review

The bare-name AST resolver is a well-targeted fix for the codebase's structural
mismatch with grimp. The new test coverage is solid, the resolver fails open on
the right axes (SyntaxError / OSError / ValueError), and the integration with
both reverse_closure and the zero-downstream-offender check is clean. I have
one blocking issue and a handful of non-blocking observations.

Blocking

1. Empty-diff short-circuit silently breaks make test PYTEST_ARGS=… on a clean tree.

scripts/select_tests.py:1479 now early-returns when diff is empty:

if not diff and baseline_source != "UNRESOLVABLE" and not lkg_was_stale:
    ... return 0

The PYTEST_ARGS bypass classifier (bypass_narrowing at line 1526) lives
after this short-circuit. Before this PR, an empty diff fell through to
evaluate_fallback_triggers, which returned "empty diff" — but the bypass
check ran first and would write mode=bypass in the selection record. The
Makefile (Makefile:308) keys off mode=bypass to actually invoke pytest with
the user's PYTEST_ARGS.

After this PR, on a clean tree:

  • Selector hits the empty-diff branch, writes mode=narrow, returns 0 with
    empty stdout.
  • Makefile sees empty stdout, checks selection record, finds mode=narrow
    (not bypass), prints select-tests: no tests selected, exits 0.
  • The user's PYTEST_ARGS=tests/foo/test_bar.py is silently ignored. Pytest
    never runs.

The PR's own docs (docs/guides/testing.md:454) still promise: "An explicit
path in PYTEST_ARGS bypasses narrowing — the developer is asking for that
specific selection."
That contract is now violated for the very common case
of running a single test on a clean tree (e.g., re-running a passing test
after rebasing onto an LKG).

The new e2e test test_empty_diff_subprocess_skips_pytest
(tests/tools/test_select_tests_fallbacks.py:404) doesn't exercise this
combination — it never sets PYTEST_ARGS_RAW, so the regression doesn't
show up in CI.

Fix: either move the empty-diff short-circuit to after the PYTEST_ARGS
bypass check (line 1557), or include the same pytest_args_have_explicit_path
guard inside the empty-diff branch. Add a regression test that sets
PYTEST_ARGS_RAW=tests/... on a clean-tree fixture and asserts pytest is
invoked (or the selection record records mode=bypass).

Non-blocking

2. _extract_imports doesn't yield the parent package for import X.Y.

scripts/select_tests.py:735-738: for ast.Import nodes, only the dotted
name as written is added (X.Y.Z), not the intermediate parents. Python
actually loads X, X.Y, AND X.Y.Z at runtime, so a test that only does
import egg_logging.signatures should be linked to changes in
shared/egg_logging/__init__.py too.

Today this mostly self-heals because the more idiomatic from egg_logging import signatures (line 744-747) already yields both egg_logging and
egg_logging.signatures, and the zero-downstream-offender check widens to
full suite when an __init__.py change has no recorded consumers. But in a
mixed diff where one module has consumers and another doesn't, this can
under-narrow silently.

Two-line fix: for ast.Import, also walk the dotted prefixes and add each
one. The existing tests (test_extract_imports_dotted_import) only assert
the leaf is captured — extend them to assert prefixes too.

3. Test test_upstream_edges_handles_missing_file is too permissive.

tests/tools/test_select_tests_bare_name.py:1056:

assert "orchestrator.action_guards" in edges or edges == {}

The or edges == {} arm makes this a tautology — the test passes even if
build_bare_name_upstream_edges returns an empty dict and silently does no
work. With a valid file plus a missing-file module in the same all_modules,
the assertion should be definite: the valid file's edge IS recorded AND the
missing module produced no key. Tighten to a single conjunction.

4. The intra-prefix-loop comment in build_bare_name_index is contradictory.

scripts/select_tests.py:775-780 says "should NOT also surface as tools.foo
AND foo" and then says "but in practice we want both views" and "Continue
the loop." The implementation does record both, which is the right call given
the over-inclusion-is-safer policy. Rewrite the comment to state the policy
once: every applicable prefix view is recorded; ambiguity is intentional.

5. AST scan cost is unbounded by the cache.

build_bare_name_upstream_edges re-AST-parses every file in all_modules
on every selector invocation. The grimp graph itself is cached
(GRIMP_CACHE_DIR) but the bare-name reverse map is not. With ~440 files
in the repo today this is fine (~1-2s), but the linear-in-repo-size cost
will erode as the codebase grows. Worth a follow-up to either persist the
map alongside the grimp cache or memoize per file mtime.

6. Loss of canary defense-in-depth is a real trade-off.

The PR argues the bare-name resolver makes the every-10th-invocation full-
suite tick redundant. The resolver covers bare-name imports but the canary
covered unknown blind spots — patterns we haven't taught any analyzer
about. The remaining named blind spots (subprocess crossings, data-file
loads, dynamic imports) are explicit triggers, but those only fire when a
known sentinel is in the diff. If a future change introduces a novel
runtime-only coupling, narrow make test will silently miss it until
someone runs make test-all before push.

The trade-off (faster inner loop vs. catching unknown unknowns) is
defensible, but I'd push for keeping a less-frequent canary (every 25th or
50th invocation?) rather than dropping it entirely. Not blocking — the PR
leans on make test-all before push as the safety net, which is the
documented contract.

Verification done

  • Walked the empty-diff regression by hand against Makefile:296-329 and
    confirmed mode=narrow (not mode=bypass) is what the empty-diff branch
    writes.
  • Confirmed is_role_readonly, read_sidecar_lkg, _canary_path removal
    is consistent — no dangling references in production code or docs (the
    remaining mentions are in .egg-state/ BRC history, which is gitignored
    pipeline artifacts).
  • Verified BARE_NAME_STRIP_PREFIXES matches the sys.path injections in
    build_graph (scripts/select_tests.py:916-923); gateway/ is
    correctly absent.
  • Reviewer #1 fallback ((c) zero-downstream-offender widening) now correctly
    uses _walk_upstream_combined, so a real bare-name path through tests
    prevents spurious widening.

— Authored by egg

@james-in-a-box

This comment has been minimized.

@james-in-a-box

This comment has been minimized.

… prefixes

Address review feedback on PR #2262:

1. Blocking: empty-diff short-circuit silently broke 'make test PYTEST_ARGS=...' on a clean tree. The bypass classifier ran AFTER the short-circuit, so an explicit user path was dropped (Makefile saw mode=narrow and skipped pytest). Reorder so the PYTEST_ARGS bypass check runs first; the bypass also avoids the now-skipped graph build. Adds in-process regression test plus an end-to-end subprocess case.

2. _extract_imports now yields every dotted prefix of 'import a.b.c' (a, a.b, a.b.c) so a change to a parent package's __init__.py reaches importers of a deeper submodule. Existing dotted-import test asserts both prefix and leaf.

3. test_upstream_edges_handles_missing_file tightened: was 'in edges or edges == {}' (tautology); now asserts a peer file's edge IS recorded and the missing module produced no key.

4. build_bare_name_index comment rewritten to state the policy once (every applicable prefix view recorded; ambiguity is intentional for safer over-inclusion).

@james-in-a-box james-in-a-box Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for the thorough review. Addressed all four actionable items in 41a81a3:

1. Blocking — empty-diff vs PYTEST_ARGS. Reordered so the bypass classifier runs first (scripts/select_tests.py:1467-1518). The bypass branch also no longer builds the graph — bypass ignores the closure entirely, so total_count falls back to len(TEST_ROOT_DIRS). Added two regression tests:

  • test_pytest_args_bypass_takes_precedence_over_empty_diff (in-process, stubbed _run_git) — confirmed it fails on the un-fixed code with mode=narrow and passes on the fix with mode=bypass.
  • test_empty_diff_with_pytest_args_explicit_path_takes_bypass (subprocess, mirrors the existing empty-diff e2e shape).

2. _extract_imports prefix walk. import a.b.c now yields {a, a.b, a.b.c} so a change to a parent package's __init__.py reaches importers of a deeper submodule. Updated test_extract_imports_dotted_import to assert both leaf and parent; added test_extract_imports_deeply_dotted_import_yields_all_prefixes for three-level coverage.

3. Tightened test_upstream_edges_handles_missing_file. Now asserts a peer file's edge IS recorded AND the missing module produced no key — no more or edges == {} tautology.

4. Rewrote the build_bare_name_index comment to state the policy once: every applicable prefix-stripped view is recorded; ambiguity is intentional for safer over-inclusion.

Non-blocking #5 (AST scan cost): Agreed it's worth tracking. Filing as a follow-up rather than addressing in this PR — the persistence boundary touches GRIMP_CACHE_DIR invalidation and is a separable change.

Non-blocking #6 (canary trade-off): Disagree on keeping a less-frequent canary. The argument for dropping it isn't that the AST resolver covers everything — it's that an unpredictable every-Nth-invocation full-suite tick is a poor signal regardless of N. Whether N=10 or N=50, the developer who happens to land on the tick eats minutes of full-suite latency for an unrelated reason, and the developer who narrowly avoids it gets the same blind-spot exposure with no warning. make test-all before push is the documented safety net, and CI's make test-all is the ground truth. A periodic-tick canary muddies the inner loop without changing the safety story. Happy to revisit if we see a concrete novel-coupling miss in practice.

— Authored by egg

@james-in-a-box

This comment has been minimized.

@james-in-a-box

This comment has been minimized.

@egg-reviewer egg-reviewer Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-review

All four actionable items from my previous review are addressed correctly. The bypass-vs-empty-diff ordering fix is the right shape, and the regression coverage is solid (both an in-process test driving _run_narrow_or_fallback directly with a stubbed _run_git, and a subprocess test against a real synthetic git repo).

Verification

Blocking #1 — bypass before empty-diff (FIXED). The bypass classifier now lives at scripts/select_tests.py:1488-1532, ahead of the empty-diff branch at :1539. The bypass branch correctly skips build_graph (the closure isn't consulted in bypass mode) and writes mode=bypass so the Makefile's Makefile:308 grep keys correctly. Walked the in-process test (tests/tools/test_select_tests_fallbacks.py:117-180) and the subprocess test (:525-593) — both exercise the empty-diff + explicit-path collision and assert mode=bypass rather than mode=narrow.

Non-blocking #2 — dotted-import prefix walk (FIXED). _extract_imports at scripts/select_tests.py:740-750 now emits every prefix for ast.Import (import a.b.c{a, a.b, a.b.c}), matching the import machinery's actual behavior. test_extract_imports_dotted_import now asserts both leaf + parent prefix; test_extract_imports_deeply_dotted_import_yields_all_prefixes covers three-level. Index lookups for non-package prefixes (e.g., os in import os.path) silently miss when nothing in all_modules matches, so this can't over-include.

Non-blocking #3 — tightened missing-file test (FIXED). test_upstream_edges_handles_missing_file (tests/tools/test_select_tests_bare_name.py:282-306) now adds orchestrator/api.py as a peer importing the present file, and asserts a definite conjunction: "orchestrator.api" in edges["orchestrator.action_guards"] AND "orchestrator.does_not_exist" not in edges. The or edges == {} tautology is gone.

Non-blocking #4 — comment rewrite (FIXED). build_bare_name_index comment at :782-789 now states the over-inclusion policy positively without contradicting itself.

Non-blocking #5 (AST scan cost): Reasonable to defer to a follow-up — the persistence boundary is a separable concern.

Non-blocking #6 (canary): The disagreement is well-reasoned. The "every-Nth-invocation full suite" pattern is poor signal regardless of N — agreed.

One non-blocking observation on the new code

Bypass mode's total_count=len(TEST_ROOT_DIRS) (scripts/select_tests.py:1523) records 4 (the number of test root dirs) when the actual repo has ~302 tests. This is purely cosmetic in the telemetry record — nothing reads it functionally — and matches the pattern at :1469 for the cannot-resolve-HEAD path. Worth noting for anyone analyzing selection records by hand. Not blocking.

What I didn't re-check

The unchanged pieces (resolver core, prefix table, fallback evaluator, end-to-end on a real diff) — all green in the prior review.

LGTM, approving.

— Authored by egg

@james-in-a-box

This comment has been minimized.

@james-in-a-box

Copy link
Copy Markdown
Contributor
egg is addressing review feedback...

@james-in-a-box

Copy link
Copy Markdown
Contributor

Thanks for the re-review and approval. On the non-blocking observation about total_count=len(TEST_ROOT_DIRS) in bypass mode: leaving as-is.

The bypass branch deliberately skips build_graph (the comment at scripts/select_tests.py:1505-1507 calls this out explicitly) — the whole point of bypass mode is that the developer's explicit PYTEST_ARGS path overrides narrowing entirely, so building the closure just to populate one telemetry field would defeat the latency win. len(TEST_ROOT_DIRS) is a cheap stand-in that's structurally identical to the cannot-resolve-HEAD path at :1469, and as you noted nothing functional reads it.

If we ever want a more accurate denominator for offline analysis of selection records, the right move is probably a separate post-hoc enrichment pass over the records (which has access to git log + the grimp cache) rather than paying the build cost on the hot path. Filing as a follow-up rather than churning this PR.

— Authored by egg

@james-in-a-box

Copy link
Copy Markdown
Contributor

egg feedback addressed. View run logs

5 previous review(s) hidden.

@jwbron
jwbron merged commit 25e7f26 into main Apr 29, 2026
28 of 29 checks passed
james-in-a-box Bot added a commit that referenced this pull request Apr 29, 2026
orchestrator/routes/pipelines.py grew from 15356 to 15593 lines due to
the post-timeout rebaseline changes in this PR.

orchestrator/peer_consensus.py grew from 1988 to 2003 lines.

scripts/select_tests.py grew from 1650 to 1850 lines due to changes
merged to main in PR #2262 (bare-name AST resolver, empty-diff skip).

Update all three baselines so the file-size lint passes in CI.
james-in-a-box Bot added a commit that referenced this pull request Apr 29, 2026
#2262 grew scripts/select_tests.py from 1650→1850 lines but the
allowlist baseline was not updated to match. Update baseline to
(1850 lines, 73711 bytes) and attribute to issue #2262.
james-in-a-box Bot added a commit that referenced this pull request Apr 29, 2026
…snapshot

- orchestrator/mcp_tools.py: 2817→2818 lines (docstring edit in this PR)
- orchestrator/peer_consensus.py: 1988→2003 lines (grown on main)
- orchestrator/routes/pipelines.py: 15356→15514 lines (grown on main)
- scripts/select_tests.py: 1650→1850 lines (bare-name AST resolver, #2262)
james-in-a-box Bot pushed a commit that referenced this pull request Apr 29, 2026
PR #2262 grew scripts/select_tests.py from 1650 -> 1850 lines without
updating the allowlist baseline (#2250 added the lint after PR #2262
was reviewed, so its CI did not catch the drift). Without this update,
the merged result fails 'make lint-custom'.
jwbron added a commit that referenced this pull request Apr 29, 2026
…2271)

* docs: update testing.md for bare-name AST resolver

Document the bare-name AST resolver added in #2262, which supplements
grimp for orchestrator/sandbox/shared bare-name imports.

Authored-by: egg

* Fix file-size allowlist baselines for files grown since last update

Update baselines for orchestrator/peer_consensus.py,
orchestrator/routes/pipelines.py, and scripts/select_tests.py to
reflect their current sizes and unblock the Custom Checks lint job.

* Tighten bare-name AST scope in testing guide

Per review feedback: 'every in-repo .py' overstated coverage.
The AST resolver iterates bundle.graph.modules, which is limited to
files registered via PACKAGES. Clarify that scripts/, integration_tests/,
and other un-registered trees are not scanned.

---------

Co-authored-by: jwbron <8340608+jwbron@users.noreply.github.com>
Co-authored-by: james-in-a-box[bot] <246424927+james-in-a-box[bot]@users.noreply.github.com>
Co-authored-by: egg-reviewer[bot] <261018737+egg-reviewer[bot]@users.noreply.github.com>
jwbron added a commit that referenced this pull request Apr 29, 2026
…ess (#2253)

* Fix #2245: rebaseline post-consensus-timeout budget on producer progress

The hardcoded `post_timeout_budget = 3600` in `_run_concurrent_phase`
counted iteration time against a single fixed bucket, so a healthy
multi-iteration BRC consensus cycle (NACK → repropose → re-review)
could be force-killed mid-iteration even when reviewers were actively
producing useful feedback.

Replace it with a per-iteration clock that rebaselines on producer
progress: each fresh CONSENSUS_PROPOSE (initial or NACK→re-propose)
resets the iteration budget so the next round of reviews gets a
clean clock instead of inheriting the prior round's wall-clock spend.
An absolute cap (`post_consensus_max_total_seconds`, default 4h)
bounds the total wait so unbounded propose churn can't stall the
pipeline.

Both budgets are exposed on `PipelineConfig` for per-deployment
tuning. Defaults (3600s per-iteration / 14400s absolute) preserve
the prior force-kill point for the no-progress case.

* Address review feedback on PR #2253

- Drop dead `TypeError` fallback in `_latest_proposal_ts`. The
  `get_peer_consensus_tracker(pipeline_id, slice_id=None)` signature
  cannot raise `TypeError` for a positional `slice_id`; the fallback
  was speculative and unreachable.
- Read the new post-consensus knobs directly off `PipelineConfig`
  instead of `getattr(..., default)`. Pydantic provides defaults; the
  `getattr` defaults silently masked any future field rename.
- Add a cross-field validator on `PipelineConfig` that rejects
  `post_consensus_max_total_seconds < post_consensus_iteration_budget_seconds`.
  Without it, a misconfigured pipeline silently makes the per-iteration
  rebaseline logic unreachable (the absolute cap fires first every time).
- Replace the `__dict__` fallback in the test helper with
  `PipelineConfig(**overrides)` — the fallback was dead code and would
  have bypassed Pydantic validation if it ever fired.
- Tighten the post-timeout-snapshot comment: the safety against
  `datetime > None` comes from the `is None` short-circuit at the
  rebaseline check, not from any datetime/None ordering.
- Add tests for the new cross-field validator (rejects mismatched
  budgets, accepts equal budgets).

* Address review feedback: pin absolute-cap warning + silence gate fallback

- test_absolute_cap_bounds_unbounded_proposal_churn now patches
  routes.pipelines.logger and asserts the warning message contains
  'absolute cap reached' (and that 'iteration budget exhausted' did
  not fire).  With iteration_budget == max_total the prior assertion
  (exit_code == 1) couldn't distinguish the two caps; this pins the
  branch so an off-by-one moving the per-iteration check above the
  rebaseline would now fail loudly.

- Both rebaseline tests now stub mock_tracker.get_latest_progress_
  timestamp.return_value = None so the pre-timeout progress gate
  (#2243) doesn't hit its exception-fallback path on the auto-attribute
  MagicMock and produce noisy 'BRC progress-gate tracker check failed'
  WARN logs that would mask a real gate-side regression.

Both items called out as non-blocking suggestions in the egg-reviewer
re-review of e5055e5.

* Fix Custom Checks: update file-size allowlist baselines for PR #2253

orchestrator/routes/pipelines.py grew from 15356 to 15593 lines due to
the post-timeout rebaseline changes in this PR.

orchestrator/peer_consensus.py grew from 1988 to 2003 lines.

scripts/select_tests.py grew from 1650 to 1850 lines due to changes
merged to main in PR #2262 (bare-name AST resolver, empty-diff skip).

Update all three baselines so the file-size lint passes in CI.

---------

Co-authored-by: egg-reviewer[bot] <261018737+egg-reviewer[bot]@users.noreply.github.com>
Co-authored-by: james-in-a-box[bot] <246424927+james-in-a-box[bot]@users.noreply.github.com>
james-in-a-box Bot pushed a commit that referenced this pull request Apr 29, 2026
…e-size-allowlist.yaml

Both sides bumped baselines for orchestrator/routes/pipelines.py and
scripts/select_tests.py. After the merge, the actual file sizes are
15594/681452 (pipelines.py) and 1875/75206 (select_tests.py), so the
baselines are set to those values. Brings in main's progress-gate (#2254),
post-timeout rebaseline (#2253), select_tests AST resolver (#2262/#2266),
and max-file-size lint (#2250).
jwbron added a commit that referenced this pull request Apr 29, 2026
…mpt fix (#2260)

* Fix #2249: tester scaffold-first telemetry + producer-orientation prompt fix

Closes the gap between the existing scaffold-first instruction and the
observed behavior on pipeline issue-1557-v2 where tester polled
wait-loop for 44 minutes without writing any test scaffolding.

Two changes:

1. Producer-orientation prompt (orchestrator/routes/pipelines.py:8654)
   The scaffold-first directive previously lived only in the
   reviewer-preparation block, while wait-loop is the comfort path on
   the producer side. Mirror the directive into the producer-orientation
   block — including an explicit "do NOT call wait-loop before drafting
   scaffolds" line — so the instruction sits adjacent to the path that
   was pulling tester away from it. Babysit/PR mode is unaffected
   (early-return at line 8643).

2. Telemetry script (scripts/scaffold_first_telemetry.py)
   Walks .egg-state/brc-history/*-implement.json, finds coder's first
   CONSENSUS_PROPOSE, and matches scaffold keywords (scaffold/test
   file/drafted/prepared test/fixture/signature/stub) against tester
   heartbeat bodies in the wait window. Reports per-pipeline rows and
   the aggregate scaffold-first fraction. Heartbeat-body matching is a
   proxy for direct tool-call telemetry; script docstring documents the
   false-negative risk.

   On the existing 18 implement-phase BRC histories the fraction is
   38.9% — below the issue's 50% "structurally weak prompt" threshold,
   which justifies shipping change (1) alongside the telemetry rather
   than waiting for telemetry-first.

* Address review feedback on #2249 telemetry script

- Tighten weak keywords: \bdrafted\b -> \bdrafted (test|scaffold|fixture)
  and \bsignature -> \btest signature so unrelated phrasing like
  'drafted plan' or 'method signature changed in dep' does not fire.
- Use statistics.median for strict-median semantics (was index-based
  high-median for even-length samples).
- Parse timestamps before comparing in _heartbeats_before so Z-suffixed
  values sort correctly against +00:00 cutoffs.
- Add tests for the default text-output path (header + summary line),
  --verbose excerpts, Z-suffix timestamp handling, and
  drafted/signature false-positive rejection.

Verified against the 18 existing implement BRC histories: still
7/18 (38.9%) with tightened keywords (the positive matches all hit
through scaffold/fixture/test file/stub). Median moves from
high-median to true median (23.33 vs prior figure).

* Sync scaffold_first_telemetry docstring with tightened keyword set

The module docstring still listed the pre-tightening keyword set
("drafted/.../signature"). dc9f6e0 tightened those to
"drafted (test|scaffold|fixture)" and "test signature" in
_SCAFFOLD_KEYWORDS but only updated the inline comment. Update the
docstring to match and point readers at the source list.

* Update file-size allowlist baselines for peer_consensus and pipelines

* Fix file-size allowlist: update select_tests.py baseline after #2262

#2262 grew scripts/select_tests.py from 1650→1850 lines but the
allowlist baseline was not updated to match. Update baseline to
(1850 lines, 73711 bytes) and attribute to issue #2262.

---------

Co-authored-by: egg-reviewer[bot] <261018737+egg-reviewer[bot]@users.noreply.github.com>
Co-authored-by: james-in-a-box[bot] <246424927+james-in-a-box[bot]@users.noreply.github.com>
jwbron added a commit that referenced this pull request Apr 29, 2026
…#2257)

* Fix #2255: replace asyncio.get_event_loop() with asyncio.run() on Python 3.14

`asyncio.get_event_loop()` no longer auto-creates a loop in Python 3.14
when none is running, raising RuntimeError. Swap to `asyncio.run()`,
which manages its own loop lifecycle — the behavior the helper actually
wants. CI runs on 3.13 and didn't surface this; the failure reproduces
locally on 3.14.

* Pin Python to 3.14 across CI, pyproject, and runtime images

CI was on 3.13 while local dev is on 3.14, which is how #2255 slipped
past CI. Pin everything to 3.14 so the supported version is what gets
tested:

- CI workflows (test, test-e2e, test-integration, lint): 3.13 → 3.14
- Root pyproject.toml: requires-python, ruff target-version, mypy
  python_version → 3.14
- Sub-package pyprojects (sandbox, shared, egg_contracts, egg_harness):
  loose >=3.11 lower bound → >=3.14 to match the actual supported floor
- Runtime images (orchestrator, gateway, sandbox): python:3.13-slim →
  3.14-slim; sandbox deadsnakes apt installs python3.14

* Fix checks: apply automated formatting fixes

* Address review: re-quote unsafe forward refs and update docs

Restore string quotes on annotations whose referenced names exist
only under TYPE_CHECKING or inside function-body imports — UP037 had
unstrung these but they still need to be forward references at
runtime even on PEP-649 (3.14):

- orchestrator/mcp_tools.py:_get_gateway_client (GatewayClient)
- orchestrator/routes/__init__.py:get_state_store_for_pipeline
  (StateStore, Pipeline)
- orchestrator/routes/pipelines.py (ContainerSpawner, MountSpec)
- orchestrator/kubernetes_spawner.py (MountSpec)

Each restored string is paired with a per-line `# noqa: UP037` so
ruff stops re-applying the autofix while leaving UP037 on for the
rest of the codebase.

Update docs that still claimed Python 3.11/3.13 to 3.14:

- README.md (top-level Python requirement)
- skills/egg-setup/SKILL.md (minimum + sample output)
- orchestrator/README.md (Dockerfile slim base)
- docs/architecture/slice-dag.md (ruff target-version reference)

* Address review nits: PEP 695 generics and stale 3.x comments

- gateway/{gateway,auth,mode_gate}.py: convert F TypeVar to PEP 695 generic
  function syntax. Removes the per-decorator # noqa: UP047 overrides whose
  rationale ('Python 3.11 compat') is moot now that the floor is 3.14.
- shared/egg_contracts/dependency_graph.py: comment said 'Python 3.13 target'
  for the PEP-695 generic class; updated to 3.14 to match pyproject.toml.
- orchestrator/mcp_tools.py: _is_timeout_error docstring rewritten to drop
  the dead 3.11 branch reference and explain why the explicit socket.timeout
  check is kept on 3.14 (urllib URLError.reason wrapping).

* Fix file-size allowlist: update baselines for files grown since last snapshot

- orchestrator/mcp_tools.py: 2817→2818 lines (docstring edit in this PR)
- orchestrator/peer_consensus.py: 1988→2003 lines (grown on main)
- orchestrator/routes/pipelines.py: 15356→15514 lines (grown on main)
- scripts/select_tests.py: 1650→1850 lines (bare-name AST resolver, #2262)

* Fix UP037: remove quotes from PipelineConfig return type annotation

---------

Co-authored-by: egg <egg@localhost>
Co-authored-by: egg-reviewer[bot] <261018737+egg-reviewer[bot]@users.noreply.github.com>
Co-authored-by: james-in-a-box[bot] <246424927+james-in-a-box[bot]@users.noreply.github.com>
jwbron added a commit that referenced this pull request Apr 29, 2026
… post-ACK threshold (#2268)

* Fix #2242: alive-signal gate on heartbeat/progress alerts; plan-phase post-ACK threshold

Heartbeat-stall and progress-stall alerts fired prematurely on plan-phase
producers during long-form Anthropic completions: the agent is mid-draft,
no tool calls in flight, so no `mcp__brc__send_heartbeat` arrives on the
bus. On `issue-1557-v2` this escalated to a 3-of-3 producer-silence alert
at 355s while every producer was simply composing its draft. Separately,
the 180s post-ACK confirmation timeout was tight for plan-phase
reconciliation (12 resolved decisions, 6 feedback bodies, slice-DAG
sanity passes).

Apply two fixes, both leaning on primitives shipped in #2254:

1. Alive-signal gate at the per-agent alert sites. Before firing
   `heartbeat_timeout` or `progress_stall`, consult
   `PeerConsensusTracker.get_latest_progress_timestamp()` plus
   peer-heartbeat snapshots; defer if either has fired within
   `orchestrator_alert_progress_gate_seconds` (default 300s, 0 disables).
   Self-excluded so a solo silent agent still escalates. The escalated
   flag is intentionally not set on defer, so the next poll re-checks.

2. Phase-aware post-ACK confirm timeout. Plan phase now uses
   `orchestrator_plan_post_ack_confirmation_timeout_seconds`
   (default 300s); refine/implement keep the existing 180s default.

Out of scope (call out in #2059 follow-ups):
- "Anthropic completion in flight" SDK telemetry — the cleanest fix for
  the heartbeat detector, but requires SDK work; the alive-signal gate
  covers the common case at far lower cost.
- Voluntary "I'm finalizing" heartbeats that reset the post-ACK timer —
  same SDK-side dependency.
- Auto-attaching log-tail evidence to OVERSEER_ALERT messages.

Same-role cross-phase pollution caveat documented in the existing
`_check_brc_progress_gate` TODO applies here too: a phase-stamped
heartbeat key would close both at once.

* Fix checks: disable alive-signal gate in multi-agent stall tests

The 4 failing tests in TestMultipleAgentsStalling create multiple agents
where one or both are deliberately stalled. The new alive-signal gate
(#2242) defers per-agent stall alerts when peer agents have heartbeats
within orchestrator_alert_progress_gate_seconds (default 300s), which
caused these tests to observe zero escalations instead of the expected
per-agent escalations.

Set orchestrator_alert_progress_gate_seconds=0 in the four failing
tests so they isolate the per-agent escalation behavior from the
peer-progress deferral, matching the pattern already used in
test_health_monitor.py::test_heartbeat_timeout_per_agent.

* Update file-size-allowlist baselines for peer_consensus.py and routes/pipelines.py

Both files grew past their recorded baselines in the allowlist, causing
the file-sizes custom check to fail on PR #2268. Update baselines to
the actual current sizes so CI passes.

- orchestrator/peer_consensus.py: 1988→2003 lines, 85268→85965 bytes
- orchestrator/routes/pipelines.py: 15356→15514 lines, 669950→677112 bytes

* Update file-size-allowlist baseline for select_tests.py

PR #2262 grew scripts/select_tests.py from 1650 -> 1850 lines without
updating the allowlist baseline (#2250 added the lint after PR #2262
was reviewed, so its CI did not catch the drift). Without this update,
the merged result fails 'make lint-custom'.

* Address review feedback: clarify BRC-bus self-deferral; filter peer
heartbeats by active-agent set

Reviewer flagged three actionable non-blocking concerns on #2268:

1. Docstring discrepancy. The PR description claims "self-excluded gate
   so a solo silent agent still escalates", but self-exclusion only
   applies to the peer-heartbeat path. ``get_latest_progress_timestamp``
   aggregates proposals + matrix entries across the whole tracker, so on
   a single-producer pipeline the producer's own propose/ACK timestamp
   defers its own alert until ``gate_seconds`` elapses past that
   timestamp (effective stall window ≈ ``heartbeat_threshold +
   gate_seconds``). ``CONTAINER_STOPPED`` covers genuinely-dead
   containers; the delay only matters for hung processes inside live
   containers. Filtering by focal agent would require a new
   ``peer_consensus`` API — deferred. Docstring updated to call out the
   behavior explicitly.

2. Cross-phase heartbeat pollution. Reviewer noted the symmetry
   argument: ``_check_brc_progress_gate`` filters peer heartbeats by
   ``active_role_names`` to drop stale prior-phase heartbeats from the
   shared HealthMonitor; this gate did not. Added a snapshot of
   ``set(self._agents.keys())`` taken under the same lock as
   ``_last_heartbeat`` so prior-phase ghosts (agents whose containers
   were stopped without ``reset_agent``) cannot defer current-phase
   alerts. Same-role cross-phase pollution remains, tracked by the
   existing TODO. New regression test:
   ``test_gate_filters_inactive_agent_heartbeats``.

3. Misleading test override. ``test_check_brc_progress_uses_plan_phase
   _threshold`` set ``orchestrator_alert_progress_gate_seconds=0`` with
   a comment about isolating the phase-aware threshold path — but
   ``check_brc_progress`` doesn't consult ``_has_recent_peer_progress``,
   so the override implied a coupling that doesn't exist. Removed.

Two other reviewer concerns left as-is: the sliced-pipeline tracker
scope is acknowledged in the docstring (peer-heartbeat fallback covers
it), and the file-size allowlist drift was absorbed by main and is now
moot.

* Active-role filter pulls from tracker graph, not self._agents

Reviewer noted in 76c3aac that the active-agent filter in
_has_recent_peer_progress is a no-op in production: every heartbeat
write also populates _agents, so set(self._agents.keys()) is a static
superset of _last_heartbeat.keys() and the filter never fires.

Replace it with the tracker graph's all_roles() — the current-phase
roster installed by concurrent_executor.spawn_active_phase_agents.
The graph IS phase-scoped, so cross-phase ghosts in _last_heartbeat
(which the singleton HealthMonitor doesn't reset on phase transition)
are now actually dropped.

When no tracker is registered (early startup, between phases, non-BRC
phases) the filter is skipped — preserves the pre-#2242 peer-heartbeat
fallback behavior.

Update test_gate_filters_inactive_agent_heartbeats to register
AGENT_ID_2 via _emit_heartbeat (production state shape) and mock a
tracker whose graph excludes it, instead of injecting an impossible
state into _last_heartbeat directly.

* Fix docstring symbol name: spawn_active_phase_agents → spawn_all

The reviewer flagged that concurrent_executor.spawn_active_phase_agents
does not exist — the actual phase-spawn entry point is
ConcurrentExecutor.spawn_all (concurrent_executor.py:349). Update both
references in health_monitor._has_recent_peer_progress to point at the
real method so docstring navigation lands at the right symbol.

* Fix docstring symbol name: ConcurrentExecutor → ConcurrentPhaseExecutor

The actual class in orchestrator/concurrent_executor.py is
ConcurrentPhaseExecutor (line 113); ConcurrentExecutor does not
exist outside test-file local aliases. Both docstring references
in health_monitor.py now point at the real symbol so Sphinx :meth:
resolves and code-navigation works.

---------

Co-authored-by: james-in-a-box[bot] <246424927+james-in-a-box[bot]@users.noreply.github.com>
Co-authored-by: egg-reviewer[bot] <261018737+egg-reviewer[bot]@users.noreply.github.com>
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.

1 participant