Skip to content

fix(mcp): unify worker-count across workers_list/system_stats/workspace_chat (#2270)#2283

Merged
federicodeponte merged 5 commits into
mainfrom
fix/worker-count-registry
Jul 17, 2026
Merged

fix(mcp): unify worker-count across workers_list/system_stats/workspace_chat (#2270)#2283
federicodeponte merged 5 commits into
mainfrom
fix/worker-count-registry

Conversation

@federicodeponte

Copy link
Copy Markdown
Member

Fixes #2270.

Root cause

Three user-facing surfaces computed "the workspace's workers" from three different queries with three different filters:

Surface Old read path Filter applied
workers_list (MCP) → list_workers → GET /workers _list_visible_workers (apps/api/services/worker_access.py:650): role-scoped repos.workers.list + on-disk stock workers + owner-scoped FS fallback + grants, minus _worker_hidden_from_api minus manifest system_worker, minus archived (inline in apps/api/routers/worker_listing.py:204)
system_stats (MCP) → GET /stats bare repos.workers.list(user_id=auth.user_id)no role, owner-only (apps/api/routers/worker_telemetry.py:131) minus archived only — counted hidden internal/system workers, missed stock/shared/granted ones
Emily workers__list_all (workspace_chat) repos.workers.list_for_agent — DB rows only, broader visibility tiers (workspace/shared/public + stock-id padding) (apps/api/services/chat_worker_tools.py:17) its own hiding: _worker_hidden_from_api + manifest system + "unowned stock", footnoted as hidden_system_count

Concretely: the 4 workers Emily was missing (csv_enricher, github-digest, outbound-approval-demo, research_brief) are exactly PUBLIC_STOCK_WORKER_IDS members that the grid surfaces from the on-disk stock path, which list_for_agent (DB-only) never sees — while dogfood-test-url-grouper came in through list_for_agent's wider public/shared visibility clause. GET /stats missed both of those sources and failed to exclude internal engine workers, landing on a third number.

Reproduced live pre-fix via MCP: workers_list → 13 workers, system_stats.total_workers → 8.

Fix

One canonical function — services.worker_access.list_operator_workers(user_id, repos, role, include_system, include_archived) — with documented inclusion rules:

  1. Base set = _list_visible_workers (role-scoped DB workers + OSS on-disk stock + owner-scoped FS fallback + grants; engine-internal ids are never in the base set and never footnoted).
  2. Manifest system_worker: true excluded unless include_system.
  3. Archived excluded unless include_archived. Draft/stage labels are cosmetic — drafts count like any worker.

Returns (visible_workers, hidden_count). All three surfaces now route through it:

  • GET /workers (grid + MCP workers_list): unchanged behavior, now the canonical caller.
  • GET /stats (MCP system_stats): total_workers = canonical count with the caller's grid identity/role (_worker_access_user_id + _worker_repo_role); new WorkspaceStats.hidden_system_workers exposes the excluded system/archived count so "total vs user-visible" is explicit.
  • Emily workers__list_all: canonical set with the grid role (auth context in requests; users-table fallback for in-process calls, degrading to member — never admin — when the row is missing). hidden_system_count keeps footnoting the excluded set; include_system opts it back in; include_all_users is retained as a schema-compat no-op.

list_for_agent remains in use for Emily's run-resolution path (_list_viewable_workers / _worker_can_view), which is a can-run gate, not a user-facing count — by-id runnability of stock workers is unchanged (covered by tests).

Tests that asserted the old narrower-than-grid Emily scope were updated to grid parity (they encoded the split-brain this issue eliminates): admin default now equals the admin's grid view; out-of-scope cross-tenant seeds are simply out of scope instead of over-fetched-and-footnoted.

Test evidence

New apps/api/tests/test_2270_worker_count_consistency.py: full-app fixture mixing plain workers, a draft-stage worker, a manifest system_worker, an archived worker, and an internal engine worker id (workspace-agent); asserts GET /workers, GET /stats total_workers and Emily's tool agree on count, ids and hidden accounting, including under include_system.

Targeted subset covering all three changed surfaces + the new fixture (test_2270_worker_count_consistency, test_emily_worker_list_system_filter, test_emily_worker_split_brain_round09, test_emily_worker_legacy_scope, test_1139_emily_workers_admin, test_identity_ownership_and_emily_visibility, test_worker_tool_guards, test_monitoring_apis):

93 passed, 54 warnings in 153.44s

The full apps/api suite runs in CI on this PR.

Note on #2264

Confirmed distinct: #2264's "workers_list shape/limit passthrough" concerns response shape/param plumbing; this PR is the count-reconciliation across data sources.

🤖 Generated with Claude Code

Co-Authored-By: Claude Fable 5 [email protected]

federicodeponte and others added 4 commits July 17, 2026 16:29
… of truth for the operator worker set (#2270)

No behavior change: GET /workers (grid + MCP workers_list) now calls the new
canonical function, which is its previous _list_visible_workers +
system/archived filter moved behind one documented API that also returns the
hidden count.

Co-Authored-By: Claude Fable 5 <[email protected]>
…rker set (#2270)

system_stats (MCP) proxies GET /stats, which counted a bare owner-scoped
repos.workers.list(user_id) read: it included hidden internal/system workers
and missed on-disk stock, workspace-shared and granted workers, so
total_workers said 8 while workers_list said 13. Now routes through
list_operator_workers (same set as the grid) and exposes the excluded
system/archived count as WorkspaceStats.hidden_system_workers.

Co-Authored-By: Claude Fable 5 <[email protected]>
…r set (#2270)

Emily read repos.workers.list_for_agent (DB rows only, broader visibility
tiers, its own stock/system hiding): she MISSED the on-disk stock workers the
grid shows (csv_enricher, github-digest, outbound-approval-demo,
research_brief) while SURFACING public/shared rows the grid hides
(dogfood-test-url-grouper) — so she told the user a wrong worker count.

Now routes through list_operator_workers with the grid role (auth context in
requests, users-table fallback in-process; missing row degrades to member,
never admin). include_system reveals the footnoted system/archived set;
include_all_users is a schema-compat no-op. Tests that asserted the old
narrower-than-grid Emily scope are updated to grid parity; out-of-scope
cross-tenant seeds are no longer over-fetched-and-footnoted, they are simply
out of scope.

Co-Authored-By: Claude Fable 5 <[email protected]>
Full-app fixture mixing plain workers, a draft-stage worker, a manifest
system_worker, an archived worker, and an internal engine worker id; asserts
GET /workers, GET /stats total_workers and Emily workers__list_all agree on
count, ids and hidden accounting, including under include_system.

Co-Authored-By: Claude Fable 5 <[email protected]>
@federicodeponte

Copy link
Copy Markdown
Member Author

Independent adversarial codex review: CHANGES REQUESTED (CI is otherwise green; the one transient Windows failure — tests/test_composio_triggers.py::test_composio_events_with_valid_hmac_creates_run, AssertionError: assert 'restart_retry' == 'composio' — reran clean and codex independently confirmed it has no connection to this diff: the registry/listing changes here neither read nor transform trigger_source).

Findings from codex (gpt-5.6-sol), reviewing the diff against 4c0d5e8:

  • High: /stats builds the canonical worker set using _worker_access_user_id(auth), but calls stats_batch with the unmapped auth.user_id. In legacy/header-alias OSS flows, workers get counted under the mapped owner while their runs are queried under a different identity — producing zero or incomplete active_workers, total_runs_7d, and success-rate values. Use the same resolved identity for listing and aggregation; add a run-bearing alias regression test. See apps/api/routers/worker_telemetry.py:141-157.
  • Medium: Admin telemetry now counts every worker returned for role="admin", including other owners' private workers, while stats_batch stays owner-scoped. So total_workers describes the admin-wide set but every other metric describes only the caller-owned subset — a semantic mismatch. The new consistency test checks counts/IDs but seeds no runs, so it can't catch this. Either aggregate authorized workers consistently or explicitly document/restrict the metrics scope.

Holding this PR pending fixes for the High/Medium items above. Not merging per delegated review process (codex flags = comment, not merge).

Codex CHANGES-REQUESTED on PR #2283:

- HIGH: get_workspace_stats built the worker set with
  _worker_access_user_id(auth) but queried stats_batch and runs.list with
  the raw auth.user_id. Under legacy/header-alias local-deploy identity
  mapping the two ids diverge, so stats_batch (owner_id-scoped) returned
  zero/incomplete active_workers, total_runs_7d and success_rate even
  though total_workers was correct. Resolve the access user id once and
  use it everywhere in the function.

- MEDIUM: for role=admin, list_operator_workers returns an admin-wide
  worker set (other owners included) while stats_batch/runs.list stayed
  scoped to the caller-owned subset, so total_workers described a wider
  population than every other metric. Added an explicit unscoped mode
  (stats_batch(scope_to_owner=False), runs.list(worker_ids=...)) that
  trusts the already role-authorized worker_ids from list_operator_workers,
  so every workspace-stats field now describes the same population. All
  other callers keep the existing owner-scoped default.

Added apps/api/tests/test_2283_stats_identity_alias.py covering both: a
resolved-identity fixture and an admin fixture with another owner's
worker, asserting active_workers/total_runs_7d/success_rate/avg_duration
match the seeded population.

Co-Authored-By: Claude Fable 5 <[email protected]>
@federicodeponte
federicodeponte merged commit be8317d into main Jul 17, 2026
7 checks passed
@federicodeponte
federicodeponte deleted the fix/worker-count-registry branch July 17, 2026 21:16
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.

Worker count disagrees across workers_list (13), system_stats.total_workers (8), and workspace_chat/Emily ("8", missing real workers)

1 participant