Skip to content

fix(llc): surface the persisted skip reason in the heartbeat monitor (#12681) - #14761

Merged
mrveiss merged 3 commits into
Dev_new_guifrom
issue-12681-skip-reason
Aug 22, 2026
Merged

mrveiss merged 3 commits into
Dev_new_guifrom
issue-12681-skip-reason

Conversation

@mrveiss

@mrveiss mrveiss commented Aug 22, 2026

Copy link
Copy Markdown
Owner

Refs #12681. Deliberately not Closes — this is one of three items the issue
covers; see "What this does not fix".

Thinking Path

#12681 reports that setup-wizard provisioning fails invisibly. Tracing where the
information is actually lost changed the fix.

A heartbeat that never dispatched raises HeartbeatDispatchSkipped, and the
scheduler already handles it deliberately — it records SKIPPED and stores
exc.reason:

except HeartbeatDispatchSkipped as exc:
    final_status = LLCRunStatus.SKIPPED.value
    error_msg = exc.reason

_run_to_dict already returns that field, so the reason survives all the way to
the client. It was never displayed: the component's AgentRun interface did not
declare error, and the run row rendered only status, date and duration.

So this is not a missing-plumbing problem. The reason is persisted, served, and
then dropped one line short of the screen. Every skip therefore looked identical
— a missing adapter binary, an active cooldown, and an agent with no work were
indistinguishable without querying the database directly, which is precisely the
"fails invisibly" symptom the issue describes.

I also found SKIPPED has no status colour: .status-skipped was never defined,
while queued, running, failed, timed_out and unknown all are. The one
status whose entire meaning is "this did not run" rendered as unstyled text.

What Changed

  • AgentRun declares error?: string | null.
  • The run row renders a reason line when the run carries one, labelled by status
    so a SKIPPED run is not misread as a crash.
  • .status-skipped styling added, matching the existing status colour scheme.
  • skipReasonLabel / errorLabel added to all 11 locales.
  • Three component tests.

Verification

The tests mount HeartbeatMonitor and drive it through the real path
(fetchAgents → click row → openHistory → render), rather than asserting on
the response shape. That distinction is the point: the field was already present
in the payload, so any test written against the API would have passed for the
entire time the defect was live.

  • skipped run with a reason → .run-reason present, contains the reason text
    and the skip label
  • failed run → carries the error label, not the skip label
  • completed run with error: null → no .run-reason element

The third is the control. Without it the first two would still pass if the row
were rendered unconditionally, which would put an empty label on every run.

Locale edits were made structurally and verified to be exactly two added lines
per file (11 files changed, 22 insertions(+)), with each file confirmed to
round-trip byte-for-byte beforehand so no reformatting is smuggled in.

Not verified locally: the frontend suite is not run here, so the component tests
are verified by CI, not by me.

What this does not fix

#12681 covers three independent items. This is item 3 only.

Item 1 — nothing installs the claude CLI onto the service PATH — and item 2 —
llc/api/agent_hires.py validates that an adapter type is registered but never
that it is available, so a hire can succeed for an adapter that cannot run —
are both untouched here.

Those two are sequenced: item 2 must not land before item 1. Today an
operator can hire an agent and hand-install the CLI afterwards. Rejecting the
hire first, while nothing installs the binary, would make provisioning strictly
worse rather than better.

This item is independent of that ordering, which is why it goes first.

Model Used

Claude Opus 5

…12681)

A heartbeat that never dispatched records SKIPPED and stores the reason in
`error`, and `GET /api/llc/agents/{id}/runs` has always returned that field.
The monitor rendered the status and dropped the reason, so every skip looked
the same: a missing adapter binary, an active cooldown and an agent with no
work were indistinguishable without reading the database.

The field was one line from visible. `AgentRun` now carries `error`, the run
row renders it, and the label distinguishes a skip from a failure so a SKIPPED
run is not read as a crash. SKIPPED also had no status colour at all — the one
status whose whole point is "this did not run" rendered like every other row.

Labels are added to all 11 locales. The tests mount the component rather than
asserting on the response shape: the field being present in the payload proves
nothing while the template never reads it, which is how this stayed invisible.
@github-actions

Copy link
Copy Markdown
Contributor

✅ SSOT Configuration Compliance: Passing

🎉 No hardcoded values detected that have SSOT config equivalents!

@codecov

codecov Bot commented Aug 22, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

…12681)

`رد شد` is the phrasing this codebase already uses for *denied* — see
`common.permissionDenied.title` ("دسترسی رد شد" = access denied) — so a Persian
operator would read a skipped heartbeat as a rejected one. That is the exact
confusion between "did not run" and "was refused" this change exists to remove,
so the label defeated its own purpose in one of the eleven locales.

Also unmounts the component between tests. It starts a 15s auto-refresh interval
on mount that only `onUnmounted` clears, and this is the first test file the
component has had, so nothing was clearing it.
@mrveiss
mrveiss merged commit 6c68ac6 into Dev_new_gui Aug 22, 2026
48 checks passed
@mrveiss
mrveiss deleted the issue-12681-skip-reason branch August 22, 2026 08:36
mrveiss added a commit that referenced this pull request Aug 22, 2026
…12681) (#14790)

The backend image installs claude and gh from signed apt repos and proves both
resolve at build time. Nothing did the equivalent on an ansible-provisioned host:
searching the ansible tree for claude returned zero files. resolve_cli_binary
therefore returned None for every claude_code / copilot_local agent hired on a
systemd deployment — the hire succeeded, dispatch could not, and the failure
surfaced as a skipped run rather than a provisioning error.

Same signed repos as the image, fetched with get_url rather than piped into a
shell, plus a resolve check on the host so a broken PATH fails provisioning
instead of coming up hosting agents that cannot run.

The tasks sit before the #12139 restart-guarded window: they reach two
third-party repos, and inside that block a transient outage would abort the whole
deploy after the service was stopped.

gh is included because copilot_local_adapter resolves it the same way; installing
only claude would leave that adapter equally unrunnable.

The guard asserts the provisioning rather than the resolver, and every assertion
was mutation-tested — removing gh from the package list, deleting the verify task
and dropping claude from its loop each turn exactly one assertion red.

Refs #12681 — item 1 of three. Item 3 shipped as #14761; item 2 is #14797.
mrveiss added a commit that referenced this pull request Aug 22, 2026
…4797)

Hiring already rejected an unregistered adapter_type, and the comment on that check
names the failure it prevents: an agent whose every heartbeat is skipped, looking
degraded forever. A registered type whose CLI is missing produced exactly that one
level down.

Nothing new was needed. is_cli_available() and cli_not_found_message() already
existed on the subprocess base, and GET /adapters already reported availability from
them; the hire path simply never asked.

Review caught the gate reproducing the defect it was written to close:
codex_subscription is registered, has no probe, and its invoke raises
NotImplementedError, so treating a missing probe as in-process let it be hired while
the UI greyed it out. Both surfaces now decide from the same hasattr. Of the five
registered adapters, four inherit the subprocess base and codex_subscription is the
only one without the probe, so a missing probe means unimplemented, never
in-process.

A crashing probe fails closed, matching GET /adapters.

Refs #12681 — item 2 of three; items 1 and 3 landed as #14790 and #14761. The
template import path creates agents without these checks: #14800.
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