Skip to content

fix(wizard): report skill-install failures accurately, and report the ones we were missing - #1073

Draft
gewenyu99 wants to merge 5 commits into
mainfrom
posthog-code/skill-install-menu-refetch
Draft

fix(wizard): report skill-install failures accurately, and report the ones we were missing#1073
gewenyu99 wants to merge 5 commits into
mainfrom
posthog-code/skill-install-menu-refetch

Conversation

@gewenyu99

@gewenyu99 gewenyu99 commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Problem

Over the last 7 days, external prod orchestrator runs: 2,658 total, 58 genuinely incomplete (2.2%), 164 stranded steps.

Skill installs are a small slice of that — 21 install failures across ~12 runs, and only 4 of the 35 partial drains involve one at all. At those volumes nothing is diagnosable unless each failure is attributed correctly, and three of the reporting paths were not.

1. The error blamed the wrong thing. The orchestrator hardcoded "If this is a permissions error, check that the project directory is writable" onto every failure kind, so users hitting a network failure were sent to check chmod. A menu fetch never touches the project directory. The accurate per-kind wording already existed in abortOnInstallFailure — but only the linear path used it.

2. Two failure kinds emitted no event at all. menu-fetch-failed and skill-not-found both resolve inside installSkillById before downloadSkill is called, and downloadSkill owned the only skill install failed capture. So a menu that wouldn't load looked, in analytics, exactly like a skill nobody attempted.

3. The failing stage was invisible. The capture sends step, but a numeric step is already defined project-wide, and the collision types this one numeric too — so every download / extract / scan value reads back null. The values were being sent correctly all along; the property name was just unusable.

Recovering the stage via JSONExtractString is what makes the last week readable, and it does not look like the 30-day total:

stage last 7d (events / runs) last 30d (events / runs)
download 14 / 9 48 / 39
scan 6 / 2 190 / 162
extract 1 / 1 26 / 8

The 30-day scan column is one bounded incident (Jul 24–28) that is already over — zero since Aug 5. The 30-day extract column is a single day (Jul 30). Reading the 30-day total as a standing problem points at the scanner; reading the last week points at downloads, and at no single dominant cause: GitHub 5xx (6 events / 3 runs), local filesystem — ENOTDIR, ENOSPC, EPERM on C:\Windows\System32 (5 / 5), fetch timeouts (4 / 2), scanner (6 / 2).

4. Enforcement can run blind, invisibly. triageFilter fails closed in two places: no provider, and the triage call throwing. Failing closed is correct. But both paths only wrote to the local log, so an enforced match is indistinguishable from one triage actually confirmed. yara triage overruled can only fire when triage worked, which makes any rule's true false-positive rate unmeasurable by construction — including during a spike like Jul 24–28.

Changes

No behaviour changes to scanning or fetching. A flagged skill is still deleted, the install still fails, retries are untouched.

  • Extract describeInstallFailure as the one place that turns an InstallSkillResult into user-facing text, used by both the orchestrator and the linear path — including the underlying download error, which the orchestrator previously discarded.
  • Capture skill install failed for menu-fetch-failed and skill-not-found, so every install failure kind is countable in one place.
  • Rename the captured step to install_step, escaping the numeric collision so the failing stage is queryable without reaching for JSONExtractString.
  • Emit yara triage unavailable when a match is enforced without ever being triaged, carrying rule identity, scan context, no-provider vs error, and the error's class.
  • scanInstalledSkill marks its reason string when the deciding match carries no triage verdict, so a skill deleted because the gateway was unreachable stops being reported to the user, and to error tracking, as a confirmed attack.

Keeping scanned content out of telemetry

A ScanMatch carries matchedStrings — the text that tripped the rule, lifted verbatim from whatever was scanned: the user's source on the Write/Edit path, their Bash commands on PreToolUse, their secrets whenever a hardcoded_secret or posthog_pii rule is what matched.

The new event never contained any of it, but the first cut passed whole ScanMatch objects into the reporting function, which put user code one ...m away from shipping — in a file that has already had to hand-guard the same mistake twice for the free-text triage reason. Telemetry now takes a RuleIdentity (rule, severity, category) produced by ruleIdentity(), so the content is gone before analytics is in scope and there is no field left to spread.

Tracing those call sites also turned up a signal bug: PreToolUse Bash passes no provider on purpose, since a flagged command is blocked whatever triage would say. The untriaged report fired there too, so every blocked Bash command would have counted as a triage outage and buried the real gateway failures. Callers now state whether triage was expected, and only expected reports.

