-
-
Notifications
You must be signed in to change notification settings - Fork 29
Expand file tree
/
Copy pathstate_reconciliation.py
More file actions
621 lines (539 loc) · 27.8 KB
/
Copy pathstate_reconciliation.py
File metadata and controls
621 lines (539 loc) · 27.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
"""
State reconciliation system.
Compares what the user wants with what is there and what runs:
- desired: config.json (which plugins are configured, and enabled) plus the
plugins directory on disk (which are installed, at which version);
- observed: the runtime snapshot the display publishes
(src/plugin_system/plugin_runtime.py) -- which plugins it has loaded, at
which version, and why one failed. Only a live snapshot is compared; a
stale, stopped or missing one is unknown and yields no findings.
Desired-state gaps (on disk but not in config, in config but not on disk)
are fixed here. Observed-state gaps (enabled but not loaded, loaded at an
older version) are reported, never "fixed": the display reconciles its own
loaded set against config, and a version gap needs a display restart.
There is no third, persisted record any more. ``data/plugin_state.json``
held a copy of config's enabled flags and the disk's versions, and this
module mostly synced it back to config; it is no longer read or written.
"""
import json
from typing import Any, Callable, Dict, List, Optional, Set
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from src.core_config_keys import CORE_CONFIG_KEYS, CORE_SECRETS_KEYS
from src.plugin_system.plugin_dirs import PluginDirectoryIndex
from src.plugin_system.plugin_runtime import PluginRuntimeView, UNKNOWN
from src.logging_config import get_logger
class InconsistencyType(Enum):
"""Types of state inconsistencies."""
PLUGIN_MISSING_IN_CONFIG = "plugin_missing_in_config"
PLUGIN_MISSING_ON_DISK = "plugin_missing_on_disk"
PLUGIN_ENABLED_MISMATCH = "plugin_enabled_mismatch"
PLUGIN_VERSION_MISMATCH = "plugin_version_mismatch"
PLUGIN_STATE_CORRUPTED = "plugin_state_corrupted"
class FixAction(Enum):
"""Actions that can be taken to fix inconsistencies."""
AUTO_FIX = "auto_fix"
MANUAL_FIX_REQUIRED = "manual_fix_required"
NO_ACTION = "no_action"
@dataclass
class Inconsistency:
"""Represents a state inconsistency."""
plugin_id: str
inconsistency_type: InconsistencyType
description: str
fix_action: FixAction
current_state: Dict[str, Any]
expected_state: Dict[str, Any]
can_auto_fix: bool = False
@dataclass
class ReconciliationResult:
"""Result of state reconciliation."""
inconsistencies_found: List[Inconsistency]
inconsistencies_fixed: List[Inconsistency]
inconsistencies_manual: List[Inconsistency]
reconciliation_successful: bool
message: str
def secrets_top_level_keys(config_manager) -> Set[str]:
"""Top-level keys load_config() merges in from the secrets file.
Deliberately fail-safe: an unreadable, absent, malformed or non-path
secrets location narrows this set rather than raising, because a failure
here must never break reconciliation.
"""
try:
path = config_manager.get_secrets_path()
with open(path, 'r') as f:
secrets = json.load(f)
except (AttributeError, OSError, TypeError, ValueError):
return set()
return set(secrets) if isinstance(secrets, dict) else set()
def ignored_config_keys(config_manager, installed_ids=frozenset()) -> Set[str]:
"""Config keys that are not plugin ids: core keys plus secrets keys.
A secrets key that is also an installed plugin's id is not ignored: plugin
secrets are namespaced by plugin id, so that key is the plugin's own entry.
Ignoring it made every installed plugin with secrets read as "installed but
missing from config" on each run.
"""
secrets_only = secrets_top_level_keys(config_manager) - set(installed_ids)
return set(StateReconciliation._SYSTEM_CONFIG_KEYS) | secrets_only
def config_plugin_ids(config: Dict[str, Any], ignored_keys: Set[str]) -> Set[str]:
"""Plugin ids a loaded config declares.
Shared with the web interface so a stored verdict is re-checked against the
same definition that produced it. ``set(config)`` is NOT equivalent: it also
contains system keys, the secrets-file keys load_config() merges in, and
non-dict values. Counting any of those as a plugin is exactly what turned a
'data' key in the secrets file into a phantom plugin, and using the loose
set to re-check findings would clear ones that are still true.
"""
return {k for k, v in (config or {}).items()
if isinstance(v, dict) and k not in ignored_keys}
def disk_plugin_ids(plugins_dir) -> Set[str]:
"""Plugin ids actually installed on disk.
A directory counts only when it is not a standalone backup (or hidden)
and its manifest.json parses. A corrupt manifest must not read as
installed, or a live "in config but not on disk" finding gets cleared on
the strength of an unreadable file.
The id is the manifest's ``id`` -- what discovery registers and what the
config is keyed by -- and the directory name only when the manifest has
none. Directory names alone made a plugin living in ``ledmatrix-stocks/``
with id ``stocks`` read as both "stocks in config but not on disk" and
"ledmatrix-stocks on disk but not in config".
"""
try:
return _disk_index(plugins_dir).installed_ids(require_parseable_manifest=True)
except OSError:
return set()
def _disk_index(plugins_dir) -> PluginDirectoryIndex:
return PluginDirectoryIndex.scan(Path(plugins_dir))
def still_unresolved(entries: List[Dict[str, Any]],
config_keys: Set[str],
installed_ids: Set[str]) -> List[Dict[str, Any]]:
"""Drop stored reconciliation findings that are no longer true.
The verdict is written once to a status file and served to the web UI from
there, and a run that fails to apply a fix also declares it "will not retry
automatically". Together those froze a single moment forever: a device whose
plugins were all present in config kept being told, for hours, that four of
them were missing and should be removed from config.json.
An "in config but not on disk" finding is stale once the plugin is
installed, and equally once the id is no longer a plugin entry in config --
whether the user removed it or it was never a plugin (a core key such as
``auto_update`` that an older build misread). Without the second check a
verdict written by such a build kept telling users to delete a real core
setting until the next full reconciliation run.
Entry kinds this cannot re-check are kept, so filtering only ever removes
findings that are provably stale.
"""
live: List[Dict[str, Any]] = []
for entry in entries:
kind = entry.get('type')
plugin_id = entry.get('plugin_id')
if kind == InconsistencyType.PLUGIN_MISSING_IN_CONFIG.value:
if plugin_id not in config_keys:
live.append(entry)
elif kind == InconsistencyType.PLUGIN_MISSING_ON_DISK.value:
if plugin_id in config_keys and plugin_id not in installed_ids:
live.append(entry)
else:
live.append(entry)
return live
RuntimeSource = Callable[[], PluginRuntimeView]
class StateReconciliation:
"""
State reconciliation system.
Compares desired state (config + disk) with observed state (the
display's runtime snapshot) and fixes what can safely be fixed.
"""
def __init__(
self,
*,
config_manager,
plugins_dir: Path,
store_manager=None,
runtime_source: Optional[RuntimeSource] = None,
):
"""
Initialize reconciliation system.
Args:
config_manager: ConfigManager instance
plugins_dir: Path to plugins directory
store_manager: Optional PluginStoreManager for auto-repair
runtime_source: Returns the display's runtime snapshot as a
PluginRuntimeView (plugin_runtime.read_plugin_runtime bound to
a cache manager). None: observed state is unknown.
"""
self.config_manager = config_manager
self.plugins_dir = Path(plugins_dir)
self.store_manager = store_manager
self.runtime_source = runtime_source
self.logger = get_logger(__name__)
# Plugin IDs that failed auto-repair and should NOT be retried this
# process lifetime. Prevents the infinite "attempt to reinstall missing
# plugin" loop when a config entry references a plugin that isn't in
# the registry (e.g. legacy 'github', 'youtube' entries). A process
# restart — or an explicit user-initiated reconcile with force=True —
# clears this so recovery is possible after the underlying issue is
# fixed.
self._unrecoverable_missing_on_disk: Set[str] = set()
def reconcile_state(self, force: bool = False) -> ReconciliationResult:
"""
Perform state reconciliation.
Compares state from all sources and fixes safe inconsistencies.
Args:
force: If True, clear the unrecoverable-plugin cache before
reconciling so previously-failed auto-repairs are retried.
Intended for user-initiated reconcile requests after the
underlying issue (e.g. registry update) has been fixed.
Returns:
ReconciliationResult with findings and fixes
"""
if force and self._unrecoverable_missing_on_disk:
self.logger.info(
"Force reconcile requested; clearing %d cached unrecoverable plugin(s)",
len(self._unrecoverable_missing_on_disk),
)
self._unrecoverable_missing_on_disk.clear()
self.logger.info("Starting state reconciliation")
inconsistencies = []
fixed = []
manual_fix_required = []
try:
# Desired: config + disk. Observed: the display's snapshot.
config_state = self._get_config_state()
disk_state = self._get_disk_state()
observed = self._get_observed_state()
# Plugins the display reports but neither config nor disk knows
# (removed while it still runs them) are not a finding of their
# own: the display unloads them when their section goes.
all_plugin_ids: Set[str] = set()
all_plugin_ids.update(config_state.keys())
all_plugin_ids.update(disk_state.keys())
# Check each plugin for inconsistencies
for plugin_id in all_plugin_ids:
plugin_inconsistencies = self._check_plugin_consistency(
plugin_id,
config_state,
disk_state,
observed,
)
inconsistencies.extend(plugin_inconsistencies)
# Attempt to fix auto-fixable inconsistencies
for inconsistency in inconsistencies:
if inconsistency.can_auto_fix and inconsistency.fix_action == FixAction.AUTO_FIX:
if self._fix_inconsistency(inconsistency):
fixed.append(inconsistency)
else:
manual_fix_required.append(inconsistency)
elif inconsistency.fix_action == FixAction.MANUAL_FIX_REQUIRED:
manual_fix_required.append(inconsistency)
# Build result
success = len(manual_fix_required) == 0
message = (
f"Reconciliation complete: {len(inconsistencies)} inconsistencies found, "
f"{len(fixed)} fixed automatically, {len(manual_fix_required)} require manual attention"
)
return ReconciliationResult(
inconsistencies_found=inconsistencies,
inconsistencies_fixed=fixed,
inconsistencies_manual=manual_fix_required,
reconciliation_successful=success,
message=message
)
except Exception as e:
self.logger.error(f"Error during state reconciliation: {e}", exc_info=True)
return ReconciliationResult(
inconsistencies_found=inconsistencies,
inconsistencies_fixed=fixed,
inconsistencies_manual=manual_fix_required,
reconciliation_successful=False,
message=f"Reconciliation failed: {str(e)}"
)
# Top-level config keys that are NOT plugins. The core keys come from the
# shared list in src/core_config_keys.py -- a private copy here missed
# #581's 'auto_update' and reported it as a plugin missing from disk.
# CORE_SECRETS_KEYS are the core's own secrets-file keys. The secrets file
# itself is read at run time too (ignored_config_keys): load_config() merges
# it in, and naming its keys one by one let a 'data' key become a phantom
# plugin permanently reported as "in config but not on disk".
_SYSTEM_CONFIG_KEYS = CORE_CONFIG_KEYS | CORE_SECRETS_KEYS
def _get_config_state(self) -> Dict[str, Dict[str, Any]]:
"""Get plugin state from config file."""
state = {}
try:
config = self.config_manager.load_config()
ignored = ignored_config_keys(self.config_manager,
disk_plugin_ids(self.plugins_dir))
for plugin_id in config_plugin_ids(config, ignored):
plugin_config = config[plugin_id]
state[plugin_id] = {
# The display's rule: it runs a plugin only when its
# section says "enabled": true.
'enabled': bool(plugin_config.get('enabled', False)),
'exists_in_config': True
}
except Exception as e:
self.logger.warning(f"Error reading config state: {e}")
return state
def _get_disk_state(self) -> Dict[str, Dict[str, Any]]:
"""Get plugin state from disk (installed plugins)."""
state = {}
try:
# Membership uses the same index and rule as disk_plugin_ids, so
# the web interface re-checks stored findings against this same
# definition; each manifest is read once, by the scan.
index = _disk_index(self.plugins_dir)
for plugin_id in index.installed_ids(require_parseable_manifest=True):
entry = index.entry_for_installed_id(plugin_id)
manifest = entry.manifest if entry is not None else None
if not isinstance(manifest, dict):
manifest = {}
state[plugin_id] = {
'exists_on_disk': True,
'version': manifest.get('version'),
'name': manifest.get('name')
}
except Exception as e:
self.logger.warning(f"Error reading disk state: {e}")
return state
def _get_observed_state(self) -> PluginRuntimeView:
"""The display's runtime snapshot; unknown when there is no source or
it cannot be read. Only a live view is compared."""
if self.runtime_source is None:
return PluginRuntimeView(status=UNKNOWN)
try:
return self.runtime_source()
except Exception as e:
self.logger.warning(f"Error reading the display's runtime state: {e}")
return PluginRuntimeView(status=UNKNOWN)
def plugin_states(self) -> Dict[str, Dict[str, Any]]:
"""Desired and observed state for every plugin config or disk knows.
Per plugin: ``installed`` and ``version`` (disk), ``in_config`` and
``enabled`` (config, by the display's rule), and the display's
``loaded`` / ``state`` / ``error_info`` / ``loaded_version`` /
``loaded_at`` (None unless its snapshot is live). What
/api/v3/plugins/state serves, in place of plugin_state.json.
"""
config_state = self._get_config_state()
disk_state = self._get_disk_state()
observed = self._get_observed_state()
states: Dict[str, Dict[str, Any]] = {}
for plugin_id in sorted(set(config_state) | set(disk_state)):
if plugin_id in CORE_CONFIG_KEYS:
continue
config = config_state.get(plugin_id, {})
disk = disk_state.get(plugin_id, {})
states[plugin_id] = {
'plugin_id': plugin_id,
'installed': bool(disk.get('exists_on_disk')),
'version': disk.get('version'),
'in_config': bool(config.get('exists_in_config')),
'enabled': bool(config.get('enabled', False)),
**observed.plugin(plugin_id),
}
return states
def _check_plugin_consistency(
self,
plugin_id: str,
config_state: Dict[str, Dict[str, Any]],
disk_state: Dict[str, Dict[str, Any]],
observed: PluginRuntimeView,
) -> List[Inconsistency]:
"""Check consistency for a single plugin."""
inconsistencies: List[Inconsistency] = []
if plugin_id in CORE_CONFIG_KEYS:
# A plugin whose id is a core setting's key ('display', 'sync',
# ...) can never have a config section of its own: that section
# is the core setting. Every check below would misfire -- "not in
# config" forever, and an auto-fix that writes {'enabled': False}
# where a core setting belongs -- so report it in the log instead.
if disk_state.get(plugin_id, {}).get('exists_on_disk'):
self.logger.warning(
"Plugin id %r is reserved for a core config setting; "
"the plugin cannot be configured and is skipped by "
"reconciliation. Rename the plugin.", plugin_id)
return inconsistencies
config = config_state.get(plugin_id, {})
disk = disk_state.get(plugin_id, {})
# Check: Plugin exists on disk but not in config
if disk.get('exists_on_disk') and not config.get('exists_in_config'):
inconsistencies.append(Inconsistency(
plugin_id=plugin_id,
inconsistency_type=InconsistencyType.PLUGIN_MISSING_IN_CONFIG,
description=f"Plugin {plugin_id} exists on disk but not in config",
fix_action=FixAction.AUTO_FIX,
current_state={'exists_in_config': False},
expected_state={'exists_in_config': True, 'enabled': False},
can_auto_fix=True
))
# Check: Plugin in config but not on disk
if config.get('exists_in_config') and not disk.get('exists_on_disk'):
# Skip plugins that previously failed auto-repair in this process.
# Re-attempting wastes CPU (network + git clone each request) and
# spams the logs with the same "Plugin not found in registry"
# error. The entry is still surfaced as MANUAL_FIX_REQUIRED so the
# UI can show it, but no auto-repair will run.
previously_unrecoverable = plugin_id in self._unrecoverable_missing_on_disk
# Also refuse to resurrect a plugin the user has persistently
# uninstalled. The record survives restarts, so the user's
# removal sticks across updates.
persistently_uninstalled = (
self.store_manager is not None
and hasattr(self.store_manager, 'is_plugin_uninstalled')
and self.store_manager.is_plugin_uninstalled(plugin_id)
)
can_repair = (
self.store_manager is not None
and not previously_unrecoverable
and not persistently_uninstalled
)
inconsistencies.append(Inconsistency(
plugin_id=plugin_id,
inconsistency_type=InconsistencyType.PLUGIN_MISSING_ON_DISK,
description=f"Plugin {plugin_id} in config but not on disk",
fix_action=FixAction.AUTO_FIX if can_repair else FixAction.MANUAL_FIX_REQUIRED,
current_state={'exists_on_disk': False},
expected_state={'exists_on_disk': True},
can_auto_fix=can_repair
))
# Observed checks: only against a live snapshot, and only for a plugin
# that is both configured and installed (the checks above cover the
# rest). Reported, never fixed here: the display loads and unloads by
# config on its own, so a gap is either transient (it is catching up)
# or something only the user can act on (a failed load, a restart).
if (observed.live and config.get('exists_in_config')
and disk.get('exists_on_disk')):
runtime = observed.plugin(plugin_id)
config_enabled = bool(config.get('enabled', False))
loaded = bool(runtime.get('loaded'))
if config_enabled != loaded:
error = runtime.get('error_info') or {}
why = f" ({error.get('message')})" if error.get('message') else ""
inconsistencies.append(Inconsistency(
plugin_id=plugin_id,
inconsistency_type=InconsistencyType.PLUGIN_ENABLED_MISMATCH,
description=(
f"Plugin {plugin_id} is {'enabled' if config_enabled else 'disabled'} "
f"in config but the display has it "
f"{'loaded' if loaded else 'not loaded'} "
f"(state {runtime.get('state')}){why}"),
fix_action=FixAction.NO_ACTION,
current_state={'loaded': loaded, 'state': runtime.get('state')},
expected_state={'loaded': config_enabled},
can_auto_fix=False
))
loaded_version = runtime.get('loaded_version')
disk_version = disk.get('version')
if loaded and loaded_version and disk_version and loaded_version != disk_version:
inconsistencies.append(Inconsistency(
plugin_id=plugin_id,
inconsistency_type=InconsistencyType.PLUGIN_VERSION_MISMATCH,
description=(
f"Plugin {plugin_id} {disk_version} is installed but the display "
f"is running {loaded_version}; restart the display to run it"),
fix_action=FixAction.NO_ACTION,
current_state={'version': loaded_version},
expected_state={'version': disk_version},
can_auto_fix=False
))
return inconsistencies
def _fix_inconsistency(self, inconsistency: Inconsistency) -> bool:
"""Attempt to fix an inconsistency."""
try:
if inconsistency.inconsistency_type == InconsistencyType.PLUGIN_MISSING_IN_CONFIG:
if inconsistency.plugin_id in CORE_CONFIG_KEYS:
# Never create or replace a core setting with a plugin stub.
self.logger.warning(
"Refusing to add plugin entry %r: that key is a core "
"config setting", inconsistency.plugin_id)
return False
config = self.config_manager.load_config()
if inconsistency.plugin_id in config:
# Detection said "not in config" but it is there -- the
# config changed under us, or the id came from a key merged
# in from elsewhere. Assigning the stub below would replace
# the real entry: one reported case would have traded 4.9KB
# of league settings for {'enabled': False}. Nothing to fix.
self.logger.info(
"Skipped: %s is already in config; not overwriting it",
inconsistency.plugin_id)
return True
# Add plugin to config with default disabled state
config[inconsistency.plugin_id] = {
'enabled': False
}
self.config_manager.save_config(config)
self.logger.info(f"Fixed: Added {inconsistency.plugin_id} to config")
return True
elif inconsistency.inconsistency_type == InconsistencyType.PLUGIN_MISSING_ON_DISK:
return self._auto_repair_missing_plugin(inconsistency.plugin_id)
except Exception as e:
self.logger.error(f"Error fixing inconsistency: {e}", exc_info=True)
return False
return False
def _auto_repair_missing_plugin(self, plugin_id: str) -> bool:
"""Attempt to reinstall a missing plugin from the store.
On failure, records plugin_id in ``_unrecoverable_missing_on_disk`` so
subsequent reconciliation passes within this process do not retry and
spam the log / CPU. A process restart (or an explicit ``force=True``
reconcile) is required to clear the cache.
"""
if not self.store_manager:
return False
# Try the plugin_id as-is, then without 'ledmatrix-' prefix
candidates = [plugin_id]
if plugin_id.startswith('ledmatrix-'):
candidates.append(plugin_id[len('ledmatrix-'):])
# Cheap pre-check: is any candidate actually present in the registry
# at all? If not, we know up-front this is unrecoverable and can skip
# the expensive install_plugin path (which does a forced GitHub fetch
# before failing).
#
# IMPORTANT: we must pass raise_on_failure=True here. The default
# fetch_registry() silently falls back to a stale cache or an empty
# dict on network failure, which would make it impossible to tell
# "plugin genuinely not in registry" from "I can't reach the
# registry right now" — in the second case we'd end up poisoning
# _unrecoverable_missing_on_disk with every config entry on a fresh
# boot with no cache.
registry_has_candidate = False
try:
registry = self.store_manager.fetch_registry(raise_on_failure=True)
registry_ids = {
p.get('id') for p in (registry.get('plugins', []) or []) if p.get('id')
}
registry_has_candidate = any(c in registry_ids for c in candidates)
except Exception as e:
# If we can't reach the registry, treat this as transient — don't
# mark unrecoverable, let the next pass try again.
self.logger.warning(
"[AutoRepair] Could not read registry to check %s: %s", plugin_id, e
)
return False
if not registry_has_candidate:
self.logger.warning(
"[AutoRepair] %s not present in registry; marking unrecoverable "
"(will not retry this session). Reinstall from the Plugin Store "
"or remove the stale config entry to clear this warning.",
plugin_id,
)
self._unrecoverable_missing_on_disk.add(plugin_id)
return False
for candidate_id in candidates:
try:
self.logger.info("[AutoRepair] Attempting to reinstall missing plugin: %s", candidate_id)
result = self.store_manager.install_plugin(candidate_id)
if isinstance(result, dict):
success = result.get('success', False)
else:
success = bool(result)
if success:
self.logger.info("[AutoRepair] Successfully reinstalled plugin: %s (config key: %s)", candidate_id, plugin_id)
return True
except Exception as e:
self.logger.error("[AutoRepair] Error reinstalling %s: %s", candidate_id, e, exc_info=True)
self.logger.warning(
"[AutoRepair] Could not reinstall %s from store; marking unrecoverable "
"(will not retry this session).",
plugin_id,
)
self._unrecoverable_missing_on_disk.add(plugin_id)
return False