Skip to content

Ask the listing's questions together - #540

Open
blooop wants to merge 3 commits into
mainfrom
claude/ls-concurrent-status
Open

Ask the listing's questions together#540
blooop wants to merge 3 commits into
mainfrom
claude/ls-concurrent-status

Conversation

@blooop

@blooop blooop commented Aug 30, 2026

Copy link
Copy Markdown
Owner

The --ls follow-up from the clean-room reconstruction (#539's "deliberately not fixed here"). Independent of that PR and based on main.

Updated for 802f292. An adversarial review found a real defect in the tests this PR added; the follow-up comment has the detail and this description reflects the fixed state.

What was wrong

dl --ls reads the workspace list once and then asks devpod status about every workspace in it, including the ones devlaunch did not make, because the STATE column is reported for every row. devpod list carries no state, so those round trips are genuinely required and this PR does not remove any of them.

What it did not have to do was wait for each answer before asking the next question. The trips are independent: nothing devpod says about one workspace changes what is asked about another. At the 0.45s a trip costs (docs/performance.md), a forty-workspace machine spent about eighteen seconds in dl --ls. They now go out in batches of eight, so the same forty cost five batches.

The count is unchanged, which is why the_listing_costs_one_list_and_one_status_per_workspace reads exactly as it did. Only the waiting overlaps.

Note this is a barrier, not a pool: a batch costs its slowest trip and the next does not start until the last returns. Five batches of 0.45s is the figure to expect when the trips are alike, and it is never worse than asking serially; a work queue starting the ninth trip the moment any of the first eight returned would be better on a machine where one workspace answers much more slowly. docs/performance.md says so rather than claiming the ideal.

The decision worth reviewing: Runner gains Sync

Handing one &dyn Runner to several threads means the seam has to promise it can be shared. This is the part to push back on if you are going to, because it binds every future implementation: a runner that wants a RefCell inside it is no longer writable.

The case for it is that the alternative is worse. A seam that can only be driven from one thread makes every concurrent flow above it impossible, and spelling the bound at each call site instead puts the requirement in the callers rather than in the contract, which permits an implementation that satisfies some callers and not others.

The cost, measured rather than estimated:

  • Production code: nothing. ProcessRunner is a unit struct. The workspace compiled with the supertrait added and no other change.
  • Test wrappers: three, all mechanical, all the change from RefCell to Mutex that a shared recorder needs anyway (agent_worktrees/tests.rs, workspace_clone.rs, and one boxed closure in repo_manager.rs).
  • Public API: one row. pub trait devlaunch_runner::Runner: core::marker::Sync in devlaunch-runner/public-api.txt. The promised api tier is untouched.
  • Out-of-tree implementations broken: none. Checked rather than assumed: wayfinder does not link devlaunch-core at all. Its Cargo.lock holds no devlaunch entry and its Cargo.toml no devlaunch dependency; the only mentions in its tree are issue references and fixture strings. The frozen api tier is a prepared surface wf has not yet consumed.

Two details not obvious from the diff

The stage is opened once around the whole batch, not once per trip. The registry admits one owner per stage, so per-trip staging would have had whichever thread opened devpod-up close it while its siblings were still running, and the spans they then recorded would have landed outside any stage. Opening it on the calling thread keeps the timing document reporting the shape it reported when the trips were serial.

An empty listing returns before opening a stage at all. The serial version never reached the function that opened one, so a machine with no workspaces had no devpod-up stage in its timing document. Opening one unconditionally would have added a reported step that did not happen.

A worker's panic is carried through with resume_unwind rather than replaced by a generic message, so a listing that panics still says why. The devpod-up stage seconds for dl --ls do shrink, since that stage is now the batch loop's wall time rather than the sum of the rows; the spans and their count are unchanged.

Testing

Three tests, each verified to fail against the defect it names rather than merely to pass:

  • the_status_trips_of_one_listing_overlap pins the overlap with a rendezvous rather than a sleep: every trip announces itself and waits for one more, so overlap returns immediately and a serial build fails on the high-water mark after the timeout. It expects a literal 2 rather than STATUS_TRIPS_AT_ONCE, because expressing the bar in terms of the constant under test is how an earlier version of it passed against a build deliberately serialised to one. Fails with high water 1 when the width is forced to 1.
  • a_batch_larger_than_the_width_still_answers_for_every_workspace_in_order covers the chunking. Two full chunks at the full width, so every trip in a chunk is released together and a collector reading completion order sees a shuffled chunk on essentially every run. The earlier shape (nine ids, a rendezvous of one) never overlapped anything and let a completion-order regression through about four runs in five. Verified: a build that reverses answers within a chunk fails it.
  • an_empty_listing_asks_nothing_and_opens_no_stage pins the early return.

All three hold timing::exclusive() through the fake, as every other fixture in the module does. Without it they wrote into whatever document a concurrent measured test had installed: beside launch's a_warm_launch_reports_the_devpod_probe_and_the_attach_and_nothing_else under --test-threads=8 that failed 12 runs in 15, and 0 in 25 with the guard.

cargo test --workspace: 1,560 pass. cargo clippy --all-targets -- -D warnings and cargo fmt --check clean. Python suite 659 pass. The only failure is the pre-existing a_write_that_cannot_start_leaves_the_previous_file_readable, which relies on chmod 0o500 blocking a write and so cannot pass as root; it fails identically on an unmodified tree in this container.

Not verified here: real devpod under eight concurrent status calls, there being no devpod in this container. CI's e2e passes with real devpod and real containers, which is partial evidence; if a provider can prompt on /dev/tty during a capture then eight children prompting one terminal at once is a path nothing has exercised. One manual DEVLAUNCH_TIMING=1 dl --ls against a remote provider would close it.

Not in this PR

The other two follow-ups from the same reconstruction are design questions rather than defects, and neither is a code change I would make without a decision first:

  • Identity has accreted a stored layer under a derived design (reconcile.rs joins by path and never by id, plus a resumable two-phase migration). Already in flight in recent commits.
  • The picker's identity is row text, which is what generates the collision-column machinery, while every_row_carries_its_own_index_or_marking_cannot_accumulate shows indices already exist.

Also still open: roughly thirty tests pin behaviour byte-for-byte against the retired Python build, an oracle nothing can run any more.

`dl --ls` reads the workspace list once and then asks `devpod status` about every
workspace in it, because the STATE column is reported for every row and `devpod
list` carries no state. That is one round trip per workspace and it cannot be
avoided. What it did not have to do was wait for each answer before asking the
next question: nothing devpod says about one workspace changes what is asked
about another.

At the 0.45s a trip costs (docs/performance.md), a forty workspace machine spent
about eighteen seconds in `dl --ls`. The trips now go out in batches of eight, so
the same forty cost about five rounds of one trip. The number of trips is
unchanged, which is why `the_listing_costs_one_list_and_one_status_per_workspace`
reads exactly as before; only the waiting overlaps.

**`Runner` gains `Sync`, which is the real decision here.** Handing one
`&dyn Runner` to several threads means the seam has to promise it can be shared.
It cost the production implementation nothing, `ProcessRunner` being a unit
struct, and cost three test wrappers the change from `RefCell` to `Mutex` that
any shared recorder needs anyway. The binding half is deliberate: no future
implementation may keep a `RefCell` inside it. A seam that can only be driven
from one thread makes every concurrent flow above it impossible, and putting the
bound at each call site instead would let an implementation exist that satisfies
some callers and not others. One row of `devlaunch-runner/public-api.txt` moves;
the promised `api` tier is untouched.

Two details that are not obvious from the diff:

The stage is opened once around the whole batch rather than once per trip. The
registry admits one owner per stage, so per-trip staging would have had whichever
thread opened `devpod-up` close it while its siblings were still running, and
their spans would have landed outside any stage. An empty listing returns before
opening one at all, because the serial version never reached the function that
opened it and an empty stage is a reported step that did not happen.

A worker's panic is carried rather than replaced, so a listing that panics still
says why.

`the_status_trips_of_one_listing_overlap` pins the property with a rendezvous
rather than a sleep: every trip announces itself and waits for one more, so
overlap returns at once and a serial build fails on the high-water mark. It
expects a literal 2 rather than the pool width, because expressing the bar in
terms of the constant under test is how an earlier version of it passed against a
build deliberately serialised to one.

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

Sorry @blooop, you've used your own review budget of 250,000 diff characters for the last 7 days.

You can request another review in 5 days and 4 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@sourcery-ai

sourcery-ai Bot commented Aug 30, 2026

Copy link
Copy Markdown

Reviewer's Guide

dl --ls now overlaps independent devpod status requests in bounded batches of eight, reducing wall-clock latency without changing request count, ordering, error/panic propagation, or timing-report behavior; the shared Runner contract and test fakes are updated for concurrent use, with focused concurrency and chunking tests.

Sequence diagram for concurrent workspace status listing

sequenceDiagram
    participant CLI as dl --ls
    participant Listing as enriched_listing
    participant Runner as Runner
    participant Devpod as devpod status

    CLI->>Listing: enriched_listing()
    Listing->>Listing: container_states()
    loop Batches of up to 8 workspaces
        par Independent status requests
            Listing->>Runner: capture(status workspace 1)
            Runner->>Devpod: status workspace 1
            Devpod-->>Runner: state 1
            Runner-->>Listing: answer 1
        and
            Listing->>Runner: capture(status workspace 2)
            Runner->>Devpod: status workspace 2
            Devpod-->>Runner: state 2
            Runner-->>Listing: answer 2
        end
        Listing->>Listing: Append answers in workspace order
    end
    Listing-->>CLI: Enriched listing
Loading

File-Level Changes

Change Details Files
Parallelize workspace status lookups while preserving bounded concurrency, result ordering, and timing semantics.
  • Collect one status result per workspace in batches of eight using scoped threads.
  • Open the devpod-up timing stage once around all non-empty batches and fail it if any status could not run.
  • Preserve empty-list behavior, panic causes, and row ordering while passing fetched state into row enrichment.
rust/devlaunch-core/src/flows/listing.rs
docs/performance.md
Make the runner abstraction safely shareable across concurrent status requests.
  • Add Sync as a supertrait bound to Runner and document the API tradeoff.
  • Update the generated public API snapshot.
rust/devlaunch-runner/src/lib.rs
rust/devlaunch-runner/public-api.txt
Adapt test doubles and shared test recorders to the new thread-safety contract.
  • Replace RefCell/Rc recorders with Mutex/Arc where shared access is possible.
  • Require fake callback effects to be Send + Sync.
rust/devlaunch-core/src/flows/agent_worktrees/tests.rs
rust/devlaunch-core/src/flows/repo_manager.rs
rust/devlaunch-core/src/flows/workspace_clone.rs
Add deterministic coverage for concurrency and bounded batch processing.
  • Use a condition-variable rendezvous to verify status calls overlap without sleep-based timing.
  • Verify a nine-workspace listing answers every workspace in original order across an eight-request batch boundary.
rust/devlaunch-core/src/flows/listing.rs

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

`rust-coverage` went red on the snapshot guard: it holds the Runner row by exact
string, and the row is now `pub trait devlaunch_runner::Runner:
core::marker::Sync`. My own fault for regenerating the snapshot after running the
workspace and not running it again, so the failure reached CI instead of this
machine.

Pinned as the whole row rather than loosened to a prefix match. The test's
subject is "the trait an implementer writes against", and a supertrait is part of
that in the same way a method is: `Sync` is what says a runner may be handed to
several threads at once, which is what lets the listing ask its status trips
together. Dropping it later would break every implementation that had come to
rely on being shareable, so it should cost a deliberate edit to this line, which
a prefix match would not have.
@codecov

codecov Bot commented Aug 30, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.15789% with 18 lines in your changes missing coverage. Please review.
✅ Project coverage is 95.11%. Comparing base (741b935) to head (802f292).

Files with missing lines Patch % Lines
rust/devlaunch-core/src/flows/listing.rs 92.06% 10 Missing ⚠️
rust/devlaunch-core/src/flows/workspace_clone.rs 63.63% 8 Missing ⚠️
Additional details and impacted files
Flag Coverage Δ
python 42.98% <ø> (ø)
rust 95.41% <88.15%> (-0.05%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Components Coverage Δ
shipped code (rust) 95.41% <88.15%> (-0.05%) ⬇️
harness and tooling (python) 42.98% <ø> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

A fresh-context review of this branch found the two new tests writing into the
process-global timing registry without holding the exclusion every other fixture
in this module takes. Measured, not argued: run either of them beside
`launch`'s `a_warm_launch_reports_the_devpod_probe_and_the_attach_and_nothing_else`
under `--test-threads=8` and it failed 12 runs in 15. Zero in 25 with the guard.

`container_states` opens `devpod-up` on the global registry and every worker
records a span into it, so a listing built without the guard writes into whatever
document a concurrent measured test installed, and its stage guard closes a stage
that test opened. `FakeDevpodRealGit` already takes `timing::exclusive()` for
exactly this reason and its doc says so; `Overlapping` is a second runner in the
same module and skipped it. It also runs both ways: the listing's stage can be
the one a launch test's `stage_result` finds `AlreadyOpen`, after which the launch
document reports `devpod-up` seconds it never spent. That would have surfaced as
an unreproducible flake on a narrowed filter, since the full suite hides it.

**The order guard was weak.** With a rendezvous of one, no trip ever overlapped
another, so the test exercised the chunk boundary but never out-of-order
completion, which is the only way a reordering implementation manifests. The
review measured a completion-order collector slipping past it four runs in five.
It is now two full chunks at the full width, so every trip in a chunk is released
together; a reordering build fails on essentially every run, which was checked.

Also from the review:

- Deleting `container_state` left its doc block orphaned onto `unsaved_work_in`,
  which rustdoc rendered as one comment opening with two paragraphs about status
  parsing. Removed, and the part that is still true (every unreadable answer
  collapses to `None`) moved onto the function that now does it.
- `docs/performance.md` said forty workspaces cost "about five rounds of a single
  trip". `chunks` is a barrier, not a pool: a batch costs its slowest trip and the
  next does not start until the last returns. Says so now, along with what a work
  queue would buy, and no longer asserts a contention figure nobody measured.
  Also records that the `devpod-up` stage seconds shrink, since the stage is now
  the batch loop's wall time rather than the sum of the rows.
- The empty-listing early return had no test. It has one.
- `zip` would drop rows rather than fail if `container_states` ever came back
  short. `debug_assert_eq!` states the invariant where it is relied on.
- `repo_manager`'s new bound said "`Send + Sync` because `Runner` is". `Runner`
  gained `Sync` only.
- CHANGELOG entry, which a seam contract change had no business omitting.

One open question the review could not settle, now settled: no out-of-tree
implementation of `Runner` is broken by the supertrait, because wayfinder does not
link `devlaunch-core` at all. Its Cargo.lock holds no devlaunch entry; the only
mentions in its tree are issue references and fixture strings.

blooop commented Aug 30, 2026

Copy link
Copy Markdown
Owner Author

A fresh-context adversarial review of this branch found a defect in the two tests I added, plus six smaller things. All are addressed in 802f292. Recording what it found, because the top one would have been unpleasant to diagnose later.

The tests were corrupting other tests' timing documents

container_states opens devpod-up on the process-global timing registry and every worker records a span into it. Overlapping did not hold timing::exclusive(), which FakeDevpodRealGit in the same module already takes for precisely this reason, with a doc comment saying so.

Measured rather than reasoned about. Running either new test beside launch's a_warm_launch_reports_the_devpod_probe_and_the_attach_and_nothing_else under --test-threads=8:

failures
before the fix 12 / 15
after the fix 0 / 25

The full suite hides it, so this would have surfaced as an unreproducible flake on a narrowed filter or a re-run. It also runs in both directions: the listing's stage can be the one a launch test's stage_result finds AlreadyOpen, after which the launch document reports devpod-up seconds it never spent.

The order guard was weak

a_batch_larger_than_the_width_still_answers_for_every_workspace_in_order used a rendezvous of one, so no trip ever overlapped another. It exercised the chunk boundary but never out-of-order completion, which is the only way a reordering implementation actually manifests. A completion-order collector was measured slipping past it in roughly four runs out of five.

It is now two full chunks at the full width, so every trip in a chunk is released together. Verified that a build which reverses answers within a chunk fails it, and that the honest version passes.

The rest

  • Deleting container_state left its doc block orphaned onto unsaved_work_in, which rustdoc rendered as a single comment opening with two paragraphs about status parsing. Removed; the part still true (every unreadable answer collapses to None) moved onto the function that now does it.
  • docs/performance.md claimed forty workspaces cost "about five rounds of a single trip". chunks is a barrier, not a pool: a batch costs its slowest trip and the next does not start until the last returns. The section now says that, notes what a work queue would buy instead, and drops a contention figure nobody had measured. It also records that devpod-up stage seconds shrink for dl --ls, since that stage is now the batch loop's wall time rather than the sum of the rows.
  • The empty-listing early return had no test. It has one.
  • zip would silently drop rows rather than fail if container_states ever came back short; debug_assert_eq! now states the invariant where it is relied on.
  • The new bound in repo_manager.rs said "Send + Sync because Runner is". Runner gained Sync only.
  • Added the CHANGELOG entry, which a seam contract change had no business omitting.

One open question, now settled

The review could not determine whether any out-of-tree Runner implementation is broken by the supertrait. None is: wayfinder does not link devlaunch-core at all. Its Cargo.lock holds no devlaunch entry and its Cargo.toml no devlaunch dependency; the only mentions anywhere in its tree are issue references in comments and fixture strings. The frozen api tier is a prepared surface that wf has not yet consumed, so this break has no current consumer.

Still not verified

Real devpod under eight concurrent status calls. There is no devpod in this container. CI's e2e job passed on the previous head with real devpod and real containers, which is partial evidence, but if a provider can prompt on /dev/tty during a capture then eight children prompting one terminal at once is a failure mode nothing here has exercised. One manual DEVLAUNCH_TIMING=1 dl --ls against a remote provider before merging would close it.

Validation on 802f292: 1,560 pass (cargo test --workspace), clippy and fmt clean, Python suite 659 pass. The only failure is the pre-existing a_write_that_cannot_start_leaves_the_previous_file_readable, which relies on chmod 0o500 blocking a write and so cannot pass as root; it fails identically on an unmodified tree here.


Generated by Claude Code

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