Skip to content

Commit 1ca6c68

Browse files
authored
Recover extension doctor readiness across LoopX releases (#3556)
* fix(extensions): stabilize doctor runtime identity Signed-off-by: huangruiteng <huangrt01@163.com> * fix(update): revalidate enabled extensions after release changes Signed-off-by: huangruiteng <huangrt01@163.com> * fix(update): surface extension revalidation outcome Signed-off-by: huangruiteng <huangrt01@163.com> * test(extensions): mutate content for stale proof Signed-off-by: huangruiteng <huangrt01@163.com> --------- Signed-off-by: huangruiteng <huangrt01@163.com>
1 parent 2dc0781 commit 1ca6c68

10 files changed

Lines changed: 411 additions & 43 deletions

File tree

‎docs/reference/extensions.md‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,18 @@ loopx extension enable openviking-semantic-preference --execute --format json
279279
loopx extension install --bundled loopx-lark --execute --format json
280280
```
281281

282+
After an install, update, or rollback changes the active LoopX release,
283+
revalidate all enabled extension runtimes as one bounded read-only batch:
284+
285+
```bash
286+
loopx extension doctor --all-enabled --execute --format json
287+
```
288+
289+
The local installer runs this batch automatically. A passing doctor refreshes
290+
only the local runtime-identity proof; it grants no new provider permission and
291+
performs no connector write. Failed providers remain fail closed and the batch
292+
names the exact repair command.
293+
282294
For a separately distributed provider, pass `--manifest <extension.toml>`.
283295
`upgrade` validates and probes the new manifest before changing the active
284296
revision. `rollback` probes the previous revision before switching back. A
@@ -524,10 +536,11 @@ authorization.
524536
`disable` is reversible, but `enable` never trusts an earlier readiness result:
525537
it reruns the configured doctor and changes the enabled bit only after that
526538
probe succeeds. A successful doctor binds readiness to both the active manifest
527-
revision and the resolved runtime identity. Missing or replaced executables,
528-
interpreters, or Python module sources fail closed until a new executed doctor
529-
succeeds; a failed executed doctor clears the stale proof without switching
530-
revisions.
539+
revision and a content-addressed runtime identity. Moving an unchanged release
540+
to a new install root, inode, or equivalent interpreter path preserves that
541+
identity; changed executable, interpreter, or Python module content fails closed
542+
until a new executed doctor succeeds. A failed executed doctor clears the stale
543+
proof without switching revisions.
531544

532545
An enabled implementation is resolved by capability id and versioned protocol,
533546
then checked against its declared permission, current revision, and current

‎loopx/cli_commands/extension.py‎

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,26 @@
11
from __future__ import annotations
22

33
import argparse
4-
from collections.abc import Callable
54
import json
6-
from pathlib import Path
75
import sys
6+
from collections.abc import Callable
7+
from pathlib import Path
88

99
from ..extensions.bundled import BUNDLED_EXTENSION_IDS, bundled_extension_manifest
1010
from ..extensions.presentation import publish_extension_projection
11-
from ..extensions.scaffold import scaffold_extension
1211
from ..extensions.runtime import (
1312
MAX_EXTENSION_REQUEST_BYTES,
1413
default_extension_state_file,
1514
disable_extension,
15+
doctor_enabled_extensions,
1616
doctor_installed_extension,
1717
enable_extension,
1818
extension_status,
1919
install_extension,
2020
rollback_extension,
2121
run_standalone_extension,
2222
)
23-
23+
from ..extensions.scaffold import scaffold_extension
2424

2525
PrintPayload = Callable[
2626
[dict[str, object], str, Callable[[dict[str, object]], str]],
@@ -128,10 +128,21 @@ def register_extension_commands(
128128
for command in ("enable", "disable", "rollback", "doctor"):
129129
operation = commands.add_parser(command)
130130
_add_common(operation, add_subcommand_format)
131-
operation.add_argument("extension_id")
131+
if command == "doctor":
132+
operation.add_argument("extension_id", nargs="?")
133+
else:
134+
operation.add_argument("extension_id")
132135
if command != "doctor":
133136
operation.add_argument("--execute", action="store_true")
134137
else:
138+
operation.add_argument(
139+
"--all-enabled",
140+
action="store_true",
141+
help=(
142+
"Revalidate every enabled extension after an install, update, "
143+
"or rollback."
144+
),
145+
)
135146
operation.add_argument(
136147
"--execute",
137148
action="store_true",
@@ -253,11 +264,22 @@ def handle_extension_command(
253264
execute=args.execute,
254265
)
255266
else:
256-
payload = doctor_installed_extension(
257-
args.extension_id,
258-
state_file=state_file,
259-
execute=args.execute,
260-
)
267+
if bool(args.extension_id) == bool(args.all_enabled):
268+
raise ValueError(
269+
"extension doctor requires exactly one extension_id or "
270+
"--all-enabled"
271+
)
272+
if args.all_enabled:
273+
payload = doctor_enabled_extensions(
274+
state_file=state_file,
275+
execute=args.execute,
276+
)
277+
else:
278+
payload = doctor_installed_extension(
279+
args.extension_id,
280+
state_file=state_file,
281+
execute=args.execute,
282+
)
261283
except ValueError as exc:
262284
payload = {
263285
"ok": False,

‎loopx/extensions/readiness.py‎

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
11
from __future__ import annotations
22

3-
from collections.abc import Mapping
4-
from dataclasses import dataclass
53
import hashlib
64
import importlib.util
75
import json
8-
from pathlib import Path
96
import shutil
107
import subprocess
118
import sys
9+
from collections.abc import Mapping
10+
from dataclasses import dataclass
11+
from pathlib import Path
1212
from typing import Any
1313

14-
1514
EXTENSION_DOCTOR_SCHEMA_VERSION = "loopx_extension_doctor_v0"
15+
RUNTIME_ENTRYPOINT_IDENTITY_SCHEMA_VERSION = "loopx_runtime_entrypoint_identity_v1"
1616

1717

1818
@dataclass(frozen=True)
@@ -39,11 +39,10 @@ def _file_identity(path: Path, *, executable: bool) -> tuple[Path, str] | None:
3939
except OSError:
4040
return None
4141
identity = {
42-
"path": str(path),
43-
"device": stat.st_dev,
44-
"inode": stat.st_ino,
42+
"schema_version": RUNTIME_ENTRYPOINT_IDENTITY_SCHEMA_VERSION,
43+
"kind": "file_artifact",
44+
"executable": executable,
4545
"size": stat.st_size,
46-
"mtime_ns": stat.st_mtime_ns,
4746
"content_sha256": content_digest,
4847
}
4948
serialized = json.dumps(identity, sort_keys=True, separators=(",", ":"))

‎loopx/extensions/runtime.py‎

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
from __future__ import annotations
22

3-
from collections.abc import Iterable, Mapping, Sequence
4-
from contextlib import nullcontext
5-
from copy import deepcopy
63
import hashlib
74
import json
85
import os
6+
from collections.abc import Iterable, Mapping, Sequence
7+
from contextlib import nullcontext
8+
from copy import deepcopy
99
from pathlib import Path
1010
from typing import Any
1111

1212
from ..file_lock import exclusive_file_lock
13-
from .process_runtime import run_capped_process
1413
from .manifest import load_extension_manifest
14+
from .process_runtime import run_capped_process
1515
from .readiness import (
1616
EXTENSION_DOCTOR_SCHEMA_VERSION,
1717
ResolvedRuntimeEntrypoint,
@@ -20,12 +20,12 @@
2020
resolve_runtime_entrypoint,
2121
)
2222

23-
2423
EXTENSION_STATE_SCHEMA_VERSION = "loopx_extension_state_v0"
2524
EXTENSION_OPERATION_SCHEMA_VERSION = "loopx_extension_operation_v0"
2625
EXTENSION_BINDING_SCHEMA_VERSION = "loopx_extension_runtime_binding_v0"
2726
EXTENSION_ACTIVATION_SCHEMA_VERSION = "loopx_extension_activation_v0"
2827
EXTENSION_RUN_SCHEMA_VERSION = "loopx_extension_run_receipt_v0"
28+
EXTENSION_DOCTOR_BATCH_SCHEMA_VERSION = "loopx_extension_doctor_batch_v0"
2929
MAX_REVISIONS = 5
3030
MAX_EXTENSION_REQUEST_BYTES = 1_000_000
3131
MAX_EXTENSION_RESPONSE_BYTES = 1_000_000
@@ -443,6 +443,57 @@ def doctor_installed_extension(
443443
return doctor
444444

445445

446+
def doctor_enabled_extensions(
447+
*,
448+
state_file: str | Path,
449+
execute: bool = False,
450+
) -> dict[str, Any]:
451+
"""Revalidate every enabled extension after a runtime release transition.
452+
453+
Extension doctors are declared read-only provider probes. Running them as
454+
one bounded batch lets installers and rollback paths refresh the runtime
455+
identity without weakening the fail-closed activation fence.
456+
"""
457+
458+
path = Path(state_file).expanduser()
459+
state = _read_state(path)
460+
extension_ids = sorted(
461+
str(extension_id)
462+
for extension_id, entry in state["extensions"].items()
463+
if isinstance(entry, Mapping) and entry.get("enabled")
464+
)
465+
doctors = [
466+
doctor_installed_extension(
467+
extension_id,
468+
state_file=path,
469+
execute=execute,
470+
)
471+
for extension_id in extension_ids
472+
]
473+
verified_count = sum(bool(doctor.get("verified")) for doctor in doctors)
474+
blocked_count = len(doctors) - verified_count if execute else 0
475+
return {
476+
"ok": blocked_count == 0,
477+
"schema_version": EXTENSION_DOCTOR_BATCH_SCHEMA_VERSION,
478+
"status": (
479+
"ready"
480+
if execute and blocked_count == 0
481+
else "blocked"
482+
if execute
483+
else "probe_required"
484+
if doctors
485+
else "ready"
486+
),
487+
"execute": execute,
488+
"enabled_count": len(doctors),
489+
"verified_count": verified_count,
490+
"blocked_count": blocked_count,
491+
"extensions": doctors,
492+
"local_state_write_performed": execute and bool(doctors),
493+
"external_writes_performed": False,
494+
}
495+
496+
446497
def _verified_entrypoint(
447498
entry: Mapping[str, Any],
448499
) -> ResolvedRuntimeEntrypoint | None:
@@ -577,7 +628,10 @@ def _resolved_active_extension(
577628
active_revision = str(entry.get("active_revision") or "")
578629
verified_entrypoint = _verified_entrypoint(entry)
579630
if verified_entrypoint is None:
580-
raise ValueError(f"extension `{extension_id}` doctor readiness is stale")
631+
raise ValueError(
632+
f"extension `{extension_id}` doctor readiness is stale; run "
633+
f"`loopx extension doctor {extension_id} --execute`"
634+
)
581635
manifest = _active_manifest(entry)
582636
return active_revision, verified_entrypoint, manifest
583637

‎loopx/self_update.py‎

Lines changed: 81 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -623,11 +623,44 @@ def execute_update_plan(payload: dict[str, Any], *, timeout_seconds: int = 600)
623623
"doctor_stdout_tail": doctor_result.stdout[-2000:],
624624
"doctor_stderr_tail": doctor_result.stderr[-2000:],
625625
}
626+
extension_doctor_result = None
627+
if install_result.returncode == 0 and doctor_result.returncode == 0:
628+
extension_doctor_result = subprocess.run(
629+
[
630+
str(loopx_bin),
631+
"--format",
632+
"json",
633+
"extension",
634+
"doctor",
635+
"--all-enabled",
636+
"--execute",
637+
],
638+
text=True,
639+
capture_output=True,
640+
env=env,
641+
timeout=timeout_seconds,
642+
)
643+
execution.update(
644+
{
645+
"extension_doctor_returncode": extension_doctor_result.returncode,
646+
"extension_doctor_stdout_tail": extension_doctor_result.stdout[-2000:],
647+
"extension_doctor_stderr_tail": extension_doctor_result.stderr[-2000:],
648+
}
649+
)
650+
else:
651+
execution["extension_doctor_status"] = "skipped_release_update_failed"
626652
updated = dict(payload)
627653
updated["execution"] = execution
628-
updated["ok"] = install_result.returncode == 0 and doctor_result.returncode == 0
654+
updated["ok"] = (
655+
install_result.returncode == 0
656+
and doctor_result.returncode == 0
657+
and extension_doctor_result is not None
658+
and extension_doctor_result.returncode == 0
659+
)
629660
if not updated["ok"]:
630-
updated["recommended_action"] = "inspect update execution tails and restore from rollback plan if needed"
661+
updated["recommended_action"] = (
662+
"inspect update execution tails and restore from rollback plan if needed"
663+
)
631664
return updated
632665
execution["restarted_services"] = restart_managed_loopx_services()
633666
return updated
@@ -686,8 +719,37 @@ def execute_rollback_plan(
686719
"doctor_stderr_tail": doctor_result.stderr[-2000:],
687720
}
688721
)
689-
updated["ok"] = doctor_result.returncode == 0
690-
if not updated["ok"] and previous_link_target:
722+
if doctor_result.returncode == 0:
723+
extension_doctor_result = subprocess.run(
724+
[
725+
str(loopx_bin),
726+
"--format",
727+
"json",
728+
"extension",
729+
"doctor",
730+
"--all-enabled",
731+
"--execute",
732+
],
733+
text=True,
734+
capture_output=True,
735+
timeout=timeout_seconds,
736+
)
737+
execution.update(
738+
{
739+
"extension_doctor_returncode": extension_doctor_result.returncode,
740+
"extension_doctor_stdout_tail": extension_doctor_result.stdout[
741+
-2000:
742+
],
743+
"extension_doctor_stderr_tail": extension_doctor_result.stderr[
744+
-2000:
745+
],
746+
}
747+
)
748+
updated["ok"] = extension_doctor_result.returncode == 0
749+
else:
750+
execution["extension_doctor_status"] = "skipped_core_doctor_failed"
751+
updated["ok"] = False
752+
if doctor_result.returncode != 0 and previous_link_target:
691753
restore_link = loopx_bin.with_name(f".{loopx_bin.name}.rollback.restore.{os.getpid()}")
692754
if restore_link.exists() or restore_link.is_symlink():
693755
restore_link.unlink()
@@ -713,11 +775,21 @@ def execute_rollback_plan(
713775
if "restore_link" in locals() and (restore_link.exists() or restore_link.is_symlink()):
714776
restore_link.unlink()
715777
updated["execution"] = execution
716-
updated["recommended_action"] = (
717-
"rollback complete; review doctor output"
718-
if updated["ok"]
719-
else "rollback failed; inspect execution error before retrying"
720-
)
778+
if updated["ok"]:
779+
updated["recommended_action"] = "rollback complete; review doctor output"
780+
elif (
781+
execution.get("doctor_returncode") == 0
782+
and execution.get("extension_doctor_returncode") not in {None, 0}
783+
):
784+
updated["recommended_action"] = (
785+
"rollback activated, but one or more enabled extensions remain fail "
786+
"closed; repair the provider and run `loopx extension doctor "
787+
"--all-enabled --execute`"
788+
)
789+
else:
790+
updated["recommended_action"] = (
791+
"rollback failed; inspect execution error before retrying"
792+
)
721793
return updated
722794

