fix(update): drop config_unreadable: an unreadable config.yaml also hides consent, so no row is ever sent

The exit is real (RuntimeError from require_readable_config_before_write), but the consent gate
reads the same config.yaml and reads it as collection off: a live run on a scratch install with
an unparseable config.yaml wrote no metrics store at all. A closed value nothing can emit only
widens the contract.
This commit is contained in:
Teknium
2026-10-09 03:57:14 -07:00
parent 2379b2e2eb
commit f549ce3fbd
5 changed files with 4 additions and 7 deletions

View File

@@ -2721,7 +2721,6 @@
"channel_unresolved",
"checkout_move_failed",
"commit_point_refused",
"config_unreadable",
"deps_failed",
"detached_head",
"disk_full",

View File

@@ -493,7 +493,6 @@ UPDATE_STOP_CLASSES = frozenset({
"channel_unresolved", # the update channel could not be resolved
"checkout_move_failed", # git could not move the checkout (ff refused, reset or switch failed)
"commit_point_refused", # the commit point could not be armed durably; nothing moved
"config_unreadable", # config.yaml could not be read or parsed, so the update channel is unknown
"detached_head", # a detached checkout stayed put, or its commits could not be backed up
"disk_full", # (see above)
"download_failed", # the ZIP fallback download failed

View File

@@ -1785,11 +1785,11 @@ def _pause_gateways_for_update(opts):
def _update_run_channel(args) -> str:
"""``_source_update_channel`` for this run, naming the exit when config.yaml decides nothing."""
"""``_source_update_channel`` for this run, naming the exit when the configured channel is invalid."""
try:
return _source_update_channel(args)
except (RuntimeError, ValueError) as exc: # unreadable config.yaml / an invalid configured channel name
_record_stop("config_unreadable" if isinstance(exc, RuntimeError) else "channel_unresolved")
except ValueError: # ChannelError: this install's configured channel is not a valid name
_record_stop("channel_unresolved")
raise

View File

@@ -227,7 +227,6 @@ def test_a_checkout_move_names_a_held_index_lock_apart_from_a_permission_error(r
@pytest.mark.parametrize(("site", "raised", "expected"), [
("channel", RuntimeError("Your settings file cannot be read"), "config_unreadable"),
("channel", ValueError("Invalid channel name"), "channel_unresolved"),
("pause", RuntimeError("Could not stop Windows gateway service x"), "gateway_pause_failed"),
])

View File

@@ -506,7 +506,7 @@ they failed. Rows recorded before the change keep their old field set and still
| Metric | Dimensions added | Question it answers |
|---|---|---|
| `hermes.extension.install.count` | failure class, registry | Why skill, plugin and MCP installs fail, and which skills-hub source fails. `failure_class` is `none` on success, else a closed name for the failure exit that stopped the install. Skills: `ambiguous`, `auth_rejected`, `fetch_failed`, `invalid_bundle`, `invalid_name`, `not_found`, `rate_limited`, `scan_blocked`, `stale_index`. Plugins: `already_installed`, `clone_failed`, `deps_declined`, `deps_failed`, `git_missing`, `incompatible`, `invalid_source`, `manifest_invalid`, `non_interactive`, `removed_from_catalog`, `scan_blocked`. MCP servers: `auth_required`, `bootstrap_failed`, `clone_failed`, `config_invalid`, `config_rejected`, `connect_failed`, `git_missing`, `missing_credentials`, `server_start_failed`. Every kind may also read `exception`, `filesystem_error`, `network`, `other`, `permission`. A raised error without a named class is classified by its Python type only (`PermissionError` reads `permission`, connection and timeout errors `network`, other `OSError`s `filesystem_error`, anything else `exception`, its type name dropped). `registry` is set on skill rows only: the skills-hub adapter that resolved or served the skill, one of `browse-sh`, `clawhub`, `github`, `hermes-index`, `lobehub`, `official`, `skills-sh`, `url`, `well-known` (the adapter ids `tools/skills_hub_search.py::create_source_router` builds), `unresolved` when no adapter answered, `none` for built-in restores and every plugin/MCP row, `other` otherwise. A row whose `failure_class` is outside its kind's set, is `none` on a failure or set on a success, or that names a registry on a plugin/MCP row is invalid. Never the identifier, URL, error text or path; `name` keeps its existing catalog-or-`custom` rule. |
| `hermes.update.run` | failure class | Why `hermes update` fails, next to where (`failed_stage`). `none` unless the outcome is `failed`, `refused` or `partial`. Derived from the final update receipt only: `build_failed`, `deps_failed`, `exception`, `fleet_stale`, `fleet_unverified`, `git_failed`, `interrupted`, `lock_held`, `managed_install`, `os_error`, `disk_full`, `permission_denied`, `restart_failed`, `subprocess_failed`, else `other`; Desktop packaged self-updates report `unknown` (their RPC carries a stage, never a reason). An exception that ended the run is read by its type name alone, never its message (a `PermissionError` reads `permission_denied`, an `OSError` whose errno is ENOSPC reads `disk_full`); a failed Windows gateway resume after the update reads `restart_failed`. Every exit that stops a run before its apply stage mark records one closed token at the exit itself, and the run reads it (a few of these, `syntax_rollback`, `stash_restore_rejected`, `unexpected_branch`, `head_moved`, fire after git moved the tree and put it back or left it for the next launch to restore): `fetch_failed`, `git_timeout` (a network git call hit the updater's time limit), `channel_unresolved` (also an invalid channel name in config.yaml), `config_unreadable` (config.yaml cannot be read or parsed, so the channel is unknown), `gateway_pause_failed` (Windows: the gateways could not be paused safely before the venv moves), `git_in_progress` (a merge/rebase/... was already running), `git_index_locked`, `merge_conflict` (local commits on a custom branch), `checkout_move_failed`, `branch_missing`, `parked_branch_blocked`, `detached_head`, `unexpected_branch`, `head_moved` (a ref moved mid-update), `target_unresolved`, `target_syntax_error` (refused before the move), `syntax_rollback` (moved, failed the syntax check, rolled back), `commit_point_refused` (the commit point could not be armed; nothing moved), `venv_foreign_owner`, `local_changes_blocked` (local changes git could not stash, or a dirty tree the ZIP fallback will not overwrite), `stash_restore_rejected`, `not_git_checkout`, `old_version_handoff` (an older updater's hand-off could not finish), `branch_unsupported`, `download_failed`, `zip_failed`, `disk_full`, `lock_held`, `managed_install`. `aborted_before_apply` is now only the fallback for such an exit with no recorded token. The startup-syntax preflight reads the target's critical files from git's object store; when that read times out (a blobless install lazily fetches each blob) the preflight skips them like absent files and the post-move syntax check decides, so the run no longer fails as `subprocess_failed`. The update-lock refusal, the Git-operation refusal and the managed-install refusal fire before the run's receipt opens; they still write no receipt (the receipt store belongs to the update holding the lock) and record one row with that token, with `kind` `desktop` when the update marker names this run's Desktop hand-off (as its delegate or its hand-off partner), never merely because another update holds the marker. A git error that ended the checkout move reads `git_index_locked` only when git reports the index lock already exists; git refused permission to write it (or any file) reads `permission_denied`. A token is read only while the run has no apply mark, so a reason can never outlive the exit that recorded it. A run that committed and then was interrupted, or whose stashed local changes could not be re-applied, reads outcome `partial` (`interrupted` / `local_changes_parked`, `failed_stage` `apply` for the latter), never `failed`; owed follow-ups alone keep the run a `success` (contract C3). A run the pre-update interpreter finishes is parked with the token, the exit code, the stop reason's leading exception type (or one of Hermes' two fixed phrases) and the restart/user-action flags (never argv, paths or messages), so it classifies exactly as the same run finished in-process. A failed run whose every stage mark passed now reads `failed_stage` `verify` (the post-restart verification wrote `partial`) or `restart` (a skipped restart left the fleet owing one) instead of `other`; a `partial` run with a failed build or restart mark keeps that stage, and its verify stage row is failed only when the fleet has a stale or down row. |
| `hermes.update.run` | failure class | Why `hermes update` fails, next to where (`failed_stage`). `none` unless the outcome is `failed`, `refused` or `partial`. Derived from the final update receipt only: `build_failed`, `deps_failed`, `exception`, `fleet_stale`, `fleet_unverified`, `git_failed`, `interrupted`, `lock_held`, `managed_install`, `os_error`, `disk_full`, `permission_denied`, `restart_failed`, `subprocess_failed`, else `other`; Desktop packaged self-updates report `unknown` (their RPC carries a stage, never a reason). An exception that ended the run is read by its type name alone, never its message (a `PermissionError` reads `permission_denied`, an `OSError` whose errno is ENOSPC reads `disk_full`); a failed Windows gateway resume after the update reads `restart_failed`. Every exit that stops a run before its apply stage mark records one closed token at the exit itself, and the run reads it (a few of these, `syntax_rollback`, `stash_restore_rejected`, `unexpected_branch`, `head_moved`, fire after git moved the tree and put it back or left it for the next launch to restore): `fetch_failed`, `git_timeout` (a network git call hit the updater's time limit), `channel_unresolved` (also an invalid channel name in config.yaml), `gateway_pause_failed` (Windows: the gateways could not be paused safely before the venv moves), `git_in_progress` (a merge/rebase/... was already running), `git_index_locked`, `merge_conflict` (local commits on a custom branch), `checkout_move_failed`, `branch_missing`, `parked_branch_blocked`, `detached_head`, `unexpected_branch`, `head_moved` (a ref moved mid-update), `target_unresolved`, `target_syntax_error` (refused before the move), `syntax_rollback` (moved, failed the syntax check, rolled back), `commit_point_refused` (the commit point could not be armed; nothing moved), `venv_foreign_owner`, `local_changes_blocked` (local changes git could not stash, or a dirty tree the ZIP fallback will not overwrite), `stash_restore_rejected`, `not_git_checkout`, `old_version_handoff` (an older updater's hand-off could not finish), `branch_unsupported`, `download_failed`, `zip_failed`, `disk_full`, `lock_held`, `managed_install`. `aborted_before_apply` is now only the fallback for such an exit with no recorded token. The startup-syntax preflight reads the target's critical files from git's object store; when that read times out (a blobless install lazily fetches each blob) the preflight skips them like absent files and the post-move syntax check decides, so the run no longer fails as `subprocess_failed`. The update-lock refusal, the Git-operation refusal and the managed-install refusal fire before the run's receipt opens; they still write no receipt (the receipt store belongs to the update holding the lock) and record one row with that token, with `kind` `desktop` when the update marker names this run's Desktop hand-off (as its delegate or its hand-off partner), never merely because another update holds the marker. A git error that ended the checkout move reads `git_index_locked` only when git reports the index lock already exists; git refused permission to write it (or any file) reads `permission_denied`. A token is read only while the run has no apply mark, so a reason can never outlive the exit that recorded it. A run that committed and then was interrupted, or whose stashed local changes could not be re-applied, reads outcome `partial` (`interrupted` / `local_changes_parked`, `failed_stage` `apply` for the latter), never `failed`; owed follow-ups alone keep the run a `success` (contract C3). A run the pre-update interpreter finishes is parked with the token, the exit code, the stop reason's leading exception type (or one of Hermes' two fixed phrases) and the restart/user-action flags (never argv, paths or messages), so it classifies exactly as the same run finished in-process. A failed run whose every stage mark passed now reads `failed_stage` `verify` (the post-restart verification wrote `partial`) or `restart` (a skipped restart left the fleet owing one) instead of `other`; a `partial` run with a failed build or restart mark keeps that stage, and its verify stage row is failed only when the fleet has a stale or down row. |
<!-- ---- end iuf c1 ---- -->
<!-- ---- iuf c2 ---- -->
#### Fresh installs