Skip to content

feat(workspace): surface peer-discovery failure reason instead of "may be isolated" - #2399

Merged
HongmingWang-Rabbit merged 1 commit into
stagingfrom
auto/peer-discovery-diagnostic-2397
Apr 30, 2026
Merged

feat(workspace): surface peer-discovery failure reason instead of "may be isolated"#2399
HongmingWang-Rabbit merged 1 commit into
stagingfrom
auto/peer-discovery-diagnostic-2397

Conversation

@HongmingWang-Rabbit

Copy link
Copy Markdown
Contributor

Summary

Closes #2397. Today, every empty-peer condition collapses to one ambiguous string: "No peers available (this workspace may be isolated)". The user has no way to tell five different conditions apart:

Condition What user/agent sees today What's actually true
Discovery 200 + empty "may be isolated" True — no parent, no children, no siblings
Discovery 401/403 "may be isolated" Workspace token bad — restart
Discovery 404 "may be isolated" Workspace ID not in registry — re-register
Discovery 5xx "may be isolated" Platform error — page on-call
Network error "may be isolated" PLATFORM_URL unreachable — check network

Five different operator actions, one ambiguous string. This is the runtime-side cousin of #2395 (deploy claimed success / nothing actually changed): success-shaped output that hides the actual failure.

How

  • New get_peers_with_diagnostic() -> tuple[list[dict], str | None] in workspace/a2a_client.py. Returns (peers, None) on 200; (peers=[], diagnostic="…") on every other branch with an actionable string.
  • Existing get_peers() shims through the new helper, discarding the diagnostic — preserves the bare-list contract for non-tool callers (e.g. get_peers_summary for system-prompt bootstrap).
  • tool_list_peers() in workspace/a2a_tools.py switches to the diagnostic helper and surfaces the actual reason. The misleading "may be isolated" string is gone; true empty now reads "no peers in the platform registry."

Tests

TestGetPeersWithDiagnostic (new) covers all branches:

  • 200 with peers
  • 200 with empty list
  • 401 / 403 (auth)
  • 404 (not registered)
  • 503 (platform error)
  • network exception
  • 200 with non-list body (regression guard)
  • bare-list shim still returns list[dict]

TestToolListPeers (rewritten) asserts each diagnostic branch surfaces the reason AND that "may be isolated" never appears anywhere.

go ./... clean, 122 a2a tests pass, coverage 91.53% (floor 86%).

Out of scope

Not changing Peers SQL in discovery.go — that's a separate design question (parent_id-scoped vs org-wide). This PR is strictly about surfacing the actual failure reason when discovery is reachable.

The duplicate list_peers in workspace/builtin_tools/a2a_tools.py is left alone — it has no active prod consumers (only set up as a stub in conftest.py), so updating it now would be churn for no benefit.

Test plan

  • Unit tests pass locally (pytest workspace/tests/)
  • Workspace runtime publishes via publish-runtime.yml on staging push
  • On staging, ask the agent mcp__a2a__list_peers from a workspace whose registry returns 404 — expect "Re-registration needed" instead of "may be isolated"

🤖 Generated with Claude Code

…y be isolated"

Closes #2397. Today, every empty-peer condition (true empty, 401/403, 404,
5xx, network) collapses to a single message: "No peers available (this
workspace may be isolated)". The user has no way to tell whether they need
to provision more workspaces (true isolation), restart the workspace
(auth), re-register (404), page on-call (5xx), or check network (timeout) —
five different operator actions, one ambiguous string.

Wire:
  - new helper get_peers_with_diagnostic() in a2a_client.py returns
    (peers, error_summary). error_summary is None on 200; a short
    actionable string on every other branch.
  - get_peers() now shims through it so non-tool callers (system-prompt
    formatters) keep the bare-list contract.
  - tool_list_peers() switches to the diagnostic helper and surfaces the
    actual reason. The "may be isolated" string is removed; true empty
    now reads "no peers in the platform registry."

Tests:
  - TestGetPeersWithDiagnostic: 200, 200-empty, 401, 403, 404, 5xx,
    network exception, 200-but-non-list-body, and the bare-list-shim
    regression guard.
  - TestToolListPeers: each diagnostic branch surfaces its reason +
    explicit assertion that "may be isolated" is gone.

Coverage 91.53% (floor 86%). 122 a2a tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@HongmingWang-Rabbit HongmingWang-Rabbit left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Five-axis pass. Substantive logic change → comment-only per loop policy.

Correctness. Right idea cleanly executed: every previously-collapsed empty-peer condition now returns a distinct, actionable diagnostic instead of all flowing into "may be isolated." Branch coverage is thorough — 200, 200-with-non-list-body, 401/403, 404, 5xx, network exception, and catch-all. The 200-with-non-list-body regression guard is a nice touch (body-schema drift is exactly the kind of silent-success failure the PR is fighting). The shim approach for get_peers() preserves the bare-list contract for get_peers_summary (system-prompt bootstrap) without ripple changes.

