Commit Graph

5 Commits

Author SHA1 Message Date
xarmian 088ba2f839 fix(mcp): raise MCP per-token burst + classify 429 as ErrRateLimited (BUG-1430) (#546)
* fix(mcp): raise MCP per-token burst + classify 429 as ErrRateLimited (BUG-1430)

BUG-1409 reported an agent hitting "Pad backend 500s on parallel writes"
during workspace onboarding via remote MCP on Pad Cloud. Triage split
that umbrella into three children; this PR addresses BUG-1430 (the
parallel-writes symptom).

Root cause investigation showed the underlying write path is fine —
local SQLite handled 24 parallel item-create POSTs cleanly (busy_timeout
+ BEGIN IMMEDIATE + WAL serialize writers without errors). The most
plausible cause of the agent's "500 on parallel writes" report is the
MCP per-token rate limiter (burst 20, 60/min) rejecting requests 21-24
of an onboarding burst with HTTP 429, which the dispatcher's classifier
then collapsed into a generic ErrServerError envelope.

Changes:

- middleware_ratelimit.go: MCPPerToken burst 20 → 60. Sustained rate
  unchanged at 60/min/token. Matches the general API limiter's burst-60
  per-user cap so the MCP path no longer imposes a tighter ceiling than
  the equivalent /api/v1 path. Comment expanded to record the rationale.

- internal/mcp/errors.go: add ErrRateLimited error code and an explicit
  case http.StatusTooManyRequests in classifyHTTPStatusKind. 429s now
  surface as a first-class rate-limited envelope with an actionable hint
  pointing at Retry-After and the per-token cap, instead of landing in
  the generic ErrServerError "other 4xx" bucket. Agents implementing
  exponential backoff can switch on code without parsing free-form text.

- handlers_cloud.go: add slog.Error instrumentation to enforcePlanLimit
  and enforceUserPlanLimit error paths. These are cloud-mode-only 500
  candidates we couldn't exercise locally (local dev runs cloudMode=false);
  the structured logs give operators a grep-able tag the next time the
  symptom surfaces on real Pad Cloud, so we can rule the path in or out
  empirically without another investigation pass.

- tests: bump iteration counts past the new burst (20 → 60), add 429
  case to classifyHTTPStatus code-mapping table + envelope hint-shape
  table.

Investigation context (full triage in BUG-1430):
- ../pad-cloud sidecar is NOT in the /api/v1 or /mcp request path
  (nginx-router proxies those directly to pad backend).
- featureCount + advisory-lock contention on Postgres remain plausible
  500 candidates under heavy bursts; the new logging is intended to
  catch those if they fire.

Siblings BUG-1431 (status field placement) and BUG-1432 (tags field)
are tracked separately and not addressed here.

* fix(mcp): drop hardcoded cap from rate-limit hint per Codex review (round 1)

Codex round 1 [P2] caught that rateLimitHintFor's "the per-token cap is
60 req/min with a burst of 60" text was misleading: classifyHTTPStatusKind
handles 429s from the dispatcher's SYNTHESIZED /api/v1/... requests, which
come from the general API limiter (600/min, burst 60), the Search limiter
(30/min, burst 10), and potentially others — NOT the MCP per-token
limiter (which fires before the dispatcher runs and so never lands in
this classifier path).

Generalize the hint: point at Retry-After (which carries the correct
limiter-specific wait) and drop the cap from prose. Update the matching
test assertion to assert the generic shape ("burst-heavy" instead of
"60 req/min").
2026-05-14 15:45:51 -04:00
xarmian 42f6ce96e1 fix(mcp): normalize error envelope shape + extend code taxonomy + actionable hints (TASK-1077/1078/1079) (#388)
Three independent improvements bundled as one PR because they all touch
the same dispatcher error-emission surface; landing them piecemeal
would churn the same lines repeatedly.

## TASK-1077 — uniform envelope shape

Pre-fix some dispatchers emitted plain-string errors via
`mcp.NewToolResultErrorf("%s: %s failed: %s", ...)`. Same underlying
404 surfaced in three different shapes across the surface (item
lookup → structured envelope; note/decide → "item note: prefetch:
404 ..."; bulk-update per-row → bare error string). Inconsistent
shape made it hard for agents to reason about errors uniformly.

Three new helpers in errors.go:

  - validationFailedResult(cmdKey, msg, fixHint) — replaces the
    "X is required" / "invalid Y" chain across every dispatcher.
  - dispatcherErrorResult(cmdKey, op, err) — replaces the internal
    "build request: %s" / "encode body: %s" / "parse current: %s"
    chain. Always emits ErrServerError with a programmer-readable
    Hint.
  - upstreamHTTPErrorResult(...) — wraps every in-handler prefetch /
    sub-call HTTP failure through classifyHTTPStatusKind so the shape
    matches the main pipeline's responses exactly.

Every NewToolResultErrorf call site in internal/mcp/dispatch_http*.go
+ catalog.go retrofitted. bulk-update's per-row `Error string` field
flipped to `Error *ErrorPayload` so every row failure carries the
same {code, message, hint} shape as a top-level failure.

## TASK-1078 — resource-kind-aware error codes

Pre-fix every 4xx 404 collapsed to ErrItemNotFound regardless of
what was being read; pad_workspace list returning 404 (route
missing) reported `code: "item_not_found"` despite the call having
nothing to do with items. Pre-fix every 5xx collapsed to
ErrServerError, indistinguishable from dispatcher internal failures.

Three new codes in errors.go:

  - ErrNotFound — resource-shaped 404s that AREN'T item lookups
    (collection, listing endpoint, link target, attachment).
  - ErrUpstreamError — 5xx with a structured body (transient backend
    failure). Distinct from ErrServerError (catch-all for dispatcher
    internal + un-mapped 4xx).
  - ErrBackendUnreachable — reserved for transport-level failures
    (DNS / connection refused / 5xx with no body); not yet emitted
    by classifyHTTPStatus but available for future transport-aware
    classification.
  - ErrWorkspaceRequired — reserved for the multi-workspace-token
    "ambiguous default" case (TASK-1076's deferred sister error;
    constant available even though dispatcher doesn't emit it yet).

New ResourceKind enum (item/workspace/collection/listing/link/
attachment/unknown) lets callers tell the classifier what they
were reading. classifyHTTPStatusKind is the new entry point;
classifyHTTPStatus preserved as a legacy adapter for callers that
haven't been retrofitted (pass ResourceUnknown → falls back to
pre-TASK-1078 behaviour).

Every retrofit call site passes its known kind + ref/slug, so 404s
now route through the right code with a contextual message
("Item TASK-7 not found.", "Workspace foo not visible.",
"Collection tasks not found.", etc.).

## TASK-1079 — actionable hints

Pre-fix `hint` was usually `"404 page not found"` (chi's default
NotFound body verbatim) or the upstream JSON envelope re-stringified.
Either way: zero diagnostic value, sometimes outright misleading
(double-stringified JSON in a hint field is hostile).

Per-code hint generators in errors.go:

  - itemMissingHint — names the ref + route + suggests pad_item
    search / list as recovery.
  - workspaceMissingHint — names the slug + route + composes with
    the existing available_workspaces enrichment.
  - notFoundHintFor — kind-aware: collection 404 → "use pad_collection
    list to enumerate"; listing 404 → "verify the route matches the
    server's API surface (build version may be stale)"; etc.
  - authHintFor / permissionHintFor — point at re-auth / scope check.
  - upstreamHintFor — flags 5xx as "usually transient — retry once or
    check pad logs."

extractUpstreamMessage parses pad's own structured `{error:{message}}`
envelope when the upstream backend returned one, so hints lift the
inner human-readable message out instead of dumping the literal JSON.
Falls back to the raw body when the JSON shape doesn't match (no
parse failure noise).

## Tests

  - TestDispatcher_AllErrorsUseStructuredEnvelope walks every
    special-case + link dispatcher's missing-required-input error
    path; pins the shape (code, message, hint all set; hint never
    just "404 page not found"). Adding a new dispatcher that uses
    NewToolResultErrorf will fail this test — it's the regression
    gate the DOD wants.
  - TestClassifyHTTPStatus_KindAware pins each ResourceKind →
    expected ErrorCode mapping for 404s.
  - TestClassifyHTTPStatus_HintsAreActionable pins that hints
    reference the actual route + ref + recovery tools, AND forbids
    the bare "404 page not found" passthrough that triggered Bug 17.
  - TestExtractUpstreamMessage covers the 7 input shapes the helper
    can see (structured envelope, empty inner, missing inner field,
    unparseable, wrong shape, empty, with extra fields).
  - Two existing tests updated to reflect the new shapes:
    TestClassifyHTTPStatus 5xx cases now expect ErrUpstreamError;
    TestMakeFanOutHandler_UnknownAction + TestActionEnv_Dispatch_
    UnknownCmdPath substring searches updated for JSON-encoded
    quotes.

## Behavior diff agents will observe

Same underlying 404, three example error envelopes:

  pad_item show TASK-MISSING:
    code: "item_not_found"
    message: "Item not found."
    hint: "Item \"TASK-MISSING\" not found. Route: /api/v1/.../items/TASK-MISSING. Try `pad_item search` or `pad_item list` to find the right ref."

  pad_workspace list (route 404):
    code: "unknown_workspace"
    message: "Workspace not visible to this session."
    hint: "Route: /api/v1/workspaces. Available workspaces: docapp, pad-web."

  pad_project dashboard (workspace doesn't exist):
    code: "unknown_workspace"
    message: "Workspace \"missing\" is not visible to this session."
    hint: "Workspace \"missing\" not visible. Route: /api/v1/workspaces/missing/dashboard. Available workspaces: docapp."

  Backend 500:
    code: "upstream_error"
    message: "pad item show failed: backend returned 500"
    hint: "Backend returned 500. Usually transient — retry once or check pad logs for the underlying error. Route: ..."
2026-05-02 22:10:12 -04:00
xarmian 55d3a078a8 fix(mcp): standup CLI ref + classifier polish for BUG-987 round 2 (#362)
Round-2 hotfix on top of PR #361 (which shipped to v0.1.0-rc.4).
Claude Desktop's re-review of rc.4 surfaced two fixes that didn't
fully land:

- Bug 8 (round 1 went to wrong layer). My HTTPHandlerDispatcher fix
  populated ref on standup blockers, but Claude Desktop's path is
  ExecDispatcher → CLI subprocess → standupCmd, which has its own
  JSON composition struct. That struct's Attention + SuggestedNext
  anonymous types didn't even define ItemRef as a parseable field.
  Now both define `item_ref` and the JSON-emit loops set Ref from it.
  Verified live: blockers now carry refs (TASK-X), not empty strings.

- Bug 11 part 2. Round 1 stripped the cobra Usage block but two
  artifacts still leaked:
  1. The "pad <verb> failed: <stderr>" prefix on server_error fallback
     messages. The verb name is the OLD CLI verb (e.g. `pad item
     block`) which doesn't match the v0.2 catalog actions agents see,
     and the cmdPath is already implicit from the invoked tool. Drop
     the prefix; emit the cleaned stderr directly.
  2. Self-link / "cannot ..." validation rejections classified as
     server_error instead of validation_failed. Extended the
     validation regex with `cannot ` so server-side rejections like
     "cannot link an item to itself" / "cannot modify archived item"
     route to ErrValidationFailed. Verified live with a self-link
     attempt — now returns code=validation_failed, hint="cannot link
     an item to itself", no prefix.
- New stripErrorPrefix helper trims leading `Error:` / `error:` /
  `ERROR:` from every classified hint+message so the envelope text
  isn't redundant with the envelope's `code` signal.

Bug 13 / Bug 14: my round-1 fixes verified working locally on rc.4
(tested with a fresh Task → convention=None; dashboard by_role shows
"Unassigned"/"unassigned" for the bucket). The reviewer's stale
results almost certainly reflect a pad server process that wasn't
restarted with the rc.4 binary swap.

Tests:
- TestClassifyExecError_CannotPhrasingClassifiesAsValidation —
  three "cannot ..." stderr cases must classify validation_failed.
- TestClassifyExecError_NoLegacyVerbPrefixInMessage — pins the
  prefix-strip behaviour on the server_error fallback path.
- TestStripErrorPrefix — trim-rule round-trip across casing
  variations and empty input.

Parent: BUG-987.
2026-05-01 21:04:26 -04:00
xarmian 0f05012169 fix(mcp): six surgical fixes for BUG-987 (bugs 6, 8, 11, 12, 13, 14) (#361)
* fix(mcp): six surgical fixes for BUG-987 (bugs 6, 8, 11, 12, 13, 14)

Hotfix follow-up to v0.1.0-rc.3's Claude Desktop dogfood. Six
surgical fixes; bigger items (5, 7, 9, 10) deferred to separate
tasks.

- Bug 6: `pad project next --format json` was emitting the entire
  dashboard, indistinguishable from `pad project dashboard --format
  json`. Now slices to suggested_next only. cmd/pad/main.go.

- Bug 8: standup blockers carried empty `ref` strings, blocking
  agent linkback to the actually-blocked items. dashboard's
  attention[].item_ref is canonical; the standup composer in
  internal/mcp/dispatch_http_slice4.go just wasn't propagating it.
  Same fix applied to suggested_next entries.

- Bug 11: cobra's auto-emitted "Usage: pad item block ..." help
  block leaked into MCP error envelopes via classifyExecError. The
  Usage text references OLD CLI verb names (pre-v0.2 catalog) that
  agents using the new surface have no business seeing, and bloats
  every error response. New stripCobraUsageBlock helper truncates
  stderr at the first line-anchored "Usage:" marker before
  classification + envelope construction.

- Bug 12: BuildCLIArgs validation errors (missing required arg, type
  mismatch) came out of env.Dispatch as bare-text NewToolResultErrorf
  results, breaking the structured envelope contract. New helper
  validationFailedFromBuildErr wraps them as ErrValidationFailed
  envelopes with the field name extracted via regex from the
  underlying message.

- Bug 13: every Task / Idea / Plan with a `priority` field got a
  phantom `convention: { enforcement: "<priority>" }` surfaced on
  its response, because ExtractItemConventionMetadata's legacy
  fallback treated `priority` as the Convention enforcement tier
  unconditionally. Restructured to track hasConventionShape
  separately from hasMetadata — only Convention-specific markers
  (structured convention field, trigger, scope, surfaces, commands,
  direct enforcement) flip the shape flag. category alone is
  insufficient (Ideas / Bugs / Roadmap items legitimately use it).
  Final guard returns nil when only category was matched.

- Bug 14: GetRoleBreakdown's unassigned row was emitted with empty
  role_name + role_slug, presenting as a "phantom" entry in the
  dashboard. Now explicitly labelled "Unassigned" / "unassigned"
  while keeping role_id null so it's still distinguishable from a
  real role.

Tests:
- internal/mcp/bug987_test.go (new) — stripCobraUsageBlock + classify
  + validation envelope wrapping + env.Dispatch integration.
- internal/models/item_test.go — three cases covering non-Convention
  items (Task, Idea, Plan with priority) returning nil metadata, and
  one preservation test for legacy Conventions with priority field.
- internal/store/agent_roles_test.go (new) — confirms unassigned row
  carries explicit "Unassigned" / "unassigned" labels.

Live verified: pad_project action=next returns just the suggestions
array; pad_item action=create with no fields returns validation_failed
with field=collection; pad_item action=link with self-target returns
without Usage-block leakage.

Deferred to separate items (per BUG-987 triage):
- Bug 5: text vs JSON returns across note, decide, star, unstar,
  delete, bulk-update — needs CLI-side handler updates per command.
- Bug 7: suggested_next algorithm — needs to consider in-progress
  items, not just open ones; behavior change needs design.
- Bug 9: fields/tags double-stringified — potentially breaking for
  web UI/CLI consumers.
- Bug 10: decision_log/notes embedded in fields blob duplicating
  top-level arrays — might require data migration.

Parent: BUG-987.

* fix(mcp): HTTP transport equivalence + ordering for BUG-987 per Codex review (round 1)

Two findings from Codex review of PR #361:

1. project.next on HTTP transport still returned the full dashboard.
   The route table mapped "project next" directly to /dashboard, so
   the CLI fix (slice to suggested_next) didn't reach OAuth-authed
   agents going through HTTPHandlerDispatcher. Catalog actions must
   produce equivalent shapes on stdio and HTTP — that's the contract
   that lets agents be transport-agnostic.

   Fix: new dispatchProjectNext method on HTTPHandlerDispatcher that
   fetches the dashboard via the existing fetchDashboardJSON helper,
   slices to suggested_next[], re-encodes, and runs through
   packageJSONResult so it gets the same {items: [...]} wrap as
   other list responses.

   Also retires the broken route-table entry — replaced with a
   comment pointing at the new method so future contributors don't
   re-add a passthrough.

   Test: TestDispatch_ProjectNext_SlicesToSuggestedNext + the empty-
   array case. Asserts dashboard-only top-level fields (summary,
   active_items) don't leak into the response — that's the whole
   point of project.next being distinct from project.dashboard.

2. ExtractItemConventionMetadata's priority→enforcement legacy
   fallback ran BEFORE surfaces/scope/commands had a chance to flip
   hasConventionShape, so a Convention with only `{scope, priority}`
   would silently drop enforcement.

   Fix: move the priority fallback to AFTER all marker checks. Direct
   `enforcement` still resolves first; the legacy priority fallback
   runs at the bottom once shape detection is complete.

   Tests: two new cases covering scope-only and commands-only legacy
   Conventions — both must resolve enforcement via the priority
   fallback.

Parent: BUG-987.
2026-05-01 20:39:10 -04:00
xarmian 1e94fcbd9d feat(mcp): structured MCP error envelopes with closed taxonomy (TASK-973) (#357)
* feat(mcp): structured MCP error envelopes with closed taxonomy (TASK-973)

Replaces raw stderr / status-text passthrough with a closed-set
ErrorCode taxonomy + structured ErrorEnvelope. Agents can now branch
on `error.code` instead of parsing free-form text:

  {
    "error": {
      "code": "no_workspace",
      "message": "No workspace context. Pass `workspace` explicitly, ...",
      "hint": "Available workspaces: docapp, pad-web",
      "available_workspaces": [{"slug": "docapp", "default": true}, ...]
    }
  }

Taxonomy (8 codes):
- no_workspace, unknown_workspace — populate available_workspaces
- auth_required, permission_denied
- item_not_found, validation_failed, conflict
- server_error (catch-all)

Implementation:
- internal/mcp/errors.go (new): ErrorCode constants, ErrorEnvelope +
  ErrorPayload + WorkspaceHint types, NewErrorResult constructor,
  classifyExecError + classifyHTTPStatus dispatchers, regex pattern
  matchers for stderr classification, WorkspaceLister interface for
  hint enrichment.
- internal/mcp/dispatch.go: ExecDispatcher.Dispatch routes failures
  through classifyExecError (with itself as the WorkspaceLister).
  Adds ListWorkspaces method that shells out to `pad workspace list
  --format json`. Adds RootArgs field so the listing inherits root
  flags (--url etc.).
- internal/mcp/dispatch_http.go: packageHTTPResponse routes 4xx/5xx
  through classifyHTTPStatus. Lookup is intentionally nil here —
  TASK-977 (PLAN-943) owns the privacy-preserving available_workspaces
  filtering by OAuth allow-list.
- cmd/pad/mcp.go: pre-flatten rootFlags into RootArgs at
  dispatcher construction.

NewErrorResult emits BOTH structured content (for Claude Desktop,
Cursor) AND a JSON text body (for older clients). Both decode to the
same envelope so wire-level shape stays uniform.

Tests:
- TestNewErrorResult_Envelope: round-trip the envelope through
  structured + text surfaces.
- TestClassifyExecError: 11 cases covering every taxonomy code via
  stderr patterns.
- TestClassifyExecError_LookupFailureStillReturnsEnvelope: lookup
  failures degrade to empty available_workspaces, never drop the
  whole envelope.
- TestClassifyHTTPStatus: 10 cases covering each HTTP status mapping
  including the workspace-vs-item 404 fork.
- TestParseWorkspaceListJSON: happy path, empty / null / malformed,
  entry-without-slug skipping.
- TestExtractUnknownWorkspaceSlug: regex helper round-trip.

Out of scope (per task description):
- HTTPHandlerDispatcher available_workspaces filtering by OAuth
  allow-list → TASK-977 (PLAN-943).
- item_not_found "recent items" hint enrichment → also TASK-977.
- Per-code docs page on getpad.dev/mcp/local → TASK-976.

Parent: TASK-973 → PLAN-969.

* fix(cli): add JSON output to pad workspace list per Codex review (round 1)

Codex P2: classifyExecError's WorkspaceLister side channel calls
`pad workspace list --format json` to populate available_workspaces
in no_workspace / unknown_workspace error envelopes (TASK-973). The
CLI command silently ignored formatFlag and always printed the
human-readable shape, so parseWorkspaceListJSON would fail and the
hint was effectively never populated.

Add JSON branch to workspacesCmd that emits a {slug, name,
updated_at, default} array. The `default: true` flag marks the
CWD-linked workspace so agents can prefer it without a separate
DetectWorkspace call.

Manual verification:
  $ pad workspace list --format json | jq '.[0]'
  {
    "slug": "docapp",
    "name": "pad",
    "updated_at": "2026-04-14T13:24:51Z",
    "default": true
  }

Parent: TASK-973 → PLAN-969.

* fix(mcp): tighten unknown-workspace slug regex per Codex review (round 2)

Codex finding: extractUnknownWorkspaceSlug's bare-word regex captured
stop-words like "not" out of generic "Workspace not found" messages.
The server emits exactly this generic body in middleware_auth.go and
handlers_workspaces.go, so the resulting envelope would say
`Workspace "not" is not visible to this session.` — pushing agents
toward retrying with a bogus slug.

Tighten the regex to only match QUOTED slug forms ("workspace 'foo'"
or "workspace \"bar\""). Bare-word phrasings yield empty slug, and
unknownWorkspaceResult now emits a generic "Workspace not visible to
this session." instead of the misleading empty-string `Workspace ""`.

Test cases updated:
- "workspace 'foo' does not exist" → "foo" (still works)
- "workspace \"bar\" not found" → "bar" (still works)
- "unknown workspace baz" → "" (was "baz", now intentionally empty)
- "workspace docapp not visible" → "" (was "docapp", now empty)
- "Workspace not found" → "" (the actual server response)

The other taxonomy / hint behavior is unchanged: ErrUnknownWorkspace
still classifies correctly, available_workspaces still populates from
ListWorkspaces, and the body text still appears in Hint via the
classifyHTTPStatus 404 branch's body-append logic.

Parent: TASK-973 → PLAN-969.
2026-05-01 18:32:27 -04:00