723795

‎scripts/install-local.sh‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -769,6 +769,10 @@ fi
769769

770770
export PATH="$bin_dir:$PATH"
771771
"$bin_dir/loopx" doctor >/dev/null
772+
if ! "$bin_dir/loopx" --format json extension doctor --all-enabled --execute >/dev/null; then
773+
echo "loopx installer warning: one or more enabled extensions failed post-install doctor revalidation" >&2
774+
echo "Run 'loopx extension doctor --all-enabled --execute' after repairing the provider." >&2
775+
fi
772776
if [[ "$install_canary" != "0" ]]; then
773777
"$bin_dir/loopx-canary" doctor >/dev/null
774778
fi

‎scripts/install-windows.ps1‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,12 @@ try {
4747
$env:PYTHONPATH = $previousPythonPath
4848
}
4949

50+
$loopxLauncher = Join-Path $BinDir "loopx.ps1"
51+
& $loopxLauncher --format json extension doctor --all-enabled --execute *> $null
52+
if ($LASTEXITCODE -ne 0) {
53+
Write-Warning "One or more enabled extensions failed post-install doctor revalidation. Run 'loopx extension doctor --all-enabled --execute' after repairing the provider."
54+
}
55+
5056
if ($AddToUserPath) {
5157
$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
5258
$entries = @($userPath -split ";" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })

0 commit comments

Comments
 (0)