Skip to content

feat(llc): a stalled run is its own status, and the sweep locks what it claims (#16817) - #17696

Merged
mrveiss merged 2 commits into
mainfrom
issue-16817-stalled-run
Sep 28, 2026
Merged

mrveiss merged 2 commits into
mainfrom
issue-16817-stalled-run

Conversation

@mrveiss

@mrveiss mrveiss commented Sep 28, 2026 •

Copy link
Copy Markdown
Owner

Thinking Path

Three of #16817's seven ACs. AC2, and the release halves of AC6 and AC7, are #16818's by
design
— the sweep's own docstring says so, and #16818 is now v0.9.0 alongside this. Hence
Refs, not Closes: partial delivery closes nothing.

The issue's problem statement is stale and I have corrected it on the issue. It says
"llc/scheduler/ contains one sweep, project_disposal_sweep.py". There are now eleven,
including stalled_run_sweep.py and liveness_monitor.py — the sweep itself landed in
8d2972a296 + a01653a8ea. So AC1, AC3 and AC7's log half were already delivered before this PR;
what remained was AC4, AC5, and an efficiency defect underneath them.

AC4 turned out to reverse a decision recorded in the sweep's own docstring, so I checked
before changing it rather than after. The original reused TIMEOUT because that "keeps the state
machine as it is"
, putting the distinction in error. That cost is close to zero and was
probably overestimated: status is sa.String(32) rather than a native enum, so no migration;
and is_terminal() is a deny-list over {QUEUED, RUNNING}, so a new terminal member needs no
edit there
. What the decision traded away is what AC4 asks for.

AC5 is the one to read first, because of its timing. A missing SKIP LOCKED is harmless while
the write is an idempotent status set — and becomes a double release the moment #16818 adds
claims, assignments and workspace leases to this path. The defect is armed by the very issue this
one is waiting on. Fixing it now costs a clause.

What Changed

AC4 — LLCRunStatus.STALLED. The sweep wrote TIMEOUT, which four sites set:
http_adapter.py:84, subprocess_base.py:406, heartbeat_scheduler.py:1062, and the sweep. So
"which runs stalled?" meant matching a formatted, human-readable error string — inference, and
it breaks on a re-wording. error still carries the detail; status now carries the fact.

AC5 — .with_for_update(skip_locked=True). Two beat workers partition rather than collide, so
the lock is in place before the side effects that will need it.

The scan. The sweep selected every non-terminal run and compared ages in Python. The
existing comment's NULL reasoning was right and the implementation did not follow it — this is
COALESCE(started_at, created_at) <= cutoff, the same rule expressed where an index can serve it.
Not a reversal of a documented decision: the decision was already this, in prose.

The statement moved into _stalled_candidates() so both properties are assertable without a
database.

test_the_error_says_the_sweep_decided_it kept its name and lost its premise. It used to
state that the error text was "the only place that distinguishes them" — which was true, and was
the defect.

Verification

Mutation-checked, each failing exactly one test and the right one:

mutation result
write TIMEOUT again the sweep's behavioural test fails
drop SKIP LOCKED, keep FOR UPDATE the lock test fails
drop locking entirely the lock test fails
age filter back in Python the in-SQL test fails

Restored: 17 passed.

The lock assertion failed on its first run, and that is the reason to trust this approach. The
default SQLAlchemy dialect renders FOR UPDATE and silently drops SKIP LOCKED — it is a
PostgreSQL extension. An assertion on FOR UPDATE alone would have passed against a rendering
production never gets
: green for the wrong reason. The check now compiles against
postgresql.dialect(), and the helper's docstring records why.

What the compiled-statement check proves, and what it does not. It proves the statement carries
FOR UPDATE SKIP LOCKED and a SQL-side age comparison. It proves nothing about how PostgreSQL
plans the query or whether an index is used — that needs a live database, which is outside what
this repo's tooling runs from here. Stated rather than implied.

A behavioural test was missing and I added it. Every other AC4 assertion passes while the sweep
writes whatever it likes, because the enum member existing is not the sweep using it.
_async_sweep is now driven through a fake session, which is also AC6's status half.

On the llc/ suite being red locally — measured, not assumed. A full llc/ run gives 15
failed / 66 errors. I reverted my three files to origin/main in place and re-ran the failing
subset both ways:

with my changes:     6 failed, 23 passed
origin/main state:   6 failed, 23 passed

Identical, so the failures are pre-existing and environmental — consistent with the dependency
floor report stating 91 of 139 comparable packages sit below their declared floor in this
interpreter. CI is the authority; that local baseline carries no information about it.

black, isort, flake8 clean. No file near the 600-line ceiling.

Single-issue rationale

#16817 is v0.9.0 and its remaining ACs belong to #16818, which is a separate change to a different
concern (workspace lease modelling). Batching them would put an unreviewed release path into a PR
whose subject is detection.

Model Used

Claude Opus 5 (1M context)

Refs #16817, #16818

Summary by CodeRabbit

  • Bug Fixes
    • Runs closed by the stalled-run sweep are now marked as stalled rather than timed out, distinguishing them from runs that time out during execution.
    • The sweep now filters eligible runs by age in the database and skips runs locked by another worker.

…it claims (#16817)

Three of #16817's ACs. The release of what a run held is #16818's and is not
touched here.

AC4 -- a stalled run is distinguishable, not inferred. The sweep wrote
`LLCRunStatus.TIMEOUT`, which four sites set: http_adapter, subprocess_base,
heartbeat_scheduler and the sweep itself. So "which runs stalled?" meant matching
a formatted, human-readable `error` string -- inference, and it breaks on a
re-wording. `STALLED` is a new member; `error` still carries the detail.

This reverses a decision recorded in the sweep's own docstring, so the reason is
recorded too. The original reused TIMEOUT because that "keeps the state machine as
it is". That cost is close to zero and was probably overestimated: `status` is
`sa.String(32)` rather than a native enum, so no migration, and `is_terminal` is a
deny-list over {QUEUED, RUNNING}, so a new terminal member needs no edit there.
The thing traded away was what AC4 asks for.

AC5 -- `FOR UPDATE SKIP LOCKED`. Two beat workers overlapping today would write
the same terminal status, so the race is invisible. #16818 adds the release of
claims, assignments and workspace leases to this path, and the same race then
releases each holding twice. The lock belongs here BEFORE the side effects that
need it: a defect armed by its own dependency is cheaper to prevent than to
diagnose.

The scan -- the sweep loaded EVERY non-terminal run and compared ages in Python.
The existing comment's NULL reasoning was right and the implementation did not
follow it; `COALESCE(started_at, created_at) <= cutoff` is that same reasoning
expressed where an index can serve it. Not a reversal of a documented decision --
the decision was already this, in prose.

The query moved into `_stalled_candidates()` so both properties are assertable
without a database.

Mutation-checked, each failing exactly one test and the right one:
  write TIMEOUT again        -> the sweep's behavioural test fails
  drop SKIP LOCKED           -> the lock test fails
  drop locking entirely      -> the lock test fails
  age filter back in Python  -> the in-SQL test fails

Two notes on the tests themselves. The lock assertion compiles against
`postgresql.dialect()`, because the default dialect renders FOR UPDATE and
silently drops SKIP LOCKED -- asserting on the generic rendering would have
reported a lock clause production never gets. And `_async_sweep` is now driven
through a fake session: every other AC4 test passes while the sweep writes
whatever it likes, because the enum member existing is not the sweep using it.

`test_the_error_says_the_sweep_decided_it` kept its name and lost its premise --
it used to state that the error was "the only place that distinguishes them",
which was true and was the defect.

Refs #16817, #16818
@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.

📝 Walkthrough

Walkthrough

The sweep now selects overdue queued or running runs in SQL, skips rows locked by another worker, and marks selected runs as STALLED. The new status is distinct from adapter-reported timeouts. Tests cover status classification, candidate queries and sweep outcomes.

Changes

Stalled Run Handling

Layer / File(s) Summary
Stalled status contract
autobot-backend/llc/models/enums.py, autobot-backend/llc/scheduler/stalled_run_sweep.py, autobot-backend/llc/scheduler/stalled_run_sweep_test.py
Adds LLCRunStatus.STALLED and checks that it is terminal and distinct from other statuses. The sweep documentation and error-message test describe the status and its accompanying detail.
Candidate selection and sweep updates
autobot-backend/llc/scheduler/stalled_run_sweep.py, autobot-backend/llc/scheduler/stalled_run_sweep_test.py
Builds a SQL candidate query with a COALESCE age comparison and FOR UPDATE SKIP LOCKED. The sweep marks eligible runs as STALLED, records completion details, counts updates and commits. Tests cover query construction and non-empty and empty sweep results.

Priority: ⬇️ Low

Merge Risk: 🟡 Moderate · up to 5c55b

Agents can currently mark their own runs as stalled without a sweep decision. Restrict that status before merging, and address the sweep query’s indexing risk as run counts grow.

Security Architecture Review

Security architecture risk: 🟡 Moderate · up to 5c55b

An agent can now report “stalled” for its own run, even though that outcome is intended to mean the sweep made the decision. Run and tenant scoping limit the exposure, but the distinction could mislead consumers of the new status.

Retained concerns

  • Medium · security · inferred: The agent-report route newly accepts STALLED and persists it with a finish time, although STALLED is defined as the sweep’s decision. An authenticated agent can therefore create that outcome on its own run without the sweep making the decision. The downstream consequence of false sweep attribution is not established.
Security review details

Security Blast Radius

  • inferred — The identified status-attribution path requires an agent report and is confined by the route’s predicates to an existing run belonging to that agent and company. No cross-tenant write or privilege gain is established.

Security Findings and Attack Paths

  • inferred — An agent able to report on its own run can submit status “stalled”; enum parsing now accepts it and the route stores it with finished_at. That can make an agent report appear to carry the sweep’s new outcome, although no downstream privileged action based on that value was verified.

Trust Boundaries and Controls

  • observed — The API checks caller-associated run ownership, while the sweep uses eligibility predicates and a transaction-held row lock. Neither the report route nor the shared enum enforces that only the sweep may produce STALLED.

Resilience and Maintainability Implications

  • inferred — The sweep’s transaction protects against duplicate sweep selection and leaves a pre-commit interruption available for a later selection. It does not establish durable ownership against subsequent completion writers; that limitation predates the new status.

Hardening Proposals

  • proposed — If STALLED is intended to attest to a sweep decision, separate agent-reportable outcomes from scheduler-owned outcomes rather than accepting every shared enum member on the agent route.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 56.52% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 23 functions across 3 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 summarises the main changes: adding a distinct stalled-run status and locking rows claimed by the sweep.
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.
  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

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.

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

@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: eaa5bab7-c051-4acb-b111-6aa794cc836a

📥 Commits

Reviewing files that changed from the base of the PR and between 0d6e432 and 5c55b30.

📒 Files selected for processing (3)
  • autobot-backend/llc/models/enums.py
  • autobot-backend/llc/scheduler/stalled_run_sweep.py
  • autobot-backend/llc/scheduler/stalled_run_sweep_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.

# TIMEOUT alone cannot say which happened and a consumer had to match the
# `error` text to find out. Terminal by construction: `is_terminal` is a
# deny-list over {QUEUED, RUNNING}, so a new member needs no edit there.
STALLED = "stalled"

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.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

sed -n '150,175p' autobot-backend/llc/models/enums.py
sed -n '320,400p' autobot-backend/llc/api/agent_api.py
rg -n 'STALLED|report_heartbeat|LLCRunStatus\(' autobot-backend/llc

Repository: mrveiss/AutoBot-AI

Length of output: 7887


🏁 Script executed:

set -eu
rg -n -A35 -B12 'class HeartbeatReport|HeartbeatReport\s*=' autobot-backend/llc
sed -n '110,165p' autobot-backend/llc/scheduler/stalled_run_sweep.py
sed -n '165,215p' autobot-backend/llc/scheduler/stalled_run_sweep.py
sed -n '340,445p' autobot-backend/llc/tests/test_agent_stub_routes_15905.py
sed -n '130,160p' autobot-backend/llc/models/enums.py

Repository: mrveiss/AutoBot-AI

Length of output: 12368


Reject STALLED in agent heartbeat reports.

HeartbeatReport.status is an unrestricted string, and report_heartbeat accepts every value in LLCRunStatus. When an agent submits status="stalled", the route writes that terminal status and finished_at to the agent’s run. The run then cannot satisfy the sweep’s non-terminal and unfinished-run conditions. Reject STALLED in this route, but keep it available to stalled_run_sweep.

Suggested fix
     try:
         status = LLCRunStatus(body.status)
     except ValueError as exc:
         raise HTTPException(status_code=422, detail=f"Unknown run status {body.status!r}") from exc
 
+    if status is LLCRunStatus.STALLED:
+        raise HTTPException(status_code=422, detail="Agents cannot report stalled runs")
+
     try:
         run_uuid = uuid.UUID(body.run_id)

Comment on lines +126 to +128
sql = _compiled(sweep).lower()
assert "coalesce" in sql
assert "started_at" in sql and "created_at" in sql

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.

🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

sed -n '90,150p' autobot-backend/llc/scheduler/stalled_run_sweep.py
sed -n '95,210p' autobot-backend/llc/scheduler/stalled_run_sweep_test.py

Repository: mrveiss/AutoBot-AI

Length of output: 7230


Assert the cutoff predicate, not only its identifiers.

The current assertions can pass if the COALESCE age predicate is removed. The timestamp names also remain in the selected columns, and the sweep-outcome tests do not inspect the submitted statement.

Assert that the compiled WHERE clause compares COALESCE(started_at, created_at) with the cutoff.

Suggested test assertion
     sql = _compiled(sweep).lower()
-    assert "coalesce" in sql
-    assert "started_at" in sql and "created_at" in sql
+    assert re.search(
+        r"coalesce\([^)]*started_at[^)]*,[^)]*created_at[^)]*\)\s*<=\s*",
+        sql,
+    )
🧰 Tools
🪛 Ruff (0.16.6)

[warning] 128-128: Assertion should be broken down into multiple parts

Break down assertion into multiple parts

(PT018)

# #16817: that reasoning was right and the implementation did not follow it --
# the comparison ran in Python over EVERY non-terminal row, so each sweep scanned
# the whole live population to act on a few. COALESCE puts the same rule in SQL.
age_anchor = func.coalesce(LLCHeartbeatRun.started_at, LLCHeartbeatRun.created_at)

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.

🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

#!/bin/bash
# Inspect repository-owned index definitions without running repository code.
rg -n -i -C 3 'llc_heartbeat_runs|coalesce\s*\(' autobot-backend/llc -g '*.py' -g '*.sql'

Repository: mrveiss/AutoBot-AI

Length of output: 14547


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- heartbeat model ---'
sed -n '1,220p' autobot-backend/llc/models/heartbeat_run.py
printf '%s\n' '--- candidate migration/index files ---'
git ls-files | rg '(^|/)(alembic|migrations|migration|.*heartbeat.*|.*schema.*)' | head -200
printf '%s\n' '--- repository index definitions and heartbeat columns ---'
rg -n -i -C 4 'llc_heartbeat_runs|started_at|created_at|index\s*\(|Index\s*\(|coalesce\s*\(' --glob '*.py' --glob '*.sql' --glob '*.yaml' --glob '*.yml' --glob '*.json' . | head -500

Repository: mrveiss/AutoBot-AI

Length of output: 40758


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- initial heartbeat migration ---'
sed -n '1,260p' autobot-backend/migrations/versions/20260523_033_llc_heartbeat_runs.py
printf '%s\n' '--- all migration references to the heartbeat table or timestamp indexes ---'
rg -n -i -C 3 'llc_heartbeat_runs|ix_.*(started|created)|create_index|coalesce' autobot-backend/migrations autobot-backend/database --glob '*.py' --glob '*.sql'

Repository: mrveiss/AutoBot-AI

Length of output: 41429


Add an index for the age predicate.

LLCHeartbeatRun filters on COALESCE(started_at, created_at). The existing column indexes do not index this expression. PostgreSQL can therefore evaluate the age condition across the rows that pass the status filter instead of using a timestamp range scan.

Add a matching expression index in a migration, or rewrite the predicate as separate started_at and created_at branches so PostgreSQL can use the column indexes.

@mrveiss
mrveiss merged commit 80b6200 into main Sep 28, 2026
80 of 85 checks passed
@mrveiss
mrveiss deleted the issue-16817-stalled-run branch September 28, 2026 14:00
mrveiss added a commit that referenced this pull request Sep 28, 2026
…v0.10.0 (#17717) (#17718)

* ci(release): baseline five squash-stamped identity trailers blocking v0.10.0 (#17717)

#17717 promotes main -> release and fails "No commit trailers" on commits
already landed. Per-PR scans passed -- including #17696's own -- because the
gate compares BASE..HEAD and the trailer does not exist until GitHub's
squash-merge creates it. Only a promotion re-scans the landed range, so the
check runs on an object no PR ever showed it.

DERIVED AND VERIFIED, NOT TAKEN FROM A LIST. The run log reports ten
"Offending commit:" lines and nine unique SHAs. The tenth is the workflow's own
printf template -- `Offending commit: %s\n' "${bad[@]}"` -- echoed into the log.
Nine real commits, and a count taken from the line count would have been the
measurement's artefact rather than the population.

The nine are TWO classes, established by reading each commit's trailers rather
than assuming one cause:

  five   Co-authored-by: t <t@example.invalid>
  four   Co-authored-by: Claude <noreply@anthropic.com>

ONLY THE FIVE ARE BASELINED. They are the same cause as the existing
`test <test@test>` entries, a second time: a local user.name/user.email
override shadowed the correct global identity, branch commits carried it, and
the squash stamped it onto the result. `t <t@example.invalid>` is not a person
and claims no attribution.

The source is CLOSED -- no worktree config and no global config resolves
user.name to `t`, checked rather than assumed. That check is what makes the
class bounded at five, and the header says so, because the previous instance's
header claimed the override was "since corrected" and a different one appeared
anyway. The claim was true and insufficient; the check is the thing.

THE FOUR ARE HELD, AND THE HEADER SAYS WHY. `Co-authored-by: Claude` is a real
attribution claim -- exactly what no-commit-trailers.yml (#13654) exists to
reject -- not a misconfigured identity. The reasoning that justifies the five
does not reach it, so folding it in would exempt the thing the rule is for on a
ruling about something else. A reader of this file must not conclude that a
`Claude` co-author is baselinable.

Its source is also not closed, which is why it is a separate question rather
than a longer list: the agent harness instructs sessions to append that
trailer while this project's rules forbid it and this gate enforces them. Until
that is resolved at the source, new instances keep landing and a baseline is
treating the symptom. Filed separately.

THE TOP RULE IS NARROWED, NOT SUSPENDED. It said entries may only be removed.
It now names the one class an addition is permitted for -- a trailer no branch
commit carried, created by the squash, found only at promotion, on a history
that cannot be rewritten -- and says explicitly that a trailer which WAS on a
branch commit does not qualify, because that one was fixable and the fix is to
fix it.

Verified the gate's own parse: of the nine, exactly the five appear in
`sed 's/#.*//' | tr -d '[:blank:]'`, and the four named in the header comment
do not.

The workflow is untouched.

Refs #17717
Refs #13654

* ci(release): baseline the four Claude-trailer commits, and correct the class-1 cause (#17717)

Two corrections to the first commit on this branch, both because the original
was wrong about a fact it asserted.

ALL NINE ARE NOW BASELINED, by explicit owner ruling. The first commit carried
five and held four; the ruling reached both this session and the coordinator's
and now agrees. The class-2 paragraph is unchanged in substance -- it still says
a `Co-authored-by: Claude` trailer is the thing the gate exists to reject, is
exempted only because `main` cannot be rewritten, and is NOT a precedent -- and
it now also says its source is open, which makes it worse than class 1 rather
than merely different.

CLASS 1 WAS NOT A MISCONFIGURED USER IDENTITY. The first commit said the source
was closed because no worktree config and no global config resolves `user.name`
to `t`. That measurement was correct and about the wrong thing.

`t <t@example.invalid>` is this repository's TEST-FIXTURE identity -- ten files
carry it, eight run `git commit`, two of those under the pre-push hook. The
reason it reaches the real repository is that an inherited `GIT_DIR` OUTRANKS
`-C` and `cwd=`; `hooks_path_override_15961_test.py:450` records learning that
the hard way. So no config override was ever there to find, and checking config
is how you fail to find it.

Three of the fixture files write the identity with `git config` rather than a
per-invocation `-c`. Worktrees share one config through the common dir, so a
single unscrubbed write sets it for every worktree at once -- which is the shape
of five commits across five branches in one afternoon.

WHAT I VERIFIED RATHER THAN RELAYED: every one of those sites currently scrubs.
All eight committing tests pass `scrubbed_git_env()`, and all three config
writers unset the ambient variables -- checked per file, not inferred from the
helper existing. So no current test leaks, and the header says that instead of
claiming the source is closed.

The open window is a NEW fixture that commits or writes config, run before its
scrub is added. That is what happened here: 969c195 is the commit that
introduced one such test. Source is #15353, open; detection gap #16124;
release-facing home #16268, which already carries 18 from an earlier promotion.

So this baseline is the third containment of one root cause -- 22, then 18, now
9 -- and the header says so, because whoever picks up #15353 should be able to
find the cost of not doing it in one place.

Gate parse verified against the file as committed: 31 entries, all nine present.

Refs #17717
Refs #13654
Refs #15353
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