Skip to content

Attribute dependency waiting, the swarm's largest unexplained cost - #362

Merged
ahernsean merged 8 commits into
mainfrom
claude/attribute-dependency-wait
Sep 18, 2026
Merged

ahernsean merged 8 commits into
mainfrom
claude/attribute-dependency-wait

Conversation

@ahernsean

@ahernsean ahernsean commented Sep 17, 2026 •

Copy link
Copy Markdown
Owner

Summary

claim_telemetry.idle_millis is not measured — it is the remainder of a coordination window after the four measured phases, computed with max(0, …). So it totals waiting without saying what was waited on. It is also the swarm's largest single cost:

phase share of all worker time (epoch 21)
idle — dependency waiting 61.53%
scheduling (the scan) 3.40%
claim transaction 2.73%
busy wait (SQLite lock) 2.32%
claim commit 0.20%
evaluation 29.82%

(Measured against worker wall time. Summing coordination_millis + candidate_evaluation_millis instead accounts for 124% of the available worker-hours, because candidate_evaluation_millis counts nested help at every level it appears.)

What this adds

telemetry.dependency_wait, one row per cooperative_solve episode that reached the wait loop: the dependency's spine, size and budget; how the episode divided between claiming that branch, helping elsewhere, and being stuck; and how it ended.

Why each sleep happened

A worker in that loop tries the branch alone, then anywhere else, then a pair onto the branch, and only then sleeps. Four counters say which:

counter cause reached by
blocks_worker_cap branch at MAX_WORKERS_PER_BRANCH raising the cap
blocks_no_candidates nothing left to hand out nothing the cap can do
blocks_awaiting_finalize every candidate done, a rival finalizing nothing the cap can do
blocks_help_capped MAX_HELP_RECURSION_DEPTH forbade scanning raising that cap

idle_millis cannot tell these apart, and only the first is reached by the worker-cap change this measurement exists to decide.

Reported, not sampled

Every reason comes from the code that made the decision. blocks_worker_cap and blocks_no_candidates come from ERDQueue.last_claim_decline(), set inside the claim transaction at each of its return None sites. The other two are the branch condition the worker is standing in. Nothing on this path reads live state.

That is the design, not an optimization. Occupancy is counted against the claims the claim transaction is about to create, so only that transaction can say whether the cap refused a claim. An earlier revision of this PR sampled holders and unclaimed-slot counts from outside afterwards, and that reconstruction produced four separate defects — a sample taken after the sleep it described, a deleted branch read as fully claimable, liveness and a count taken in two statements, two counters taken in two calls. Each fix closed one instance of one mistake. Reporting the decision instead removes the class by construction, deletes a query, and makes the answer exact rather than inferred.

An episode answered before the loop opens writes nothing, so write volume tracks contention rather than throughput.

Deployment

The table is created by _TELEMETRY_SCHEMA_SQL with CREATE TABLE IF NOT EXISTS, which runs on every open, so the existing telemetry file gains it with no migration to sequence. Workers fork from the supervisor, so deploying still needs a swarm stop.

Tests

TestClaimDeclineReason pins each of the five decline reasons against the transaction that decides it; TestDependencyWaitAttribution pins the episode accounting. Every guard is verified to fail with its behaviour reverted, including:

mutation test that caught it
cap decline mislabelled no_candidates test_the_worker_cap_is_named_as_the_reason, test_a_pair_refused_by_the_cap_is_recorded_as_worker_cap
decline not cleared on a served claim test_a_served_claim_clears_the_previous_decline
recursion-cap block recorded as worker_cap test_the_recursion_cap_names_its_own_block
finalize takeover charged as blocked time test_a_finalize_takeover_is_not_charged_as_blocked_time
_record_dependency_wait made a no-op test_a_solved_dependency_names_the_branch_it_waited_on
should_record always True test_an_episode_that_never_iterates_writes_no_row

Three tests in this PR's history passed under mutation on the first attempt and were rewritten: the cache-hit test could not reach should_record, no test reached the recursion-capped branch, and the clear-on-success test ran on a fresh connection where the field was already None.

