Skip to content

feat(llc): make the workspace lease real — acquire, release, reclaim, and dispose on evidence (#16818) - #17725

Merged
mrveiss merged 24 commits into
mainfrom
issue-16818-lease-wiring
Oct 2, 2026
Merged

mrveiss merged 24 commits into
mainfrom
issue-16818-lease-wiring

Conversation

@mrveiss

@mrveiss mrveiss commented Sep 28, 2026 •

Copy link
Copy Markdown
Owner

Thinking Path

#16822 gave the workspace a model, a migration and a model-level test, and stopped there. Nothing
acquired a lease, nothing released one, nothing reclaimed one — the table would have stayed empty
for ever. This is the wiring that makes the record mean something.

Three decisions shaped it.

Freeing the slot and removing the directory are separate steps. Reclaiming a lease returns
capacity unconditionally; disposing of the directory requires evidence that the work reached the
remote. Fusing them gives only bad answers: either a workspace with unpushed work blocks the
reclaim — which is the original deadlock — or it gets removed to free the slot, which loses the
work. Split, the sweep can say "slot back, directory stays, here is why".

Disposal has three verdicts, not two. LANDED authorises removal; NOT_LANDED and UNKNOWN
both refuse, for different reasons. Collapsing UNKNOWN into LANDED would delete a workspace
every time git happened to error — silently, permanently, and indistinguishably from correct
operation. They are kept apart so nobody later simplifies them in the wrong direction.

Removal is git worktree remove with no --force. Git refuses a worktree with uncommitted
changes, and that refusal is information, not an obstacle. Forcing it would convert every one of
those signals into data loss.

One lease site, not four. The four subprocess adapters each resolve workspace_dir themselves.
Leasing in each would mean four acquire sites and four chances to forget one — the shape that
produced this defect. The lease is taken once in the scheduler, where the run is created.

What Changed

File What
llc/services/workspace_lease.py new — acquire, release, reclaim, capacity. Release ≠ reclaim, and both write a reason
llc/services/workspace_disposal.py new — the evidence gate: three verdicts, git worktree remove without --force
llc/scheduler/workspace_lease_sweep.py new — celery beat: reclaim, commit, then dispose only what landed
llc/scheduler/run_workspace.py new — the scheduler's lease integration, one site for all adapters
llc/scheduler/stalled_run_sweep.py a stalled run now hands its workspace back (#16817's missing half)
llc/scheduler/heartbeat_scheduler.py acquires on dispatch, releases on every run ending
migrations/.../20260928_097_*.py new — path unique among live leases only (see below)
run_context.py, run_output.py extracted from heartbeat_scheduler.py to stay under its ceiling

A defect in the merged model, fixed here

093 created uq_llc_workspace_leases_path as a table-wide UNIQUE. The model's own comment beside
that column says what was meant: "two live leases on one directory is the collision the ledger
exists to prevent"
. The comment says live; the constraint said ever. A path could be leased
exactly once in the lifetime of an installation — the first release permanently burned that
directory, and since workspace paths are derived from issue numbers and reused constantly, that is
every path on its second use. 097 replaces it with a partial unique index carrying the intent the
comment always stated. Nothing depended on the stricter reading, because at 093 nothing acquired a
lease at all.

Reviewing 097: read the comment beside the column, not just the migration. The migration on its
own looks like it is loosening a constraint, which is the kind of change a reviewer is right to be
suspicious of. What makes it correct is that workspace_lease.py's comment on path already states
the intended rule — live, not ever — so this brings the schema to the documented intent rather
than relaxing it to suit the code. The disagreement was between the comment and the constraint; the
code is what exposed it, by being the first thing to ever take a lease.

A behavioural change worth naming

A run whose workspace is already leased does not dispatch — it re-queues. Two agents in one
worktree corrupt each other's work, which is exactly what the lease exists to prevent; dispatching
anyway would make the lease a record of the collision rather than a guard against it. The lease's
own expiry is what guarantees this cannot wedge an agent permanently, and acquire reclaims expired
leases before counting capacity, so the ceiling clears itself.

What this does not cover

Session-owned worktrees are still outside the domain. Nothing in llc/ creates a worktree —
adapters are handed a workspace_dir. So this leases the workspaces agent runs use, and the
worktrees that sessions create for themselves are untouched by it. The 2026-09-16 deadlock was
three merged worktrees held by a dead session, and AC4 points at that case explicitly, so I am
not claiming it closed: worktree-reap.sh still owns that population, and bringing it into the
domain needs a decision about whether session workspaces are Company OS's to model at all.

Everything else in the AC list is delivered for agent workspaces. Refs rather than Closes for that
one reason.

Verification

Owner ruling: the sweep proposes, a human disposes (#17038)

The first version of this sweep removed directories itself once it had proved the work was on a
remote. The proof was sound; the automation was not. An agent removing data on its own leaves a
logger.info in a worker as its only record, and that is not a paper trail. Per the ruling, the
sweep now writes a disposal proposal to the approval queue with the landedness evidence attached,
and stops
.

What that changes:

  • The evidence travels with the proposal. A reviewer approving a deletion sees why it is safe
    on the record they are approving, not in a log they cannot reach.
  • Landedness is proved again at execution, not trusted from the proposal. A proposal approved
    hours ago describes the directory as it was hours ago; someone may have worked in it since. The
    approval authorises the removal — it does not vouch for the evidence — so the check reruns and can
    still refuse, and that refusal is written back onto the approval.
  • A mixed-company batch is filed with no company rather than a guessed one. company_id is
    nullable for exactly this case, and guessing would put one company's directories in front of
    another company's reviewer.
  • requested_by is the string llc-workspace-lease-sweep. There is no agent here, and minting a
    UUID to satisfy a type hint would put a fabricated identifier in an audit record. The column is
    String(255), documented for "a plain string for a platform-general requester" — the service's hint
    was narrower than its own column, and is widened to match.

Reclaiming is unchanged and still needs no approval: it is a database write about a lease, touches no
directory, and is what frees capacity. The slot comes back on the sweep's authority; the directory
waits for a person.

Review round 2 — a silent stop, and two tests that could not fail

The blocker: a SQL LIMIT ahead of a Python filter. An executed proposal keeps
status = APPROVED — execution is recorded in context["executed_at"], because the approval
vocabulary is six values shared by every gate and inventing a seventh to fix a query would change
all of them. So executed rows match the query for ever. With .limit(20) in SQL, once twenty
executed proposals existed the limit would fill entirely with them, the filter would drop all
twenty, and no approved disposal would ever execute again — reporting disposed 0, refused 0,
which is exactly what a sweep with nothing to do reports. Cannot see the work and there is no
work
, rendered identically, in the sweep written to stop that.

Fixed by removing the SQL LIMIT entirely, ordering by decided_at, and applying
MAX_EXECUTIONS_PER_SWEEP after the filter — where the constant's own comment always said it
belonged: a cap on git operations per tick, not rows per query. No limit is pushed into SQL because
any bound applied before the filter reintroduces the same class.

My test for it could not fail, and I caught that by mutation-checking it. The first version
passed with the bug restored, because the fake session ignored the statement — so a SQL LIMIT had
no effect in the test either way. The fake now honours LIMIT, and the test fails with the defect
and passes without it. That is the same finding 87 raised about the schema test, in my own new test,
one commit later.

The model/migration comparison moved to a real guard. It compared the model to literals written
in the test, so editing 097 left it green. It is now
repo_tests/workspace_lease_index_matches_migration_16818_test.py, reading both sides from
their own files, comparing the predicate exactly rather than by substring — released_at IS NULL is
a substring of released_at IS NULL AND expires_at > now(), which is both a divergence and
impossible as a partial index — and asserting the predicate can never reference now.
Mutation-checked: renaming the migration's index fails it.

The tautology is labelled as one. test_the_partial_predicate_is_weaker_than_is_live... asserts
two things true by construction, with no index and no database. Its docstring now says so and names
what actually pins the invariant, rather than reading as protection it does not provide.

Known gap — stated, not designed around

candidates holds only the leases reclaimed this run, and executed_at retires an approval
whatever the outcome. So a workspace that fails work_landed at proposal time, or refuses at
execution, is never proposed again: it keeps its directory, its lease is already released, and the
only record is a log line. It becomes exactly the debris this sweep exists to remove.

That is the safe direction — nothing is deleted — but it is incomplete, not finished. A later change
should re-examine unreleased-but-undisposed workspaces rather than only newly reclaimed ones.

Review round 1 (87's findings, both fixed in this PR)

The model was not in the diff, and would have reproduced the bug. 097 dropped
uq_llc_workspace_leases_path, but workspace_lease.py still declared unique=True on the column
— so a database built from the models rather than by alembic (a fixture's create_all, a fresh dev
box) carried exactly the constraint the migration removes. The model now declares 097's partial
index in __table_args__, and three tests compare the two: no table-wide unique on the column, the
partial index present by name and predicate, and the gap below.

The partial predicate is weaker than is_live(), and cannot be otherwise. is_live is
released_at IS NULL AND expires_at > now; a partial index cannot call now(), so the index can
only express the first half and permits a second lease on a path whose lease has expired but not
been released
. That gap is closed by convention, not schema: every writer reclaims before
inserting, which acquire_lease does as its first statement. The migration docstring previously
implied equivalence; it now states the invariant, in the file the next person writing an insert path
will actually read.

Verification — updated

Most of this now has run locally. The 3.11 floor applies to llc/scheduler/base.py, which these
tests do not import, so they execute on 3.10:

llc/services/workspace_lease_16818_test.py
llc/scheduler/workspace_lease_sweep_test.py
llc/scheduler/stalled_run_sweep_test.py      51 passed in 1.47s

Eleven of those cover the proposal behaviour, including the one that would catch a regression to the
old design: a freshly reclaimed lease must reach a reviewer, not dispose_workspace.

Caveat the harness prints itself: 91 of 139 dependency floors are below declared in this
environment, so a local pass carries no general guarantee about CI. These particular tests are
SQLAlchemy metadata and pure-Python fakes, so the divergence does not reach them — but CI remains the
authority.

The original push was not verified: The pre-push hook reported it
verbatim rather than my paraphrase:

[pre-push COULD NOT RUN] pytest cannot execute here — this push is NOT verified.
[pre-push COULD NOT RUN]   reason: the interpreter floor: Python 3.10.12 is below the 3.11+ that
                           llc/scheduler/base.py requires (platform floor and CI are 3.14)

Pushed with AUTOBOT_PREPUSH_ALLOW_UNRUNNABLE=1, which the hook documents for exactly this case and
explicitly distinguishes from --no-verify. CI on 3.14 is the verification for every test claim
below
— I have not run them and am not asserting they pass.

All pre-commit hooks ran and pass. Two of them caught real defects in this change:

  • git-toplevel-env-scrubbed — my _git helper inherited the caller's environment. GIT_DIR
    outranks cwd=, so the disposal check would have answered about whatever repository the
    environment named, with a zero exit code. A LANDED verdict read off the wrong repository
    authorises removing a workspace whose work was never pushed. Fixed with env=scrubbed_git_env().
  • destructive-migration-marker — 097 dropped a constraint without a NO DATA LOSS statement.

Tests written (25 across three files), the two AC6 names among them:

  • an expired lease is reclaimed and its directory disposed; a live lease is never reclaimed — the
    second hands reclaim_expired a live lease directly, which is what a mistyped WHERE would
    do, rather than letting the database do the filtering and proving nothing about the code
  • UNKNOWN refuses disposal; a git failure is UNKNOWN, not a clean miss
  • disposal issues no --force, and touches nothing when the check refuses
  • a stalled run hands its workspace back, with a reason distinct from an expiry reclaim
  • the sweep frees the slot even when the directory must stay

heartbeat_scheduler.py went 1078 → 1043 lines; both recorded ceilings lowered to match, since the
ratchet only turns down.

Model Used

Claude Opus 5 (1M context)

Refs #16818
Refs #16817

Summary by CodeRabbit

  • New Features

    • Workspaces are reserved while agent runs are active, helping prevent overlapping use. Reservations are released when runs finish or stop, and expired reservations are reclaimed automatically.
    • Reclaimed workspaces that appear safe to dispose can be submitted for approval. Approved disposal requests are checked again before execution and are only eligible within a configurable time window.
    • Agent runs can include recent decision context when configured.
  • Bug Fixes

    • Improved workspace reservation handling when runs are delayed, stalled or encounter early failures.
    • Workspace disposal now verifies repository status before removal, helping prevent unsafe cleanup.

@coderabbitai

coderabbitai Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The change adds workspace lease acquisition, release, and reclamation. It integrates leases with heartbeat scheduling and adds a scheduled process for reclaimed workspaces and approved disposal proposals. Worktree removal now resolves the owning Git repository first.

Changes

Workspace lease lifecycle

Layer / File(s) Summary
Lease model and service
autobot-backend/llc/models/workspace_lease.py, autobot-backend/migrations/versions/20260928_097_workspace_lease_live_path_unique.py, autobot-backend/llc/services/workspace_lease.py, autobot-backend/llc/services/workspace_lease_16818_test.py, repo_tests/workspace_lease_index_matches_migration_16818_test.py, autobot-backend/tests/migrations/test_probe_ladder_selfcheck.py
The model and migration use a partial unique index for unreleased lease paths. The service adds lease acquisition, release, and expiry reclamation. Tests cover lease behaviour and model–migration consistency.
Scheduler run integration
autobot-backend/llc/scheduler/run_context.py, autobot-backend/llc/scheduler/run_workspace.py, autobot-backend/llc/scheduler/heartbeat_scheduler.py, autobot-backend/llc/scheduler/stalled_run_sweep.py, autobot-backend/llc/scheduler/stalled_run_sweep_test.py, autobot-backend/llc/tests/test_heartbeat_workspace_release.py
Heartbeat scheduling enriches run context and acquires a workspace lease before dispatch. Completion and stalled-run handling release leases. Failed acquisition requeues the agent.
Workspace landedness and disposal
autobot-backend/llc/services/workspace_disposal.py
Disposal resolves the owning Git repository before removing a worktree. Removal follows a successful landedness check.
Expiry sweep and disposal approvals
autobot-backend/llc/scheduler/workspace_lease_sweep.py, autobot-backend/llc/scheduler/workspace_lease_sweep_test.py, autobot-backend/llc/services/approval.py, autobot_shared/env_registry_llc.py, docs/developer/ENV_VARS.md, autobot-backend/celery_app.py, autobot-backend/llc/scheduler/__init__.py, autobot-backend/llc/scheduler/lazy_import_test.py
The scheduled sweep reclaims expired leases, proposes landed workspaces for approval, and rechecks approved proposals before disposal. Its approval window is configurable. The task is registered to run hourly at minute 50.

Priority: ⬇️ Low

Merge Risk: 🟡 Moderate · up to 9fc89

Concurrent workspace acquisition and delayed lease reclamation remain material risks. Resolve or explicitly accept them before merging.

Security Architecture Review

Security architecture risk: 🟡 Moderate · up to 9fc89

Human approval, fresh Git checks, and non-forced removal substantially limit risk. However, a workspace can be leased again between the ownership check and removal, and successful removal can precede durable recording of its outcome. Worker permissions and filesystem containment also remain unresolved.

Retained concerns

  • Medium · security · inferred: The new disposal path checks for a live lease and then awaits Git operations without reserving the path against acquisition. A heartbeat can acquire the released path during that interval, allowing an earlier approval to remove an actively owned, still-clean worktree. Fresh landedness checks and non-forced removal protect Git state but do not enforce current workspace ownership.
  • Medium · reliability · inferred: Removal occurs before execution outcomes and their transaction are committed. Interruption or commit failure after successful removal can leave an approved proposal without durable evidence of the destructive action. Recovery may reconsider it and record a missing-directory refusal instead of the original successful removal, weakening the approval audit and reconciliation guarantee.
Security review details

Security Blast Radius

  • inferred — The destructive outcome is removal of approved Git worktrees accessible to the worker. One proposal can contain several paths, and a sweep can span companies. Actual tenant, host, and environment exposure cannot be bounded without the worker's identity, permissions, and filesystem topology.

Security Findings and Attack Paths

  • inferred — An existing approved proposal can pass its live-lease check, overlap a new heartbeat acquisition, and then remove the newly owned clean worktree. This is an ownership-control failure under concurrency; no arbitrary-path or cross-tenant attacker exploit was established.

Trust Boundaries and Controls

  • observed — The ordinary approval API requires an interactive human decision, verifies the caller, and loads a tenant-authorized approval. Request creation requires a UUID requester, preventing that API from directly supplying the sweep's non-UUID requester marker. The sweep additionally filters approval type, status, requester, kind, execution marker, and decision age.
  • observed — Paths originate from adapter configuration or run context and are persisted with a company-owned lease. Execution later consumes paths from approval context without comparing company or lease identity and without an explicit workspace-root allowlist. Whether untrusted actors can exploit this gap remains unresolved; ordinary guarded approval requests are not sufficient to demonstrate it.

Resilience and Maintainability Implications

  • observed — Expiry reclamation locks eligible rows with SKIP LOCKED and repeats the liveness check before release. Git timeouts kill and await the child process. These are meaningful failure-containment controls, but neither spans the final disposal authorization and filesystem side effect.

Hardening Proposals

  • proposed — Use a stable, canonical workspace identity with exclusion shared by acquisition and disposal, covering the final ownership check through removal. Bind approvals to the company and workspace generation, and validate filesystem containment before granting destructive authority.
  • proposed — Persist a disposal operation identity and intent before removal, with recoverable outcome reconciliation afterward. Make interrupted execution distinguishable from a fresh attempt so database failure cannot silently replace the original removal history.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 54.17% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 144 functions across 21 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarises the main changes: workspace lease acquisition, release, reclamation, and evidence-based disposal.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
🛠️ Fix failing CI checks 💡
  • Commit to this branch
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Autopilot is currently an internal CodeRabbit preview.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@mrveiss mrveiss left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Review — one real defect (the model still declares the constraint the migration drops), one question that is the owner's, and both of the named checks verified.

Scope, stated so it is not mistaken for more: +1403 lines. I reviewed the migration, workspace_disposal.py, acquire_lease, the model, and the beat wiring — the two areas asked about plus what they depend on. I did not line-review the sweep, the run-context/output modules or the ~450 lines of tests beyond reading their names. Nothing was run.

The defect: the ORM still declares unique=True on path

The migration drops uq_llc_workspace_leases_path and replaces it with a partial index. The model is not in this PR's diff, and at origin/main it still says:

autobot-backend/llc/models/workspace_lease.py:48
    path: Mapped[str] = mapped_column(sa.Text(), nullable=False, unique=True)

So after this lands the declared schema and the migrated schema disagree: a database built from the models carries the table-wide UNIQUE and reproduces the exact bug this migration exists to remove, while a migrated one does not. Nothing catches it — I looked for a guard comparing declared constraints to migrated ones and found only repo_tests/orphaned_database_migrations_test.py, which is about orphaned revisions, and no test in the tree calls get_unique_constraints on this table. Silent divergence, in the same PR that fixes the thing it diverges about.

Drop unique=True from the column and carry the partial index in __table_args__, in this PR.

The predicate is weaker than is_live() — correct today, on an implicit coupling

The migration says it carries "the intent the comment always stated". The comment's intent is defined in code three lines below it:

autobot-backend/llc/models/workspace_lease.py:71-75
    def is_live(self, now=None) -> bool:
        """Held and not yet expired. A released lease is never live, whatever its expiry."""
        if self.released_at is not None:
            return False
        return self.expires_at > (now or ...)

Live is released_at IS NULL and expires_at > now. The index is released_at IS NULL alone — and it cannot be otherwise, because a partial index cannot call now(). The service layer agrees with is_live() everywhere (live_lease_select:78, live_lease_count:111, lease_for_path:129), so an expired-but-unreleased row is invisible to the application and still occupies the index slot.

I traced whether that bites and it does not: acquire_lease calls await reclaim_expired(session, now=moment) as its first statement, so by the INSERT no expired-unreleased row for the path survives, and test_acquire_reclaims_expired_leases_before_counting_capacity pins that ordering. One non-test writer constructs a lease row, so the coupling has exactly one call site.

So: not a defect, an unstated invariant. The index is correct only while every writer reclaims before inserting. Say that in the migration docstring instead of claiming equivalence with the comment — the next writer is the one who will not know, and the failure it produces is an IntegrityError on the path the fleet most wants.

Both named checks: one verified, one is the finding above

_git scrubs the environment — verified. workspace_disposal.py:80 passes env=scrubbed_git_env() (autobot_shared.paths), with the reason stated at :69-71: GIT_DIR would point the command at whatever repository the environment names, with a zero exit code, so a LANDED verdict could be about a different repository entirely (#15783). That is the right instrument and the right explanation.

The disposal discipline around it is the strongest part of the PR: work_landed refuses on a dirty tree and on commits not on a remote, a git failure returns UNKNOWN and still refuses, and dispose_workspace uses no --force — "git refuses a worktree it considers unsafe to remove, and that refusal is a finding rather than an obstacle." That is this repository's own rule implemented rather than restated, and test_a_git_failure_is_unknown_and_still_refuses and test_nothing_is_removed_when_the_check_refuses pin both directions.

The question I cannot answer and should not decide

celery_app.py schedules run_workspace_lease_sweep hourly at minute=50, and that sweep can reach dispose_workspace, which deletes a worktree from disk. The standing rule is that no agent cleans up data on its own — deletion is proposed, a human approves it through the always-available review queue, it is never auto-approved, and it leaves a durable paper trail (#17038).

The case for exemption is real and this PR makes it well: landedness is proven before removal, nothing is forced, and a refusal is recorded. But two things keep it from being obviously exempt, and both are the owner's call rather than a reviewer's:

  1. it is automatic and scheduled, which is the shape the rule was written about;
  2. the paper trail is logger.info in a celery worker, not a durable record. Even under the exemption reading, #17038 asks for a trail that outlives a log buffer.

Settle this before merge. Deleting a worktree is not reversible, and an hourly job is the wrong place to discover a rule disagreement.

CI

No verdict — the suite was cancelled at 18:13 Riga. A migration, a new celery schedule and a filesystem deleter is the combination that least deserves to merge on an uncancelled-but-unrun suite. Re-run.

Verdict: fix the model declaration in this PR, settle the #17038 question, re-run CI. The rest I would land — and the disposal module in particular is the shape other cleanup code here should copy.

@github-actions

Copy link
Copy Markdown
Contributor

✅ SSOT Configuration Compliance: Passing

🎉 No new hardcoded values of either class — ssot and other both block.

Known backlog in pipeline-scripts/hardcoded_values_baseline.txt is suppressed and tracked in #14371.

@mrveiss

mrveiss commented Sep 28, 2026

Copy link
Copy Markdown
Owner Author

Owner ruling 2026-09-28: the hourly deleter goes through the approval gate

Asked and answered directly by the repository owner, relayed by the coordinating session. Recorded here so the ruling has the paper trail its own subject requires.

The hourly celery-beat job may find expired leases and prove landedness — but it proposes disposals to the approval queue and stops. A human approves each batch. Nothing is removed unattended. The approval record is the durable trail; a logger.info line in a worker is not.

This is #17038 applied as written — no agent cleans up data or credentials on its own; it proposes, a human approves through the approval gates, never auto-approved, always a durable paper trail — with no exemption carved for proven-landed agent workspaces. The exemption case was put to the owner and declined.

The landedness-proof code stays as the proposal's evidence. Refs #16818 remains correct: the reclaim half is delivered as proposal-plus-approval, and AC4's session-worktree scope is a separate open question.

@mrveiss mrveiss left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Second look at ab928a22de — all three findings are fixed, both of the hard questions check out, and there is one new blocker: the sweep stops disposing after twenty proposals, silently.

Verified at ab928a22de rather than taken from the report. Nothing run.

Blocker: .limit() is applied before the executed-ness filter

# workspace_lease_sweep.py:93-105
select(Approval)
  .where(Approval.approval_type == …DESTRUCTIVE_ACTION.value,
         Approval.status == …APPROVED.value,
         Approval.requested_by_agent == SWEEP_REQUESTER)
  .limit(MAX_EXECUTIONS_PER_SWEEP)            # 20, in SQL
…
return [a for a in result.scalars().all() if _is_sweep_proposal(a)]   # executed-ness, in PYTHON

_is_sweep_proposal excludes anything carrying executed_at, and nothing ever leaves the APPROVED set — _execute_approved writes context and never touches approval.status. So executed proposals accumulate as APPROVED rows that still match every SQL predicate.

Once twenty of them exist, the LIMIT 20 is satisfied entirely by executed rows, the Python filter drops all twenty, and _approved_proposals returns [] on every subsequent sweep. No approved disposal is ever executed again. There is also no ORDER BY, so which twenty come back is unspecified — the failure can arrive earlier and intermittently rather than at exactly the twentieth.

And it is invisible: the sweep logs disposed 0, refused 0 and reads as a quiet hour. This is the one place in the PR where nothing to do and cannot see the work render identically.

The minimal fix keeps the bound doing its stated job. The constant's own comment says it exists so "a backlog of approvals cannot turn one beat tick into an unbounded run of git operations" — that is a bound on disposals, not on rows selected. So filter first and slice after:

rows = (await session.execute(select(Approval).where(…).order_by(Approval.created_at))).scalars().all()
return [a for a in rows if _is_sweep_proposal(a)][:MAX_EXECUTIONS_PER_SWEEP]

Pushing executed_at IS NULL into the SQL (a JSON predicate on context) is the tighter version and equally fine. What I would not do is add an EXECUTED status — the vocabulary is PENDING/APPROVED/REJECTED/REVISION_REQUESTED/WITHDRAWN/EXPIRED (models/approval.py:29-34), and inventing a seventh value to fix a LIMIT would change the approval system for every other gate.

A test for it is cheap and belongs in this PR: twenty-one approved proposals, twenty carrying executed_at, assert the twenty-first is the one acted on.

Both of the questions asked

The refusal is written back — verified, and it persists. Every entry appends to outcomes regardless of verdict, and the record then carries executed_at plus execution_outcomes with path, verdict and detail per path (:112-127). _async_sweep commits at :178. A refusal at execution lands on the same record as the approval and its evidence, which is what #17038 asks for. Re-proof itself is real: dispose_workspace calls work_landed as its first statement, so an approval authorises a removal and does not vouch for the evidence.

The predicate is literally the same expression, in both places:

llc/models/workspace_lease.py:60    postgresql_where=sa.text("released_at IS NULL")
migrations/…_097_…py:85             postgresql_where=sa.text("released_at IS NULL")

Same index name, same single column, same unique=True, and unique=True is gone from the path column (:72). Two copies of one literal rather than one shared constant — which brings me to the test that is supposed to keep them in step.

The model-vs-migration test does not read the migration

test_the_model_declares_the_same_partial_index_the_migration_creates asserts the model against literals written in the test:

index = indexes.get("uq_llc_workspace_leases_live_path")
assert index.unique is True
assert [c.name for c in index.columns] == ["path"]
predicate = str(index.dialect_options["postgresql"]["where"])
assert "released_at IS NULL" in predicate

Nothing here opens 20260928_097_…py. Its docstring says "Name and predicate both, because either one differing is a silent divergence" — and the comparison is model-versus-test, so if 097's predicate or index name is edited, this test still passes and the divergence it exists to catch is back. There are now three copies of the truth and the test pins two of them to itself.

Also assert "released_at IS NULL" in predicate is a substring check, so a model predicate of released_at IS NULL AND expires_at > now() would pass — a predicate that is both a divergence from 097 and impossible as a partial index.

repo_tests/knowledge_upload_accept_matches_server_17528_test.py is the shape to copy: it reads both sides by concrete literal and compares them. Reading 097's source for its _NEW_INDEX and postgresql_where and comparing to the model's is a handful of lines and makes the claim true.

I am flagging this plainly because it is the same shape as the finding it was written to fix, and as #17729's name-versus-identity assertion: a check whose name states a relationship and whose mechanism examines one side of it.

And the third test is a tautology, though a useful comment

test_the_partial_predicate_is_weaker_than_is_live_and_the_gap_is_the_reclaim constructs a lease and asserts is_live(NOW) is False and released_at is None. Both are true of the object by construction; no index and no database are involved. Its docstring says "if anyone later removes the reclaim, the reason this test exists is in front of them" — the prose will be, and nothing will fail.

What actually fails if the reclaim is removed is test_acquire_reclaims_expired_leases_before_counting_capacity. That is the real pin, and this docstring should point at it instead of implying its own assertions do the work. Worth keeping the test — as documentation it earns its place — with an honest docstring.

Everything else I checked and found right

  • test_the_model_declares_no_table_wide_unique_on_path reads __table__.c.path.unique and does fail on the old model.
  • The invariant is now stated in both the model's __table_args__ comment and 097's docstring: weaker than is_live, cannot be otherwise, permits a second lease on an expired-but-unreleased path, closed by convention rather than schema, with the future-writer failure mode named.
  • requested_by=SWEEP_REQUESTER is the plain string, with the reason recorded — no minted UUID in an audit record.
  • proposed_company files a mixed batch under no company and reaches the platform queue, rather than guessing one company's reviewer.
  • The sweep logs every count including the zeros, "a sweep that found nothing and a sweep that did not run must not look the same".

One gap for a follow-up, not a blocker

candidates is built only from leases reclaim_expired returned in this run, so a path is proposed exactly once, at reclaim. Combined with executed_at retiring an approval whatever the outcome, a workspace that fails work_landed at proposal time (the continue at :137) or refuses at execution is never proposed again. It becomes precisely the debris the sweep exists to remove, with a logger.info and no record.

That is a gap rather than a regression — before this PR nothing disposed of anything — and it is the right side to err on. But the design reads as complete and is not, so it wants an issue rather than silence.

Verdict: fix the LIMIT ordering in this PR and I would land it. The test-reads-both-sides change and the third docstring are small enough to ride along; the never-re-proposed gap is a follow-up.

@mrveiss mrveiss left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Third look at f0f1a0d9d9 — the blocker is fixed, the schema guard is now a real guard, and the instrument was fixed too. I would land this.

Verified at f0f1a0d9d9; nothing run.

The LIMIT fix, and the docstring is the better half of it

No LIMIT, order_by(Approval.decided_at), pending[:MAX_EXECUTIONS_PER_SWEEP] after the filter. The docstring states the defect, why the bound belongs after the filter, and the sentence that makes it stick — "cannot see the work and there is no work would be identical, in the sweep written to stop exactly that." Declining the JSONB executed_at IS NULL variant on the grounds that an unexercised predicate silently matching nothing is this finding's own failure mode is the right call, and the right reason.

The narrowness question: narrow in predicate, unbounded in time

Asked whether the fetched set stays safe over months. It is safe now and it is unbounded in principle, and the distinction matters because the growth has no ceiling rather than a high one.

The set is DESTRUCTIVE_ACTION + APPROVED + requested_by_agent == SWEEP_REQUESTER. One row per sweep run that proposes at least one landed workspace, hourly: an upper bound near 24/day, far fewer in practice since it fires only when a lease expires and its work is already on a remote. But nothing ever leaves the set — that is the docstring's own premise for having no LIMIT. Executed rows keep status = APPROVED for ever, each carrying a context with the workspace list plus execution_outcomes.

So the hourly sweep deserialises the entire history of its own approvals into ORM objects, every hour, to find the few unexecuted ones. At a pessimistic 8,760 rows a year at a few KB each that is tens of MB per tick, growing monotonically. It will not fail, and it is the same shape as the defect being fixed — a scheduled job whose per-tick work grows with history — on a much gentler slope.

The fix is a bound on time, not on count, and it is not the mistake that was just removed. The LIMIT bug was a bound on which rows the filter could see, applied before the filter, so the filter's target could be pushed out by rows it would have rejected. A window is a bound on which rows are eligible at all:

.where(Approval.decided_at >= _now() - timedelta(days=EXECUTION_WINDOW_DAYS))

An approval outside it is not hidden from the filter; it is genuinely not executable. Indexable, cheap, and nothing can be pushed out by rows that were never candidates.

And it makes a claim this PR already relies on literal. The re-proof exists because "an approval authorises the disposal, it does not vouch for hours-old evidence." That protects against the evidence going stale. It does nothing about the decision going stale: a human who approved the removal of a workspace six weeks ago, on the state they were shown then, is not authorising a deletion today. A window says so in code, and ApprovalStatus.EXPIRED already exists if anyone later wants to mark them rather than merely skip them.

The trade to state honestly: a window can miss an approval granted just before its edge if the sweep is down, so it must be days rather than hours, and the count bound stays as the git-operation cap. Follow-up, not a blocker — the current code is correct and this PR is carrying enough.

The instrument was the finding, and fixing it is the better outcome

That the LIMIT test could not fail — because the fake ignored the statement, so a restored .limit() changed nothing — is worth more than the bug it missed. The fake now reads the limit two ways and applies it:

limit = getattr(statement, "_limit", None)
if limit is None:
    limit = getattr(getattr(statement, "_limit_clause", None), "value", None)
if limit is not None:
    rows = rows[:limit]

So a restored .limit() truncates the fake's rows and test_a_backlog_of_executed_proposals_does_not_hide_the_unexecuted_one fails, which is the regression test I asked for, built exactly as asked — MAX_EXECUTIONS_PER_SWEEP executed approvals plus the one that must still be seen.

One level further down, and this is the only thing I would add to the tests. Those two attribute names are SQLAlchemy internals. If both ever move, the fake silently stops honouring limits and this test quietly returns to being unfailable — the exact state it was just rescued from, arriving through a dependency bump rather than a code change. A two-line test of the fake — hand it a select(...).limit(1) and assert it returns one row — keeps the instrument honest, and it is the same lesson one layer down: a test that cannot fail is indistinguishable from a test that passes.

The schema guard is genuinely two-sided now

repo_tests/workspace_lease_index_matches_migration_16818_test.py reads both files by path, fails loudly if either has moved (assert path.is_file()), asserts its regexes matched rather than letting a shape change yield None, and compares name and predicate for equality rather than containment. Renaming 097's index fails it. That is the claim its title makes.

The now assertion is the part I would not have thought of. Postgres rejects a volatile function in a partial index, so released_at IS NULL AND expires_at > now() would fail at migration time on a real database — and a pure string comparison between model and migration would happily pass two identically-wrong predicates. Asserting the predicate never mentions now catches the one divergence that agreeing copies cannot.

And both files it reads are .py, so **/*.py covers them in .github/filters/python-paths.yml for free. No filter entry needed — worth stating because it is the check that half this milestone exists for, and here it passes without anyone having to do anything.

The rest

  • The tautology is labelled as one and its docstring names test_acquire_reclaims_expired_leases_before_counting_capacity as the real pin. Kept as documentation, no longer claiming to be protection.
  • My never-re-proposed gap is its own Known gap — stated, not designed around section, called incomplete rather than finished, with the direction a later change should take. That is the correct treatment: the safe side, admitted.
  • Rebased rather than force-pushed.

Verdict: land it. The time-window bound and the test-of-the-fake are follow-ups, and the second one is small enough that whoever picks up the first should take both.

@mrveiss

mrveiss commented Sep 28, 2026

Copy link
Copy Markdown
Owner Author

Caretaker hold — do not merge this PR before #17738 and #17739 are folded in, even if it is green.

Both issues review this PR's own content, which is not on main yet. So they are not defects in main that this PR happens to be near; they are defects that become live the moment this merges, already filed, already described, with a named fix each. Merging green would mean landing two known defects and then reopening them from the other side — and the merge would make them look like new findings against base rather than things we knew.

That is worth stating on the PR rather than only in a queue, because green is the usual permission to merge and here it is not.

issue what it is about in this PR
#17738 the disposal sweep rereads its whole approval history every hour and acts on decisions that have aged out
#17739 the sweep's fake session reads SQLAlchemy internals, so it would stop honouring LIMIT silently if they move

#17739 is the more interesting of the two as a class: a test double that reaches into a library's private structure does not fail when the library changes, it starts agreeing with everything. The LIMIT it is meant to be checking would simply stop being checked, and the test would keep passing. That is the same shape as a guard reporting into a channel nobody reads (#17748) and a test asserting a security property from inside a parked file (#16375) — the check survives, its meaning does not.

I am caretaker for this PR (claimed in the ledger; the authoring session handed over). Both fixes go in one push on this branch when it comes up in the queue — it is 7th, behind #15907 and three other caretaker fixes, so there is time and there is no reason to spend a separate CI run on each. Closes #17738 and Closes #17739 will go on separate lines, and only for whichever acceptance criteria are fully delivered; anything partial gets Refs and stays open.

No commits from me on this branch until then.

…about LIMIT (#16818)

Closes #17738
Closes #17739

Two issues that review this PR's own content, folded in rather than left to go
live on merge.

#17738. The no-LIMIT design above `_approved_proposals` is right and its
docstring does not draw its own consequence: an executed proposal keeps
`status = APPROVED` -- execution is recorded in `context["executed_at"]` -- so it
matches this query for ever, and the hourly sweep re-read its entire approval
history, growing without bound.

The fix is a time predicate, not a row cap. `Approval.decided_at >=
_window_start()` bounds the read WITHOUT reintroducing the trap the docstring
warns about, because a window does not fill up with executed rows; they age out
of it.

It also fixes a correctness problem the growth was hiding. A disposal approved
months ago and never executed would still be executed today, against a workspace
whose branch and landedness have moved on since a human looked. An approval is a
decision about a state of the world, and `AUTOBOT_LLC_DISPOSAL_EXECUTION_WINDOW_DAYS`
is how long that decision is assumed to still describe it -- floored at 1 day,
because 0 would place every decision outside the window and disable the sweep
silently.

The decision #17738 asked for: aged-out approvals are **skipped, not marked**.
`ApprovalStatus.EXPIRED` exists in the vocabulary and is written by nothing in
this repository, and an unattended sweep transitioning a human's decision record
is exactly the unattended state change this PR exists to remove -- "the sweep
proposes, a human approves, nothing is removed unattended" applies to the
approval record as much as to the workspace. The record is left re-approvable.

#17739. The fake session read `statement._limit` and
`statement._limit_clause.value` -- two SQLAlchemy internals. When either moved it
would have found neither, left `limit = None`, and silently stopped applying
limits, taking with it the regression test asserting a backlog cannot hide an
unexecuted proposal. It reads the COMPILED SQL now, which is public and says
LIMIT in every version that emits one.

Three tests, and two of them exist because my first attempts could not fail:

- The window test asserted only the boundary arithmetic, so removing the WHERE
  clause left all 16 tests green -- the fake does not filter, so a missing
  predicate is invisible in the returned rows. `_Session` records the statements
  it is given now, and the test asserts the compiled SQL carries `decided_at >=`.
- The skip-not-mark test asserted `"EXPIRED" not in source` and failed on its own
  docstring explaining why the sweep does not write it. It parses the AST now and
  looks for the attribute in code, which is the same distinction between prose
  and code that this repo's comment guards draw.

Mutation-checked, each killing exactly one test: removing the window predicate,
and making the fake's limit unreadable.

Rebase notes, since this branch and #17726 extracted overlapping code from
`heartbeat_scheduler.py`:

- Both moved `_read_file_text` and `_resolve_adapter_output_file` out, to
  different modules. #17726 landed first, so `replay_recording.py` on main owns
  them along with `record_run_for_replay`, which used them. This branch's
  `run_output.py` was a strict subset with one importer -- the conflicted line
  itself -- so it is removed. Proved before removing rather than assumed: both
  functions are AST-identical to main's copies.
- The commit pointing the replay test at `run_output` is obsolete and was
  skipped: main already points that import at `replay_recording`, so its intent
  is satisfied by a different home.
- The ceiling is 1050, measured from the resolved file, not either side's number
  (base said 1053, this branch 1043).

Verified: 50 tests across the sweep, the stalled-run sweep and the replay
resolution; 31 ratchet tests; env registry and generated docs clean.

@coderabbitai coderabbitai 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.

Actionable comments posted: 6


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository: mrveiss/AutoBot-AI/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: b6a90184-5742-41a3-961e-2a6e55737986

📥 Commits

Reviewing files that changed from the base of the PR and between 2b88c1c and 20f7f3d.

⛔ Files ignored due to path filters (2)
  • repo_tests/python_file_size_ratchet_baseline.py is excluded by !repo_tests/python_file_size_ratchet_baseline.py
  • scripts/python_file_size_known_large.py is excluded by !scripts/python_file_size_known_large.py
📒 Files selected for processing (19)
  • autobot-backend/celery_app.py
  • autobot-backend/llc/models/workspace_lease.py
  • autobot-backend/llc/scheduler/__init__.py
  • autobot-backend/llc/scheduler/heartbeat_scheduler.py
  • autobot-backend/llc/scheduler/lazy_import_test.py
  • autobot-backend/llc/scheduler/run_context.py
  • autobot-backend/llc/scheduler/run_workspace.py
  • autobot-backend/llc/scheduler/stalled_run_sweep.py
  • autobot-backend/llc/scheduler/stalled_run_sweep_test.py
  • autobot-backend/llc/scheduler/workspace_lease_sweep.py
  • autobot-backend/llc/scheduler/workspace_lease_sweep_test.py
  • autobot-backend/llc/services/approval.py
  • autobot-backend/llc/services/workspace_disposal.py
  • autobot-backend/llc/services/workspace_lease.py
  • autobot-backend/llc/services/workspace_lease_16818_test.py
  • autobot-backend/migrations/versions/20260928_097_workspace_lease_live_path_unique.py
  • autobot_shared/env_registry_llc.py
  • docs/developer/ENV_VARS.md
  • repo_tests/workspace_lease_index_matches_migration_16818_test.py

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.

Comment thread autobot-backend/celery_app.py Outdated
Comment thread autobot-backend/llc/scheduler/heartbeat_scheduler.py
Comment thread autobot-backend/llc/scheduler/workspace_lease_sweep.py
Comment thread autobot-backend/llc/scheduler/workspace_lease_sweep.py
Comment thread autobot-backend/llc/services/workspace_disposal.py
Comment thread autobot-backend/llc/services/workspace_lease.py
…E variable (#16818)

`test_observability_coverage` failed on `20260928_097`, reddening four checks
from one assertion: `migration-tests`, `migration-matrix`, `python-suite shard
9/12` and the `python-suite` aggregator.

The assertion's hint points at `_TABLE`-variable `add_column` calls (#17125).
That is not the cause here and inlining the literal would have changed nothing:
097 has no `add_column` calls at all. It drops a table-wide UNIQUE constraint
and creates a partial unique index, so it creates no table and no column, and
`extract_artifacts` observes revisions only through `create_table`/`add_column`.
It is unobservable by construction.

Extending the allowlist is the documented route for exactly this, and 097 joins
an existing category rather than opening one: 20260821_081 is index-only and
20260906_089 is a constraint swap, both allowed for this same structural reason.

Idempotence, which is what the allowlist actually buys with the exemption:
`upgrade()` reads `get_unique_constraints` before dropping and `get_indexes`
before creating, acting only where the prior state is still present, and returns
early on a non-postgresql dialect or a missing table. The adoption re-run this
permits is a genuine no-op, not a "constraint does not exist" or "relation
already exists" failure.
@mrveiss

mrveiss commented Oct 2, 2026

Copy link
Copy Markdown
Owner Author

The current 12 reds are #17837 (base), not this PR — and not the observability defect either

Read-only diagnosis, posted so this is not re-derived a third time. The reds have changed cause since the last diagnosis, which is why the earlier reading no longer matches.

These checks are current, not stale. Head is a72a7d86a0, updated 2026-10-02T04:50Z; the failing python-suite run started 05:06Z, after it.

The failure is a dependency surface change, in base:

= 3727 passed, 31 skipped, 2 xfailed, 17 errors in 595.89s =
ERROR autobot-backend/tests/coordination/test_claim_yield.py::... -
  AttributeError: module 'fakeredis.aioredis' has no attribute 'FakeServer'
ERROR autobot-backend/tests/coordination/test_dev_loop_actions.py::... - (same)

17 errors, all the same missing attribute, 3727 tests passing around them. That is #17837 — "fakeredis 2.39 dropped aioredis.FakeServer — every python-suite run fails" — with the unpinned-package class at #17839.

Evidence it is base rather than this branch: autobot-backend/tests/coordination/test_claim_yield.py:51 on origin/main reads fakeredis_async.FakeRedis(server=fakeredis_async.FakeServer(), ...), and fakeredis is declared in no requirements file — not requirements-ci/, not autobot-backend/requirements.txt. Both facts are properties of main, so any run that resolves the newer fakeredis fails identically regardless of what this PR contains.

Correction to the earlier diagnosis on this PR. The 12 failures were previously attributed to one defect — test_observability_coverage flagging migration 20260928_097, echoed through migration-tests, migration-matrix, a shard and its aggregator. That was true of the earlier run. In the 05:06Z run I can find no observability_coverage or 20260928_097 line at all; the failing shard ends on the fakeredis errors. So that diagnosis is stale by one cycle rather than wrong — the echo observation still holds as a general caution, and the current cause is different.

One instrument note worth keeping, because it is the same shape as #17839's class. These tests already defend against fakeredis being missing:

try:
    import fakeredis.aioredis as fakeredis_async
except ImportError:
    fakeredis_async = None
...
    pytest.skip("fakeredis not installed")

That guard catches absence and cannot catch a surface change — an AttributeError is not an ImportError, so the skip never fires and the suite errors instead. Presence is not agreement, which is exactly why #17839's pinning matters more than the local try/except.

Nothing here is this PR's to fix. It clears when #17837 lands; re-run after that rather than chasing it on this branch. I am read-only and have not touched the branch, the worktree, or the claim.

mrveiss added a commit that referenced this pull request Oct 2, 2026
…pt (#17837)

Base is red on every PR. `fakeredis` 2.39.0 reached PyPI 2026-10-01 and
`requirements-ci-test.txt:21` declares `fakeredis[lua]` unpinned, so CI took it.
It removed `FakeServer` from `fakeredis.aioredis`:

    AttributeError: module 'fakeredis.aioredis' has no attribute 'FakeServer'

215 errors across 10+ python-suite shards on #17708; 17 more on #17725. main's
last green was 2b88c1c on 09-30, before the release.

**A code fix rather than a pin, and the wheel says why.** Read out of the 2.39.0
wheel rather than inferred:

    fakeredis/__init__.py:11   from ._core import FakeServer
    fakeredis/__init__.py:29   "FakeServer",      <- in __all__
    fakeredis/aioredis.py      imports only ._clients._async and ._socket

The top-level export is intact and public. On 2.37.0 `FakeServer` is reachable
through `aioredis` but is **not in that module's `__all__`** -- we were using an
incidental re-export that was never public API. Pinning would freeze a test
dependency to keep an attribute we were never entitled to.

23 files move to `fakeredis.FakeServer`, which resolves on 2.37 and 2.39 alike.

**Three binding styles, two of which bit.** `import fakeredis.aioredis as X` does
not bind `fakeredis`, so the import has to be added in the SAME scope: module
level, inside the fixture for those that import there, and a sibling
`pytest.importorskip("fakeredis")` for the four that bind dynamically, which no
import-statement regex can see. My first two passes missed the latter two shapes
and produced nine `NameError`s main does not have. The guarded `try/except
ImportError` blocks also set `fakeredis = None`, so an absent package still skips.

**One pre-existing defect surfaced and fixed, because it blocked the commit.**
`workflow_redis_backfill_test.py` carries two `# canonical: ignore
py-adhoc-db-engine` waivers that never applied: the rule honours a waiver only on
the call's OWN line (`py_adhoc_db_engine.py:64`, `lines[node.lineno - 1]`), and
both sat elsewhere. Verified against `origin/main`'s unmodified file at the same
path, which reports the same 2 violations -- latent only because the hook runs on
changed files and nobody had touched it. Waivers moved onto the call lines.

Verified: 411 passed across a2a and llm_shared (the erroring suites), 321 across
all 23 changed files, and canonical-check clean.

Not fixed here: `fakeredis[lua]` is unpinned in the CI plane, the same defect
that let torch 2.14.1 break every shard the day before. #17839 owns that,
including the cache angle -- an unpinned line never changes the requirements
hash, so a cache hit keeps the old version while a miss pulls the new one.

Closes #17837
Refs #17839

@coderabbitai coderabbitai 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.

Actionable comments posted: 4


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository: mrveiss/AutoBot-AI/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 66c0c921-337a-4eee-b0a0-0ca896752ff1

📥 Commits

Reviewing files that changed from the base of the PR and between 86f7329 and 0e6b6a5.

⛔ Files ignored due to path filters (2)
  • repo_tests/python_file_size_ratchet_baseline.py is excluded by !repo_tests/python_file_size_ratchet_baseline.py
  • scripts/python_file_size_known_large.py is excluded by !scripts/python_file_size_known_large.py
📒 Files selected for processing (13)
  • autobot-backend/celery_app.py
  • autobot-backend/llc/scheduler/__init__.py
  • autobot-backend/llc/scheduler/heartbeat_scheduler.py
  • autobot-backend/llc/scheduler/lazy_import_test.py
  • autobot-backend/llc/scheduler/stalled_run_sweep.py
  • autobot-backend/llc/scheduler/stalled_run_sweep_test.py
  • autobot-backend/llc/scheduler/workspace_lease_sweep.py
  • autobot-backend/llc/scheduler/workspace_lease_sweep_test.py
  • autobot-backend/llc/services/approval.py
  • autobot-backend/llc/services/workspace_disposal.py
  • autobot-backend/llc/tests/test_heartbeat_scheduler.py
  • autobot-backend/migrations/versions/20260928_097_workspace_lease_live_path_unique.py
  • autobot_shared/env_registry_llc.py

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.

Comment thread autobot-backend/llc/scheduler/workspace_lease_sweep.py
Comment thread autobot-backend/llc/scheduler/workspace_lease_sweep.py Outdated
Comment thread autobot-backend/llc/services/workspace_disposal.py Outdated
Comment thread autobot-backend/llc/tests/test_heartbeat_scheduler.py Outdated
…he owning repo (#16818)

Four CodeRabbit findings, each confirmed against the code before acting, plus the
two blockers that surfaced while fixing them.

1. The proposal was never announced (workspace_lease_sweep.py)
-------------------------------------------------------------
`request_approval` only adds and flushes; `publish_requested` is a separate call
whose own docstring says to make it AFTER the transaction commits. `_propose`
discarded the returned `Approval` and nothing in the sweep published, so
`llc:approval_requested` never fired for a disposal proposal. A human approval is
the only path to disposal (#17038), so the sweep proposed into silence and
reported success. `_propose` now returns `(count, approval)`; `_async_sweep`
publishes after `session.commit()`.

2. The promised log did not exist (workspace_lease_sweep.py)
-----------------------------------------------------------
`_window_start`'s docstring states aged-out approvals are "skipped, not marked"
and that "the count of skipped ones is logged rather than silently dropped". The
window predicate cannot honour that: it filters in SQL, so those rows never reach
the sweep and there is nothing left to count. `_log_aged_out` reads the other side
of the window and warns with the ids. A SQL COUNT cannot stand in, because sweep
proposals are identified by `_is_sweep_proposal`, a Python predicate.

3. `worktree remove` ran wherever the worker lived (workspace_disposal.py)
-------------------------------------------------------------------------
Every other `_git` call passes `cwd=`; the removal did not, so the outcome
depended on the worker's directory and git could refuse, returning UNKNOWN and
leaving an approved workspace in place. The review was right about the defect, and
the obvious fix fails differently: `cwd=path` is refused outright, because git
will not remove the worktree it is standing in. Resolves the owner with
`rev-parse --git-common-dir` and runs there.

4. A dead assertion message (the moved test)
-------------------------------------------
`assert_awaited_once(), "..."` builds a tuple and discards the string. Fixed as
`assert session.commit.await_count == 1, "<message>"` rather than the suggested
comment-plus-call: one line instead of two, and the intent becomes a LIVE
assertion message rather than a comment.

Blocker A: the branch had pushed a grandfathered file past its ceiling
---------------------------------------------------------------------
`test_heartbeat_scheduler.py` stood at 1144 lines against a recorded ceiling of
1075 (#14236, #5060). `origin/main` has it at exactly 1075, so the 69 lines are
THIS branch's, and the ratchet refused every commit on the branch -- it checks the
tree, not the changeset, so the whole PR was unable to accept work until this was
resolved. Split rather than re-pinned, which is what the rule requires: the class
this branch added, `TestEarlyExitsReleaseTheWorkspace`, moves to
`test_heartbeat_workspace_release.py` (83 lines). The original returns to exactly
1075. No test is lost and no ceiling is raised.

Blocker B: the `code-quality` required context was red on formatting
-------------------------------------------------------------------
`workspace_disposal.py` was Black-dirty at the previous head -- CI said *"1 file
would be reformatted"* -- which is what failed `code-quality`. Now clean, with a
pre-existing 122-char line in the same file split as well, since it only becomes a
lint failure once the file is in the changeset.

Tests, because a fix with no test is decoration
----------------------------------------------
The fake `_Approvals` had no `publish_requested` at all, so fix 1 would have
broken every proposing test. Extended it to record the publish WITH the commit
state, plus three tests: the proposal publishes and does so after the commit, an
empty sweep publishes nothing, and the aged-out query bounds `decided_at` from
above. Ordering is asserted rather than occurrence -- publishing inside the
transaction would announce a proposal a rollback could still erase.

Refs #16818, #17038

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to GitHub limitations.

⚠️ Outside diff range comments (1)

🟠 Major · Serialise lease acquisition with approved disposal. · workspace_lease_sweep.py:205-226

autobot-backend/llc/scheduler/workspace_lease_sweep.py:205-226
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Serialise lease acquisition with approved disposal.

An approved proposal can refer to a path released by an earlier sweep. _execute_approved checks the path and then calls dispose_workspace. acquire_lease can commit a new lease between these operations. The partial unique index does not prevent this because it excludes the released row.

Use the same transaction-scoped, path-specific PostgreSQL advisory lock in acquire_lease and _execute_approved. Hold the lock until disposal and the sweep transaction commit. This is separate from the existing live-lease check.

Suggested fix
-from llc.services.workspace_lease import lease_for_path, reclaim_expired
+from llc.services.workspace_lease import lease_for_path, lock_workspace_path, reclaim_expired
 def _now(now: Optional[datetime] = None) -> datetime:
     return now or datetime.now(timezone.utc)
 
 
+async def lock_workspace_path(session: AsyncSession, path: str) -> None:
+    await session.execute(
+        select(func.pg_advisory_xact_lock(func.hashtextextended(path, 0)))
+    )
+
+
 async def acquire_lease(
@@
     """
     moment = _now(now)
+    await lock_workspace_path(session, path)
     await reclaim_expired(session, now=moment)
         for entry in (approval.context or {}).get("workspaces", []):
             path, branch = entry.get("path"), entry.get("branch")
+            await lock_workspace_path(session, path)
             held = await lease_for_path(session, path)

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository: mrveiss/AutoBot-AI/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: c9847790-4732-4cde-becc-415cdb5bd1a3

📥 Commits

Reviewing files that changed from the base of the PR and between 0e6b6a5 and a012f71.

📒 Files selected for processing (4)
  • autobot-backend/llc/scheduler/workspace_lease_sweep.py
  • autobot-backend/llc/scheduler/workspace_lease_sweep_test.py
  • autobot-backend/llc/services/workspace_disposal.py
  • autobot-backend/llc/tests/test_heartbeat_workspace_release.py

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.

Comment thread autobot-backend/llc/scheduler/workspace_lease_sweep.py
Comment thread autobot-backend/llc/tests/test_heartbeat_workspace_release.py Outdated
… fail (#16818)

Two more CodeRabbit findings, both correct, both about code from the previous
commit rather than pre-existing.

Bound the aged-out approval read (workspace_lease_sweep.py)
----------------------------------------------------------
`_log_aged_out` was bounded only from above, so it read every historical approved
sweep approval on every sweep. An executed proposal keeps `status == APPROVED`, so
"older than the cutoff" is the whole approval history -- Python discarded the
executed ones only after the database had returned them, and the hourly sweep's
read grew without limit to report nothing.

Bounded from below as well, one window of lookback. The cost was the finding, but
the lower bound fixes a second defect it did not name: without it, a proposal that
aged out in March is re-reported in every sweep thereafter, forever. That is a
warning nobody can act on and everybody learns to skip, which is worse than no
warning. One window reports each aged-out proposal while the fact is still news.

Ids in the warning are capped at `MAX_AGED_OUT_IDS_LOGGED` with an explicit
"+N more"; the COUNT stays exact, because it is the actionable part.

Make the early-return guard able to fail (test_heartbeat_workspace_release.py)
-----------------------------------------------------------------------------
`body.count("_release_workspace") == 3` asserts that three release calls exist,
not that every exit has one. Add a fourth early `return` with no release and the
count is still three: the assertion passes and the regression it exists to catch
goes straight through. A guard that cannot fail, in the file that exists to stop
leaked leases.

Replaced with two tests that can:

* `test_the_failed_early_exit_actually_releases` DRIVES the exit -- the RUNNING
  update raises, and the release is asserted to receive the run id and `failed`.
* `test_every_return_in_run_adapter_is_preceded_by_a_release` walks each statement
  list and requires a release before the return in that same block, so a new
  unreleased exit fails by construction rather than by someone remembering to
  update a number.

Measured shape it encodes: 3 returns at 584/616/621, each immediately preceded by
a `_release_workspace` call at 583/615/620, with `_finish_run` at 623 on the
normal path.

Also tests the new lower bound, so the fix above is not decoration.

Refs #16818, #17738

@coderabbitai coderabbitai 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.

Actionable comments posted: 3


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository: mrveiss/AutoBot-AI/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 3972ce70-8d8f-45c6-9f53-ccc17bfd677d

📥 Commits

Reviewing files that changed from the base of the PR and between a012f71 and 9fc8995.

📒 Files selected for processing (3)
  • autobot-backend/llc/scheduler/workspace_lease_sweep.py
  • autobot-backend/llc/scheduler/workspace_lease_sweep_test.py
  • autobot-backend/llc/tests/test_heartbeat_workspace_release.py

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.

Comment thread autobot-backend/llc/scheduler/workspace_lease_sweep_test.py Outdated
Comment thread autobot-backend/llc/tests/test_heartbeat_workspace_release.py Outdated
Comment thread autobot-backend/llc/tests/test_heartbeat_workspace_release.py Outdated
github-actions Bot and others added 3 commits October 2, 2026 15:09
… readable (#16818)

Three CodeRabbit findings, all on code from my previous two commits.

The assertion message printed its own placeholder
-------------------------------------------------
`"...aged out arbitrarily long ago:\n{sql}"` had no `f` prefix, so a failure would
have printed the literal `{sql}` instead of the query. Worse than flagged: the
`\n` was escaped too, so the message carried a literal backslash-n as well. Both
fixed.

This is the aged-out-bound test from the previous commit -- a guard whose failure
message cannot be read is most of the way back to a guard that cannot fail, since
the diagnosis it exists to hand over is the part that goes missing.

Nested `with` (SIM117)
---------------------
Two patches, one `with` holding both contexts.

`_is_release` read its node before deciding what the node was
-------------------------------------------------------------
It computed `node.value.value` first and checked `isinstance(node, ast.Expr)`
last. The review is right that the RESULT was correct -- the trailing check
rejected anything wrong, so an `ast.Assign` whose value is an `Await` could not
slip through -- but the order made the reader verify that by hand. Now it returns
early unless the node is `Expr(Await(...))`, then reads the call, which is the
order the question is actually asked in. Return annotation added (ANN202) and a
one-line docstring, so the helper states its own subject.

Refs #16818
@mrveiss

mrveiss commented Oct 2, 2026

Copy link
Copy Markdown
Owner Author

Note on state: the three resolved threads are fixed in a commit that is not pushed yet

Flagging this rather than leaving it implicit, because the PR currently reads more ready than it is.

The three threads resolved just now each cite f57a394f1e. That commit exists locally and is not on the remote — git merge-base --is-ancestor f57a394f1e origin/issue-16818-lease-wiring returns false. So anyone reading this PR sees every thread resolved, zero unresolved, behind=0, and a tree that does not yet contain the fixes those replies describe.

That is deliberate and it is the queue discipline working as intended: a push is a CI relaunch, the gate has this PR at WAIT behind another PR holding the only slot, and relaunching now would spend a suite that a later refresh discards. The fixes land with the single run this PR needs once the slot frees.

But the combination is a shape worth naming, because it is the same one that produced a true-green-on-defective-content earlier in this queue: a fix held back plus state that advances around it equals a PR that looks readier than it is. There the branch advanced while the fix waited; here the thread state advanced while the fix waited. Resolved threads are read as "addressed", and for the moment they mean "answered, with the fix committed elsewhere".

Concretely, until the next push appears on this branch:

  • workspace_lease_sweep_test.py's aged-out assertion still prints a literal {sql}
  • test_heartbeat_workspace_release.py still has the nested with and the pre-check ordering in _is_release

None is a correctness defect in shipped behaviour — one is a failure message, two are readability — which is why holding them is safe. A correctness fix would not have waited; the queue rules exempt those from any hold, and that exemption exists for exactly this reason.

@mrveiss

mrveiss commented Oct 2, 2026

Copy link
Copy Markdown
Owner Author

The two reds are one defect: a grandfathered file grown past a ceiling with zero headroom

Read-only diagnosis at head 44607cc508. Both failures are the same assertion — python-suite shard 5/12 and 8/12, and shard 8's log carries four hits for the same line, so this is two reds and one cause, not two problems.

FAILED repo_tests/python_file_size_ratchet_test.py::test_recorded_ceilings_match_the_files_today
E  'autobot-backend/llc/scheduler/heartbeat_scheduler.py: 1076 lines, over its
   recorded ceiling of 1050. A grandfathered file may not grow (#14236)'

Two things stack, and only one of them is this branch's doing

value
heartbeat_scheduler.py on this branch 1076 lines
heartbeat_scheduler.py on origin/main 1053 lines
recorded ceiling on this branch 1050
recorded ceiling on origin/main 1053 — re-pinned by #17726, comment # #17726: replay recording extracted

So the branch is behind main on a hub file. repo_tests/python_file_size_ratchet_baseline.py is a ratchet baseline, which CLAUDE.md exempts from the same-file-one-PR rule precisely so this can be refreshed by merging — and main re-pinned it to 1053 after extracting the replay recording.

But merging main does not clear this. The ceiling becomes 1053 and the file is 1076. The post-merge size is not something I can state exactly — if #17726's extraction removed lines this branch also carries, the merge will reduce it — so it needs re-measuring after the merge rather than predicting. What is certain is that 1053 is the ceiling it must come in under, not 1050.

The ceiling has no headroom by construction

Main's file is 1053 and its recorded ceiling is 1053. Exactly equal. #17726 reduced the file and re-pinned to the new exact size, so any addition to this file fails the ratchet — this branch's 68 added lines are not unusually large, they are the first change to arrive after the pin was tightened to zero margin.

That is the same shape as the saturated-floor class #17818 owns and as #17870: a pin set to the current measured value leaves no room, and the next legitimate change pays for it. Worth knowing so this does not read as carelessness in the branch.

The remedy is extraction, not a higher ceiling

CLAUDE.md is explicit — no file exceeds MAX_LINES (600); split, don't raise the ceiling — and the ratchet is shrink-only with A grandfathered file may not grow (#14236). Raising 1050 or 1053 to 1076 is the one fix the rules exclude.

The added content suggests its own seam. The #16818 workspace-lease logic is a cohesive concern: acquiring or deferring (the run leases its workspace, or defers rather than sharing it), releasing on finish, and the invariant #16818 AC2: a workspace must not outlive the run that took it. Extracting that into its own module takes the lease wiring out of the scheduler, brings heartbeat_scheduler.py back under 1053, and gives the lease lifecycle a place where it can be tested directly.

Noting for whoever does it: the branch already absorbed a review finding in this file — the # #17725 review: comment about two paths returning without reaching _finish_run, so a rate-limited retry's acquire fails against a lease nobody is using. That reasoning should move with the code, not be left behind in the scheduler.

Not base

To be explicit about the direction, since I checked it: origin/main is at 1053 against a 1053 ceiling and passes. This is not a base red and will not clear on its own.

Read-only — I have not touched the branch, the worktree or the claim.

…e ratchet pins (#16818)

Clears both reds on #17725. They had opposite remedies, so both were needed.

code-quality: stale base, not this branch
-----------------------------------------
mypy errors in `context_aware_decision/collectors/main.py` and `api/code_sync.py`.
Neither is among this branch's changed files and main is green on both, so
merging main is the entire fix.

python-suite: the ratchet, and the pin had to be reconciled first
-----------------------------------------------------------------
`heartbeat_scheduler.py` was 1076 against a ceiling of 1050, and 1050 was
UNREACHABLE: main's own copy of the file is 1053 lines, so satisfying it would
have meant deleting three lines of base's unrelated code.

Neither pin was wrong when written. At `2f6369e006` this branch had the file at
exactly 1050 and pinned 1050; main independently reached 1053 and pinned 1053.
Two true records of two diverged trees. The merge has to pick one, and base's is
the authoritative one for the merged tree.

There are TWO records of this ceiling and only one is read by the guard:
`scripts/python_file_size_known_large.py` (the hook's source) and
`repo_tests/python_file_size_ratchet_baseline.py`. Editing the second alone looked
like a fix and changed nothing -- the guard kept reporting 1050. Both are now set
to the same value; the duplication itself is worth a separate issue.

Extraction, which is what the rule asks for instead of a higher ceiling
-----------------------------------------------------------------------
Two seams, both pulled out rather than re-pinned:

* `_release_workspace` -> `run_workspace.release_workspace_best_effort`,
  consolidating into the module that already owns `acquire_run_workspace` and
  `release_run_workspace`. Its reasoning moves with it.
* the mark-RUNNING prologue -> `run_status.mark_running`, a new module beside
  `run_context` and `run_workspace`, which hold the other per-run concerns. It
  needs no `self`, so it was never a method in substance.

That also fixes a function-length red the move surfaced: `_run_adapter` was 67
body lines against a 65 limit (#620). It had been over for a while and the guard
skips long functions a change does not touch, so touching it is what revealed it.

Ceilings lowered to the achieved 1028, in both records. The ratchet turns down.

The release stays with its caller, deliberately
-----------------------------------------------
`mark_running` writes the FAILED status on failure -- that belongs with the
attempt that failed -- but does NOT release the workspace. The caller does, so
that every exit from `_run_adapter` has its release visible to a reader of
`_run_adapter`. Moving it inside would have satisfied the line count and hidden
the thing #17725's review was about.

Tests retargeted, and the structural guard re-proved
----------------------------------------------------
The guard matched `call.func.attr == "_release_workspace"`, an ast.Attribute. The
calls are now a module-level ast.Name, so it would have found zero release calls
and flagged every return -- failing loudly, for the wrong reason. Re-verified by
mutation rather than by assumption:

    real code                       -> no returns without a release
    bare `return` injected at :620  -> detected

Refs #16818, #17725, #620
github-actions Bot and others added 2 commits October 2, 2026 19:52
…counted two (#16818)

`python-suite shard 5/12` failed on
`test_run_adapter_applies_backoff_for_registry_rate_limit` with
`AttributeError: 'coroutine' object has no attribute 'all'`.

The production path is correct; the test's session arithmetic was not
---------------------------------------------------------------------
Its factory returns the first context manager on call 1 and the second on EVERY
later call. `_run_adapter` now opens three sessions on the rate-limited path:

    1  mark_running          SELECT retry_count + UPDATE RUNNING
    2  _handle_rate_limited  UPDATE ... status = rate_limited
    3  release_workspace_best_effort   hands the workspace back (#17725 review)

so the release reused session 2 and two things broke. `execute.call_args` returns
the LAST call, so the lease query became the subject of the RATE_LIMITED
assertion -- which is why the failure message compared `'rate_limited'` against a
`SELECT llc_workspace_leases...`. And session 2's result is a bare `AsyncMock`,
whose auto-created `.scalars` is itself async, so `result.scalars()` returned a
coroutine and `.all()` raised. Session 1 already used a `MagicMock` result for
exactly that reason.

NOT the extraction. `mark_running` replaced an inline `async with factory()` with
a function that opens one session -- same count, relocated. The third session is
the LEASE RELEASE this PR adds on purpose: a rate-limited retry is re-dispatched
as a new run that acquires its own lease, so holding this one keeps the directory
unusable until its deadline. The test predates that behaviour.

The fix also covers the behaviour rather than just restoring green
-----------------------------------------------------------------
A third mock with a correctly shaped result, and an assertion that the exit
actually touched `llc_workspace_leases`. Asserting only the status write would
have gone green while saying nothing about the release -- the thing the review
asked for.

Mutation-proved rather than assumed:

    release call present  ->  21 passed
    release call removed  ->   1 failed   (this test)

Refs #16818, #17725
@mrveiss
mrveiss merged commit 919c49e into main Oct 2, 2026
81 checks passed
@mrveiss
mrveiss deleted the issue-16818-lease-wiring branch October 2, 2026 20:51
mrveiss added a commit that referenced this pull request Oct 2, 2026
…17872)

Finding 1 of the issue, which the first two commits left standing: a lowered
baseline failed a test called `test_no_ceiling_may_be_raised`, and nothing had
been raised. The reader is sent after a cause that did not occur, which is worse
than being told only that something disagrees.

`ceiling > baseline` is produced equally by a raised ceiling and a lowered
baseline -- the predicate cannot distinguish them -- so neither the name nor the
message claims one any more:

  test_no_ceiling_may_be_raised  ->  test_no_ceiling_may_exceed_its_baseline
  "ceilings raised (now, baseline)" -> "the hook's ceiling exceeds its baseline
  (hook, baseline) ... which record moved is not established here"

The predicate itself is unchanged and still one-directional on purpose. The
reverse direction is the parity test's job; writing it in both places would make
two records of one check, which is the shape this issue is about.

Mutation-verified in both directions, which is also what proves the new file is
load-bearing rather than redundant:

  hook ceiling 910 -> 911   both tests fail, both reporting (911, 910)
  baseline 910 -> 960       the renamed test PASSES; only the parity test fails,
                            reporting (910, 960) -- the #17725 direction
  both files restored, md5 matches, 37 passed

One more thing the reviewer should see: `python_file_size_ratchet_test.py` was at
589 of the 600-line hard limit with no KNOWN_LARGE entry, and my first version of
this change took it to 608. Adding an entry to the hook's record to accommodate
that would have been raising a ceiling to fit a docstring. So the rationale moved
into the parity test's docstring, where there is room, and this file keeps a
one-line pointer and 5 lines of headroom.
mrveiss added a commit that referenced this pull request Oct 3, 2026
…d stop the cross-check naming a cause it cannot establish (#17872) (#17882)

* test(gates): compare each gate's two ceiling records for EQUALITY, not one direction (#17872)

The duplication is NOT the defect, and #17872's original ask was wrong
---------------------------------------------------------------------
I filed that issue asking for one record to be derived from the other. Reading
the files says not to. Both baselines document the two copies as a mechanism:

  python: "neither copy is derived from the other at runtime ... a list only ever
           compared against itself can drift anywhere ... no ceiling can be
           raised by editing one file alone"
  shell:  "Two copies is the point, not an accident ... The mirror is what makes
           a shrink irreversible: the gap between a lowered hook and an
           unlowered mirror is exactly the lines just cut"

The hook's copy is anchored to real file sizes by `audit_ceilings`; the baseline
is anchored by the ratchet test. Deriving one from the other collapses two
independent anchors into one and destroys exactly the protection that makes a
raise detectable. Derivation would have been a regression dressed as cleanup.

The real defect: the comparison was asymmetric on the Python side
----------------------------------------------------------------
The mechanism only holds if the two records are compared BOTH ways. They were
not. `test_no_ceiling_may_be_raised` fires on `ceiling > baseline` alone, and
`test_no_entry_may_be_added` compares keys in one direction and skips
`rel not in RATCHET_BASELINE` silently. There was no equality assertion at all.

So raising the BASELINE alone passed every check -- the direction that actually
happened, on #17725: I edited the baseline, confirmed the new number was in the
file, and the hook kept enforcing the old one. An edit that looks applied and
changes nothing.

The shell gate has had the equality assertion all along
-------------------------------------------------------
`shell_file_size_ratchet_test.py` asserts `hook.KNOWN_LARGE == RATCHET_BASELINE`
outright, names both files, and asserts no direction. Fourth time in this batch
that the shell copy was the correct one.

Asserted for BOTH pairs rather than only the broken one: the defect is two
hand-kept copies per gate with an asymmetric comparison, and fixing one gate
leaves the identical arrangement one directory away with nothing pointing at it.
The data-source difference -- 479 entries loaded from a module versus 10 declared
inline -- is a parameter, not an obstacle: the hook is the authority either way.

Demonstrated, not argued
------------------------
Raised one baseline entry alone, 713 -> 763, hook untouched:

    test_no_ceiling_may_be_raised       PASSED   <- the existing check misses it
    test_the_two_records_are_equal      FAILED   <- this one catches it

Both green again on revert.

The message names both values and asserts no direction, because lowering a
baseline used to fail a test called "no ceiling may be raised" -- sending the
reader after a raise that never happened. A wrong diagnosis costs more than a
vague one.

Touches no baseline file and no gate, so it does not collide with #17865.

Refs #17872, #14236, #17353

* test(gates): drive the disagreement message on synthetic records (#17872)

The issue's last criterion asks for a test that proves the one-sided edit fails
"with the message asserted too, since the message is the whole deliverable here".
The first commit did not deliver that. I had mutation-verified the one-sided edit
by hand -- raise the baseline alone, watch this file go red -- but a check I ran
once in a worktree is not a check the repo keeps running, and the AC asks for the
latter. Ticking it on my own transcript would have been evidence of the wrong
kind.

The obstacle was that a message only ever produced by a real disagreement in the
checked-in records cannot be asserted on without breaking the tree. So the
comparison moves into `_disagreement_report(known, recorded, ...)`, which takes
its two records as arguments; the real pair feeds it the checked-in records, and
three new tests feed it synthetic ones:

  * a one-sided edit (1050 vs 1028, the exact #17725 shape) must name the file,
    carry BOTH values in (hook, baseline) order so the reader knows which file to
    edit, name both records, and headline the symmetric fact rather than a
    direction -- the old failure said "no ceiling may be raised" about an edit
    that raised nothing;
  * a key in one record only, BOTH directions -- the Python key check covered one
    and skipped the other in silence;
  * agreeing records produce no report at all. Without that contrast case a
    reporter that always complains would pass both tests above.

Mutation-verified, each mutation confirmed applied by grep before trusting the
result:

  swap (hook, baseline) order        -> 1 failed  (the both-values test)
  drop only_in_baseline              -> 1 failed  (the baseline-only case)
  reporter always returns ""         -> 3 failed  (both failure tests + ...)
  restored, md5 matches              -> 6 passed

* test(gates): stop the cross-check naming a cause it cannot establish (#17872)

Finding 1 of the issue, which the first two commits left standing: a lowered
baseline failed a test called `test_no_ceiling_may_be_raised`, and nothing had
been raised. The reader is sent after a cause that did not occur, which is worse
than being told only that something disagrees.

`ceiling > baseline` is produced equally by a raised ceiling and a lowered
baseline -- the predicate cannot distinguish them -- so neither the name nor the
message claims one any more:

  test_no_ceiling_may_be_raised  ->  test_no_ceiling_may_exceed_its_baseline
  "ceilings raised (now, baseline)" -> "the hook's ceiling exceeds its baseline
  (hook, baseline) ... which record moved is not established here"

The predicate itself is unchanged and still one-directional on purpose. The
reverse direction is the parity test's job; writing it in both places would make
two records of one check, which is the shape this issue is about.

Mutation-verified in both directions, which is also what proves the new file is
load-bearing rather than redundant:

  hook ceiling 910 -> 911   both tests fail, both reporting (911, 910)
  baseline 910 -> 960       the renamed test PASSES; only the parity test fails,
                            reporting (910, 960) -- the #17725 direction
  both files restored, md5 matches, 37 passed

One more thing the reviewer should see: `python_file_size_ratchet_test.py` was at
589 of the 600-line hard limit with no KNOWN_LARGE entry, and my first version of
this change took it to 608. Adding an entry to the hook's record to accommodate
that would have been raising a ceiling to fit a docstring. So the rationale moved
into the parity test's docstring, where there is room, and this file keeps a
one-line pointer and 5 lines of headroom.

* test(gates): the parity guard had the defect it was written to catch (#17872)

Review on #17882 found it, and it is the third variant of the same class tonight
-- the first one inside a guard built specifically to close that class.
`test_the_two_records_are_equal` passed when BOTH records were empty, because
`{} == {}` is True. Ask what that PASS licensed: "the two ceiling records agree",
on the strength of having read neither.

Measured before fixing, emptying only each mapping so the result isolates
emptiness rather than a broken module:

  before   3 failed, 34 passed   all three in python_file_size_ratchet_test.py;
                                 this file PASSED
  after    this file FAILS, naming both records

So the suite was not blind -- test_recorded_ceilings_match_the_files_today and
two others do fail on an emptied record -- but a guard has to fail for its own
reason, and this one reported agreement.

A non-empty assert, deliberately NOT a declared floor
-----------------------------------------------------
Both records only ever shrink; the hook's header says so. A static floor near
today's size fails the first time a file is legitimately split, and a floor an
order of magnitude below it is the number-chosen-by-feel that `declare`'s own
docstring names as that mechanism's recurring failure. A floor needing to be
lowered every time the ratchet succeeds is an incentive against doing the work.

Nor is partial degradation reachable: each record is a literal dict run by
`exec_module`, so a broken loader raises rather than returning a subset. Empty is
the only silent degradation, and empty is what this catches. The reasoning is in
the code so it is not "simplified" in either direction, and it says what would
reopen the floor question -- a loader that parses or filters.

The tree-anchored direction stays where it already lives: every file over
MAX_LINES must be recorded, owned by test_recorded_ceilings_match_the_files_today.
Asserting it here too would make two records of one check.

Mutation-verified both ways:

  both python records emptied        -> 1 failed, message names both records
  reach check always returns ""      -> 3 failed; the contrast case still passes
  restored                           -> md5 matches, 56 passed

`test_populated_records_produce_no_reach_failure` is that contrast case: without
it a reach check that always complains would pass the three failure tests -- the
same hole one level down.

* test(gates): one owner for ceiling equality, and two files per comparison (#17872)

Review found this PR's own thesis turned on itself: parametrising over "shell"
made a SECOND record of a check `shell_file_size_ratchet_test.py` already
performed -- `test_the_two_copies_of_the_ceilings_agree` asserted
`hook.KNOWN_LARGE == RATCHET_BASELINE` for that gate already.

Why I missed it is worth recording: Python has NO equality test (31 `def test_`
in its ratchet file, none asserting equality -- that absence is why #17872 was
filed), and shell had one. Reasoning about the file I was editing was right for
Python and silently wrong for shell, so parametrising generalised the gap along
with the fix. I had asked exactly this question of the tree-anchored direction
and declined to duplicate it; I never asked it of the shell file.

The old shell test is removed and this file now owns equality for both gates.
Mine is strictly stronger, and that is measured rather than asserted:

  shell hook ceiling 818 -> 800 (one side only)
      -> [shell] fails: disagreeing (hook, baseline) {'scripts/pr-preflight.sh': (800, 818)}
  shell hook record emptied
      -> [shell] fails the reach check, naming scripts/check_shell_file_size.py

The deleted test said something neither of mine did -- the gap between a lowered
hook and an unlowered mirror "is exactly the lines just cut, and it is spendable".
That is what a disagreement MEANS, so it moved into the failure message a reader
actually sees, not into a comment.

Also found while in here: equality passes trivially if both halves of a pair
resolve to ONE module, which is precisely "a list only ever compared against
itself can drift anywhere" -- the baselines' own warning, reachable through my
own fixture. Two files and two objects are now asserted before the comparison.

Smaller items from the same review:
  * `_empty_record_failure` was 34 lines of which ~8 were body; the rationale
    moved to the module docstring. Longest function in the file is now 29.
  * the `_PAIRS` comment quoted "479 entries" and "10 inline". Both shrink by
    design, so both would go stale -- dropped, for the same reason a numeric
    floor was rejected.
  * `test_no_ceiling_may_exceed_its_baseline`'s message said "Lower BOTH in the
    same commit", which is wrong for ITS direction: when the hook exceeds the
    baseline, lowering both RECORDS the raise instead of undoing it. It now says
    lower the hook to its baseline, or find the raise that put it above.

Left alone deliberately, with the reason recorded in the module docstring so it is
not re-derived: both gates' exceed-direction tests are logically subsumed by this
equality, but each gives the common direction a specific diagnosis, and the
redundancy is pre-existing and symmetric rather than introduced here.

55 passed (56 before, minus the deleted duplicate). Both gates exit 0.
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