Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,11 @@ jobs:
- '**/*.ps1'
- '**/*.feature'
- 'tests/e2e-cucumber/expectations.toml'
# skills/rocm-doctor/reference.md is an e2e FIXTURE: the
# rocm_doctor_skill.feature scenarios parse its catalog table and
# compare it to `rocm fix`. A skill-only edit must therefore run
# the E2E job, which gates on `heavy`.
- 'skills/**'
- 'install*'
# Pinned-key consistency compares docs/keys/* against the installers,
# so a canonical-key-only change must trigger the heavy job that runs
Expand Down Expand Up @@ -178,6 +183,61 @@ jobs:
--body "$PR_BODY" \
--commits-file /tmp/pr-commits.txt >> "$GITHUB_STEP_SUMMARY"

# Advisory nudge: `skills/rocm-doctor/` is a byte-verbatim mirror of the skill
# published in amd/skills, kept here so the e2e suite can test its claims
# against the binary it drives. The mirror rots when the UPSTREAM copy moves
# and this one doesn't, so the check runs on every PR rather than only when
# skills/ is touched — otherwise the case it exists for never fires. Never
# blocks: advisory only, fail-open on any fetch problem, and deliberately NOT
# a required check.
skill-mirror-drift:
name: rocm-doctor mirror drift (advisory)
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Compare skills/rocm-doctor against amd/skills
env:
UPSTREAM: https://raw.githubusercontent.com/amd/skills/main/skills/rocm-doctor
run: |
drifted=0
missing=0
for f in SKILL.md reference.md skill-card.md; do
if ! curl --proto '=https' --tlsv1.2 -fsSL "${UPSTREAM}/${f}" -o "/tmp/upstream-${f}"; then
echo "- \`${f}\`: could not fetch from amd/skills (not published yet, or upstream unreachable)" >> /tmp/drift.md
missing=$((missing + 1))
continue
fi
if ! diff -u "/tmp/upstream-${f}" "skills/rocm-doctor/${f}" > "/tmp/diff-${f}"; then
drifted=$((drifted + 1))
{
echo "<details><summary><code>${f}</code> differs from amd/skills</summary>"
echo
echo '```diff'
head -c 8000 "/tmp/diff-${f}"
echo '```'
echo "</details>"
} >> /tmp/drift.md
fi
done
{
echo "## rocm-doctor skill mirror"
echo
if [ "${drifted}" -eq 0 ] && [ "${missing}" -eq 0 ]; then
echo "In sync with \`amd/skills@main\`."
elif [ "${drifted}" -eq 0 ]; then
echo "Nothing to compare against yet — the skill is not on \`amd/skills@main\`."
echo
cat /tmp/drift.md
else
echo "\`skills/rocm-doctor/\` is a verbatim mirror. Reconcile it with the"
echo "published copy in \`amd/skills\` — and remember the failure-mode catalog"
echo "itself is authoritative in \`crates/rocm-core\`, so the docs follow the CLI."
echo
cat /tmp/drift.md
fi
} >> "$GITHUB_STEP_SUMMARY"

build-and-test:
runs-on: ubuntu-latest
# Don't spend per-OS build/test cycles unless the cheap lint gate is green.
Expand Down
21 changes: 21 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ When changing assistant-adjacent behavior, keep consistency with:

- `docs/llm-tool-use.md`
- `skills/rocm-cli-assistant/SKILL.md`
- `skills/rocm-doctor/SKILL.md` and `skills/rocm-doctor/reference.md`

Required consistency points:

Expand All @@ -160,6 +161,26 @@ Required consistency points:
- avoid invented shell/package-manager commands in assistant behavior paths
- preserve built-in assistant constraints and no-CPU-fallback policy

### `skills/rocm-doctor/` — a mirror, and a test fixture

`skills/rocm-cli-assistant/SKILL.md` is compiled into the binary
(`include_str!` in `apps/rocm/src/main.rs`). `skills/rocm-doctor/` is different
on two counts, and both change how you edit it:

- **It is a byte-verbatim mirror** of the skill published in
[`amd/skills`](https://github.com/amd/skills/tree/main/skills/rocm-doctor).
Change both copies together. The advisory `skill-mirror-drift` CI job diffs
them on every PR and reports in the step summary; it never blocks. Because the
files must stay byte-identical to upstream they carry no AMD licence header,
which is why `licenserc.toml` force-includes markdown per scope rather than
with one blanket `!**/*.md` — read the note at the top of that file before
changing it.
- **`reference.md` is an e2e fixture.** `tests/e2e-cucumber/features/rocm_doctor_skill.feature`
parses its closed-catalog table and compares it to what `rocm fix` reports.
The catalog is authoritative in `crates/rocm-core/src/fix.rs` — adding,
renaming, or re-scoping a failure mode means changing the CLI **first**, then
the two docs. That feature is what catches you if you forget.

## 8) Verification Matrix For This Repo

Minimum quality gate before upstream-ready status:
Expand Down
123 changes: 115 additions & 8 deletions crates/rocm-core/src/diagnose.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,14 +79,17 @@ impl DiagnoseReport {
}

/// Upstream tracker for a framework key.
/// Upstream tracker for a routing target.
///
/// Only the targets [`route_when_no_match`] can actually produce are listed.
/// Arms for lemonade / ollama / lm-studio / amdgpu-install were unreachable —
/// the host probe never reports those frameworks — so they described a
/// capability the CLI does not have. Routing an app that merely appears in the
/// symptom text is the caller's job, not the probe's.
fn upstream_tracker(target: &str) -> &'static str {
match target {
"pytorch" => "https://github.com/pytorch/pytorch/issues (tag with rocm label)",
"llama-cpp" => "https://github.com/ggml-org/llama.cpp/issues",
"lemonade" => "https://github.com/lemonade-sdk/lemonade/issues",
"ollama" => "https://github.com/ollama/ollama/issues",
"lm-studio" => "https://lmstudio.ai/docs/app (use in-app support; no public repo)",
"amdgpu-install" => "https://repo.radeon.com (raise via your AMD support contact)",
_ => "https://github.com/ROCm/ROCm/issues",
}
}
Expand Down Expand Up @@ -926,7 +929,11 @@ fn check_9_igpu_dgpu_collision(e: &Examination, symptom: &str) -> Diagnosis {
"# Persist in your shell rc or your launch script.".to_owned(),
],
fix_id: "fix-9-igpu-dgpu".to_owned(),
auto_applicable: false,
// `rocm fix fix-9-igpu-dgpu --device-index N` really does apply this
// on Linux (see `fix::run_hip_visible_devices_linux`), so the
// diagnosis must not tell an agent otherwise — it branches on this
// flag to decide whether to offer to run the fix or just print it.
auto_applicable: true,
verify: "HIP_VISIBLE_DEVICES=1 python -c \"import torch; print(torch.cuda.device_count())\"".to_owned(),
notes: vec![note],
..Fix::default()
Expand Down Expand Up @@ -1280,13 +1287,17 @@ fn run_all_checks(e: &Examination, symptom: &str) -> Vec<Diagnosis> {
results
}

/// Where to send a user when nothing in the catalog matched.
///
/// Keyed off the *host-detected* framework, which `Examination::probe` only
/// ever sets to `pytorch`, `llama-cpp`, `unknown` or `skipped` — so those two
/// named arms plus the ROCm-core default are the whole reachable set. If a probe
/// for another framework is added, add its arm here and in [`upstream_tracker`];
/// `routing_targets_cover_every_framework_the_probe_reports` will flag it.
fn route_when_no_match(e: &Examination) -> Route {
let target = match e.framework.as_str() {
"pytorch" => "pytorch",
"llama-cpp" => "llama-cpp",
"lemonade" => "lemonade",
"ollama" => "ollama",
"lm-studio" => "lm-studio",
_ => "rocm-core",
};
Route {
Expand Down Expand Up @@ -1473,6 +1484,102 @@ mod tests {
assert_eq!(top.score, 100);
}

/// Routing must stay defined for every framework the probe can report, and
/// must not claim targets it can never reach.
///
/// `Examination::probe` sets `framework` to exactly these four values (see
/// `examine.rs`). The catalog docs previously advertised lemonade / ollama /
/// lm-studio routing that no probe could ever trigger; this pins the
/// reachable set so a re-added arm has to come with a probe that reaches it.
#[test]
fn routing_targets_cover_every_framework_the_probe_reports() {
let mut targets = Vec::new();
for framework in ["skipped", "pytorch", "llama-cpp", "unknown"] {
let e = Examination {
framework: framework.to_owned(),
..Examination::default()
};
let route = route_when_no_match(&e);
assert!(
route.url.starts_with("http"),
"{framework}: routed to a non-URL {:?}",
route.url
);
targets.push(route.target);
}
targets.sort_unstable();
targets.dedup();
assert_eq!(
targets,
vec!["llama-cpp", "pytorch", "rocm-core"],
"the reachable routing targets changed; update the docs in \
skills/rocm-doctor/reference.md (and the amd/skills copy) to match"
);
}

/// Every diagnosis must agree with the fix catalog about whether the CLI
/// can apply the fix itself.
///
/// An agent following the rocm-doctor skill branches on `auto_applicable`
/// from `diagnose --json` to decide whether to offer to run `rocm fix` or
/// merely print the plan, while `fix::apply` dispatches on `RECIPES`. When
/// the two disagree the CLI contradicts itself: fix-9 advertised
/// `auto_applicable: false` on Linux and then applied itself anyway.
///
/// Both OS families are exercised because the checkers build their `Fix`
/// per-OS and only one branch is taken per run — the Linux branch was the
/// stale one, and a Linux-only test would not have seen it.
#[test]
fn every_diagnosis_agrees_with_the_fix_catalog_on_auto_applicability() {
for os in ["linux", "windows"] {
let mut e = Examination {
os_family: os.to_owned(),
..Examination::default()
};
// Trip several checkers at once so this covers more of the catalog
// than a single fix.
e.in_render_group = Some(false);
e.in_video_group = Some(false);
e.has_apu = true;
e.has_discrete_amd = true;
e.gpus = vec![
Gpu {
gfx_target: "gfx1103".to_owned(),
is_amd: true,
is_apu: Some(true),
..Gpu::default()
},
Gpu {
gfx_target: "gfx1100".to_owned(),
is_amd: true,
is_apu: Some(false),
..Gpu::default()
},
];

let report = diagnose(&e, "torch crashes with a segfault");
assert!(
!report.matched.is_empty(),
"{os}: expected at least one diagnosis to compare against the catalog"
);
for d in &report.matched {
let fix = d
.fix
.as_ref()
.unwrap_or_else(|| panic!("{os}/{}: matched with no fix", d.id));
let catalog = crate::fix::auto_applicable_for(&fix.fix_id).unwrap_or_else(|| {
panic!("{os}/{}: emitted a fix-id not in the catalog", fix.fix_id)
});
assert_eq!(
fix.auto_applicable, catalog,
"{os}/{}: diagnose reports auto_applicable={}, but the fix catalog \
(what `rocm fix` actually does) says {catalog}",
fix.fix_id, fix.auto_applicable
);
}
}
}

#[test]
fn igpu_dgpu_collision_fires_for_rdna3_apu_plus_discrete() {
// End-to-end guard for EAI-7412: a gfx1103 (Phoenix APU) + gfx1100
Expand Down
Loading