Follow-up, not included

No view report yet — the table is queryable directly, and a reporting surface belongs in its own change alongside whatever the first read of this data says is worth showing.

claim_telemetry.idle_millis is computed as the remainder of a
coordination window after the four measured phases, so it totals waiting
without naming what was waited on. It is also the largest single line
item in the swarm: 61.5% of all worker time on epoch 21, against 3.4%
for the scheduling scan and 2.3% for lock waits.

telemetry.dependency_wait records one row per cooperative_solve episode
that reached the wait loop: the dependency's spine, size and budget, how
the episode divided between claiming that branch, helping elsewhere and
being stuck, and how it ended.

holders_at_first_block and unclaimed_at_first_block are the columns the
worker-cap question turns on. A worker in that loop tries the branch
alone, then anywhere else, then a pair onto the branch, and only then
sleeps; holders at MAX_WORKERS_PER_BRANCH means the cap refused the
pair, while zero unclaimed candidates means the branch had nothing left
to hand out and the wait is for its finalize. Those are different
problems with different fixes and idle_millis cannot separate them.

They are sampled once, at the first blocked iteration: that path already
runs every 50 ms on a starving worker, and re-reading them per poll
would put two queries on the branch everyone is waiting for.
branch_claim_holders is the single-branch form of
claim_holders_by_branch, so the sample costs one indexed query rather
than a map of every branch.

The table is created by _TELEMETRY_SCHEMA_SQL with CREATE TABLE IF NOT
EXISTS, which runs on every open, so an existing telemetry file gains it
without a migration step to sequence. Workers fork from the supervisor,
so deploying still needs a swarm stop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rawcKNbdKoVpUW1Avr4sS

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 98413fefed

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread erd_swarm.py Outdated
Comment thread erd_swarm.py Outdated
Two review findings on the first-block sample, both of which defeated the
distinction the columns exist to draw.

unclaimed_at_first_block used n_candidates - done, which counts a
candidate held in an unfinished claim as available. That is the ordinary
finalize-wait shape — one rival holding every remaining slot — so the
pair attempt would find no bundle while telemetry reported unclaimed
work, reading as a cap refusal on a branch with nothing left to hand
out. branch_unclaimed_candidates counts slots no claim row covers, so a
freed position still counts as claimable and an in-flight one does not.

The recursion-capped path sampled after its 50 ms poll rather than
before it. A holder that finished mid-sleep read as zero holders, so
both columns described a moment that blocked nothing. It now samples
first, as the uncapped path already did.

test_candidates_held_in_flight_do_not_read_as_unclaimed and
test_the_capped_path_snapshots_before_it_sleeps each fail with their fix
reverted. The second drives the capped path directly, with a patched
_idle_wait that finishes the rival mid-sleep, because no existing test
reaches that branch and the ordering is invisible without it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rawcKNbdKoVpUW1Avr4sS
@ahernsean

Copy link
Copy Markdown
Owner Author

@codex review

Both P2s are fixed in 14b30b4:

  • unclaimed_at_first_block now counts slots no claim row covers (branch_unclaimed_candidates), not n_candidates - done, so an in-flight claim no longer reads as available work.
  • The recursion-capped path samples before its 50 ms poll, matching the uncapped path.

Each has a test verified to fail with the fix reverted, including one that drives the capped branch directly — it had no coverage before, so the ordering change passed the suite untested until that test existed.

* origin/main:
  Require 25 effective samples before a cost-model cell reads warm

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 14b30b459a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread erd_swarm.py Outdated
Comment thread erd_queue.py Outdated
Two more review findings, both on cases the columns claim to name.

_await_rival_finalize ends in the same 50 ms poll but was called
outside the accounting, so an episode that lost the finalize race put
that sleep in episode_millis while blocked_millis read zero and both
first-block counters stayed NULL. That is the wait-for-finalize case
these columns exist to identify, and it is the common one. It now
samples before the call and returns whether it polled, so the takeover
path — which reopens a dead finalizer's row and completes the finalize —
is counted as the work it is rather than as waiting.