Test plan

  • pnpm build && pnpm test — 1758 tests pass.
  • wizard-tools.test.ts: menu-fetch-failed and skill-not-found each emit a capture carrying the right install_step; describeInstallFailure never mentions permissions or writability for a menu fetch, and surfaces the underlying error for a failed download.
  • yara-hooks.test.ts: the payload has exactly the six rule-identity keys and contains none of the matched content even when warlock returns a match carrying it; a blocked Bash command emits nothing while still returning block; the event fires with reason: 'error' plus the error class when the gateway throws, with reason: 'no-provider' where triage was expected, and stays silent when triage actually ran.

Follow-ups, deliberately not here

  • downloadWithRetry calls fetchWithRetry, then reads resp.arrayBuffer() outside the retry loop, still bound to the 60s abort signal. A body read that stalls or drops mid-transfer is therefore never retried — which matches the The operation was aborted due to timeout failures, the most recent on Aug 10.
  • The prompt_injection_posthog_integration_attack rule lives in warlock. The one sentence that tripped it for data-warehouse-source-setup is fixed in posthog.com#19364; the rule still treats any PostHog-<word> compound as a package name.

Created with PostHog from a Slack thread

The orchestrator fetches the full skill menu once at run start and keeps it in
`menuSkillEntries`, but `installSkillById` re-fetched `skill-menu.json` from
GitHub for every single skill it installed. A run installs a skill per task, so
one run made a double-digit number of redundant menu requests, and any one of
them hitting a transient GitHub blip killed the task — and with it the rest of
the drain, because a task that cannot load its instructions must not run blind.

`menu-fetch-failed` was the single most common skill-install failure. Every
occurrence in the orchestrator path was a refetch of data already in memory.

- `SkillInstallOptions` gains `menuEntries`; when supplied, `installSkillById`
  skips the menu round trip entirely. The orchestrator passes the entries it
  already has at both call sites, so `menu-fetch-failed` is now unreachable
  there and only a real per-asset download can fail.
- The orchestrator's thrown error hardcoded "If this is a permissions error,
  check that the project directory is writable" for every failure kind — wrong
  and misleading for a menu fetch, which never touches the project directory.
  The accurate per-kind vocabulary already existed in `abortOnInstallFailure`
  for the linear path; it moves to a shared `describeInstallFailure` so both
  paths report the same true cause, including the underlying error text for a
  failed download.
- Widen the fetch retry defaults from 3 attempts over ~1.5s to 4 over ~7s. The
  old window expired inside a typical GitHub release-download blip.

Generated-By: PostHog Code
Task-Id: 7ffe8598-04c1-41c6-ba1d-9faab4656e8b
@github-actions

Copy link
Copy Markdown

🧙 Wizard CI

Run the Wizard CI and test your changes against wizard-workbench example apps by replying with a GitHub comment using one of the following commands:

Test all apps:

  • /wizard-ci all

Test all apps in a directory:

  • /wizard-ci ai-observability
  • /wizard-ci basic-integration
  • /wizard-ci mcp-analytics
  • /wizard-ci revenue
  • /wizard-ci self-driving

Test an individual app:

  • /wizard-ci ai-observability/anthropic
  • /wizard-ci ai-observability/groq
  • /wizard-ci ai-observability/manual-capture
Show more apps
  • /wizard-ci ai-observability/openai
  • /wizard-ci ai-observability/openai-agents
  • /wizard-ci ai-observability/vercel-ai
  • /wizard-ci basic-integration/android
  • /wizard-ci basic-integration/angular
  • /wizard-ci basic-integration/astro
  • /wizard-ci basic-integration/django
  • /wizard-ci basic-integration/fastapi
  • /wizard-ci basic-integration/flask
  • /wizard-ci basic-integration/javascript-node
  • /wizard-ci basic-integration/javascript-web
  • /wizard-ci basic-integration/laravel
  • /wizard-ci basic-integration/next-js
  • /wizard-ci basic-integration/nuxt
  • /wizard-ci basic-integration/python
  • /wizard-ci basic-integration/rails
  • /wizard-ci basic-integration/react-native
  • /wizard-ci basic-integration/react-router
  • /wizard-ci basic-integration/sveltekit
  • /wizard-ci basic-integration/swift
  • /wizard-ci basic-integration/tanstack-router
  • /wizard-ci basic-integration/tanstack-start
  • /wizard-ci basic-integration/vue
  • /wizard-ci mcp-analytics/custom-dispatcher
  • /wizard-ci mcp-analytics/typescript-sdk
  • /wizard-ci revenue/stripe
  • /wizard-ci self-driving/astro
  • /wizard-ci self-driving/fastapi
  • /wizard-ci self-driving/nuxt
  • /wizard-ci self-driving/react-router
  • /wizard-ci self-driving/sveltekit