Readability. Docstrings on both functions explain the contract AND link the user-visible motivation (#2397). Branch ordering reads top-down (network → 200 → auth → not-registered → 5xx → unknown), so a maintainer can scan it without thinking. Each diagnostic string includes the operator's next action ("restart usually re-mints," "re-registration needed," etc.) — good operator UX.

Architecture. Right shape: rename existing helper to a _with_diagnostic variant + slim shim for legacy callers. This avoids the alternative of either (a) breaking the bare-list signature on the system-prompt path, or (b) bolting an Optional[diagnostic] parameter onto the legacy function. The "out of scope" notes are honest — discovery.go Peers SQL and the duplicate builtin_tools/a2a_tools.py:list_peers are correctly punted.

Security.

  • No credentials, no auth-bypass; auth_headers() continues to flow through platform_auth (the typed signature gate from #2387 guards the surface).
  • 404 diagnostic includes WORKSPACE_ID — that's tenant-scoped, not secret, already in logs; no leak.
  • No SQL string concat (this is HTTP-only).
  • No deleted tests — actually expands TestGetPeersWithDiagnostic + rewritten TestToolListPeers.
  • No .github/workflows/* changes.
  • No leaked credentials in diff (sk-, gh_, AKIA, sk-ant-, sk-cp- — none).

Performance. Same single HTTP call as before; only the post-response branch logic changed. The tuple[list[dict], str | None] return adds ~80 bytes of allocation per call vs the old list[dict] — well under any conceivable hot path budget for a registry/peers lookup.

LGTM. CI checks (Analyze Go/JS/Python) still in progress; once green this is straightforwardly mergeable.

HongmingWang-Rabbit pushed a commit that referenced this pull request Apr 30, 2026
Three findings from re-reviewing PR #2401 with fresh eyes:

1. Critical — port binding to 0.0.0.0
   compose.yml's cf-proxy bound 8080:8080 (default 0.0.0.0). The harness
   uses a hardcoded ADMIN_TOKEN so anyone on the local network or VPN
   could hit /workspaces with admin privileges. Switch to 127.0.0.1:8080
   so admin access is loopback-only — safe for E2E and prevents the
   known-token leak.

2. Required — dead code in cp-stub
   peersFailureMode + __stub/mode + __stub/peers were declared with
   atomic.Value setters but no handler ever READ from them. CP doesn't
   host /registry/peers (the tenant does), so the toggles couldn't
   drive responses. Removed the dead vars + handlers; kept
   redeployFleetCalls counter and __stub/state since those have a real
   consumer in the buildinfo replay.

3. Required — replay's auth-context dependency
   peer-discovery-404.sh's Python eval ran a2a_client.get_peers_with_
   diagnostic() against the live tenant. Without a workspace token
   file, auth_headers() yields empty headers — so the helper might
   exercise a 401 branch instead of the 404 branch the replay claims
   to test.

   Split the assertion into (a) WIRE — direct curl proves the platform
   returns 404 from /registry/<unregistered>/peers — and (b) PARSE —
   feed the helper a mocked 404 via httpx patches, no network/auth.
   Each branch tests exactly what it claims.

   Also added a graceful skip when the workspace runtime in the
   current checkout pre-dates #2399 (no get_peers_with_diagnostic
   yet) — replay falls back to wire-only verification with a clear
   message instead of an opaque AttributeError. After #2399 lands on
   staging, both branches will run.

cp-stub still builds clean. compose.yml validates. Replay's bash
syntax + Python eval both verified locally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@HongmingWang-Rabbit
HongmingWang-Rabbit added this pull request to the merge queue Apr 30, 2026
Merged via the queue into staging with commit c5aaca2 Apr 30, 2026
19 checks passed
@HongmingWang-Rabbit
HongmingWang-Rabbit deleted the auto/peer-discovery-diagnostic-2397 branch April 30, 2026 18:40

@HongmingWang-Rabbit HongmingWang-Rabbit left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Five-axis pass. Real fix for the success-shaped-failure-hides-the-actual-reason bug class — the runtime cousin of #2395.

Correctness. Walked all six branches in a2a_client.py against the new tests:

  • 200 + valid list → (peers, None) ✓ (test: test_200_returns_peers_and_no_diagnostic)
  • 200 + empty → ([], None) — correctly classified as success, not failure ✓
  • 200 + non-list → ([], "...not a list") ✓ (regression guard for body-shape drift)
  • 401/403 → auth diagnostic + restart hint ✓
  • 404 → registration hint ✓
  • 5xx → "Platform error" ✓
  • Network exception → unreachable diagnostic with PLATFORM_URL + underlying error ✓

The split between client.get() (network) and resp.json() (parse) try/except blocks is exactly right — distinct error sources get distinct diagnostics instead of one catch-all.

Branch coverage gap (Optional/Consider). Production code has two branches without explicit tests:

  1. 200 + invalid JSON — handled by except Exception as e: return [], f"...not JSON: {e}" but no test_200_with_invalid_json (the test_200_with_non_list_body uses a valid JSON dict, not a JSON decode error).
  2. The catch-all return [], f"Unexpected platform response: HTTP {resp.status_code}." — fires on 4xx other than 401/403/404 (e.g. 429, 451). No test pins this.

Both are belt-and-suspenders. The PR body claim "all branches" is ~9/11 by my count. Worth two more 4-line tests if you want clean 100%.

Readability. The get_peers_with_diagnostic docstring is exemplary — it explains the return shape, the relationship to the legacy get_peers() shim, AND cites the issue. Diagnostic strings are user-facing and contain the HTTP code + the actionable next step. The shim pattern (get_peers() discards the diagnostic) preserves the bare-list contract for the system-prompt formatters without breaking them.

Architecture. Clean layering: client returns (peers, diag), tool surfaces. Slight crossover — the client embeds operator advice ("Restart the workspace usually re-mints it") in the diagnostic string, which arguably belongs at the tool layer. Optional/FYI: future refactor could push human-readable strings to a2a_tools.py and have the client return structured error info (e.g. (peers, ErrorReason.AUTH_FAILED) enum), so the client stays format-agnostic. For Phase 1 the current shape is fine.

Security. Diagnostic strings include HTTP status codes but NOT raw response bodies — prevents accidental leakage from a misbehaving platform. Network exception message includes e (could include URLs/IPs) but the runtime is sandboxed and this is workspace-internal output. Low risk.

Performance. Same single HTTP call, same 10s timeout. No regression.

The tool_list_peers rewrite removes the misleading "may be isolated" string in every branch:

  • True empty → "You have no peers in the platform registry. (No parent, no children, no siblings registered.)"
  • Auth/404/5xx/network → "No peers found. {diagnostic}"

assert "may be isolated" not in result appears in three test cases — locks the regression closed.

Verdict: approve. Approving via secondary account so this can land.

HongmingWang-Rabbit pushed a commit that referenced this pull request Jun 12, 2026
Serialized merge by gitea-merge-queue after current-main, genuine approvals, and required CI checks were green.
HongmingWang-Rabbit pushed a commit that referenced this pull request Jun 12, 2026
… tier refs

Completes the SOP tier system removal started in #2407 by cleaning
remaining tier artifacts and salvaging the non-tier fixes from
#2396/#2397/#2399 branches.

Changes:

1. **qa-review.yml + security-review.yml** — salvage #2139 + #2159:
   - Add `labeled, unlabeled` to `pull_request_target` triggers so
     gates re-evaluate when labels change (#2139).
   - Remove unreliable `github.event.review.state` guard (#2159);
     evaluator (review-check.sh) already reads actual reviews from API.
   - Replace `SOP_TIER_CHECK_TOKEN` with `SOP_CHECKLIST_GATE_TOKEN`.

2. **Workflow token cleanup** — zero SOP_TIER_CHECK_TOKEN refs:
   - sop-checklist.yml, gate-check-v3.yml, audit-force-merge.yml,
     ci-required-drift.yml: replace or remove all SOP_TIER_CHECK_TOKEN
     references.

3. **Lint + runbook cleanup** — remove stale tier-check mentions:
   - lint-required-no-paths.yml + lint-required-no-paths.py: update
     example context from `sop-checklist / tier-check` to
     `sop-checklist / all-items-acked`.
   - gitea-operational-quirks.md: update token name references.

4. **Mutation test enhancement** (test_no_tier_regression.sh):
   - Fail if SOP_TIER_CHECK_TOKEN reappears anywhere.
   - Fail if qa-review/security-review lose labeled/unlabeled triggers.
   - Fail if review.state guard reappears.

5. **Unit test updates** (test_gate_review_auto_fire.py):
   - Assert absence of review.state guard instead of presence.
   - Assert SOP_CHECKLIST_GATE_TOKEN instead of SOP_TIER_CHECK_TOKEN.

All tests pass:
- test_gate_review_auto_fire.py: 11 passed
- test_gitea_merge_queue.py: 70 passed
- test_gate_check.py: 9 passed
- test_lint_required_no_paths.py: 21 passed
- test_sop_checklist.py: 101 passed
- test_no_tier_regression.sh: PASS

Fixes #2403
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.

list_peers + get_peers swallow non-200 + exceptions, leaving agent + user blind to root cause

1 participant