branch_unclaimed_candidates trusted a registry id as evidence the branch
exists. delete_branch deliberately keeps the append-only branches row so
branch_id stays stable across a re-promotion, while dropping every
candidate_claims row, so a finished branch counted zero claims and
reported all n_candidates as claimable: completed work described as
untouched, and a cap refusal where nothing remains to claim. It now
requires a live active_branches row.

Each fix has a test verified to fail with it reverted, including one
that pins the takeover path as unblocked so the poll and the takeover
cannot be conflated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rawcKNbdKoVpUW1Avr4sS
@ahernsean

Copy link
Copy Markdown
Owner Author

@codex review

Both P2s from the second pass are fixed in 4f51b53:

  • Rival-finalize polling is now inside the accounting. _await_rival_finalize returns whether it polled, so the sleep is charged to blocked_millis and the first-block snapshot is taken before the call — while the takeover path (reopening a dead finalizer's row and completing the finalize) is counted as work, not waiting. This was the worse of the two: unclaimed == 0 means waiting for finalize is the reading the PR documents, and that path skipped the instrumentation entirely.
  • A deleted branch now reads as finished. branch_unclaimed_candidates requires a live active_branches row; a registry id survives delete_branch by design, so the id alone was not evidence of existence and a finished branch reported all n_candidates as claimable.

Three tests, each verified to fail with its fix reverted — including test_a_finalize_takeover_is_not_charged_as_blocked_time, so the poll and the takeover cannot be conflated by a later change.

Also merged main in (PR #361 landed), so this is now on top of the raised cost-model warmth threshold.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4f51b53bc1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread erd_queue.py Outdated
The liveness check added for the deleted-branch case was a separate
autocommit select from the claim count, so the two described different
database states. A rival finalizing between them leaves the branch
present and its claim rows already deleted, which reports a completed
branch as fully claimable — the same telemetry error, reached by timing
rather than by order.

Both now come from one statement, which sees one snapshot.

The race is not reproducible from a sequential fixture, so the guard is
structural: test_unclaimed_reads_liveness_and_claims_in_one_snapshot
traces the connection and requires a single SELECT naming both tables.
It fails when the query is split back in two.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rawcKNbdKoVpUW1Avr4sS
@ahernsean

Copy link
Copy Markdown
Owner Author

@codex review

Fixed in 988bd15. Liveness and the claim count now come from one statement, so they describe one snapshot:

SELECT EXISTS(SELECT 1 FROM active_branches WHERE branch_id = ?),
       (SELECT COUNT(*) FROM candidate_claims WHERE branch_id = ?)

Since a sequential fixture cannot exercise the race, the guard is structural — test_unclaimed_reads_liveness_and_claims_in_one_snapshot traces the connection and requires a single SELECT naming both tables, and fails when the query is split back in two.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 988bd150f0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread erd_swarm.py Outdated
Making liveness and the claim count atomic inside one method did not make
the two diagnostic columns atomic with each other. _record_first_block
still called branch_claim_holders and branch_unclaimed_candidates
separately, so a rival could finish, reclaim or finalize between them and
leave a holder count from one state beside an unclaimed count from
another.

That pair is read against itself — holders at MAX_WORKERS_PER_BRANCH
versus nothing left to claim — so a mismatched pair does not blur the
answer, it inverts it.

branch_block_snapshot replaces both methods and returns liveness, the
holder count and the unclaimed count from a single statement, which sees
a single snapshot.

The race is not reachable from a sequential fixture, so the guard stays
structural: the trace test now covers all three reads and fails when the
query is split.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rawcKNbdKoVpUW1Avr4sS
@ahernsean

Copy link
Copy Markdown
Owner Author

@codex review

Fixed in d62ba30. branch_block_snapshot replaces branch_claim_holders and branch_unclaimed_candidates, returning liveness, the holder count and the unclaimed count from a single statement:

SELECT EXISTS(SELECT 1 FROM active_branches WHERE branch_id = ?),
       (SELECT COUNT(DISTINCT claimed_by) FROM candidate_claims
          WHERE branch_id = ? AND done = 0
            AND (? IS NULL OR claimed_by IS NOT ?)),
       (SELECT COUNT(*) FROM candidate_claims WHERE branch_id = ?)

You were right that atomicity inside the second call was not the property that mattered — the pair is interpreted against itself, so a mismatched pair inverts the classification rather than blurring it.

The structural trace guard now covers all three reads and fails when the query is split. AGENTS.md updated to state the one-statement requirement rather than just the sample-once one.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Bravo.

Reviewed commit: d62ba308f6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Both first-block columns reconstructed, from outside and after the fact,
a decision claim_next_bundle already makes atomically inside its own
BEGIN IMMEDIATE. That reconstruction was the source of four separate
defects: a sample taken after the sleep it described, a deleted branch
read as fully claimable, liveness and a claim count taken in two
statements, and two counters taken in two calls. Each fix closed one
instance of the same mistake.

Occupancy is counted against the claims the claim transaction is about
to create, so only that transaction can say whether the cap refused a
claim. It now records which of its five return-None sites it took, and
last_claim_decline reports it.

dependency_wait's holders_at_first_block and unclaimed_at_first_block
are replaced by four counters — blocks_worker_cap, blocks_no_candidates,
blocks_awaiting_finalize, blocks_help_capped — one per cause, counted
over the whole episode rather than sampled once. Two come from the claim
transaction, two from the branch condition the worker is standing in.
Nothing on this path reads live state, so no counter can disagree with
the moment it describes, and the race class is gone by construction
rather than by vigilance.

branch_block_snapshot and _record_first_block are deleted along with
their query: the attribution is now free.

test_a_served_claim_clears_the_previous_decline asserts the clear after
a real decline; on a fresh connection the field is already None and
removing the clear proved nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rawcKNbdKoVpUW1Avr4sS
@ahernsean

Copy link
Copy Markdown
Owner Author

Redesigned: report the decision instead of sampling for it

Pushed as 738c196. The six review findings were two mistakes, and four of them were one mistake — reconstructing, from outside and after the fact, a decision claim_next_bundle already makes atomically inside its own BEGIN IMMEDIATE. Each fix closed one instance rather than the class, which is why it kept recurring.

claim_next_bundle now records which of its five return None sites it took, and last_claim_decline() reports it. dependency_wait's two sampled columns are replaced by four counters, one per cause, counted over the whole episode:

counter cause reached by
blocks_worker_cap branch at MAX_WORKERS_PER_BRANCH raising the cap
blocks_no_candidates nothing left to hand out nothing the cap can do
blocks_awaiting_finalize every candidate done, a rival finalizing nothing the cap can do
blocks_help_capped MAX_HELP_RECURSION_DEPTH forbade scanning raising that cap

branch_block_snapshot and _record_first_block are deleted along with their query — the attribution is now free, exact rather than inferred, and the race class is gone by construction rather than by vigilance.

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 738c196ef5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread erd_swarm.py
wait.blocks counted whatever reason came back, but only four of them had
a column. A claim transaction can also report branch_gone,
budget_mismatch or owner_mismatch when the dependency changes identity
under the waiter, and an exhausted retry loop reports nothing at all.
Those sleeps were counted and then dropped, leaving blocked_millis
holding time no counter accounted for — which is the defect idle_millis
has and this table exists to avoid repeating.

blocks_other carries them, so every sleep increments exactly one counter
and the five sum to the episode's sleep count. A counter that is a
partition can be audited; a counter that is a selection cannot.

The re-loop-without-sleeping alternative is deliberately not taken here:
it changes what a blocked worker does, which is a scheduling change and
wants its own justification rather than arriving inside instrumentation.

_assert_blocks checks every column on every test rather than the one
under test, because the first version of these tests passed with
other_blocks summing named reasons too — the only reasons present were
unnamed ones, so a double-count was indistinguishable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rawcKNbdKoVpUW1Avr4sS
@ahernsean
ahernsean merged commit 65232dc into main Sep 18, 2026
8 checks passed
@ahernsean
ahernsean deleted the claude/attribute-dependency-wait branch September 18, 2026 03:41
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