Skip to content

aitools: typed fetch, state and agent CLI timeout errors - #7475

Merged
anton-107 merged 1 commit into
mainfrom
aitools/typed-install-errors
Nov 12, 2026
Merged

anton-107 merged 1 commit into
mainfrom
aitools/typed-install-errors

Conversation

@anton-107

@anton-107 anton-107 commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #7474.

Stacked on: aitools: do not block plugin installs on the latest-release lookup

Summary

aitools install failures that used to be UNCATEGORIZED now get a specific error_category. The installer returns typed errors and classifyInstallError maps them:

classifyInstallError(err)
  BlockedError
    TimedOut                     -> PLUGIN_CLI_TIMEOUT   (agent CLI killed by its timeout)
  FetchError{Stage, StatusCode, RateLimited}   (release / manifest / skill file)
    context.Canceled             -> COMMAND_INTERRUPTED
    transport failure            -> NETWORK / TIMEOUT     (no response, or a timeout/reset while reading the body)
    RateLimited                  -> RATE_LIMITED          (429, or 403 with GitHub's rate-limit headers)
    clicompat.ErrNotFound        -> MANIFEST_NOT_FOUND
    Stage == release             -> RELEASE_LOOKUP_FAILED
    JSON syntax/type error       -> MANIFEST_INVALID
    otherwise                    -> SKILLS_FETCH_FAILED
  StateError{Path}               -> STATE_INVALID         (unparsable .state.json)
  • FetchError and StateError keep the wrapped error's message, so what the user sees does not change.
  • A fetch that fails on the transport (no response, or a timeout or reset while reading the body) keeps reporting NETWORK or TIMEOUT, as before.
  • A missing manifest wraps clicompat.ErrNotFound, so clicompat.IsNotFoundError stays the one way to check for a missing ref.
  • An unreadable state file is not a StateError: it keeps its filesystem category.
  • FetchManifest reads the whole body (capped at 1 MiB, like the release lookup; a larger body is reported as too large, MANIFEST_INVALID) before parsing it, so a connection closed cleanly mid-body (SKILLS_FETCH_FAILED; a reset reports NETWORK) is told apart from an empty or truncated manifest (MANIFEST_INVALID).
  • Changelog fragment: .nextchanges/cli/aitools-install-fetch-error-categories.md.

Evidence

Run at this head, with libs/aitools/installer/source.go and state.go temporarily set back to the parent's version (not committed):

  • Before:
    --- FAIL: TestClassifyInstallErrorCorruptStateIsStateInvalid   expected "STATE_INVALID", actual "UNCATEGORIZED"
    --- FAIL: TestClassifyRealManifestFetchErrors
        200_with_HTML / wrong_JSON_shape / empty_body / incomplete_JSON   expected "MANIFEST_INVALID",    actual "UNCATEGORIZED"
        connection_closed_mid-body / 503                                  expected "SKILLS_FETCH_FAILED", actual "UNCATEGORIZED"
    FAIL  github.com/databricks/cli/cmd/aitools
    
    After:
    ok  github.com/databricks/cli/cmd/aitools               (TestClassify*, funnel tests)
    ok  github.com/databricks/cli/libs/aitools/installer    (TestFetchLatestSkillsReleaseRef*, TestFetchErrorsCarryStage, TestLoadState*, TestRunAgentCmdReportsTimeout)
    ok  github.com/databricks/cli/acceptance                (TestAccept/cmd/aitools/telemetry/install-failure: broken-ref now SKILLS_FETCH_FAILED)
    

Merge Danger

Door: two-way

Reclassification of error_category (telemetry and --output json) plus typed wrappers; reverting restores the old categories.

Blast Radius: telemetry and the error_category field of aitools install --output json

  • Fetches that got an error response (non-OK status, unusable payload) move from UNCATEGORIZED to the categories above; fetches that fail on the transport (no response, or a timeout or reset mid-body) still report NETWORK or TIMEOUT.
  • The detail of a timed-out agent CLI now starts with timed out after <timeout>, followed by whatever stderr it wrote before it was killed.
  • FetchManifest now rejects trailing data after the manifest object (json.Unmarshal instead of a streaming decoder).
  • No change to exit codes, network calls or other error messages.
  • TestLoadStateUnreadableIsNotStateError skips on Windows and as root.

This pull request and its description were written by Isaac.

@anton-107
anton-107 requested review from a team and lennartkats-db as code owners November 11, 2026 14:26
@anton-107 anton-107 added the ai-auto PRs made by AI loops & factories, label Nov 11, 2026
@anton-107
anton-107 requested review from a team as code owners November 11, 2026 14:26
@anton-107
anton-107 force-pushed the aitools/plugin-ref-fallback branch from 491114e to 3081662 Compare November 11, 2026 14:33
@anton-107
anton-107 requested a review from a team as a code owner November 11, 2026 14:33
@anton-107
anton-107 force-pushed the aitools/typed-install-errors branch from 9132948 to 405e077 Compare November 11, 2026 14:33
@github-actions

github-actions Bot commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

Commit: 3f7223206 | Workflow run

Regression test report

Selected 1 changed tests (limit=20, 0 not selected)

1 tests: regression=1 unreleased=0 coverage=0 failing=0
test branch main (b48c9fb) latest (v1.26.0)
TestAccept/cmd/aitools/telemetry/install-failure ✅ ❌ ❌
TestAccept/cmd/aitools/telemetry/install-failure: branch ✅ | main (b48c9fb) ❌ | latest (v1.26.0) ❌

main (b48c9fb):

acceptance_test.go:1242: Substituted DATABRICKS_CLI_GITHUB_API_URL "$DATABRICKS_HOST" -> "http://127.0.0.1:33495" ("[DATABRICKS_HOST]")
    acceptance_test.go:1242: Substituted DATABRICKS_SKILLS_BASE_URL "$DATABRICKS_HOST" -> "http://127.0.0.1:33495" ("[DATABRICKS_HOST]")
    acceptance_test.go:1374: Diff:
        --- cmd/aitools/telemetry/install-failure/output.txt
        +++ /tmp/TestAcceptcmdaitoolstelemetryinstall-failure4068518162/002/output.txt
        @@ -47,8 +47,8 @@
         {"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"SCOPE_RESOLVED","interactive":false,"install_mode":"SKILLS_ONLY","scope":"GLOBAL"}}
         {"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"AGENTS_SELECTED","interactive":false,"install_mode":"SKILLS_ONLY","scope":"GLOBAL"}}
         {"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"PLAN_CONFIRMED","interactive":false,"install_mode":"SKILLS_ONLY","scope":"GLOBAL"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"FINISHED","outcome":"FAILURE","error_category":"SKILLS_FETCH_FAILED","interactive":false,"install_mode":"SKILLS_ONLY","agents":["CURSOR"],"scope":"GLOBAL"}}
        -{"command":"aitools_install","exit_code":1,"aitools_install_event":{"agents":["CURSOR"],"scope":"GLOBAL","error_category":"SKILLS_FETCH_FAILED"}}
        +{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"FINISHED","outcome":"FAILURE","error_category":"UNCATEGORIZED","interactive":false,"install_mode":"SKILLS_ONLY","agents":["CURSOR"],"scope":"GLOBAL"}}
        +{"command":"aitools_install","exit_code":1,"aitools_install_event":{"agents":["CURSOR"],"scope":"GLOBAL","error_category":"UNCATEGORIZED"}}
         
         === a named agent skipped for the scope takes its category from the agent's outcome
         
        
FAIL

latest (v1.26.0):

acceptance_test.go:1242: Substituted DATABRICKS_CLI_GITHUB_API_URL "$DATABRICKS_HOST" -> "http://127.0.0.1:42993" ("[DATABRICKS_HOST]")
    acceptance_test.go:1242: Substituted DATABRICKS_SKILLS_BASE_URL "$DATABRICKS_HOST" -> "http://127.0.0.1:42993" ("[DATABRICKS_HOST]")
    acceptance_test.go:1374: Diff:
        --- cmd/aitools/telemetry/install-failure/output.txt
        +++ /tmp/TestAcceptcmdaitoolstelemetryinstall-failure62321421/002/output.txt
        @@ -8,12 +8,6 @@
         Error: skill "nonexistent" not found
         
         >>> print_telemetry
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STARTED","interactive":false,"install_mode":"SKILLS_ONLY"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"FLAGS_VALIDATED","interactive":false,"install_mode":"SKILLS_ONLY"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"SCOPE_RESOLVED","interactive":false,"install_mode":"SKILLS_ONLY","scope":"GLOBAL"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"AGENTS_SELECTED","interactive":false,"install_mode":"SKILLS_ONLY","scope":"GLOBAL"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"PLAN_CONFIRMED","interactive":false,"install_mode":"SKILLS_ONLY","scope":"GLOBAL"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"FINISHED","outcome":"FAILURE","error_category":"SKILL_NOT_FOUND","interactive":false,"install_mode":"SKILLS_ONLY","agents":["CURSOR"],"scope":"GLOBAL"}}
         {"command":"aitools_install","exit_code":1,"aitools_install_event":{"agents":["CURSOR"],"scope":"GLOBAL","error_category":"SKILL_NOT_FOUND"}}
         
         === a manifest the server does not have is MANIFEST_NOT_FOUND
        @@ -25,12 +19,6 @@
         Error: skills manifest at databricks-agent-skills@missing-ref: not found
         
         >>> print_telemetry
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STARTED","interactive":false,"install_mode":"SKILLS_ONLY"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"FLAGS_VALIDATED","interactive":false,"install_mode":"SKILLS_ONLY"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"SCOPE_RESOLVED","interactive":false,"install_mode":"SKILLS_ONLY","scope":"GLOBAL"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"AGENTS_SELECTED","interactive":false,"install_mode":"SKILLS_ONLY","scope":"GLOBAL"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"PLAN_CONFIRMED","interactive":false,"install_mode":"SKILLS_ONLY","scope":"GLOBAL"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"FINISHED","outcome":"FAILURE","error_category":"MANIFEST_NOT_FOUND","interactive":false,"install_mode":"SKILLS_ONLY","agents":["CURSOR"],"scope":"GLOBAL"}}
         {"command":"aitools_install","exit_code":1,"aitools_install_event":{"agents":["CURSOR"],"scope":"GLOBAL","error_category":"MANIFEST_NOT_FOUND"}}
         
         === any other HTTP error status from the manifest fetch is SKILLS_FETCH_FAILED
        @@ -42,13 +30,7 @@
         Error: failed to fetch manifest: HTTP 500
         
         >>> print_telemetry
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STARTED","interactive":false,"install_mode":"SKILLS_ONLY"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"FLAGS_VALIDATED","interactive":false,"install_mode":"SKILLS_ONLY"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"SCOPE_RESOLVED","interactive":false,"install_mode":"SKILLS_ONLY","scope":"GLOBAL"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"AGENTS_SELECTED","interactive":false,"install_mode":"SKILLS_ONLY","scope":"GLOBAL"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"PLAN_CONFIRMED","interactive":false,"install_mode":"SKILLS_ONLY","scope":"GLOBAL"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"FINISHED","outcome":"FAILURE","error_category":"SKILLS_FETCH_FAILED","interactive":false,"install_mode":"SKILLS_ONLY","agents":["CURSOR"],"scope":"GLOBAL"}}
        -{"command":"aitools_install","exit_code":1,"aitools_install_event":{"agents":["CURSOR"],"scope":"GLOBAL","error_category":"SKILLS_FETCH_FAILED"}}
        +{"command":"aitools_install","exit_code":1,"aitools_install_event":{"agents":["CURSOR"],"scope":"GLOBAL","error_category":"UNCATEGORIZED"}}
         
         === a named agent skipped for the scope takes its category from the agent's outcome
         
        @@ -57,10 +39,4 @@
         Error: Cursor: does not support project-scoped skills
         
         >>> print_telemetry
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STARTED","interactive":false,"install_mode":"SKILLS_ONLY"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"FLAGS_VALIDATED","interactive":false,"install_mode":"SKILLS_ONLY"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"SCOPE_RESOLVED","interactive":false,"install_mode":"SKILLS_ONLY","scope":"PROJECT"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"AGENTS_SELECTED","interactive":false,"install_mode":"SKILLS_ONLY","scope":"PROJECT"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"STEP_COMPLETED","step":"PLAN_CONFIRMED","interactive":false,"install_mode":"SKILLS_ONLY","scope":"PROJECT"}}
        -{"command":"aitools_install","exit_code":1,"aitools_command_event":{"command":"INSTALL","stage":"FINISHED","outcome":"FAILURE","error_category":"UNSUPPORTED_SCOPE","interactive":false,"install_mode":"SKILLS_ONLY","agents":["CURSOR"],"scope":"PROJECT"}}
         {"command":"aitools_install","exit_code":1,"aitools_install_event":{"agents":["CURSOR"],"scope":"PROJECT","error_category":"TYPE_UNSPECIFIED","agent_results":[{"agent":"CURSOR","error_category":"UNSUPPORTED_SCOPE"}]}}
        
FAIL

@eng-dev-ecosystem-bot

eng-dev-ecosystem-bot commented Nov 11, 2026 •

Copy link
Copy Markdown
Collaborator

✅ Integration test report

Commit: 3f72232

Run: 43304903185

Env 🔄​flaky 💚​RECOVERED ✅​pass 🙈​skip Time
💚​ aws linux-2core-8gb 1 270 16 10:33
💚​ aws-windows-latest-4core-16gb 1 272 14 7:50
💚​ azure linux-2core-8gb 1 269 16 13:06
🔄​ azure-windows-latest-4core-16gb 1 1 270 14 11:55
🔄​ gcp linux-2core-8gb 1 1 269 16 12:51
💚​ gcp-windows-latest-4core-16gb 1 272 14 10:07
Test Name aws linux-2core-8gb aws-windows-latest-4core-16gb azure linux-2core-8gb azure-windows-latest-4core-16gb gcp linux-2core-8gb gcp-windows-latest-4core-16gb
💚​ TestAccept 💚​R 💚​R 💚​R 💚​R 💚​R 💚​R
🔄​ TestSyncFullFileSync ✅​p ✅​p ✅​p 🔄​f 🔄​f ✅​p
Top 21 slowest tests (at least 2 minutes):
duration env testname
4:40 gcp-windows-latest-4core-16gb TestFilerWorkspaceFilesExtensionsRead
4:12 azure-windows-latest-4core-16gb TestAccept
4:01 gcp-windows-latest-4core-16gb TestAccept
3:58 azure linux-2core-8gb TestAccept
3:53 gcp linux-2core-8gb TestAccept
3:52 azure-windows-latest-4core-16gb TestFilerWorkspaceFilesExtensionsStat
3:49 aws linux-2core-8gb TestAccept
3:41 gcp linux-2core-8gb TestFilerWorkspaceFilesExtensionsDelete
3:35 aws-windows-latest-4core-16gb TestAccept
3:28 azure linux-2core-8gb TestImportDirWithOverwriteFlag
3:19 azure-windows-latest-4core-16gb TestFilerWorkspaceFilesExtensionsReadDir
2:55 aws-windows-latest-4core-16gb TestFilerWorkspaceFilesExtensionsReadDir
2:51 azure-windows-latest-4core-16gb TestFilerWorkspaceNotebook/rNb.r
2:49 gcp-windows-latest-4core-16gb TestExportDir
2:48 azure-windows-latest-4core-16gb TestLock
2:45 aws linux-2core-8gb TestImportDirWithOverwriteFlag
2:32 gcp-windows-latest-4core-16gb TestFilerWorkspaceFilesExtensionsReadDir
2:20 gcp linux-2core-8gb TestExportDir
2:07 azure linux-2core-8gb TestFilerRecursiveDelete/workspace_files
2:04 gcp-windows-latest-4core-16gb TestFilerReadWrite/workspace_files
2:01 aws-windows-latest-4core-16gb TestFilerWorkspaceFilesExtensionsStat

@simonfaltum simonfaltum left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤖 Automated review from simonfaltum's review agent (pr-review skill) at 405e077. Each finding was verified against the code; simonfaltum may follow up.

Not approving yet: 1 open design question.

This PR adds typed installer errors and maps them to specific error_category values: SKILLS_FETCH_FAILED, RELEASE_LOOKUP_FAILED, RATE_LIMITED, MANIFEST_INVALID, STATE_INVALID and PLUGIN_CLI_TIMEOUT. It is well tested, lint-clean, and CI is green, and nothing found is blocking. The main points are these: transport failures (DNS errors, refused connections, timeouts) no longer report NETWORK/TIMEOUT in the released --output json contract, and the changelog doesn't mention it; and a timed-out agent CLI is still shown to the user as cli-not-on-path. The rest are small cleanups, plus one design question about reusing clicompat.HTTPStatusError.

Questions

  1. FetchError adds a typed HTTP-status error for the skills fetches, while clicompat.HTTPStatusError (libs/clicompat/clicompat.go:28) already models a non-200 response from raw.githubusercontent.com, and clicompat.IsNotFoundError understands it. Was keeping two separate types intentional? If not, would you rather extend the clicompat type (stage, rate limit, wrapped cause) and reuse it here, or keep FetchError and make IsNotFoundError recognize its 404s, so that later status checks don't have to handle both?

Comments outside the changed lines

  • [nit] cmd/aitools/categorize_test.go:151: These cases build fmt.Errorf("failed to fetch manifest: %w", <url.Error>) and expect NETWORK/TIMEOUT. FetchManifest now returns that error inside a FetchError (source.go:37), so the real error classifies as SKILLS_FETCH_FAILED. The test passes while describing behavior production no longer has. Wrap the cases in FetchError and update the expected values, or drop the "failed to fetch manifest" prefix so they test the generic stdlib path. If transport errors go back to NETWORK/TIMEOUT (see the categorize.go comment), wrapping them in FetchError{StatusCode: 0} makes them valid regression tests for that.
  • [nit] libs/aitools/installer/plugin.go:65: The doc comment says "Errors are *process.ProcessError", but on timeout runAgentCmd now returns *agentCmdTimeoutError, which wraps one. A caller that trusts the contract and type-asserts err.(*process.ProcessError) gets a failed assertion on every timeout. Update it to say a timeout returns an error wrapping the ProcessError, so callers should use errors.AsType.

Comment thread cmd/aitools/categorize.go
// The response arrived but was not a manifest: content, not transport.
case isManifestDecodeError(e):
return protos.AitoolsErrorCategoryManifestInvalid
default:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[nice to have] Every installer fetch now wraps its transport error in FetchError (source.go:37, installer.go:163, installer.go:255), and fetchErrorCategory has no transport case. A DNS failure, a refused connection or the 30s client timeout now all report SKILLS_FETCH_FAILED / RELEASE_LOOKUP_FAILED, the same as a GitHub 500 (funnel_test.go:334/374 show a refused connection moving from NETWORK to SKILLS_FETCH_FAILED). In both telemetry and the --output json error_category that shipped in v1.25.0, "the user can't reach GitHub" can no longer be told apart from "GitHub failed", and a script that retries on NETWORK/TIMEOUT silently stops matching. Merge Danger mentions NETWORK but not TIMEOUT, and lists the blast radius as telemetry-only. Either classify a FetchError with StatusCode == 0 through stdlibErrorCategory(e.Err) (after the Canceled check) to keep NETWORK/TIMEOUT, or say in the changelog fragment and Merge Danger that these failures no longer report NETWORK/TIMEOUT in --output json.

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.

Done in af32385: fetchErrorCategory now sends a FetchError with StatusCode == 0 (no response arrived) through stdlibErrorCategory, right after the Canceled check. So DNS failures and refused connections report NETWORK again, and client timeouts report TIMEOUT, the same as the released --output json contract. The specific categories now only apply to fetches that got a response. funnel_test.go is back to NETWORK for the refused connection. I added cases for a release lookup with no response (NETWORK), a manifest refused connection (NETWORK) and a skill file timeout (TIMEOUT). I also updated the changelog fragment and the Merge Danger section to match.

// newBlockedError builds the BlockedError for a failed agent CLI command.
func newBlockedError(agent *agents.Agent, reason string, err error) *BlockedError {
_, timedOut := errors.AsType[*agentCmdTimeoutError](err)
return &BlockedError{Agent: agent.Name, Reason: reason, Detail: stderrOf(err), TimedOut: timedOut}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[nice to have] newBlockedError keeps the ReasonCLINotOnPath passed in by probePluginCLI, and BlockedError.Error() prints agent: reason: detail. A slow claude plugin --help therefore shows Skipped Claude Code: claude-code: cli-not-on-path: timed out after 20s, both in the text output (install.go:589) and in the JSON message (install.go:597). Only the telemetry category is fixed; the test comment says this used to be reported as "not on PATH", but the message still says it. A dedicated reason (e.g. ReasonCLITimedOut), set in newBlockedError when the timeout fired, would fix the message and the category together and remove the need for the TimedOut precedence rule in blockedErrorCategory.

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.

Not changed here. The later recovery-steps PR (#7480) branches on BlockedError.TimedOut and builds the user-facing line for a timeout itself ('claude plugin' timed out after 20s plus a re-run step), so the cli-not-on-path wording doesn't reach the text or JSON output once the stack lands. A new ReasonCLITimedOut would change the Reason contract that PR and #7482 depend on. I'd rather do it as a follow-up across the stack than in the middle of it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Our review found this too. One note on the reply: #7480 fixes the text output, but the JSON output still uses the full error. cmd/aitools/install.go:847 (in #7482) sets message: err.Error(), so --output json still says claude-code: cli-not-on-path: timed out after 20s. A coding agent that reads the JSON then gets the wrong cause.

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.

Right: #7480 changes only the text output. The JSON message is err.Error(), and BlockedError.Error() prints agent: reason: detail, so for a timeout it still shows cli-not-on-path. This PR leaves the JSON message as it was. The machine-readable signal for a timeout is error_category: PLUGIN_CLI_TIMEOUT, which this PR sets. The wording in Error() comes from Reason, so it gets fixed by the dedicated timeout reason discussed above. That would be a separate follow-up PR after the stack lands, because #7480 and #7482 depend on the current Reason values.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Agreed, a follow-up after the stack is fine. I checked the base branch: a timeout already showed cli-not-on-path there, so this PR adds no regression. The JSON entry now also has error_category: PLUGIN_CLI_TIMEOUT, so an agent can find the correct cause. Please open an issue for ReasonCLITimedOut and link it here, so that the follow-up stays visible.

// when no HTTP response arrived (DNS, proxy, TLS, timeout).
type FetchError struct {
Stage string
StatusCode int

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[nit] In this PR nothing reads FetchError.StatusCode or StateError.Path, and Stage is only compared to FetchStageRelease. Yet the comments say stages exist "so the command layer can name the host the user has to reach" (lines 44-45) and that Path exists "so the user can be told which one to move aside" (line 93). Those uses arrive in #7480's recovery hints. That's fine if the stack lands together; if this PR could merge alone, reword the comments to describe what the fields do today, or move the fields into #7480.

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.

Reworded in af32385 so the comments describe what the fields do in this PR. Stages: "so a failed release lookup can be told apart from a failed download". StatusCode: it is now also read here (0 means no response, which maps to NETWORK/TIMEOUT). StateError.Path: "the state file that failed to parse".

}

func (e *agentCmdTimeoutError) Error() string {
return fmt.Sprintf("timed out after %s", e.timeout)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[nit] agentCmdTimeoutError.Error() returns only timed out after <timeout>, and stderrOf now uses it instead of the process's stderr. With several plugin update or uninstall steps (pluginUpdateSteps, disable then uninstall), the message doesn't say which step hung. Include the subcommand in the message (e.g. plugin marketplace update timed out after 2m0s) by storing the argv, minus the binary path, on the error.

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.

Not changed. #7480 renders a timeout as '<binary> plugin' <Detail>, so putting the subcommand into Detail would print it twice ('claude plugin' plugin marketplace update timed out ...). Naming the step that hung fits better there, where the user-facing line is built. I'll pick it up in that PR.

Comment thread cmd/aitools/categorize.go
}
}

func fetchErrorCategory(e *installer.FetchError) protos.AitoolsErrorCategory {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[nit] .agents/rules/comments.md asks for a doc comment on Go functions, and this switch depends on its order: cancel, then rate limit, then not-found, then release stage, then decode. A one-line doc saying the first matching case wins, and why the release stage comes before the decode check, would stop a later reorder from silently changing categories.

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.

Added in af32385: a doc comment on fetchErrorCategory saying that the first matching case wins and explaining the order (interrupted, then no response, then the release stage before the decode check).

return errors.New("signal: killed")
})

_, err := runAgentCmd(ctx, time.Millisecond, []string{"claude", "plugin", "--help"})

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[nit] err from runAgentCmd goes straight into newBlockedError. If a regression ever made runAgentCmd return nil here, stderrOf(nil) would panic on err.Error() instead of failing the test with a clear message. Add require.Error(t, err) before building the BlockedError.

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.

Added require.Error(t, err) before building the BlockedError in af32385.


func TestLoadStateCorruptReturnsStateError(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, ".state.json"), []byte("{not json"), 0o644))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[nit] TestLoadStateCorruptReturnsStateError and TestLoadStateUnreadableIsNotStateError (lines 1596, 1601, 1609) use the literal ".state.json", but they are in package installer, which defines stateFileName (state.go:29), and state_test.go already uses it. Use the constant so a rename doesn't leave these tests checking the wrong file. These tests would also fit better in state_test.go, next to TestLoadStateCorruptJSON.

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.

Done in af32385: both tests moved to state_test.go, next to TestLoadStateCorruptJSON, and they now use stateFileName.

@anton-107
anton-107 force-pushed the aitools/plugin-ref-fallback branch from 3081662 to 9075cd6 Compare November 11, 2026 16:53
@anton-107
anton-107 force-pushed the aitools/typed-install-errors branch from 405e077 to 1aa2b9f Compare November 11, 2026 16:53
@anton-107
anton-107 force-pushed the aitools/plugin-ref-fallback branch from 9075cd6 to 4b0c705 Compare November 11, 2026 16:54
@anton-107
anton-107 force-pushed the aitools/typed-install-errors branch from 1aa2b9f to bbd0a95 Compare November 11, 2026 16:54
@anton-107
anton-107 force-pushed the aitools/plugin-ref-fallback branch from 4b0c705 to 521c720 Compare November 11, 2026 16:57
@anton-107
anton-107 force-pushed the aitools/typed-install-errors branch from bbd0a95 to f18c292 Compare November 11, 2026 16:57
@anton-107
anton-107 force-pushed the aitools/plugin-ref-fallback branch from 521c720 to dd8dd43 Compare November 11, 2026 17:28
@anton-107
anton-107 force-pushed the aitools/typed-install-errors branch from f18c292 to af32385 Compare November 11, 2026 17:31
@anton-107

Copy link
Copy Markdown
Contributor Author

Thanks for the review. All in af32385:

Question 1 (FetchError vs clicompat.HTTPStatusError). I kept FetchError, and there is still one way to check a 404: clicompat.IsNotFoundError. A manifest that isn't at the ref already wraps clicompat.ErrNotFound inside the FetchError, and IsNotFoundError checks that sentinel first. That is how FetchSkillsManifestWithFallback recognizes it for the latest-ref fallback, and the "latest manifest missing" case in TestFetchSkillsManifestWithFallback covers it. The FetchError doc comment now says to check a 404 this way, not through StatusCode. I didn't reuse HTTPStatusError: it has no wrapped cause and its message is fixed to "HTTP %d fetching manifest" for the compat manifest, so reusing it would change clicompat's own messages. clicompat also can't name an installer type without an import cycle.

Outside-diff comments.

  • categorize_test.go:151: the generic-cause cases no longer use the "failed to fetch manifest" prefix, so they test the stdlib path for errors no FetchError wraps. The FetchError transport cases are new regression tests for NETWORK/TIMEOUT.
  • plugin.go:65: the doc comment now says that on timeout the error is an *agentCmdTimeoutError wrapping the ProcessError, so callers must use errors.AsType.

The inline threads have their own replies. I declined two, the timed-out reason and the step name in the timeout message, because the user-facing timeout line is built in #7480.

@anton-107
anton-107 force-pushed the aitools/plugin-ref-fallback branch from dd8dd43 to 270ba1d Compare November 11, 2026 19:28
@anton-107
anton-107 force-pushed the aitools/typed-install-errors branch from af32385 to 2408150 Compare November 11, 2026 19:28
@anton-107
anton-107 force-pushed the aitools/plugin-ref-fallback branch 2 times, most recently from da3637c to f435418 Compare November 11, 2026 19:59
@anton-107
anton-107 force-pushed the aitools/typed-install-errors branch from 2408150 to 5275455 Compare November 11, 2026 21:04

@simonfaltum simonfaltum left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤖 Automated review from simonfaltum's review agent (pr-review skill) at 5275455. Each finding was verified against the code; simonfaltum may follow up.

Not approving yet: 1 open design question.

This PR gives aitools install failures specific error categories. It adds typed FetchError and StateError errors and a TimedOut flag on BlockedError, and most points from the earlier round are now addressed. One nice-to-have remains: a timeout or connection reset while the response body is being read now reports SKILLS_FETCH_FAILED or RELEASE_LOOKUP_FAILED instead of TIMEOUT or NETWORK. There is also a small nit about reading the manifest without a size cap, plus one question about the long-term plan for TimedOut vs a dedicated timeout reason. CI is green and nothing is blocking.

Questions

  1. A timeout is now carried as a TimedOut flag next to Reason. A slow claude plugin --help therefore becomes Reason=cli-not-on-path, TimedOut=true: Error() still prints cli-not-on-path, and every consumer has to check the flag before switching on Reason (blockedErrorCategory here, the recovery step in #7480). You mentioned moving to a dedicated timeout reason in a follow-up across the stack, since #7480 and #7482 depend on the current Reason values. Is that follow-up tracked somewhere, such as an issue or a specific later PR in this stack? Or do you plan to keep the flag as the long-term contract? If you keep it, could the BlockedError doc say that TimedOut takes precedence over Reason?

Agreeing with existing threads

Comment thread cmd/aitools/categorize.go Outdated
// No response arrived (DNS, refused connection, proxy, TLS, client
// timeout): the user cannot reach the host, which NETWORK and TIMEOUT
// already report.
case e.StatusCode == 0:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[nice to have] A timeout or connection reset while the response body is being read now loses its TIMEOUT/NETWORK category. Those FetchErrors already carry StatusCode: 200, so they skip the StatusCode == 0 case. A manifest or skill file download that hits the 30s client timeout mid-body reports SKILLS_FETCH_FAILED, a release lookup that does reports RELEASE_LOOKUP_FAILED, and a reset mid-body reports SKILLS_FETCH_FAILED. At the base commit these were TIMEOUT and NETWORK in --output json.

I reproduced it with a test server that sends the headers and part of the manifest and then stalls: TIMEOUT before, SKILLS_FETCH_FAILED now. A mid-body RST gives NETWORK before, SKILLS_FETCH_FAILED now. The changelog and Merge Danger only mention fetches that got an error response.

Suggest routing any FetchError whose cause stdlibErrorCategory reads as NETWORK or TIMEOUT through that path, e.g. case e.StatusCode == 0 || isTransportFailure(e.Err): return stdlibErrorCategory(e.Err). A clean early close (io.ErrUnexpectedEOF) still reports SKILLS_FETCH_FAILED, so the existing TestClassifyRealManifestFetchErrors cases still pass. A stalled-body case would cover the new path.

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.

Fixed in 4982a91. fetchErrorCategory now also routes a FetchError whose cause stdlibErrorCategory reads as NETWORK or TIMEOUT through that path (isTransportFailure), after the StatusCode == 0 case. A stalled body or a reset mid-body now reports TIMEOUT or NETWORK again, for the manifest, a skill file and the release lookup. A clean early close (io.ErrUnexpectedEOF) still reports SKILLS_FETCH_FAILED, and the existing TestClassifyRealManifestFetchErrors cases are unchanged. The new TestClassifyManifestBodyTransportFailure covers a real stalled body against a test server (TIMEOUT), a reset mid-body (NETWORK) and a release-body timeout (TIMEOUT). It fails without the fix. I updated the changelog fragment and Merge Danger to say transport failures keep NETWORK/TIMEOUT.

Comment thread libs/aitools/installer/source.go Outdated
// Read the whole body before parsing it: a connection dropped mid-body is a
// fetch failure, while a body that arrived but is empty or truncated JSON is
// an invalid manifest. A streaming decoder reports both as io.ErrUnexpectedEOF.
data, err := io.ReadAll(resp.Body)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[nit] io.ReadAll(resp.Body) has no size cap, while the release lookup reads through io.LimitReader(resp.Body, 1<<20). With the old streaming decoder, a 200 response that isn't JSON (a captive portal, or a misconfigured DATABRICKS_SKILLS_BASE_URL mirror) failed at its first byte. Now the whole body is read into memory, for up to the 30s client timeout, before it is reported as MANIFEST_INVALID. Wrap the body in an io.LimitReader with a cap well above the real manifest's size.

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.

Fixed in 4982a91: the manifest body is now read through io.LimitReader(resp.Body, maxManifestSize) with maxManifestSize = 1 << 20, the same cap as the release lookup. The real manifest is far below that. A body over the cap is cut off and reports MANIFEST_INVALID. There is a new case for it in TestClassifyRealManifestFetchErrors.

@anton-107
anton-107 force-pushed the aitools/typed-install-errors branch from 57c5e35 to 8c24df0 Compare November 12, 2026 09:11
@anton-107
anton-107 force-pushed the aitools/plugin-ref-fallback branch from 21231db to f6f7904 Compare November 12, 2026 09:19
@anton-107
anton-107 force-pushed the aitools/typed-install-errors branch from 8c24df0 to 2d5e5ca Compare November 12, 2026 09:19
@anton-107
anton-107 force-pushed the aitools/plugin-ref-fallback branch from f6f7904 to 9f26165 Compare November 12, 2026 10:01
@anton-107
anton-107 force-pushed the aitools/typed-install-errors branch from 2d5e5ca to 64a25aa Compare November 12, 2026 10:01
@anton-107
anton-107 force-pushed the aitools/plugin-ref-fallback branch 2 times, most recently from 8f2ef4e to ae31019 Compare November 12, 2026 10:24
@anton-107
anton-107 force-pushed the aitools/typed-install-errors branch from 64a25aa to 7f0dcdf Compare November 12, 2026 10:25
// to the error's own message. Callers must not branch on this string.
func stderrOf(err error) string {
// A killed process has partial or no stderr; the timeout is the real cause.
if terr, ok := errors.AsType[*agentCmdTimeoutError](err); ok {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[nit] On a timeout, stderrOf returns only "timed out after X" and drops the stderr that the CLI wrote before it was killed. That text often tells why the CLI hung, for example a login prompt that waits for input. Keep both: "timed out after 20s: ".

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.

Done in 4deb804. On a timeout, stderrOf now returns timed out after <timeout> followed by whatever the CLI wrote to stderr before it was killed, e.g. timed out after 20s: Waiting for login.... With no stderr it is still timed out after <timeout>. The new TestRunAgentCmdTimeoutKeepsPartialStderr covers it. TimedOut and the category are unchanged.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Verified at 43ac795. On a timeout, stderrOf now returns timed out after <timeout>: <stderr>. With no stderr, it returns only timed out after <timeout>. TestRunAgentCmdTimeoutKeepsPartialStderr passes, and it fails when I remove the stderr suffix. Note: 4deb804 is not on the branch now. 43ac795 has the same patch on the new base. Thank you.

@anton-107
anton-107 force-pushed the aitools/plugin-ref-fallback branch from ae31019 to bb8734f Compare November 12, 2026 12:27
@anton-107
anton-107 force-pushed the aitools/typed-install-errors branch from 7f0dcdf to 45cb4be Compare November 12, 2026 12:27
@anton-107
anton-107 force-pushed the aitools/plugin-ref-fallback branch from bb8734f to 7a75603 Compare November 12, 2026 12:31
@anton-107
anton-107 force-pushed the aitools/typed-install-errors branch from 45cb4be to dccb816 Compare November 12, 2026 12:31
@anton-107
anton-107 force-pushed the aitools/plugin-ref-fallback branch from 7a75603 to 9138ddc Compare November 12, 2026 12:32
@anton-107
anton-107 force-pushed the aitools/typed-install-errors branch from dccb816 to 4deb804 Compare November 12, 2026 12:36
@anton-107
anton-107 force-pushed the aitools/plugin-ref-fallback branch from 9138ddc to ae5eede Compare November 12, 2026 12:37
@anton-107
anton-107 force-pushed the aitools/typed-install-errors branch from 4deb804 to 43ac795 Compare November 12, 2026 12:37

@rugpanov rugpanov left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I checked the three replies against 43ac795. The two fixes work as the replies say, and the new tests catch a revert of each fix. go test passes for libs/aitools/installer, cmd/aitools, libs/process and libs/telemetry/protos, and for the cmd/aitools acceptance tests (Linux). The JSON wording can wait for the follow-up after the stack.

@anton-107
anton-107 force-pushed the aitools/plugin-ref-fallback branch from ae5eede to 276f78c Compare November 12, 2026 16:38
@anton-107
anton-107 force-pushed the aitools/typed-install-errors branch from 43ac795 to 9ef9292 Compare November 12, 2026 16:38
@anton-107
anton-107 force-pushed the aitools/plugin-ref-fallback branch from 276f78c to e84d3cc Compare November 12, 2026 17:13
@anton-107
anton-107 force-pushed the aitools/typed-install-errors branch from 9ef9292 to 6bd9077 Compare November 12, 2026 17:13
@anton-107
anton-107 force-pushed the aitools/plugin-ref-fallback branch from e84d3cc to 9bfea48 Compare November 12, 2026 20:40
@anton-107
anton-107 changed the base branch from aitools/plugin-ref-fallback to main November 12, 2026 20:45
`aitools install` failures that used to be `UNCATEGORIZED` now get a specific `error_category`. The installer returns typed errors and `classifyInstallError` maps them:

```text
classifyInstallError(err)
  BlockedError
    TimedOut                     -> PLUGIN_CLI_TIMEOUT   (agent CLI killed by its timeout)
  FetchError{Stage, StatusCode, RateLimited}   (release / manifest / skill file)
    context.Canceled             -> COMMAND_INTERRUPTED
    transport failure            -> NETWORK / TIMEOUT     (no response, or a timeout/reset while reading the body)
    RateLimited                  -> RATE_LIMITED          (429, or 403 with GitHub's rate-limit headers)
    clicompat.ErrNotFound        -> MANIFEST_NOT_FOUND
    Stage == release             -> RELEASE_LOOKUP_FAILED
    JSON syntax/type error       -> MANIFEST_INVALID
    otherwise                    -> SKILLS_FETCH_FAILED
  StateError{Path}               -> STATE_INVALID         (unparsable .state.json)
```

- `FetchError` and `StateError` keep the wrapped error's message, so what the user sees does not change.
- A fetch that fails on the transport (no response, or a timeout or reset while reading the body) keeps reporting `NETWORK` or `TIMEOUT`, as before.
- A missing manifest wraps `clicompat.ErrNotFound`, so `clicompat.IsNotFoundError` stays the one way to check for a missing ref.
- An unreadable state file is not a `StateError`: it keeps its filesystem category.
- `FetchManifest` reads the whole body (capped at 1 MiB, like the release lookup; a larger body is reported as too large, `MANIFEST_INVALID`) before parsing it, so a connection closed cleanly mid-body (`SKILLS_FETCH_FAILED`; a reset reports `NETWORK`) is told apart from an empty or truncated manifest (`MANIFEST_INVALID`).
- Changelog fragment: `.nextchanges/cli/aitools-install-fetch-error-categories.md`.

Co-authored-by: Isaac <no-reply@databricks.com>
@anton-107
anton-107 force-pushed the aitools/typed-install-errors branch from 6bd9077 to 3f72232 Compare November 12, 2026 20:53
@anton-107
anton-107 added this pull request to the merge queue Nov 12, 2026
Merged via the queue into main with commit 155f0a0 Nov 12, 2026
29 checks passed
@anton-107
anton-107 deleted the aitools/typed-install-errors branch November 12, 2026 21:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-auto PRs made by AI loops & factories,

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants