diff --git a/.github/workflows/package-smoke.yml b/.github/workflows/package-smoke.yml index 7e2d9399ef..4f9014e42f 100644 --- a/.github/workflows/package-smoke.yml +++ b/.github/workflows/package-smoke.yml @@ -4,6 +4,9 @@ on: pull_request: paths: - ".github/workflows/package-smoke.yml" + - "packages/loopx-core/**" + - "loopx/**" + - "tests/test_core_distribution.py" - "packages/loopx-repo-health/**" - "packages/loopx-obelisk/**" - "packages/loopx-codex-provider-routing/**" @@ -14,6 +17,9 @@ on: - main paths: - ".github/workflows/package-smoke.yml" + - "packages/loopx-core/**" + - "loopx/**" + - "tests/test_core_distribution.py" - "packages/loopx-repo-health/**" - "packages/loopx-obelisk/**" - "packages/loopx-codex-provider-routing/**" @@ -28,6 +34,33 @@ concurrency: cancel-in-progress: true jobs: + core: + name: loopx-core + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v6 + with: + python-version: "3.11" + - uses: actions/setup-node@v6 + with: + node-version: "26.0.0" + - uses: astral-sh/setup-uv@v7 + - name: Prepare source validation environment + run: uv sync --extra test + - name: Build and separately install the standalone core + run: | + uv run --extra test python packages/loopx-core/prepare.py --output "$RUNNER_TEMP/core-source" + uv build "$RUNNER_TEMP/core-source" --out-dir "$RUNNER_TEMP/core-dist" + uv venv "$RUNNER_TEMP/core-env" --python .venv/bin/python + uv pip install --python "$RUNNER_TEMP/core-env/bin/python" "$RUNNER_TEMP"/core-dist/loopx_core-*.whl + echo "LOOPX_CORE_TEST_PYTHON=$RUNNER_TEMP/core-env/bin/python" >> "$GITHUB_ENV" + - name: Qualify installed execution, recovery, replay and refusal + env: + LOOPX_USAGE_PING: "0" + run: uv run --extra test python -m pytest -q tests/test_core_distribution.py + codex-provider-routing: name: loopx-codex-provider-routing runs-on: ubuntu-latest diff --git a/docs/architecture/rfcs/monorepo-distribution-split-v0.md b/docs/architecture/rfcs/monorepo-distribution-split-v0.md index 790490ca13..366573c3b6 100644 --- a/docs/architecture/rfcs/monorepo-distribution-split-v0.md +++ b/docs/architecture/rfcs/monorepo-distribution-split-v0.md @@ -3405,3 +3405,35 @@ compatibility to older readers and does not upgrade an installed runtime. Real File/SQLite RPC and CLI regressions, plus an isolated PostgreSQL read, qualify the failure boundary without rewriting authority. The original installed-host qualification and full-cost/adoption gaps remain open. + + +### 2026-11-12 — Standalone core artifact and installation diagnosis + +The opt-in `packages/loopx-core` source builder reuses tracked runtime sources +and produces an independently installed wheel without Chat, web assets, the +canary fleet or the full CLI facade. It retains existing built-in capability +and host compatibility imports. This is the core-artifact prerequisite for +R1/M3; it does not qualify the complete minimal closure or publish a release. + +The installed journey uses the existing generic CLI host, independent artifact +validation and canonical hard leases. The original admitted key/version travels +through the shared TypeScript preparation and checked Turn context to the +existing completion owner. Process loss resumes the confirmed result; replay +does not invoke the host or debit quota again. A renewed/released lease or an +invalid artifact refuses completion without state or quota effects. + +Deep installation diagnosis now selects the actual distribution's RECORD and +required entrypoints. A healthy core passes; an unrecorded launcher or missing +runtime file remains a failure. Ordinary distributions still require their full +CLI. Core supports the advertised history action through its existing handler +and parses global version options without the omitted facade. The installed +qualification covers default SQLite and explicitly configured File hard leases; +help/import remain usable without Node, while governed work retains the existing +Node prerequisite. Ordinary-distribution CLI and installation parity have their +own matched checks. The package-smoke lane executes the installed qualification. + +Preserve D1-D7 and all original milestones, historical failures and acceptance +boundaries. Workspace/meta-package publication, complete install/load/context +closure, independent Standard packages, other host/platform adoption, matched +model effectiveness and complete costs remain open. This synthetic task and +recovery evidence establishes neither model benefit nor an installed-host upgrade. diff --git a/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md b/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md index ebd1e2bea7..82609b51ff 100644 --- a/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md +++ b/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md @@ -2658,3 +2658,27 @@ legacy fallback;已支持的历史字段清单仍可读取。这是既有源 真实 File/SQLite RPC 与 CLI 回归,以及隔离 PostgreSQL 读取,验证该失败边界 且不重写 authority。原 installed-host 资格、完整成本和 adoption 缺口继续开放。 + + +### 2026-11-12 — 独立 core 产物与安装诊断 + +可选的 `packages/loopx-core` 源码构建器复用 tracked 运行时源码,生成可独立安装的 +wheel,省略 Chat、web 资源、canary 集群及完整 CLI facade。它仍保留现有内建能力 +和宿主兼容导入。这是 R1/M3 的 core 产物前提,不代表完整最小闭包已合格,也不 +发布发行版本。 + +安装后任务沿用 generic CLI 宿主、独立产物验收及 canonical 硬租约。最初准入的 +key/version 经共享 TypeScript preparation 和已校验的 Turn context 传入既有 +完成 owner。进程丢失后恢复已确认结果;重放不再次调用宿主或扣额。租约更新/ +释放或产物无效时拒绝完成,不产生状态写入和扣额。 + +深度安装诊断按实际发行物的 RECORD 和必要入口检查。健康 core 通过,未登记的 +启动器或缺失运行文件仍失败;普通发行物仍要求完整 CLI。core 通过现有 handler +执行目录列出的 history action,并在缺少完整 facade 时解析全局版本选项。安装 +资格覆盖默认 SQLite 和显式配置的 File 硬租约;无 Node 时可导入和查看帮助, +受治理任务保留既有 Node 前提。普通发行物的 CLI 和安装兼容有独立配对检查。 +package-smoke 工作流执行这套安装后验证。 + +保留 D1-D7、原有里程碑、历史失败与验收边界。workspace/meta 发行、完整安装/ +加载/context 闭包、独立 Standard 包、其他宿主/平台采用、配对模型效果和完整 +成本仍开放。合成任务与恢复证据不证明模型收益,也不代表已升级当前宿主。 diff --git a/loopx/cli_commands/turn_run_once.py b/loopx/cli_commands/turn_run_once.py index 9df16f9641..d5af537fde 100644 --- a/loopx/cli_commands/turn_run_once.py +++ b/loopx/cli_commands/turn_run_once.py @@ -366,7 +366,10 @@ def todo_completion( agent_id=args.agent_id, completion_delivery_workspace=completion_delivery_workspace, completion_validation_workspace_path=delivery_workspace_path, - **(dict(native_completion_lease_resolver()) if native_completion_lease_resolver else {}), + **(dict(native_completion_lease_resolver()) if native_completion_lease_resolver else { + "task_lease_idempotency_key": execution_context.get("task_lease_proof", {}).get("idempotency_key"), + "task_lease_expected_version": execution_context.get("task_lease_proof", {}).get("expected_version"), + }), ) if completion.get("ok") is not True: return completion diff --git a/loopx/cli_runtime.py b/loopx/cli_runtime.py index 240920984f..5624deb6d2 100644 --- a/loopx/cli_runtime.py +++ b/loopx/cli_runtime.py @@ -75,6 +75,7 @@ _REGISTRY_INSPECTION_COMMANDS = frozenset({"registry", "registry-boundary"}) _SELECTED_COMMANDS = _STATUS_COMMANDS | GLOBAL_READ_COMMANDS | PROJECT_LIFECYCLE_COMMANDS | _REGISTRY_INSPECTION_COMMANDS | { "todo", "quota", "task-lease", "change-window", "delegation", "turn", "doctor", "commands", "version", + "bootstrap", "connect", "configure-goal", "authority-archive", "coordination-shadow", "extension", "slash-commands", "manager-inbox", "agent-context", "agent-directory", "agent-capabilities", @@ -392,7 +393,15 @@ def _handle_registry_inspection_command( def _build_selected_parser(command: str) -> LoopXArgumentParser: parser, subparsers = build_cli_parser(parser_class=_SelectedArgumentParser) - if command in _REGISTRY_INSPECTION_COMMANDS: + if command in {"bootstrap", "connect"}: + from .cli_commands.bootstrap_connect import register_bootstrap_connect_command + + register_bootstrap_connect_command(subparsers) + elif command == "configure-goal": + from .cli_commands.registry_admin_configure import register_configure_goal_command + + register_configure_goal_command(subparsers) + elif command in _REGISTRY_INSPECTION_COMMANDS: _register_registry_inspection_commands(subparsers) elif command in _STATUS_COMMANDS: from .cli_commands.status_registration import register_status_commands @@ -517,6 +526,18 @@ def _dispatch_common_command( registry_path: Path, allow_missing_registry: bool, ) -> int | None: + if args.command in {"bootstrap", "connect"}: + from .cli_commands.bootstrap_connect import handle_bootstrap_connect_command + + return handle_bootstrap_connect_command( + args, registry_path=registry_path, print_payload=print_payload, + ) + if args.command == "configure-goal": + from .cli_commands.registry_admin import handle_registry_admin_command + + return handle_registry_admin_command( + args, registry_path=registry_path, print_payload=print_payload, + ) if args.command == "host-mode-plan": from .cli_commands.host_mode_plan import handle_host_mode_plan_command @@ -637,7 +658,9 @@ def _dispatch_common_command( ) print_payload( - build_command_reference_payload(), output_format(args), + build_command_reference_payload( + available_commands=_SELECTED_COMMANDS if _standalone_core() else None, + ), output_format(args), render_command_reference_markdown, ) return 0 @@ -853,9 +876,32 @@ def _dispatch_selected(args: argparse.Namespace, raw_argv: list[str]) -> int: return result -def _run_full_cli(raw_argv: list[str]) -> int: - from .cli import main as cli_main +def _standalone_core() -> bool: + from importlib.util import find_spec + if find_spec("loopx.cli") is not None: + return False + from importlib.metadata import PackageNotFoundError, distribution + from .python_install_owner import installed_loopx_distribution + + try: + return installed_loopx_distribution(distribution).name == "loopx-core" + except PackageNotFoundError: + return False + + +def _run_full_cli(raw_argv: list[str]) -> int: + try: + from .cli import main as cli_main + except ModuleNotFoundError as exc: + if exc.name != "loopx.cli" or not _standalone_core(): + raise + # Only the omitted full CLI is optional; never hide a broken dependency. + parser, _ = build_cli_parser() + parser.error( + "this command or argument is unavailable in the standalone core; " + "use its supported CLI options or the ordinary LoopX installation" + ) return cli_main(raw_argv) @@ -868,9 +914,16 @@ def main(argv: list[str] | None = None) -> int: from .help_surface import render_concise_help, top_level_help_requested if top_level_help_requested(raw_argv): - print(render_concise_help(sys.argv[0] if argv is None else "loopx"), end="") + print(render_concise_help(sys.argv[0] if argv is None else "loopx", + standalone_core=_standalone_core()), end="") return 0 command = _top_level_command(raw_argv) + if command is None and _standalone_core(): + # The canonical global parser handles version and malformed/missing + # arguments without importing an intentionally omitted command facade. + parser, _ = build_cli_parser() + parser.parse_args(raw_argv) + return 0 from .usage_ping import begin, finish ticket = begin(command or "") @@ -908,7 +961,7 @@ def _run_command(command: str | None, raw_argv: list[str]) -> int: args = _build_selected_parser(command).parse_args(raw_argv) except _SelectedParseRejected: return _run_full_cli(raw_argv) - if command == "history" and args.history_action is not None: + if command == "history" and args.history_action is not None and not _standalone_core(): # Select ordinary reads; maintenance keeps its complete original route. return _run_full_cli(raw_argv) if command == "capability": diff --git a/loopx/control_plane/quota/execution_preparation.py b/loopx/control_plane/quota/execution_preparation.py index 0af63ab9d9..9382711dc6 100644 --- a/loopx/control_plane/quota/execution_preparation.py +++ b/loopx/control_plane/quota/execution_preparation.py @@ -83,6 +83,8 @@ def project_selected_todo_execution_preparation( "status": status if status in {"active", "released"} else "unknown", "active": lease_is_active(dict(lease)), **({"owner": lease["owner"]} if normalize_todo_claimed_by(lease.get("owner")) else {}), + "lease_proof": {"idempotency_key": lease.get("idempotency_key"), + "expected_version": lease.get("version")}, }) facts = { "schema_version": "execution_preparation_facts_v0", diff --git a/loopx/control_plane/quota/turn_envelope.ts b/loopx/control_plane/quota/turn_envelope.ts index 3ee7c81864..0977753697 100644 --- a/loopx/control_plane/quota/turn_envelope.ts +++ b/loopx/control_plane/quota/turn_envelope.ts @@ -2571,6 +2571,7 @@ export function evaluateTurnEnvelope(value: unknown): JsonObject { sha256: completeTaskTextSha256(decision, selectedWorkTodoId(work) ?? ""), effective_action: decision.effective_action ?? null, writeback: object(projection.writeback), + task_lease_proof: object(object(decision.execution_preparation).lease_proof), replan_guard_scoped: Object.hasOwn(projection, "replan_action_packet"), replan_action_packet: projection.replan_action_packet ?? null}; } diff --git a/loopx/control_plane/todos/read_transaction.ts b/loopx/control_plane/todos/read_transaction.ts index 472bf9daa5..bcd04563f7 100644 --- a/loopx/control_plane/todos/read_transaction.ts +++ b/loopx/control_plane/todos/read_transaction.ts @@ -239,7 +239,8 @@ export async function readExecutionPreparationSource(input: JsonObject, dependen const todo = originalRecords(canonical.todos).find(row => row.todo_id === todoId); if (!todo) return unavailable; const leases = originalRecords(canonical.leases).filter(row => row.todo_id === todoId) - .map(({todo_id, schema_version, status, owner, expires_at}) => ({todo_id, schema_version, status, owner, expires_at})); + .map(({todo_id, schema_version, status, owner, expires_at, idempotency_key, version}) => + ({todo_id, schema_version, status, owner, expires_at, idempotency_key, version})); return {schema_version: TODO_READ_RESULT, execution_preparation_source: { goal_id: goalId, todo_id: todoId, handoff_mode: canonical.handoff_mode, source_authority: canonical.source_authority, provider_revision: canonical.provider_revision, diff --git a/loopx/control_plane/work_items/execution_preparation.ts b/loopx/control_plane/work_items/execution_preparation.ts index 1a932b809a..30c1f4ce17 100644 --- a/loopx/control_plane/work_items/execution_preparation.ts +++ b/loopx/control_plane/work_items/execution_preparation.ts @@ -6,6 +6,7 @@ import { optionalNonEmptyString, requireBoolean, requireJsonObject, + requireInteger, requireNonEmptyString, requireStringLiteral, } from "../runtime_decode.ts"; @@ -25,7 +26,18 @@ function leaseFact(value: unknown, label: string): JsonObject { if (active && status !== "active") { throw new EffectRuntimeRequestError(`${label}.active requires status=active`); } - return {status, active, ...(owner === null ? {} : {owner})}; + let proof: JsonObject | undefined; + if (lease.lease_proof !== undefined) { + proof = requireJsonObject(lease.lease_proof, `${label}.lease_proof`); + const key = requireNonEmptyString(proof.idempotency_key, `${label}.lease_proof.idempotency_key`); + const version = requireInteger(proof.expected_version, `${label}.lease_proof.expected_version`); + if (key !== key.trim() || !Number.isSafeInteger(version) || version < 1) { + throw new EffectRuntimeRequestError(`${label}.lease_proof requires an exact key and positive safe-integer version`); + } + proof = {idempotency_key: key, expected_version: version}; + } + return {status, active, ...(owner === null ? {} : {owner}), + ...(proof === undefined ? {} : {lease_proof: proof})}; } function claimAction(goalId: string, todoId: string, agentId: string): JsonObject { @@ -113,7 +125,10 @@ export function projectExecutionPreparation(value: unknown): JsonObject { reason: "The selected Todo has an active lease owned by another agent; do not take over its execution."}; } return {...base, state: "ready", delivery_allowed: true, - reason_code: "matching_active_task_lease"}; + reason_code: "matching_active_task_lease", + // Capture the admitted generation once. Recovery must not borrow a + // replacement lease after the original Host result was produced. + ...(active[0].lease_proof === undefined ? {} : {lease_proof: active[0].lease_proof})}; } const prior = leases[0]; diff --git a/loopx/doctor.py b/loopx/doctor.py index 00d7a06c1e..2b67287ab8 100644 --- a/loopx/doctor.py +++ b/loopx/doctor.py @@ -28,6 +28,7 @@ from .paths import configured_runtime_route, default_runtime_route, global_registry_path from .python_install_owner import ( PythonInstallOwner, + installed_loopx_distribution, python_distribution_upgrade_command, resolve_python_install_owner, ) @@ -142,7 +143,7 @@ def python_distribution_install(module_path: Path) -> dict[str, Any]: """Identify a wheel/sdist install that owns the imported LoopX module.""" try: - installed = distribution("loopx") + installed = installed_loopx_distribution(distribution) except PackageNotFoundError: return {"available": False} installer = (installed.read_text("INSTALLER") or "").strip() or "unknown" @@ -168,6 +169,8 @@ def python_distribution_install(module_path: Path) -> dict[str, Any]: "installer": owner.manager, "installer_environment": owner.environment, "root": str(Path(cast(Path, installed.locate_file(""))).resolve()), + **({"distribution_name": "loopx-core"} + if getattr(installed, "name", "loopx") == "loopx-core" else {}), } @@ -316,6 +319,7 @@ def build_install_freshness( owner=distribution_owner, python_executable=sys.executable, doctor_command=f"loopx doctor{doctor_agent_arg}", + distribution_name=str((distribution_install or {}).get("distribution_name") or "loopx"), ) if distribution_owner else no_clone_command manifest_source_git_commit = manifest_source.get("git_commit") manifest_source_revision = ( diff --git a/loopx/help_surface.py b/loopx/help_surface.py index 3fa0c916d6..9bfc84fcd7 100644 --- a/loopx/help_surface.py +++ b/loopx/help_surface.py @@ -2,6 +2,7 @@ import argparse import re +from collections.abc import Collection from typing import Any from . import __version__ @@ -12,7 +13,7 @@ HELP_FLAGS = {"-h", "--help"} -def register_command_reference(subparsers: argparse._SubParsersAction) -> None: +def register_command_reference(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None: parser = subparsers.add_parser( "commands", help="Show grouped LoopX command reference for operators and contributors.", @@ -480,8 +481,26 @@ def top_level_help_requested(argv: list[str]) -> bool: return True -def render_concise_help(program: str = "loopx") -> str: +def render_concise_help(program: str = "loopx", *, standalone_core: bool = False) -> str: program = _program_name(program) + if standalone_core: + return "\n".join([ + "LoopX keeps long-running agent work moving with durable state and evidence.", + "", "Usage:", f" {program} [global options] [command options]", + "", "Start:", + " loopx connect --help Connect a project and its objective.", + " loopx configure-goal --help Configure its Agent and execution budget.", + " loopx todo --help Create, claim and complete work.", + "", "Execute and recover:", + " loopx turn run-once --help Execute one task with a selected host and validation.", + " loopx turn inspect-journal --help", + " Inspect an original attempt before resuming it.", + " loopx status Read current work and its next action.", + " loopx doctor --installation-only", + " Check this installation.", + "", "Global options: --registry PATH --runtime-root PATH --format markdown|json --cli-bin PROGRAM", + "More: loopx commands; loopx --help", "", + ]) return "\n".join( [ "LoopX keeps long-running agent work moving with durable state and evidence.", @@ -524,17 +543,38 @@ def render_concise_help(program: str = "loopx") -> str: ) -def build_command_reference_payload() -> dict[str, Any]: +def build_command_reference_payload(*, available_commands: Collection[str] | None = None) -> dict[str, Any]: + groups = COMMAND_GROUPS + more = ["loopx --help", "man loopx", "docs/guides/getting-started.md#command-reference"] + if available_commands is not None: + # Filter display entries against the actual dispatcher, without loading + # omitted registrars. This is discovery, never execution admission. + groups = [] + for group in COMMAND_GROUPS: + entries = group.get("commands") + if not isinstance(entries, list): + continue + commands = [] + for entry in entries: + if not isinstance(entry, dict) or not isinstance(entry.get("command"), str): + continue + match = re.match(r"loopx ([a-z0-9-]+)(?: |$)", entry["command"]) + if match and match[1] in available_commands: + commands.append(entry) + if commands: + groups.append({**group, "commands": commands}) + remaining = set(available_commands) - manpage_top_level_commands() + if remaining: + groups.append({"title": "Command-specific help", "commands": [ + {"command": f"loopx {name} --help", "purpose": "Show available command options."} + for name in sorted(remaining)]}) + more = ["loopx --help"] return { "ok": True, "schema_version": "loopx_command_reference_v0", "summary": "Grouped LoopX command reference for operators and contributors.", - "groups": COMMAND_GROUPS, - "more": [ - "loopx --help", - "man loopx", - "docs/guides/getting-started.md#command-reference", - ], + "groups": groups, + "more": more, } @@ -545,13 +585,17 @@ def render_command_reference_markdown(payload: dict[str, Any]) -> str: str(payload.get("summary") or ""), "", ] - groups = payload.get("groups") if isinstance(payload.get("groups"), list) else [] + groups = payload.get("groups") + if not isinstance(groups, list): + groups = [] for group in groups: if not isinstance(group, dict): continue title = str(group.get("title") or "Commands") lines.extend([f"## {title}", ""]) - commands = group.get("commands") if isinstance(group.get("commands"), list) else [] + commands = group.get("commands") + if not isinstance(commands, list): + commands = [] for command in commands: if not isinstance(command, dict): continue diff --git a/loopx/python_install_owner.py b/loopx/python_install_owner.py index 41e388867e..8270d105ce 100644 --- a/loopx/python_install_owner.py +++ b/loopx/python_install_owner.py @@ -8,12 +8,27 @@ from __future__ import annotations from dataclasses import dataclass +from collections.abc import Callable +from importlib.metadata import Distribution, PackageNotFoundError import json import os from pathlib import Path import shlex +def installed_loopx_distribution(lookup: Callable[[str], Distribution]) -> Distribution: + """Select the existing distribution, or the standalone core when absent. + + Callers still verify that its RECORD owns their actual imported file. + Missing metadata, redirected records and content checks keep their original + failure behavior; the core name is not an ownership assertion. + """ + try: + return lookup("loopx") + except PackageNotFoundError: + return lookup("loopx-core") + + @dataclass(frozen=True) class PythonInstallOwner: manager: str @@ -77,18 +92,17 @@ def python_distribution_upgrade_command( owner: PythonInstallOwner, python_executable: str, doctor_command: str, + distribution_name: str = "loopx", ) -> str | None: if owner.manager == "pipx": - package_command = f"pipx upgrade {shlex.quote(owner.environment or 'loopx')}" + package_command = f"pipx upgrade {shlex.quote(owner.environment or distribution_name)}" elif owner.manager == "pip": package_command = ( - f"{interpreter_invocation(python_executable)} -m pip install --upgrade loopx" + f"{interpreter_invocation(python_executable)} -m pip install --upgrade {shlex.quote(distribution_name)}" ) else: return None - return ( - f"{package_command}\n" - "loopx workflow-skills --install\n" - "loopx slash-commands --install\n" - f"{doctor_command}" - ) + steps = [package_command] + if distribution_name == "loopx": + steps.extend(["loopx workflow-skills --install", "loopx slash-commands --install"]) + return "\n".join([*steps, doctor_command]) diff --git a/loopx/release_candidate.py b/loopx/release_candidate.py index e5c72e6baa..b324511884 100644 --- a/loopx/release_candidate.py +++ b/loopx/release_candidate.py @@ -10,6 +10,7 @@ from typing import Any, Sequence from .command_invocation import command_argv +from .python_install_owner import installed_loopx_distribution from .release_manifest import release_runtime_identity @@ -40,11 +41,15 @@ *REPRESENTATIVE_DISTRIBUTION_PATHS, "scripts/loopx", ) +CORE_CLI_IMPORTS = ("loopx.cli_runtime", *REPRESENTATIVE_CLI_IMPORTS[1:]) +CORE_DISTRIBUTION_PATHS = ( + "loopx/entrypoint.py", "loopx/cli_runtime.py", *REPRESENTATIVE_DISTRIBUTION_PATHS[1:], +) -def _import_summary() -> dict[str, Any]: +def _import_summary(module_names: Sequence[str] = REPRESENTATIVE_CLI_IMPORTS) -> dict[str, Any]: results: dict[str, str] = {} - for module_name in REPRESENTATIVE_CLI_IMPORTS: + for module_name in module_names: try: importlib.import_module(module_name) except Exception as exc: # pragma: no cover - partial-package errors vary @@ -115,7 +120,7 @@ def _distribution_command_summary(command_path: Path | None) -> dict[str, Any]: } try: - installed = distribution("loopx") + installed = installed_loopx_distribution(distribution) except PackageNotFoundError: return { "ok": False, @@ -142,6 +147,7 @@ def _distribution_command_summary(command_path: Path | None) -> dict[str, Any]: resolved_command = command_path.resolve() return { "ok": resolved_command in recorded_commands, + **({"distribution_name": "loopx-core"} if getattr(installed, "name", None) == "loopx-core" else {}), "command": str(resolved_command), "recorded_commands": [str(path) for path in recorded_commands], "error": None if recorded_commands else "console_script_not_recorded", @@ -218,13 +224,14 @@ def collect_python_distribution_checks( ) -> dict[str, Any]: """Validate a wheel/sdist install without requiring source-only wrappers.""" - imports = _import_summary() + distribution_command = _distribution_command_summary(command_path) + standalone_core = distribution_command.get("distribution_name") == "loopx-core" + imports = _import_summary(CORE_CLI_IMPORTS) if standalone_core else _import_summary() commands = _command_summary(command_path) package_paths = _package_path_summary( package_root, - REPRESENTATIVE_DISTRIBUTION_PATHS, + CORE_DISTRIBUTION_PATHS if standalone_core else REPRESENTATIVE_DISTRIBUTION_PATHS, ) - distribution_command = _distribution_command_summary(command_path) checks = [ { "id": "command_package_same_distribution", diff --git a/loopx/release_manifest.py b/loopx/release_manifest.py index dc9d7688ca..d0eb679d27 100644 --- a/loopx/release_manifest.py +++ b/loopx/release_manifest.py @@ -10,6 +10,7 @@ from . import __version__ from .doctor_git import GitSourceState, git_source_metadata +from .python_install_owner import installed_loopx_distribution RELEASE_MANIFEST_SCHEMA_VERSION = "loopx_release_manifest_v0" @@ -316,7 +317,7 @@ def _distribution_runtime_fingerprint() -> str | None: caches are generated by Python and are not part of the installed artifact. """ try: - installed = distribution("loopx") + installed = installed_loopx_distribution(distribution) direct_url = json.loads(installed.read_text("direct_url.json") or "{}") if direct_url.get("dir_info", {}).get("editable") or installed.version != __version__: return None @@ -343,7 +344,7 @@ def _distribution_runtime_fingerprint() -> str | None: digest = hashlib.sha256() for name, path in sorted(files.items()): expected = package_root.parent / name - if ".." in Path(name).parts or path.is_symlink() or path.absolute() != expected: + if ".." in Path(name).parts or path.is_symlink() or path.resolve() != expected: return None digest.update(name.encode("utf-8") + b"\0") digest.update(_sha256_file(path).encode("ascii") + b"\n") diff --git a/packages/loopx-core/README.md b/packages/loopx-core/README.md new file mode 100644 index 0000000000..cc2c5a43e1 --- /dev/null +++ b/packages/loopx-core/README.md @@ -0,0 +1,49 @@ +# Standalone core distribution candidate + +This opt-in source build installs the existing CLI and TypeScript task runtime +without the Chat package, web assets or canary fleet. It uses the same state, +authority, validation and recovery owners as the ordinary distribution. It does +not start a workspace, install a model host or change existing defaults. + +Build from a source checkout, then install into a **new** virtual environment: + +```sh +uv run --extra test python packages/loopx-core/prepare.py --output /tmp/loopx-core-source +uv build /tmp/loopx-core-source --out-dir /tmp/loopx-core-dist +uv venv /tmp/loopx-core-env +uv pip install --python /tmp/loopx-core-env/bin/python /tmp/loopx-core-dist/loopx_core-*.whl +/tmp/loopx-core-env/bin/loopx --help +/tmp/loopx-core-env/bin/loopx doctor --installation-only --deep --format json +``` + +Python 3.11+ is required. Import and help do not need Node. Executing governed +tasks requires a qualified Node runtime (22.22.3+ and the existing SQLite +reliability checks), an explicitly selected host and its own prerequisites. +Use the existing `turn run-once --host generic-cli` entry, a separate validation +command, `turn inspect-journal` and `--resume-turn-key` for execution and recovery. +Bootstrap with `connect`, configure one Agent with `configure-goal`, and create +and claim work through the existing `todo` commands. An ordinary managed claim +prepares the default SQLite lease without asking the task model to manage lease +flags. Completion carries the original captured lease generation; recovery +refuses a renewed, released or replaced generation rather than adopting it. +The installed-only qualification in +[`tests/test_core_distribution.py`](../../tests/test_core_distribution.py) +exercises setup, independent result validation, process loss, replay and refusal. +No model service, credentials or paid task execution is supplied by this wheel. + +The candidate owns the same `loopx` import namespace and console command as the +ordinary distribution. Do not co-install or overlay them. Return to the ordinary +installation by switching back to its environment; preserve task state and +reconcile the original attempt before changing an execution owner. There is no +new default, migration, automatic upgrade, PyPI publication or platform release. + +This is the first independently installable core-artifact slice of R1/M3 in the +[small-kernel RFC](../../docs/architecture/rfcs/monorepo-distribution-split-v0.md). +It still retains built-in capability implementations, policy helpers and several +host/CLI dependencies because the current imports are coupled. This makes it a +workspace-free installation prerequisite, not a qualified minimal mechanism +closure. Those dependencies are migration debt, not evidence that every +capability belongs in the kernel. Full workspace/meta-package splitting, +independent capability packaging, other host/platform qualification and the +complexity/coverage hypothesis remain separate work. Smaller installation or a +passing process fixture alone does not establish model effectiveness. diff --git a/packages/loopx-core/prepare.py b/packages/loopx-core/prepare.py new file mode 100644 index 0000000000..6fd749cc23 --- /dev/null +++ b/packages/loopx-core/prepare.py @@ -0,0 +1,65 @@ +"""Prepare a standalone core source distribution from the single source tree. + +Build the returned directory with a PEP 517 frontend. No runtime code is copied +back into the repository and the ordinary distribution remains unchanged. +""" +from __future__ import annotations + +import argparse +import shutil +import subprocess +from pathlib import Path + +REPOSITORY = Path(__file__).resolve().parents[2] +# These are distribution exclusions, not an authority or activation policy. +EXCLUDED_TREES = ("loopx/chat/", "loopx/web/", "loopx/canary/") +EXCLUDED_MODULES = {"loopx/cli.py", "loopx/chat.py"} +SOURCE_SUFFIXES = {".py", ".ts", ".json", ".mjs", ".js", ".md"} + + +def prepare_core_source(output: Path, *, repository: Path = REPOSITORY) -> Path: + repository = repository.resolve() + output = output.resolve() + if output == repository or output.is_relative_to(repository): + raise ValueError("prepare the core distribution outside the source checkout") + if output.exists() and any(output.iterdir()): + raise ValueError("the output directory must be empty") + names = subprocess.check_output( + ["git", "ls-files", "--cached", "-z", "--", "loopx"], cwd=repository, + ).split(b"\0") + sources = [Path(name.decode("utf-8")) for name in names if name] + sources = [path for path in sources if path.suffix in SOURCE_SUFFIXES + and path.as_posix() not in EXCLUDED_MODULES + and not (path.parent == Path("loopx") and path.name.startswith("chat_")) + and not path.as_posix().startswith(EXCLUDED_TREES)] + if not sources or not (repository / "loopx/entrypoint.py").is_file(): + raise ValueError("a complete LoopX source checkout is required") + output.mkdir(parents=True, exist_ok=True) + for relative in sources: + source = repository / relative + if source.is_symlink() or not source.resolve().is_relative_to(repository): + raise ValueError("distribution source must stay inside the checkout") + target = output / relative + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source, target) + package = repository / "packages/loopx-core" + for name in ("pyproject.toml", "README.md"): + shutil.copyfile(package / name, output / name) + for name in ("LICENSE", "LICENSE-MIT", "NOTICE"): + shutil.copyfile(repository / name, output / name) + (output / "MANIFEST.in").write_text( + "recursive-include loopx *.py *.ts *.json *.mjs *.js *.md\n", encoding="utf-8", + ) + return output + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + print(prepare_core_source(args.output)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/loopx-core/pyproject.toml b/packages/loopx-core/pyproject.toml new file mode 100644 index 0000000000..3fe9ed8f10 --- /dev/null +++ b/packages/loopx-core/pyproject.toml @@ -0,0 +1,28 @@ +[build-system] +requires = ["setuptools==83.0.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "loopx-core" +dynamic = ["version"] +description = "Standalone LoopX CLI and durable task runtime without the workspace." +readme = "README.md" +requires-python = ">=3.11" +license = "Apache-2.0" +license-files = ["LICENSE", "NOTICE", "LICENSE-MIT"] +dependencies = ["tzdata; sys_platform == 'win32'"] + +[project.scripts] +loopx = "loopx.entrypoint:main" + +[tool.setuptools.dynamic] +version = {attr = "loopx.__version__"} + +[tool.setuptools.packages.find] +include = ["loopx*"] + +[tool.setuptools.package-data] +"*" = ["*.ts", "*.json", "*.mjs", "*.js", "README.md", "README.zh-CN.md", "docs/*.md", "docs/protocols/*.md"] + +[tool.setuptools.exclude-package-data] +"loopx.semantics" = ["project_registry_io_manifest_v1.json"] diff --git a/tests/control_plane/test_quota_execution_preparation.py b/tests/control_plane/test_quota_execution_preparation.py index a394b4748e..2c01787ca4 100644 --- a/tests/control_plane/test_quota_execution_preparation.py +++ b/tests/control_plane/test_quota_execution_preparation.py @@ -181,6 +181,9 @@ def test_should_run_requires_one_atomic_claim_before_delivery( def test_matching_active_lease_preserves_delivery(tmp_path, monkeypatch, provider): packet = _run(tmp_path, monkeypatch, provider, [_lease()]) assert packet["execution_preparation"]["state"] == "ready" + assert packet["execution_preparation"]["lease_proof"] == { + "idempotency_key": _lease()["idempotency_key"], "expected_version": _lease()["version"], + } assert packet["normal_delivery_allowed"] is True assert packet["interaction_contract"]["agent_channel"]["delivery_allowed"] is True diff --git a/tests/control_plane_ts/execution_preparation.test.ts b/tests/control_plane_ts/execution_preparation.test.ts index 0a29d2b801..3cd3ccf7ee 100644 --- a/tests/control_plane_ts/execution_preparation.test.ts +++ b/tests/control_plane_ts/execution_preparation.test.ts @@ -107,3 +107,20 @@ test("legacy facts stay readable and an old matching lease grants no new executi assert.equal(retired.delivery_allowed, false); assert.equal(Object.hasOwn(retired, "claim"), false); }); + + +test("only a matching active lease contributes its exact completion fence", () => { + const proof = {idempotency_key: "original-attempt", expected_version: 7}; + const active = {status: "active", active: true, owner: "agent-a", lease_proof: proof}; + assert.deepEqual(projectExecutionPreparation(facts({leases: [active]})).lease_proof, proof); + for (const lease of [{...active, owner: "agent-b"}, + {...active, active: false}, {...active, status: "released", active: false}]) { + assert.equal(Object.hasOwn(projectExecutionPreparation(facts({leases: [lease]})), "lease_proof"), false); + } + for (const lease_proof of [{...proof, expected_version: true}, + {...proof, expected_version: 0}, {...proof, expected_version: Number.MAX_SAFE_INTEGER + 1}, + {...proof, idempotency_key: " original-attempt"}, {expected_version: 7}]) { + assert.throws(() => projectExecutionPreparation(facts({leases: [{...active, lease_proof}]})), + EffectRuntimeRequestError); + } +}); diff --git a/tests/test_cli_entrypoint.py b/tests/test_cli_entrypoint.py index 128b8ebf2f..f2e7ce2bb0 100644 --- a/tests/test_cli_entrypoint.py +++ b/tests/test_cli_entrypoint.py @@ -825,6 +825,9 @@ def test_quota_accounting_routes_skip_should_run_composition(tmp_path, monkeypat def test_selected_parser_matches_full_help_and_diagnostics() -> None: argv_cases = [ + *[[command, flag] for command in ("bootstrap", "connect", "configure-goal") + for flag in ("--help", "--unknown-option")], + ["connect", "--max-children", "invalid"], ["heartbeat-prequota", "--help"], ["heartbeat-prequota"], ["heartbeat-prequota", "-g", "goal"], @@ -2038,3 +2041,18 @@ def original_path(args, **kwargs): result = run_isolated_script(script) assert result.returncode == 0, result.stderr assert not list(tmp_path.iterdir()) + + +@pytest.mark.parametrize("command", ["bootstrap", "connect"]) +def test_selected_connect_preview_keeps_full_cli_result_without_workspace(tmp_path, command): + registry = tmp_path / "project/.loopx/registry.json" + argv = ["--registry", str(registry), "--runtime-root", str(tmp_path / "runtime"), + "--format", "json", command, "--project", str(tmp_path / "project"), + "--objective", "Write and validate one artifact.", "--goal-id", "preview-goal", + "--no-global-sync", "--dry-run"] + selected = run_cli_main("loopx.entrypoint", argv, + forbidden_modules=("loopx.cli", "loopx.chat", "loopx.canary")) + ordinary = run_cli_main("loopx.cli", argv) + assert selected.returncode == ordinary.returncode == 0, (selected.stderr, ordinary.stderr) + assert json.loads(selected.stdout) == json.loads(ordinary.stdout) + assert not registry.exists() diff --git a/tests/test_core_distribution.py b/tests/test_core_distribution.py new file mode 100644 index 0000000000..1d8f45ba65 --- /dev/null +++ b/tests/test_core_distribution.py @@ -0,0 +1,375 @@ +"""Opt-in installed-core qualification through real CLI processes and SQLite. + +Build/install the standalone wheel in a clean environment and set +LOOPX_CORE_TEST_PYTHON to that environment's Python. All task preparation and +product invocation run from the installed artifact, isolated from this checkout. +""" +from __future__ import annotations + +import importlib.util +import json +import os +import re +import shlex +import shutil +import signal +import subprocess +import time +from pathlib import Path + +import pytest + +REPOSITORY = Path(__file__).resolve().parents[1] +GOAL = "loopx-turn-fixture" +AGENT = "codex-fixture" +HOST_SOURCE = '''import json,pathlib,sys +request=json.load(sys.stdin) +pathlib.Path('squares.json').write_text(json.dumps([n*n for n in range(8)])) +with pathlib.Path('host-invocations').open('a') as f:f.write('called\\n') +json.dump({'schema_version':'loopx_turn_result_v0','turn_key':request['turn_key'], +'result_kind':'validated_completion','completed_phases':['host_execute','typed_result'], +'classification':'verified_squares','recommended_action':'Read the checked result.', +'next_action':'Return the verified result.','delivery_batch_scale':'implementation', +'delivery_outcome':'outcome_progress','vision_unchanged_reason':'The requested squares remain the objective.', +'summary':'Produced squared integers zero through seven.', +'work_evidence':['artifact:squares.json'],'validation_evidence':['check:independent-squares']},sys.stdout) +''' +VALIDATOR_SOURCE = '''import json,os,pathlib,sys,time +json.load(sys.stdin) +if pathlib.Path('pause-validator').exists(): + pathlib.Path('validator-ready').write_text(str(os.getpid())) + while pathlib.Path('pause-validator').exists():time.sleep(0.05) +raise SystemExit(0 if json.loads(pathlib.Path('squares.json').read_text())==[0,1,4,9,16,25,36,49] else 7) +''' + + +def _prepare_module(): + spec = importlib.util.spec_from_file_location( + "core_distribution_prepare", REPOSITORY / "packages/loopx-core/prepare.py", + ) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_core_staging_excludes_workspace_and_reuses_kernel_bytes(tmp_path): + staged = _prepare_module().prepare_core_source(tmp_path / "source") + for name in ("chat", "web", "canary", "cli.py", "chat.py"): + assert not (staged / "loopx" / name).exists() + assert not list((staged / "loopx").glob("chat_*.py")) + for name in ("entrypoint.py", "control_plane/turn_driver/executor.py", + "control_plane/coordination/sqlite_authority_store.ts"): + assert (staged / "loopx" / name).read_bytes() == (REPOSITORY / "loopx" / name).read_bytes() + assert not (staged / ".loopx").exists() + assert not (staged / "tests").exists() + + +@pytest.fixture +def core_python(): + value = os.environ.get("LOOPX_CORE_TEST_PYTHON") + if not value: + pytest.skip("set LOOPX_CORE_TEST_PYTHON to a clean installed core environment") + executable = Path(value).absolute() + assert executable.is_file(), executable + return str(executable) + + +def _command(python, arguments): + # -I excludes PYTHONPATH and the current directory. No product import may + # resolve to the parent test's editable source checkout. + return [python, "-I", "-c", + "import json,sys;from loopx.entrypoint import main;raise SystemExit(main(json.loads(sys.argv[1])))", + json.dumps(arguments)] + + +def _environment(python, overrides=None): + env = {**os.environ, "PATH": str(Path(python).parent) + os.pathsep + os.environ.get("PATH", ""), + **(overrides or {}), "LOOPX_USAGE_PING": "0"} + env.pop("PYTHONPATH", None) + return env + + +def _run(python, arguments, cwd, *, environment=None, expected=0): + result = subprocess.run(_command(python, arguments), cwd=cwd, + env=_environment(python, environment), + capture_output=True, text=True, timeout=90) + assert result.returncode == expected, (result.stdout, result.stderr) + return result + + +def test_installed_core_identity_help_and_unavailable_workspace(core_python, tmp_path): + script = '''import importlib.metadata as m,importlib.util as u,json,loopx +from pathlib import Path +assert m.version('loopx-core')==loopx.__version__ +try:m.distribution('loopx') +except m.PackageNotFoundError:pass +else:raise AssertionError('the ordinary distribution must not be installed') +for name in ('loopx.cli','loopx.chat','loopx.web','loopx.canary'):assert u.find_spec(name) is None,name +from loopx.release_manifest import _distribution_runtime_fingerprint +assert _distribution_runtime_fingerprint() is not None +from loopx import doctor +assert doctor.python_distribution_install(Path(doctor.__file__))['distribution_name']=='loopx-core' +print(json.dumps({'package':str(Path(loopx.__file__).resolve())})) +''' + probe = subprocess.run([core_python, "-I", "-c", script], cwd=tmp_path, + env=_environment(core_python), capture_output=True, text=True, timeout=30) + assert probe.returncode == 0, probe.stderr + assert not Path(json.loads(probe.stdout)["package"]).is_relative_to(REPOSITORY) + help_text = _run(core_python, ["--help"], tmp_path, environment={"PATH": str(tmp_path)}).stdout + assert "loopx connect --help" in help_text and "loopx turn run-once --help" in help_text + assert "preset list" not in help_text and "start-goal" not in help_text + reference = json.loads(_run(core_python, ["commands", "--format", "json"], tmp_path).stdout) + commands = [entry["command"] for group in reference["groups"] for entry in group["commands"]] + assert "loopx configure-goal --help" in commands + assert not any(command.startswith(("loopx dashboard", "loopx chat", "loopx canary")) for command in commands) + _run(core_python, ["--version"], tmp_path, environment={"PATH": str(tmp_path)}) + _run(core_python, ["--format", "json", "--version"], tmp_path) + invalid = _run(core_python, ["--format", "invalid", "--version"], tmp_path, expected=2) + assert "invalid choice" in invalid.stderr + refusal = _run(core_python, ["dashboard"], tmp_path, expected=2) + assert "unavailable in the standalone core" in refusal.stderr + assert not list(tmp_path.iterdir()) + + +def test_installed_core_catalog_entries_have_executable_parsers(core_python, tmp_path): + reference = json.loads(_run(core_python, ["commands", "--format", "json"], tmp_path).stdout) + for group in reference["groups"]: + for entry in group["commands"]: + for command in re.split(r" / (?=loopx )", entry["command"]): + tokens = shlex.split(command)[1:] + prefix = [] + for token in tokens: + if token.startswith(("-", "<")): + break + prefix.append(token) + _run(core_python, [*prefix, "--help"], tmp_path) + + +def test_installed_core_deep_doctor_verifies_distribution_ownership(core_python, tmp_path, monkeypatch): + from tests.control_plane.canonical_authority_fixture import isolate_sqlite_runtime + + isolate_sqlite_runtime(tmp_path, monkeypatch) + console = Path(core_python).parent / ("loopx.exe" if os.name == "nt" else "loopx") + arguments = ["doctor", "--installation-only", "--deep", "--format", "json"] + result = subprocess.run([str(console), *arguments], cwd=tmp_path, + env=_environment(core_python), capture_output=True, text=True, timeout=90) + assert result.returncode == 0, (result.stdout, result.stderr) + payload = json.loads(result.stdout) + assert payload["ok"] is True + paths = payload["release_candidate"]["representative_cli"]["package_paths"] + assert "loopx/cli.py" not in paths["required"] + assert "loopx/cli_runtime.py" in paths["required"] + # An executable copied outside this distribution's RECORD must still fail. + copied = tmp_path / console.name + shutil.copy2(console, copied) + rejected = subprocess.run([str(copied), *arguments], cwd=tmp_path, + env=_environment(core_python), capture_output=True, text=True, timeout=90) + assert rejected.returncode == 1, (rejected.stdout, rejected.stderr) + checks = {check["id"]: check for check in json.loads(rejected.stdout)["checks"]} + assert checks["command_package_same_distribution"]["ok"] is False + + +def test_installed_core_deep_doctor_rejects_missing_runtime_file(core_python, tmp_path, monkeypatch): + from tests.control_plane.canonical_authority_fixture import isolate_sqlite_runtime + + isolate_sqlite_runtime(tmp_path, monkeypatch) + probe = subprocess.run([core_python, "-I", "-c", + "from pathlib import Path;import loopx;print(Path(loopx.__file__).parent/'history.py')"], + capture_output=True, text=True, check=True) + original = Path(probe.stdout.strip()) + backup = original.with_suffix(".py.core-test-backup") + original.rename(backup) + try: + console = Path(core_python).parent / ("loopx.exe" if os.name == "nt" else "loopx") + result = subprocess.run([str(console), "doctor", "--installation-only", "--deep", "--format", "json"], + cwd=tmp_path, env=_environment(core_python), capture_output=True, text=True, timeout=90) + assert result.returncode == 1, (result.stdout, result.stderr) + checks = {check["id"]: check for check in json.loads(result.stdout)["checks"]} + assert checks["representative_cli_imports"]["ok"] is False + assert checks["representative_distribution_paths"]["ok"] is False + finally: + backup.rename(original) + + +@pytest.fixture(params=["sqlite", "file"]) +def task(tmp_path, monkeypatch, core_python, request): + from tests.control_plane.canonical_authority_fixture import isolate_sqlite_runtime + + isolate_sqlite_runtime(tmp_path, monkeypatch) + project, runtime = tmp_path / "project", tmp_path / "runtime" + project.mkdir() + registry = project / ".loopx/registry.json" + prefix = ["--registry", str(registry), "--runtime-root", str(runtime), "--format", "json"] + + if request.param == "file": + # Configure the existing installed owner before creating synthetic + # state; never migrate or edit a live Goal for qualification. + setup = '''import json,sys +from pathlib import Path +from loopx.capabilities.machine_configuration.builtins import build_builtin_machine_configuration_registry +from loopx.capabilities.machine_configuration.contract import MACHINE_CONFIGURATION_SCHEMA +from loopx.capabilities.machine_configuration.store import configure_machine_configuration +configuration={'schema_version':MACHINE_CONFIGURATION_SCHEMA,'namespaces':{'goal_storage':{ +'schema_version':'loopx_goal_storage_defaults_v1','new_goal_provider':'file', +'canonical_creation':True,'new_goal_handoff_mode':'hard_lease'}}} +arguments={'runtime_root':Path(sys.argv[1]),'configuration':configuration, +'registry':build_builtin_machine_configuration_registry()} +preview=configure_machine_configuration(**arguments) +result=configure_machine_configuration(**arguments,execute=True,expected_plan_revision=preview['plan_revision']) +assert result['readback_verified'] +''' + configured = subprocess.run([core_python, "-I", "-c", setup, str(runtime)], cwd=project, + env=_environment(core_python), capture_output=True, text=True, timeout=45) + assert configured.returncode == 0, configured.stderr + + def command(*arguments): + return json.loads(_run(core_python, [*prefix, *arguments], project).stdout) + + created = command("connect", "--project", str(project), "--goal-id", GOAL, + "--objective", "Produce and independently check squared integers zero through seven.", + "--write-scope", "docs/**", "--no-global-sync") + assert created["storage_selection"]["provider"] == request.param + assert created["storage_selection"]["handoff_mode"] == "hard_lease" + command("configure-goal", "--goal-id", GOAL, "--registered-agent", AGENT, + "--quota-compute", "1", "--execute") + added = command("todo", "add", "--goal-id", GOAL, "--role", "agent", "--claimed-by", AGENT, + "--text", "Produce and independently validate squares.json.", "--priority", "P1", + "--task-class", "advancement_task", "--action-kind", "validate", + "--operation-id", "core-create-task") + command("todo", "claim", "--goal-id", GOAL, "--todo-id", added["todo_id"], + "--agent-id", AGENT, "--claimed-by", AGENT, "--claim-operation-id", "core-new-task") + workspace = project / "docs/isolated-work" + workspace.mkdir(parents=True) + arguments = ["--registry", str(registry), "--runtime-root", str(runtime), + "--format", "json", "turn", "run-once", "--host", "generic-cli", + "--goal-id", GOAL, "--agent-id", AGENT, "--project", str(workspace), + "--execution-mode", "isolated-headless", + "--host-adapter-request-schema", "loopx_turn_host_request_v1", + "--host-adapter-command-json", json.dumps([core_python, "-I", "-c", HOST_SOURCE]), + "--validation-command-json", json.dumps([core_python, "-I", "-c", VALIDATOR_SOURCE]), + "--no-global-sync", "--execute"] + return workspace, runtime, registry, arguments + + +def _spends(runtime): + index = runtime / "goals" / GOAL / "runs/index.jsonl" + return sum(json.loads(line).get("classification") == "quota_slot_spent" + for line in index.read_text().splitlines()) if index.exists() else 0 + + +def _assert_committed(core_python, workspace, runtime, result): + assert result["status"] == "committed", result + assert result["validation"]["status"] == "passed" + assert _spends(runtime) == 1 + assert json.loads((workspace / "squares.json").read_text()) == [0, 1, 4, 9, 16, 25, 36, 49] + assert (workspace / "host-invocations").read_text().splitlines() == ["called"] + journals = [p for p in (runtime / "goals" / GOAL / "turns").glob("*.json") + if not p.name.endswith(".lock.holder.json")] + assert len(journals) == 1 + journal = json.loads(journals[0].read_text()) + assert journal["status"] == "committed" + contract = journal["plan"]["work_contract"] + readback = json.loads(_run(core_python, ["--registry", contract["registry"], + "--runtime-root", str(runtime), "--format", "json", "todo", "list", "--goal-id", GOAL, + "--todo-id", contract["selected_todo"]["todo_id"]], workspace).stdout) + assert readback["todo"]["status"] == "done" + return result["resume_turn_key"] + + +def test_installed_core_executes_checks_and_replays_once(core_python, task): + workspace, runtime, registry, arguments = task + history = json.loads(_run(core_python, ["--registry", str(registry), "--runtime-root", str(runtime), + "--format", "json", "history", "trajectory-hygiene", "--goal-id", GOAL], workspace).stdout) + assert history["ok"] is True + result = json.loads(_run(core_python, arguments, workspace).stdout) + key = _assert_committed(core_python, workspace, runtime, result) + replay = json.loads(_run(core_python, [*arguments, "--resume-turn-key", key], workspace).stdout) + _assert_committed(core_python, workspace, runtime, replay) + assert replay["effects"]["host_invoked"] is False + assert replay["effects"]["state_written"] is False + assert replay["effects"]["quota_spent"] is False + inspected = json.loads(_run(core_python, ["--registry", str(registry), "--runtime-root", str(runtime), + "--format", "json", "turn", "inspect-journal", "--goal-id", GOAL, + "--agent-id", AGENT, "--turn-key", key], workspace).stdout) + assert inspected["ok"] is True + + +@pytest.mark.skipif(os.name == "nt", reason="this qualification injects a POSIX SIGKILL") +@pytest.mark.parametrize("lease_change", [None, "renew", "release"]) +def test_installed_core_recovers_confirmed_result_after_process_loss(core_python, task, lease_change): + workspace, runtime, registry, arguments = task + pause = workspace / "pause-validator" + pause.touch() + process = subprocess.Popen(_command(core_python, arguments), cwd=workspace, + env=_environment(core_python), stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True) + journal = None + try: + deadline = time.monotonic() + 45 + while time.monotonic() < deadline: + if process.poll() is not None: + pytest.fail(str(process.communicate())) + for path in (runtime / "goals" / GOAL / "turns").glob("*.json"): + if path.name.endswith(".lock.holder.json"): + continue + candidate = json.loads(path.read_text()) + if "typed_result" in candidate.get("completed_phases", []) and (workspace / "validator-ready").exists(): + journal = candidate + break + if journal: + break + time.sleep(0.05) + assert journal is not None, "the confirmed host result was not durably observed" + process.send_signal(signal.SIGKILL) + process.wait(timeout=10) + finally: + if process.poll() is None: + process.kill() + process.wait(timeout=10) + pause.unlink(missing_ok=True) + process.communicate(timeout=10) + assert not journal.get("quota_spend") + assert _spends(runtime) == 0 + if lease_change: + todo_id = journal["plan"]["work_contract"]["selected_todo"]["todo_id"] + prefix = ["--registry", str(registry), "--runtime-root", str(runtime), "--format", "json"] + lease = json.loads(_run(core_python, [*prefix, "task-lease", "inspect", "--goal-id", GOAL, + "--todo-id", todo_id], workspace).stdout)["lease"] + _run(core_python, [*prefix, "task-lease", lease_change, "--goal-id", GOAL, + "--todo-id", todo_id, "--owner", AGENT, "--idempotency-key", lease["idempotency_key"], + "--expected-version", str(lease["version"])], workspace) + rejected = json.loads(_run(core_python, [*arguments, "--resume-turn-key", journal["turn_key"]], + workspace, expected=1).stdout) + assert rejected["status"] == "failed" + assert rejected["effects"]["host_invoked"] is False + assert rejected["effects"]["state_written"] is False + assert rejected["effects"]["quota_spent"] is False + assert _spends(runtime) == 0 + readback = json.loads(_run(core_python, [*prefix, "todo", "list", "--goal-id", GOAL, + "--todo-id", todo_id], workspace).stdout) + assert readback["todo"]["status"] == "open" + assert (workspace / "host-invocations").read_text().splitlines() == ["called"] + return + result = json.loads(_run(core_python, [*arguments, "--resume-turn-key", journal["turn_key"]], workspace).stdout) + _assert_committed(core_python, workspace, runtime, result) + assert result["effects"]["host_invoked"] is False + replay = json.loads(_run(core_python, [*arguments, "--resume-turn-key", journal["turn_key"]], workspace).stdout) + assert replay["effects"]["quota_spent"] is False + assert (workspace / "host-invocations").read_text().splitlines() == ["called"] + + +def test_installed_core_rejects_unverified_artifact_without_settlement(core_python, task): + workspace, runtime, _, arguments = task + invalid = list(arguments) + host = invalid.index("--host-adapter-command-json") + 1 + invalid[host] = json.dumps([core_python, "-I", "-c", HOST_SOURCE.replace( + "[n*n for n in range(8)]", "[0,1,4,9,16,25,36,50]", + )]) + result = json.loads(_run(core_python, invalid, workspace, expected=1).stdout) + assert result["status"] == "failed" + assert result["effects"]["host_invoked"] is True + assert result["effects"]["state_written"] is False + assert result["effects"]["quota_spent"] is False + assert _spends(runtime) == 0 + assert (workspace / "host-invocations").read_text().splitlines() == ["called"] diff --git a/tests/test_distribution_runtime_identity.py b/tests/test_distribution_runtime_identity.py index 5aeec6a38c..dbaee0d644 100644 --- a/tests/test_distribution_runtime_identity.py +++ b/tests/test_distribution_runtime_identity.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import os from pathlib import Path from types import SimpleNamespace import subprocess @@ -63,6 +64,7 @@ def test_real_pip_compiled_and_uncompiled_wheel_have_the_same_identity(tmp_path: "loopx/__init__.py": Path(manifest.__file__).with_name("__init__.py").read_text(), "loopx/release_manifest.py": Path(manifest.__file__).read_text(), "loopx/doctor_git.py": Path(manifest.__file__).with_name("doctor_git.py").read_text(), + "loopx/python_install_owner.py": Path(manifest.__file__).with_name("python_install_owner.py").read_text(), "loopx/extensions/__init__.py": "", "loopx/extensions/process_runtime.py": Path(manifest.__file__).with_name( "extensions" @@ -78,7 +80,7 @@ def test_real_pip_compiled_and_uncompiled_wheel_have_the_same_identity(tmp_path: identities = [] for compile_bytecode in (True, False): environment = tmp_path / ("compiled" if compile_bytecode else "uncompiled") - venv.EnvBuilder(with_pip=True).create(environment) + venv.EnvBuilder(with_pip=True, symlinks=os.name != "nt").create(environment) python = environment / ("Scripts/python.exe" if sys.platform == "win32" else "bin/python") subprocess.run([str(python), "-I", "-m", "pip", "install", "--no-index", "--no-deps", "--disable-pip-version-check", *([] if compile_bytecode else ["--no-compile"]), @@ -121,3 +123,14 @@ def test_incomplete_or_redirected_distribution_cannot_claim_an_artifact(owned_pa def test_unrelated_source_root_cannot_adopt_an_installed_distribution(owned_package, tmp_path): assert "package_fingerprint" not in manifest.release_runtime_identity(tmp_path / "checkout") + + +def test_record_root_alias_keeps_exact_physical_ownership(owned_package): + package, installed = owned_package + before = manifest.release_runtime_identity() + alias = package.parent.parent / "site-alias" + alias.symlink_to(package.parent, target_is_directory=True) + for item in installed.files: + name = item.as_posix() + item.locate = lambda name=name: alias / name + assert manifest.release_runtime_identity() == before diff --git a/tests/test_python_install_owner_platform.py b/tests/test_python_install_owner_platform.py index e3f7c263a3..aacc086e66 100644 --- a/tests/test_python_install_owner_platform.py +++ b/tests/test_python_install_owner_platform.py @@ -275,3 +275,15 @@ def test_windows_interpreter_line_runs_a_real_executable( assert completed.returncode == 0, completed.stderr assert "loopx-interpreter-witness-ok" in completed.stdout + + +@pytest.mark.parametrize("manager", ["pip", "pipx"]) +def test_standalone_core_upgrade_keeps_its_composition(manager): + command = python_distribution_upgrade_command( + owner=PythonInstallOwner(manager), python_executable=sys.executable, + doctor_command="loopx doctor", distribution_name="loopx-core", + ) + assert command is not None and "loopx-core" in command.splitlines()[0] + assert "workflow-skills" not in command + assert "slash-commands" not in command + assert command.splitlines()[-1] == "loopx doctor"