Results will be posted here when complete.

…closed silently

The dominant real-world skill install failure is not a download — it is the
scanner deleting a first-party skill it flagged as a prompt-injection attack.
Those failures far outnumber every network cause put together.

`triageFilter` fails closed in two places: no provider, and the triage call
throwing. Failing closed is correct, but both paths only wrote to the local log,
so an enforced match looked identical in telemetry whether triage had confirmed
it or never ran at all. `yara triage overruled` can only fire when triage
worked, so a rule's real false-positive rate is unmeasurable by construction —
and gateway auth failures are common enough that some share of enforcement is
very likely running blind.

- Emit `yara triage unavailable` on both fail-closed paths, carrying the rule
  metadata, the scan context, whether the cause was `no-provider` or `error`,
  and the error's class. Deliberately not the error message or any scanned
  content, matching the discipline already applied to the triage reason.
- `scanInstalledSkill` marks its reason string when the deciding match carries
  no triage verdict, so a skill deleted because the gateway was unreachable
  stops being reported to the user, and to error tracking, as a confirmed
  attack.

No change to enforcement: a flagged skill is still deleted and the install still
fails. This only makes the difference between "triage confirmed it" and "triage
never ran" visible, which is the prerequisite for tuning the rule in warlock.

Generated-By: PostHog Code
Task-Id: 7ffe8598-04c1-41c6-ba1d-9faab4656e8b
… gaps

Drops the menu-reuse plumbing and the retry-default widening from this branch.
Both were guesses at a fix aimed at the download path, which the data does not
support as the main problem — and neither was measurable before or after.

What stays is the part that makes the failures legible, plus the reporting holes
that made them hard to read in the first place:

- `installSkillById` now reports `skill install failed` for `menu-fetch-failed`
  and `skill-not-found`. Both resolve before `downloadSkill` is called, and
  `downloadSkill` owned the only capture, so neither kind produced an event at
  all — a menu that would not load was indistinguishable in analytics from a
  skill nobody tried to install.
- Rename the captured `step` to `install_step`. A numeric `step` is already
  defined project-wide; the collision types this one numeric too, so every
  `download` / `extract` / `scan` value reads back null and the failing stage is
  invisible in queries. The values were being sent correctly the whole time,
  only the name was unusable.

Generated-By: PostHog Code
Task-Id: 7ffe8598-04c1-41c6-ba1d-9faab4656e8b
@gewenyu99 gewenyu99 changed the title fix(orchestrator): stop refetching the skill menu on every skill install fix(wizard): report skill-install failures accurately, and report the ones we were missing Aug 10, 2026
`reportTriageUnavailable` took `ScanMatch[]`. A `ScanMatch` carries
`matchedStrings` — the text that tripped the rule, lifted verbatim out of
whatever was scanned, which is the user's source on the Write/Edit path, their
Bash commands on the PreToolUse path, and their secrets whenever a
`hardcoded_secret` or `posthog_pii` rule is what matched.

The emitted payload picked fields individually, so nothing leaked in practice.
But handing a content-carrying object to a function whose whole job is to call
`wizardCapture` is the wrong shape: it puts the user's code one `...m` away from
being shipped, in a file where the same mistake has already had to be guarded
against by hand twice for the free-text triage reason.

Project first instead. `ruleIdentity()` reduces a match to rule, severity, and
category, and telemetry takes `RuleIdentity` — so the content is gone before
analytics is in scope and there is no field left to spread.

Also fixes a signal bug found while tracing the call sites: PreToolUse Bash
passes no provider deliberately, because a flagged command is blocked whatever
triage would say, so the LLM call would be wasted. The untriaged report fired
there too, meaning every blocked Bash command would have been counted as a
triage outage — swamping the gateway failures the event exists to surface.
Callers now state whether triage was expected, and only `expected` reports.

Tests pin both: the payload has exactly the six rule-identity keys and contains
none of the matched content even when warlock hands back a match carrying it,
and a blocked Bash command emits nothing while still returning `block`.

Generated-By: PostHog Code
Task-Id: 7ffe8598-04c1-41c6-ba1d-9faab4656e8b
The rationale lives in the commit messages and the PR description; it did not
need repeating as 8-12 line blocks above every new function and type.

Generated-By: PostHog Code
Task-Id: 7ffe8598-04c1-41c6-ba1d-9faab4656e8b
@gewenyu99

Copy link
Copy Markdown
Collaborator Author

@sarahxsanders For you to look at during your Warlock sprint. I'm not super sure this makes sense tbh, but do take a looksies for what bit of the analytics is useful. It does seem we're still killing some first party skills and we don't have the fullest docs around this

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant