Repository navigation
[release] append the observability agent analysis to the buildkite job annotation - #66006
Conversation
There was a problem hiding this comment.
Code Review
This pull request integrates the observability agent with Buildkite annotations, allowing test failure summaries to be appended directly to Buildkite jobs. It configures the Docker plugin to mount the buildkite-agent binary and implements the annotation logic in the reporter, along with temporary mock responses and forced failures for testing. The review feedback suggests avoiding the use of !r (repr) inside HTML attribute values within the annotation formatting, as it can lead to fragile HTML rendering, and recommends using explicit double quotes instead.
|
The Observability Agent response can be viewed in the logs of the forced The agent's summary and thread URL are printed in the buildkite job-level annotations and the build-level annotation, and also given their own The 4th attempt is the final automated retry of the test, and the 5th attempt is a manual retry of the test. Attempts 1-3 do not query the Observability Agent because they are treated as having failed due to transient errors. |
79ff865 to
ec97b2c
Compare
d7682a6 to
e434abd
Compare
|
Actual Observability Agent reports are visible at buildkite/release for commit 4792b54. |
elliot-barn
left a comment
There was a problem hiding this comment.
Approved! But can you update the pr description that mentions the annotation showing up on the build page
60ca086 to
cb49f5e
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit cb49f5e. Configure here.
2c92eed to
600a4c6
Compare
Iterating on what happens after the agent responds -- the log group, and the buildkite annotation this branch is about -- otherwise creates a real debug session and posts a real slack thread on every run, which is noise for the people watching that channel. RELEASE_TEST_OBS_AGENT_FAKE_RESPONSE names a file holding a query response; when it is set the reporter serves that and calls nothing. The response shape is copied from a real one, em dash included, so the encoding and rendering paths stay exercised. run_release_test.sh points it at the checked-in file, so every release test run on this branch uses it. Revert this commit, and delete release/obs_agent_fake_response.json, before merging. Signed-off-by: sai.miduthuri <sai.miduthuri@anyscale.com>
Option D of the prominence plan. The step log only reaches someone who opens the step; an annotation is on the build page, above every step, so a red nightly shows its analyses without a single click. The annotation is keyed on the test name, not the job id. A buildkite retry is a new job with a new id, so keying on the job would open a separate annotation per attempt rather than appending to one, and `step_key` is null for release test jobs. `--job` is still passed, for attribution, but it is not the identity. Each attempt appends a block headed with the test name, the attempt number from BUILDKITE_RETRY_COUNT, and a link to the anyscale job, so an accumulated annotation reads as a sequence of attempts rather than one run. Guarded on BUILDKITE and wrapped so that neither a missing binary nor a non-zero exit can change the outcome of the test run, the same rule the rest of this reporter follows. Signed-off-by: sai.miduthuri <sai.miduthuri@anyscale.com>
Temporarily raise an exception inside the hello_world ray task so that a release test run on this PR fails the way a real test does, and the buildkite annotation this branch adds can be observed on a real build. The analysis itself is canned on this branch, so running this repeatedly creates no debug sessions and no slack threads. Note that the agent fires on the retried attempt rather than the first: _is_transient_error rewrites the first attempt's status to TRANSIENT_INFRA_ERROR, which is not a trigger status. hello_world sets num_retries: 3, so the annotation should accumulate a block per triggering attempt -- which is the behaviour this PR exists to observe. Revert this commit before taking the PR out of draft. Signed-off-by: sai.miduthuri <sai.miduthuri@anyscale.com>
The annotation added in the previous commit never reached buildkite. The
job log shows why:
Could not annotate the buildkite job:
[Errno 13] Permission denied: 'buildkite-agent'
Two separate reasons, both from the docker plugin config:
- The step hand-mounted /usr/local/bin/buildkite-agent, but the agent on
these hosts is /usr/bin/buildkite-agent-stable, so the source path does
not exist and docker creates an empty *directory* at the destination.
Executing a directory is EACCES, which is the error above -- not the
ENOENT a missing file would give. /usr/local/bin also precedes /usr/bin
on PATH in the python image, so that directory shadows any working
binary mounted elsewhere; removing the volume is required, not tidying.
- BUILDKITE_AGENT_ACCESS_TOKEN is not among the variables reaching the
container. `propagate-environment` does not carry it, so even a working
binary could not authenticate.
`mount-buildkite-agent` handles both: the plugin mounts the binary the
agent is running and passes the token. This is the same capability the
release build steps already get, though they ask for it through rayci's
`mount_buildkite_agent` key, which release test steps cannot use because
they are uploaded straight to buildkite rather than generated by rayci.
Signed-off-by: sai.miduthuri <sai.miduthuri@anyscale.com>
The annotation landed on the build page rather than the job. `--job` was not the reason: per the agent docs it only records "which job should the annotation come from", while `--scope` is what "control[s] where the annotation is displayed in the Buildkite UI", and it defaults to "build". So the first run was always going to be build-scoped -- nothing was passing --scope at all. Pass --scope=job and keep --job for attribution. Also log the command before running it. The first run left it ambiguous whether --job had even been included, because the argv is built conditionally and nothing recorded it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: sai.miduthuri <sai.miduthuri@anyscale.com>
Buildkite labels the first try "Retry 1 of N", counting every attempt as a retry, while BUILDKITE_RETRY_COUNT counts retries *after* the first and is 0 there. Rendering the raw value put "attempt 3" on the annotation hanging off the attempt the UI calls "Retry 4 of 5" -- two numbers for the same attempt, on the same screen. Render both: the +1 lines up with the UI label the annotation is attached to, and the raw value stays visible because that is what the job log prints, so log, annotation and UI can all be reconciled. No "of N": the total is not derivable from the environment, because a manual retry takes it past the configured limit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: sai.miduthuri <sai.miduthuri@anyscale.com>
…ation" This reverts commit 162f134. The forced failure has served its purpose: builds 106453, 106459 and 106662 exercised the annotation path end to end, and the findings are recorded in the PR description. Restore hello_world so the PR carries only the change itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: sai.miduthuri <sai.miduthuri@anyscale.com>
This reverts commit 36265d5. The canned response existed so that iterating on the annotation did not create a debug session and post a slack thread on every run. That iteration is done: builds 106453, 106459 and 106662 settled how scoping and appending behave, and the findings are in the PR description. The revert conflicted in observability_agent.py because _annotate was added immediately above _fake_response by a later commit, so git could not tell the two apart. Resolved by keeping _annotate and removing only _fake_response; the constants, the call site and release/obs_agent_fake_response.json go with it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: sai.miduthuri <sai.miduthuri@anyscale.com>
Buildkite renders the annotation as markup, and the body interpolated values straight in. `!r` on the two urls, flagged in review, is the wrong tool: repr quotes for Python, not for HTML, and a value containing both quote characters comes out backslash-escaped, which HTML does not understand. Switching to plain double quotes, as suggested, would have been worse for the case that matters. The slack thread url comes from the agent's response, and a `"` in it closes the attribute and turns the rest into markup; repr happened to survive that by switching quote style. Neither escapes HTML. Escape every interpolated value instead, and quote the attributes explicitly. That includes the summary, which review did not mention and which is the likeliest source of stray markup: it is free-form prose from the agent, the same field that already had to be defended against opening a buildkite log group. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: sai.miduthuri <sai.miduthuri@anyscale.com>
The gate added in the first PR of this stack exports the flag only for AUTOMATIC runs on master, so a PR pipeline gets no reporter at all: no analysis, no log group, no annotation. Export it unconditionally as well, to exercise the agent end to end on this PR. Revert this commit before merging. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: sai.miduthuri <sai.miduthuri@anyscale.com>
…s pipeline" This reverts commit 4792b54. The unconditional export existed so that a PR pipeline, where AUTOMATIC is 0 and the branch is not master, would run the reporter at all. Build 106873 exercised it end to end against a real failing test and produced the reports as intended, so the flag goes back to what it gates in production: automatic runs on master, at any release frequency. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: sai.miduthuri <sai.miduthuri@anyscale.com>
The comments claimed that a stable context plus --append make a retry add to the previous attempt's annotation. Build 106662 says otherwise: the two attempts of hello_world.aws that reached the agent produced two separate job-scoped annotations under the identical context obs-agent-hello_world.aws, one block each -- "attempt 3" on one job, "attempt 4" on the next. Scope is what separates them. A job-scoped annotation is identified by its context *within its job*, and a retry is a new job, so each attempt annotates separately whatever the context is. Every attempt's report is still kept and buildkite still shows them together on the build page -- the outcome the comments described -- but the context and --append are not what produce it. So the context is explained by what it is actually for: an annotation needs a context, buildkite defaults it to "default", and the test name makes this one identifiable in the UI and clear of anything else annotating the job. --append stays, for the case of a job annotating twice under this context; that does not happen today, but replacing would be wrong if it ever did. The test keeps its assertions and loses the reason it had wrong, name included. Comments and one test name. No behaviour change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: sai.miduthuri <sai.miduthuri@anyscale.com>
Three comments explained a flag by what an earlier run on this branch did. The branch does not survive the squash-merge, so a reader a year from now has nothing to resolve "the first run on this branch" against -- and the anecdote was never the reason anyway, only how the reason was found. Each now states the fact that outlives the PR: - ANNOTATION_SCOPE: scope decides placement and defaults to "build", so passing --job alone leaves the annotation on the build page rather than the job it is about. - The annotate logging: a build should be self-describing about what was actually run, because the flags an annotation was created with are otherwise invisible from the job log and decide where it lands. - The same note in the annotation test, which cited the same run. Comments only. No behaviour change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: sai.miduthuri <sai.miduthuri@anyscale.com>
600a4c6 to
e4682f4
Compare
`buildkite-agent annotate` reaches buildkite's api exactly as `meta-data` does, and was the last unbounded call in the file. Same 30s; the existing `except Exception` already turns a TimeoutExpired into a warning, since an annotation must not change the outcome of a test run. It came in with #66006 rather than this PR, so it is strictly speaking out of scope, but it is one argument and leaving the two inconsistent invites the question. The test now asserts that *every* buildkite-agent invocation is bounded, rather than the meta-data ones only, so a future unbounded call fails it. Audited the rest of the file: the only other external call is requests.post in _post_json, which already carries its own timeouts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: sai.miduthuri <sai.miduthuri@anyscale.com>
…ub issue (#66477) ## Description When a release test fails on master, #65906 asks the Anyscale observability agent why, #65910 prints the answer in its own buildkite group, and #66006 attaches it to the job as an annotation. All three reach whoever opens the build. None of them reaches the person who owns the failing test, who is watching its **GitHub issue**. This posts the analysis there. When the state machine is already tracking an open issue for the test, the reporter comments the agent's summary on it, with the failing build in the opening line and a link to the slack thread holding the full report. Nothing new is created: the issue is the one `ReleaseTestStateMachine` opened when the test started failing, and the comment only appears while that issue is open. A test with no tracked issue is skipped before the repo handle is built, so the common case does not pay for the AWS Secrets Manager fetch that building it costs. `Test.get_open_github_issue` is extracted from `is_jailed_with_open_issue`, which now delegates to it via `has_open_github_issue`. Checking that the issue is *open* is required rather than defensive: `_close_github_issue` closes the issue but leaves `KEY_GITHUB_ISSUE_NUMBER` on the test, so a recovered test points at a closed issue indefinitely. ## Related issues None. ## Additional information ### What the comment looks like ``` Latest run: https://buildkite.com/ray-project/release/builds/108659#01a0e9e5-… Observability Agent RCA: <the agent's summary> [Full report and feedback](<slack thread>) — rate it with the 'All good' or 'Needs correction' buttons in the thread. ``` Laid out like the buildkite annotation, and the test is not named because the comment is on that test's own issue. Each part is dropped rather than left empty when the agent did not supply it; a response with neither a summary nor a thread is not posted at all. ### One comment per build, not one per job A test with `repeated_run` gets one job per repeat — `microbenchmark.aws` has five — and a manual retry adds another. All of them are separate processes in one build, so the only state they share is build-scoped agent meta-data. Each job claims `obs-agent-commented-<test>` before posting; a job that finds it claimed skips. The claim is written and then **read back**, because `buildkite-agent meta-data` has `set`/`get`/`exists`/`keys` and no compare-and-set. `set` is last-writer-wins, which is what makes the read-back work: when two jobs both find the key unset and both write, the one whose write landed last reads its own id back and owns the claim, and the other reads a stranger's and stands down. Two details make that safe: - The claimant is `BUILDKITE_JOB_ID`, and the protocol is skipped entirely when there is none — two jobs writing the same constant would each read it back and both proceed. - A read-back returning nothing means the *agent call* failed, not that somebody else won: the key was just written. Standing down there would let a broken agent silently drop the analysis, so it stands down only on positively reading another job's id. It narrows the race rather than closing it: both jobs still post if one reads back before the other writes at all, which needs the second write to land in the gap between one job's two consecutive local calls. A failed post hands the claim back, by writing an empty value since the agent cannot delete a key — but **only when retrying could succeed** (`>= 500`, `429`, timeouts). Releasing on a permanent failure would make every remaining job fail the same way. **The buildkite annotation is deliberately not deduped this way.** One agent report per test *job* is the point of it, and `test_the_annotation_is_not_deduped_by_build` pins that. ### Everything interpolated into the comment is defused first The body is markdown and the summary is free-form text from an LLM, so three things in it would be side effects rather than intentions: - `@name` notifies a real person and `#123` posts a backlink on that issue. Both autolinks are broken with an empty html comment, which renders as nothing. - Anything github reads as an html tag is dropped by its sanitizer — `<lambda>` and `<module>` are routine in a traceback — so the markup is escaped. - Code spans and fenced blocks are left alone entirely: github autolinks neither and renders neither as html there, and rewriting inside one would show the escapes and the comment marker literally in a quoted log line. The slack thread url is another value the agent's response decides, so only a plain http url becomes a link, in the `<...>` destination form; anything else is shown as code rather than given the chance to close the link early. ### Oversized summaries are trimmed, not dropped Github rejects a body over 65536 characters. The rest of the body is measured first and the summary given what is left, so the failing build and the slack link always survive. The trim cuts the *raw* summary and re-sanitizes, because sanitizing expands — `<` becomes four characters and `@` eight — so a budget spent on raw characters would still overflow, and cutting sanitized text would cut through a half-written entity. Each pass shortens in proportion to the overshoot rather than by it; subtracting it outright discarded the whole analysis for a summary full of `@` or `<`. This matters beyond tidiness: the 422 an over-long body produces is exactly the permanent failure the claim above does not release on, so without the trim one oversized analysis meant no comment from any job in the build. ### Nothing here can change a test result `glue.py` runs the reporting loop without guarding `reporter.report_result(...)`, so every failure in this path is caught and logged. `GitHubClient` also gained a 30s timeout on every request: it had none, and `requests` waits forever, so a github that accepted the connection and never answered would have held the step open until buildkite killed it. ### Not duplicating existing work ```bash gh pr list --repo ray-project/ray --state open --search "observability agent github issue comment" gh pr list --repo ray-project/ray --state open --search "ray_release reporter github issue" ``` Neither returns anything in this area. The three PRs this builds on (#65906, #65910, #66006) are all merged. ### Tests `//release/...` cannot be analysed on current master — `//release:requirements_py310_test` references `@rules_python//python/pip_install:pip_compile.py`, which no longer exists in the pinned rules_python. That reproduces on a clean master checkout with no changes, so the release_unit targets were run explicitly instead: ```bash $ bazel test $(bazel query 'attr(tags, release_unit, tests(//release:all))') --test_output=errors Executed 29 out of 29 tests: 29 tests pass. $ pre-commit run # all hooks passed ``` New coverage in `test_observability_agent_reporter.py` and `test_test.py`: | Behavior | Test | | --- | --- | | Comments on an open issue, fetching it exactly once | `test_comments_on_an_open_issue` | | Silent on a closed issue, and without a tracked issue | `test_does_not_comment_on_a_closed_issue`, `test_does_not_reach_github_without_a_tracked_issue` | | A github failure never propagates | `test_a_github_failure_does_not_propagate` | | The agent's prose cannot mention people or link issues | `test_the_comment_cannot_mention_people_or_link_issues` | | Markup is escaped, but not inside code | `test_the_comment_escapes_what_the_agent_sent`, `test_the_comment_leaves_the_agents_code_spans_alone` | | The slack url cannot escape its link | `test_the_slack_link_target_cannot_escape_the_link`, `test_the_slack_link_uses_a_bounded_destination` | | One comment per build across five jobs | `test_only_one_job_per_build_comments` | | A simultaneous claim is settled by the read-back | `test_the_loser_of_a_simultaneous_claim_does_not_comment` | | An unverifiable claim still comments; no job id means no claim | `test_an_unverifiable_claim_still_comments`, `test_no_claim_without_a_job_id_to_claim_with` | | Transient failures release the claim, permanent ones keep it | `test_a_transient_failure_hands_the_claim_back`, `test_a_permanent_failure_keeps_the_claim` | | The annotation is *not* deduped | `test_the_annotation_is_not_deduped_by_build` | | Oversized summaries are trimmed and still fit | `test_an_oversized_summary_is_trimmed_to_fit` | | With no summary the thread is the body; with neither, no comment | `test_the_comment_points_at_the_thread_when_there_is_no_summary`, `test_no_comment_when_the_agent_returned_nothing` | | `get_open_github_issue` over open / closed / unreachable | `test_get_open_github_issue` | The dedupe and response-shape tests were checked against the bug rather than only the fix: with them in place, removing the claim fails five of them, and removing just the read-back fails the simultaneous-claim one. Note that `bazel test //release/...` cannot be analysed on current master — `//release:requirements_py310_test` references `@rules_python//python/pip_install:pip_compile.py`, which no longer exists in the pinned rules_python. That reproduces on a clean master checkout, hence the explicit target list above. ### Exercised end to end on a real build The reporter is gated to `AUTOMATIC=1` runs on master, so a PR pipeline would normally run none of this. Four temporary commits opened that path — a canned agent response, a stand-in anyscale job id and issue number, a forced failure of `hello_world.aws` before anything reached anyscale, and an unconditional export of the flag — and **all four are reverted in this PR** (`73cc06406a`, `07d9850788`, `34c63a2014`, `3eb60acc13`). The canned summary was written to carry exactly what the comment path defuses. Verified on builds 108426 and 108659, commenting on a scratch issue: | Behaviour | | | --- | --- | | Comment posted on the tracked issue | verified | | `Latest run:` names the failing job | verified | | `<class 'ValueError'>` and `<lambda>` render as text, not eaten by the sanitizer | verified | | `@someone` and `#1234` render plainly — no notification, no issue backlink | verified | | A fenced code block passes through verbatim | verified | | A url `#fragment` in the prose survives | verified | | Slack link in the bounded `<...>` destination form | verified | | **One comment per build**, across the final automatic retry and each manual one | verified | | **The annotation is not deduped** — one per job, per attempt | verified | One defect surfaced and was fixed: the comment came out with no `Latest run:` line, because `result.buildkite_url` is filled in by `_load_test_configuration` and the forced failure raised above the call to it. That was the scaffolding rather than the feature — a real failure cannot reach the reporters that way, since that call is the first thing in the `try`. ### AI assistance AI assistance (Claude Code) was used for this change. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: sai.miduthuri <sai.miduthuri@anyscale.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…b annotation (ray-project#66006) ## Description Stacked on ray-project#65910, which is stacked on ray-project#65906. **Review those first; this PR's diff is only the annotation on top of them.** ray-project#65910 puts the observability agent's analysis in its own buildkite group at the end of the failed step — but that only reaches someone who opens the step. This adds a buildkite **annotation**, so a red nightly shows its analyses without digging through step output. The annotation is **job-scoped** (`--scope=job`), so it renders on the job it came from; buildkite also surfaces job-scoped annotations on the build page, so one call reaches both places. Scope is what decides this: `--scope` defaults to `build`, and `--job` alone only records which job an annotation came from — the first run on this branch passed `--job` without `--scope` and the annotation landed at the build level, which is how this was found. The full command: ``` buildkite-agent annotate --style=info --scope=job --context=obs-agent-<test name> --append --job <job id> ``` **Each attempt of a retried test gets its own annotation**, because a buildkite retry is a *new job with a new id* — the API shows `retry_source.job_id` differing from `id` — and a job-scoped annotation is identified by its context *within its job*. So every attempt's report is kept, attributed to the attempt that produced it, and buildkite shows them together on the build page. The context is `obs-agent-<test name>` because an annotation needs one and buildkite defaults it to `"default"`; the test name makes it identifiable in the UI and keeps it clear of anything else annotating the same job. It is not what separates the attempts — scope already does that. `--append` is passed for the case of a job annotating twice under this context, which does not happen today (one release test job runs one test once), but where replacing would be wrong. Each block is headed with the test name, the attempt number, and a link to the Anyscale job: ``` hello_world.aws — attempt 1 (BUILDKITE_RETRY_COUNT=0) — prodjob_w9q946e1l8xvk6p6bdywmzh8li <the agent's summary> Full report and feedback — rate it with the 'All good' or 'Needs correction' buttons in the thread. ``` Both numbers are rendered because they disagree by one: buildkite labels the first try "Retry 1 of N", while `BUILDKITE_RETRY_COUNT` counts retries *after* it and is `0` there. Rendering the raw value alone put "attempt 3" on the attempt the UI calls "Retry 4 of 5"; rendering both reconciles the annotation with the label it hangs off and with the job log, which prints the raw value. Guarded on `BUILDKITE`, and wrapped so that neither a missing binary nor a non-zero exit can change the outcome of the test run — the same rule the rest of this reporter follows. Everything interpolated into the body is HTML-escaped: buildkite renders the body as markup, and the agent's summary is free-form prose where an unescaped `<` would be rendered rather than shown. **This is not new ground for the repo.** `buildkite-agent annotate` already has two call sites: `release/ray_release/byod/build.py:71-81`, from Python inside `ray_release`, and `.buildkite/hooks/post-command:19-20`, from the host hook. ### It also needs a change to the release test step config The first CI run on this branch produced no annotation: ``` Could not annotate the buildkite job: [Errno 13] Permission denied: 'buildkite-agent' ``` Two separate causes, both in the step's docker plugin config, and both fixed in `f51462c6f4`: - The step hand-mounted `/usr/local/bin/buildkite-agent`, but the agent on these hosts is `/usr/bin/buildkite-agent-stable`. The source path does not exist, so docker creates an empty *directory* at the destination, and executing a directory is `EACCES` — the error above, rather than the `ENOENT` a missing file would give. That volume had to be **removed**, not supplemented: `/usr/local/bin` precedes `/usr/bin` on `PATH` in the `python` image, so the stale directory shadows any working binary mounted elsewhere. - `BUILDKITE_AGENT_ACCESS_TOKEN` is not among the variables reaching the container — `propagate-environment` does not carry it — so even a working binary could not authenticate. `mount-buildkite-agent: true` handles both. Note that the rayci keys (`allow_mount_buildkite_agent` in `.buildkite/release/config.yml`, `mount_buildkite_agent` in `build.rayci.yml`) do not apply here: release test steps are written to `release_tests.json` and uploaded straight to buildkite by `custom_image_build_and_test_init`, never generated by rayci, so the docker plugin's own hyphenated option is the reachable lever. **Worth a reviewer's attention:** this grants *every* release test container a working `buildkite-agent` and the agent access token, so test code could annotate the build or upload artifacts as the agent. It is the same trust level the release *build* steps already run at, and release tests are internal code, but it is a change in posture on a shared file rather than a change local to this reporter. ## Related issues None. ## Additional information ### Verified on real builds Three temporary commits carried this PR through CI and have all been reverted; each pair is on the branch so the history shows what was run: | Temporary commit | Revert | What it was for | | --- | --- | --- | | `131706a3e3` serve a canned agent response | `98c8f488b3` | Iterating without creating a real debug session and slack thread on every run | | `e5f741e939` force `hello_world` to fail | `ab9f02ebf3` | Getting a release test run to actually reach the agent | | `e456942239` export `TRIGGER_OBSERVABILITY_AGENT` unconditionally | `cb49f5edf4` | ray-project#65906 gates the reporter to `AUTOMATIC` master runs, so a PR pipeline would otherwise run none of this | **No temporary commit remains in effect.** The final run was against a genuinely failing test with the real agent, and produced the reports as intended. ### What the builds settled **A job-scoped annotation does not accumulate across retries — each attempt annotates its own job.** The docs were not explicit about whether `--append` with a stable context across a new `--job` grows one annotation or opens several. Build 106662 answers it: the two attempts of `hello_world.aws` that reached the agent produced **two** annotations under the identical context `obs-agent-hello_world.aws`, one block each — "attempt 3" on one job, "attempt 4" on the next. Scope, not the context, is what separates them. That is the behaviour this PR wants: every attempt's analysis is kept and attributed to the attempt that produced it, rather than concatenated into one block where it is unclear which run said what. Two related notes for anyone reading these programmatically: `Build.annotations` returns only *build*-scoped annotations over both the REST and GraphQL APIs — the job-scoped ones are reachable under `Job.annotations` — and the build page showing them is a UI behaviour, not API-level promotion. **Automatic retries produce exactly one trigger per build.** Since ray-project#65907 landed, the step publishes `BUILDKITE_MAX_RETRIES=3` for `hello_world` (from its `num_retries: 3`) and the script honours it, so `_is_transient_error` rewrites every attempt except the last to `TRANSIENT_INFRA_ERROR`, which is not a trigger status: | attempt | `BUILDKITE_RETRY_COUNT` | `retry_count >= max_retry` | status | agent | | --- | --- | --- | --- | --- | | 0 | 0 | false → transient | `TRANSIENT_INFRA_ERROR` | skipped | | 1, 2 | 1, 2 | false → transient | `TRANSIENT_INFRA_ERROR` | skipped | | 3 | 3 | **true** | `ERROR` | **triggers** | That is ray-project#65907 working as intended — one recorded result, and one analysis, per build. No `num_retries` value changes it. The multiple-attempt case above was therefore reached with a manual retry: once the automatic retries are exhausted at `retry_count=3`, a manual retry gives `retry_count=4`, so `4 >= 3` holds and the attempt reports a real error and triggers. That is also the realistic case, since a manual retry is what a human does while debugging a failure. ### Not duplicating existing work ```bash gh pr list --repo ray-project/ray --state open --search "buildkite annotation observability agent" gh pr list --repo ray-project/ray --state open --search "buildkite-agent annotate" ``` Neither returns anything in this area beyond the two PRs this one is stacked on. ### Tests ```bash $ bazel test --test_tag_filters=release_unit //release/... --test_output=errors Executed 29 out of 29 tests: 29 tests pass. $ bazel test //release:test_observability_agent_reporter //release:test_observability_agent_reporter PASSED # 31 tests, 6 new for the annotation ``` New coverage: | Behavior | Test | | --- | --- | | argv: job scope, context keyed on the test name, style, append, `--job` for attribution | `test_the_annotation_is_job_scoped_and_keyed_on_the_test` | | Attempt number matches buildkite's own label | `test_the_attempt_number_matches_the_buildkite_label` | | The agent's prose cannot inject markup into the body | `test_the_annotation_escapes_what_the_agent_sent` | | No annotation off a buildkite agent | `test_no_annotation_outside_buildkite` | | A missing `buildkite-agent` binary is swallowed | `test_annotation_failures_never_propagate` | | A non-zero exit is logged, not raised | `test_a_non_zero_annotate_exit_is_logged_not_raised` | Two bugs the tests caught while writing them: `--job` was appended *after* the body, leaving the job id as the positional argument rather than the annotation text; and the guard was `"BUILDKITE" not in os.environ`, which treats `BUILDKITE=""` as being on an agent. The guard now matches `byod/build.py`. ### AI assistance AI assistance (Claude Code) was used for this change. --------- Signed-off-by: sai.miduthuri <sai.miduthuri@anyscale.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ub issue (ray-project#66477) ## Description When a release test fails on master, ray-project#65906 asks the Anyscale observability agent why, ray-project#65910 prints the answer in its own buildkite group, and ray-project#66006 attaches it to the job as an annotation. All three reach whoever opens the build. None of them reaches the person who owns the failing test, who is watching its **GitHub issue**. This posts the analysis there. When the state machine is already tracking an open issue for the test, the reporter comments the agent's summary on it, with the failing build in the opening line and a link to the slack thread holding the full report. Nothing new is created: the issue is the one `ReleaseTestStateMachine` opened when the test started failing, and the comment only appears while that issue is open. A test with no tracked issue is skipped before the repo handle is built, so the common case does not pay for the AWS Secrets Manager fetch that building it costs. `Test.get_open_github_issue` is extracted from `is_jailed_with_open_issue`, which now delegates to it via `has_open_github_issue`. Checking that the issue is *open* is required rather than defensive: `_close_github_issue` closes the issue but leaves `KEY_GITHUB_ISSUE_NUMBER` on the test, so a recovered test points at a closed issue indefinitely. ## Related issues None. ## Additional information ### What the comment looks like ``` Latest run: https://buildkite.com/ray-project/release/builds/108659#01a0e9e5-… Observability Agent RCA: <the agent's summary> [Full report and feedback](<slack thread>) — rate it with the 'All good' or 'Needs correction' buttons in the thread. ``` Laid out like the buildkite annotation, and the test is not named because the comment is on that test's own issue. Each part is dropped rather than left empty when the agent did not supply it; a response with neither a summary nor a thread is not posted at all. ### One comment per build, not one per job A test with `repeated_run` gets one job per repeat — `microbenchmark.aws` has five — and a manual retry adds another. All of them are separate processes in one build, so the only state they share is build-scoped agent meta-data. Each job claims `obs-agent-commented-<test>` before posting; a job that finds it claimed skips. The claim is written and then **read back**, because `buildkite-agent meta-data` has `set`/`get`/`exists`/`keys` and no compare-and-set. `set` is last-writer-wins, which is what makes the read-back work: when two jobs both find the key unset and both write, the one whose write landed last reads its own id back and owns the claim, and the other reads a stranger's and stands down. Two details make that safe: - The claimant is `BUILDKITE_JOB_ID`, and the protocol is skipped entirely when there is none — two jobs writing the same constant would each read it back and both proceed. - A read-back returning nothing means the *agent call* failed, not that somebody else won: the key was just written. Standing down there would let a broken agent silently drop the analysis, so it stands down only on positively reading another job's id. It narrows the race rather than closing it: both jobs still post if one reads back before the other writes at all, which needs the second write to land in the gap between one job's two consecutive local calls. A failed post hands the claim back, by writing an empty value since the agent cannot delete a key — but **only when retrying could succeed** (`>= 500`, `429`, timeouts). Releasing on a permanent failure would make every remaining job fail the same way. **The buildkite annotation is deliberately not deduped this way.** One agent report per test *job* is the point of it, and `test_the_annotation_is_not_deduped_by_build` pins that. ### Everything interpolated into the comment is defused first The body is markdown and the summary is free-form text from an LLM, so three things in it would be side effects rather than intentions: - `@name` notifies a real person and `ray-project#123` posts a backlink on that issue. Both autolinks are broken with an empty html comment, which renders as nothing. - Anything github reads as an html tag is dropped by its sanitizer — `<lambda>` and `<module>` are routine in a traceback — so the markup is escaped. - Code spans and fenced blocks are left alone entirely: github autolinks neither and renders neither as html there, and rewriting inside one would show the escapes and the comment marker literally in a quoted log line. The slack thread url is another value the agent's response decides, so only a plain http url becomes a link, in the `<...>` destination form; anything else is shown as code rather than given the chance to close the link early. ### Oversized summaries are trimmed, not dropped Github rejects a body over 65536 characters. The rest of the body is measured first and the summary given what is left, so the failing build and the slack link always survive. The trim cuts the *raw* summary and re-sanitizes, because sanitizing expands — `<` becomes four characters and `@` eight — so a budget spent on raw characters would still overflow, and cutting sanitized text would cut through a half-written entity. Each pass shortens in proportion to the overshoot rather than by it; subtracting it outright discarded the whole analysis for a summary full of `@` or `<`. This matters beyond tidiness: the 422 an over-long body produces is exactly the permanent failure the claim above does not release on, so without the trim one oversized analysis meant no comment from any job in the build. ### Nothing here can change a test result `glue.py` runs the reporting loop without guarding `reporter.report_result(...)`, so every failure in this path is caught and logged. `GitHubClient` also gained a 30s timeout on every request: it had none, and `requests` waits forever, so a github that accepted the connection and never answered would have held the step open until buildkite killed it. ### Not duplicating existing work ```bash gh pr list --repo ray-project/ray --state open --search "observability agent github issue comment" gh pr list --repo ray-project/ray --state open --search "ray_release reporter github issue" ``` Neither returns anything in this area. The three PRs this builds on (ray-project#65906, ray-project#65910, ray-project#66006) are all merged. ### Tests `//release/...` cannot be analysed on current master — `//release:requirements_py310_test` references `@rules_python//python/pip_install:pip_compile.py`, which no longer exists in the pinned rules_python. That reproduces on a clean master checkout with no changes, so the release_unit targets were run explicitly instead: ```bash $ bazel test $(bazel query 'attr(tags, release_unit, tests(//release:all))') --test_output=errors Executed 29 out of 29 tests: 29 tests pass. $ pre-commit run # all hooks passed ``` New coverage in `test_observability_agent_reporter.py` and `test_test.py`: | Behavior | Test | | --- | --- | | Comments on an open issue, fetching it exactly once | `test_comments_on_an_open_issue` | | Silent on a closed issue, and without a tracked issue | `test_does_not_comment_on_a_closed_issue`, `test_does_not_reach_github_without_a_tracked_issue` | | A github failure never propagates | `test_a_github_failure_does_not_propagate` | | The agent's prose cannot mention people or link issues | `test_the_comment_cannot_mention_people_or_link_issues` | | Markup is escaped, but not inside code | `test_the_comment_escapes_what_the_agent_sent`, `test_the_comment_leaves_the_agents_code_spans_alone` | | The slack url cannot escape its link | `test_the_slack_link_target_cannot_escape_the_link`, `test_the_slack_link_uses_a_bounded_destination` | | One comment per build across five jobs | `test_only_one_job_per_build_comments` | | A simultaneous claim is settled by the read-back | `test_the_loser_of_a_simultaneous_claim_does_not_comment` | | An unverifiable claim still comments; no job id means no claim | `test_an_unverifiable_claim_still_comments`, `test_no_claim_without_a_job_id_to_claim_with` | | Transient failures release the claim, permanent ones keep it | `test_a_transient_failure_hands_the_claim_back`, `test_a_permanent_failure_keeps_the_claim` | | The annotation is *not* deduped | `test_the_annotation_is_not_deduped_by_build` | | Oversized summaries are trimmed and still fit | `test_an_oversized_summary_is_trimmed_to_fit` | | With no summary the thread is the body; with neither, no comment | `test_the_comment_points_at_the_thread_when_there_is_no_summary`, `test_no_comment_when_the_agent_returned_nothing` | | `get_open_github_issue` over open / closed / unreachable | `test_get_open_github_issue` | The dedupe and response-shape tests were checked against the bug rather than only the fix: with them in place, removing the claim fails five of them, and removing just the read-back fails the simultaneous-claim one. Note that `bazel test //release/...` cannot be analysed on current master — `//release:requirements_py310_test` references `@rules_python//python/pip_install:pip_compile.py`, which no longer exists in the pinned rules_python. That reproduces on a clean master checkout, hence the explicit target list above. ### Exercised end to end on a real build The reporter is gated to `AUTOMATIC=1` runs on master, so a PR pipeline would normally run none of this. Four temporary commits opened that path — a canned agent response, a stand-in anyscale job id and issue number, a forced failure of `hello_world.aws` before anything reached anyscale, and an unconditional export of the flag — and **all four are reverted in this PR** (`73cc06406a`, `07d9850788`, `34c63a2014`, `3eb60acc13`). The canned summary was written to carry exactly what the comment path defuses. Verified on builds 108426 and 108659, commenting on a scratch issue: | Behaviour | | | --- | --- | | Comment posted on the tracked issue | verified | | `Latest run:` names the failing job | verified | | `<class 'ValueError'>` and `<lambda>` render as text, not eaten by the sanitizer | verified | | `@someone` and `ray-project#1234` render plainly — no notification, no issue backlink | verified | | A fenced code block passes through verbatim | verified | | A url `#fragment` in the prose survives | verified | | Slack link in the bounded `<...>` destination form | verified | | **One comment per build**, across the final automatic retry and each manual one | verified | | **The annotation is not deduped** — one per job, per attempt | verified | One defect surfaced and was fixed: the comment came out with no `Latest run:` line, because `result.buildkite_url` is filled in by `_load_test_configuration` and the forced failure raised above the call to it. That was the scaffolding rather than the feature — a real failure cannot reach the reporters that way, since that call is the first thing in the `try`. ### AI assistance AI assistance (Claude Code) was used for this change. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: sai.miduthuri <sai.miduthuri@anyscale.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…is on the test's github issue (#66477) (#66859) Cherry-pick of #66477 (master commit f733abf) onto `releases/2.60.0`. Bottom of a two-PR stack: #66860 (the pick of #66557) sits on top of this one and depends on it. #66557 isn't retroactive: a release-branch build runs that branch's own copy of the init script and reporter, and `releases/2.60.0` was cut before either PR merged. Both picks are needed to run the observability agent on 2.60.0 release tests. On its own this pick changes no behavior on the branch, since the agent stays master-only until #66860. It adds the GitHub issue comment path that #66557 builds on (and keeps master-only). Its prerequisites #65906, #65910 and #66006 are already on the branch. Applied cleanly with `git cherry-pick -x`; the diff is identical to the master commit. Not a duplicate: no existing PR against a release branch references #66477. Testing, from the repo root of this branch with the repo venv (`responses` added to `PYTHONPATH` for `test_test.py`): ``` python -m pytest release/ray_release/tests/test_observability_agent_reporter.py release/ray_release/tests/test_test.py 107 passed ``` AI assistance was used to prepare this cherry-pick. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: sai.miduthuri <sai.miduthuri@anyscale.com> Signed-off-by: Sai Miduthuri <sai.miduthuri@anyscale.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

Description
Stacked on #65910, which is stacked on #65906. Review those first; this PR's
diff is only the annotation on top of them.
#65910 puts the observability agent's analysis in its own buildkite group at the
end of the failed step — but that only reaches someone who opens the step. This
adds a buildkite annotation, so a red nightly shows its analyses without
digging through step output.
The annotation is job-scoped (
--scope=job), so it renders on the job itcame from; buildkite also surfaces job-scoped annotations on the build page, so
one call reaches both places. Scope is what decides this:
--scopedefaults tobuild, and--jobalone only records which job an annotation came from — thefirst run on this branch passed
--jobwithout--scopeand the annotationlanded at the build level, which is how this was found.
The full command:
Each attempt of a retried test gets its own annotation, because a buildkite
retry is a new job with a new id — the API shows
retry_source.job_iddifferingfrom
id— and a job-scoped annotation is identified by its context within itsjob. So every attempt's report is kept, attributed to the attempt that produced
it, and buildkite shows them together on the build page.
The context is
obs-agent-<test name>because an annotation needs one andbuildkite defaults it to
"default"; the test name makes it identifiable in theUI and keeps it clear of anything else annotating the same job. It is not what
separates the attempts — scope already does that.
--appendis passed for thecase of a job annotating twice under this context, which does not happen today
(one release test job runs one test once), but where replacing would be wrong.
Each block is headed with the test name, the attempt number, and a link to the
Anyscale job:
Both numbers are rendered because they disagree by one: buildkite labels the
first try "Retry 1 of N", while
BUILDKITE_RETRY_COUNTcounts retries afterit and is
0there. Rendering the raw value alone put "attempt 3" on the attemptthe UI calls "Retry 4 of 5"; rendering both reconciles the annotation with the
label it hangs off and with the job log, which prints the raw value.
Guarded on
BUILDKITE, and wrapped so that neither a missing binary nor anon-zero exit can change the outcome of the test run — the same rule the rest of
this reporter follows. Everything interpolated into the body is HTML-escaped:
buildkite renders the body as markup, and the agent's summary is free-form prose
where an unescaped
<would be rendered rather than shown.This is not new ground for the repo.
buildkite-agent annotatealready hastwo call sites:
release/ray_release/byod/build.py:71-81, from Python insideray_release, and.buildkite/hooks/post-command:19-20, from the host hook.It also needs a change to the release test step config
The first CI run on this branch produced no annotation:
Two separate causes, both in the step's docker plugin config, and both fixed in
f51462c6f4:/usr/local/bin/buildkite-agent, but the agent on thesehosts is
/usr/bin/buildkite-agent-stable. The source path does not exist, sodocker creates an empty directory at the destination, and executing a
directory is
EACCES— the error above, rather than theENOENTa missingfile would give. That volume had to be removed, not supplemented:
/usr/local/binprecedes/usr/binonPATHin thepythonimage, so thestale directory shadows any working binary mounted elsewhere.
BUILDKITE_AGENT_ACCESS_TOKENis not among the variables reaching thecontainer —
propagate-environmentdoes not carry it — so even a workingbinary could not authenticate.
mount-buildkite-agent: truehandles both. Note that the rayci keys(
allow_mount_buildkite_agentin.buildkite/release/config.yml,mount_buildkite_agentinbuild.rayci.yml) do not apply here: release teststeps are written to
release_tests.jsonand uploaded straight to buildkite bycustom_image_build_and_test_init, never generated by rayci, so the dockerplugin's own hyphenated option is the reachable lever.
Worth a reviewer's attention: this grants every release test container a
working
buildkite-agentand the agent access token, so test code couldannotate the build or upload artifacts as the agent. It is the same trust level
the release build steps already run at, and release tests are internal code,
but it is a change in posture on a shared file rather than a change local to
this reporter.
Related issues
None.
Additional information
Verified on real builds
Three temporary commits carried this PR through CI and have all been reverted;
each pair is on the branch so the history shows what was run:
131706a3e3serve a canned agent response98c8f488b3e5f741e939forcehello_worldto failab9f02ebf3e456942239exportTRIGGER_OBSERVABILITY_AGENTunconditionallycb49f5edf4AUTOMATICmaster runs, so a PR pipeline would otherwise run none of thisNo temporary commit remains in effect. The final run was against a genuinely
failing test with the real agent, and produced the reports as intended.
What the builds settled
A job-scoped annotation does not accumulate across retries — each attempt
annotates its own job. The docs were not explicit about whether
--appendwith a stable context across a new
--jobgrows one annotation or opensseveral. Build 106662 answers it: the two attempts of
hello_world.awsthatreached the agent produced two annotations under the identical context
obs-agent-hello_world.aws, one block each — "attempt 3" on one job, "attempt 4"on the next. Scope, not the context, is what separates them.
That is the behaviour this PR wants: every attempt's analysis is kept and
attributed to the attempt that produced it, rather than concatenated into one
block where it is unclear which run said what.
Two related notes for anyone reading these programmatically:
Build.annotationsreturns only build-scoped annotations over both the REST and GraphQL APIs — the
job-scoped ones are reachable under
Job.annotations— and the build pageshowing them is a UI behaviour, not API-level promotion.
Automatic retries produce exactly one trigger per build. Since #65907 landed, the step publishes
BUILDKITE_MAX_RETRIES=3forhello_world(from itsnum_retries: 3) and thescript honours it, so
_is_transient_errorrewrites every attempt except thelast to
TRANSIENT_INFRA_ERROR, which is not a trigger status:BUILDKITE_RETRY_COUNTretry_count >= max_retryTRANSIENT_INFRA_ERRORTRANSIENT_INFRA_ERRORERRORThat is #65907 working as intended — one recorded result, and one analysis, per
build. No
num_retriesvalue changes it. The multiple-attempt case above wastherefore reached with a manual retry: once the automatic retries are exhausted at
retry_count=3, a manual retry givesretry_count=4, so4 >= 3holds and theattempt reports a real error and triggers. That is also the realistic case, since
a manual retry is what a human does while debugging a failure.
Not duplicating existing work
Neither returns anything in this area beyond the two PRs this one is stacked on.
Tests
New coverage:
--jobfor attributiontest_the_annotation_is_job_scoped_and_keyed_on_the_testtest_the_attempt_number_matches_the_buildkite_labeltest_the_annotation_escapes_what_the_agent_senttest_no_annotation_outside_buildkitebuildkite-agentbinary is swallowedtest_annotation_failures_never_propagatetest_a_non_zero_annotate_exit_is_logged_not_raisedTwo bugs the tests caught while writing them:
--jobwas appended after thebody, leaving the job id as the positional argument rather than the annotation
text; and the guard was
"BUILDKITE" not in os.environ, which treatsBUILDKITE=""as being on an agent. The guard now matchesbyod/build.py.AI assistance
AI assistance (Claude Code) was used for this change.