Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/guides/personal-workspace-user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,25 @@ loopx goal-lifecycle --goal-id <goal-id> --operation resume --actor-kind owner -

执行时,LoopX 会写入权威 source registry、同步全局 registry,并验证两端 readback;任一端未验证成功时不会宣称操作完成。

暂停/恢复只由持久化的 `activation.state` 控制。Registry 的 `status` 继续表达注册、
归档等状态,不再接受旧的 `paused` / `stopped` 值;前端响应中的 `activation_state`
是派生展示值,不能写回 registry。

使用 `loopx upgrade` 或 snapshot 安装器时,会在启用候选运行时前,通过已有的
`authority-archive upgrade --all-known --execute` 流程备份并迁移这些旧元数据。
直接通过 pip 安装、源码运行或恢复旧备份时,可先只读检查:

```bash
loopx authority-archive upgrade --all-known --require-current
# 需要迁移时,先备份原字节,再执行迁移
loopx authority-archive upgrade --all-known --execute
```

原 registry 保存在相邻 `backups/registry-preimages/` 下,迁移结果返回具体备份路径。
迁移保留暂停意图;字段冲突时保持停止,不会自动 Resume,也不改 Todo、执行回执或额度。
之后仍可从「已停止」区或上面的同一 CLI 恢复。迁移失败时升级不会宣称完成,备份保留,
原命令可重试;不要用备份覆盖迁移之后产生的新工作。

切换到 SSH 状态来源后,只有来源与本机 OpenSSH 配置中的精确 Host alias 绑定时,
侧边栏才显示停止/恢复按钮。操作通过 SSH 在目标主机执行同一个
`goal-lifecycle` typed contract,并验证远端投影;不会回退修改本机同名 Goal。
Expand Down
2 changes: 1 addition & 1 deletion loopx/capabilities/periodic_report/machine_defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -399,7 +399,7 @@ def resolve_active_goal_periodic_report_subscription(
if (
not isinstance(goal, Mapping)
or goal_is_stopped(goal)
or goal.get("status") in {"stopped", "paused", "archived"}
or goal.get("status") == "archived"
):
return None
subscription = resolve_goal_periodic_report_subscription(goal, machine_defaults)
Expand Down
43 changes: 39 additions & 4 deletions loopx/cli_commands/authority_archive.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@

from ..control_plane.effect_runtime import EffectRuntimeResponseAmbiguous, effect_runtime_result
from ..paths import global_registry_path, resolve_runtime_root, select_default_runtime_root
from ..control_plane.projects.registry_codec import load_registry
from ..control_plane.projects.registry_codec import (
load_registry,
read_activation_upgrade_registry,
)
from ..control_plane.goals.activation_service import upgrade_goal_activation_registries


def register_authority_archive_command(
Expand Down Expand Up @@ -76,6 +80,9 @@ def handle_authority_archive_command(
if args.authority_archive_action == "inspect":
request["source"] = str(args.source.expanduser().resolve())
elif args.authority_archive_action == "upgrade":
registries = authority_upgrade_registries(
registry_path, runtime_root_arg, all_known=args.all_known)
activation_plan = upgrade_goal_activation_registries(registries, execute=False)
request.update(runtime_roots=authority_upgrade_roots(
registry_path, runtime_root_arg, all_known=args.all_known), execute=args.execute)
elif args.authority_archive_action in {"plan-migration", "migrate"}:
Expand Down Expand Up @@ -107,6 +114,16 @@ def handle_authority_archive_command(
result = effect_runtime_result(
"coordination.authority_archive.manage", request, timeout=300.0, retry_safe=False
)
if args.authority_archive_action == "upgrade" and result.get("status") != "failed":
result["goal_activation_upgrade"] = (
upgrade_goal_activation_registries(registries, execute=True)
if args.execute else activation_plan
)
if args.require_current and any(
row["status"] == "planned"
for row in activation_plan["results"]
):
result.update(status="failed", reason="Goal activation metadata upgrade required before activating this runtime.")
except (OSError, RuntimeError, ValueError) as error:
uncertain = args.authority_archive_action == "migrate" and args.execute
result = {"status": "failed", "reason": str(error),
Expand Down Expand Up @@ -137,15 +154,15 @@ def handle_authority_archive_command(
def authority_upgrade_roots(registry_path: Path, runtime_root_arg: str | None,
*, all_known: bool) -> list[str]:
"""Bounded discovery from existing registries; never scan arbitrary home paths."""
registry = load_registry(registry_path) if registry_path.exists() else {}
registry = read_activation_upgrade_registry(registry_path)
override = runtime_root_arg or os.environ.get("LOOPX_RUNTIME_ROOT")
selected = resolve_runtime_root(registry, override, registry_path=registry_path).resolve()
roots = {selected}
if all_known:
common = Path(override).expanduser().resolve() if override else select_default_runtime_root().resolve()
roots.add(common)
global_path = global_registry_path(common)
global_registry = load_registry(global_path) if global_path.exists() else {}
global_registry = read_activation_upgrade_registry(global_path)
for goal in global_registry.get("goals", []):
source = goal.get("source_registry") if isinstance(goal, dict) else None
if not source:
Expand All @@ -155,5 +172,23 @@ def authority_upgrade_roots(registry_path: Path, runtime_root_arg: str | None,
raise ValueError("Registered source_registry must be absolute for automatic upgrade")
# A disconnected checkout does not hide the common runtime store.
if path.exists():
roots.add(resolve_runtime_root(load_registry(path), registry_path=path).resolve())
roots.add(resolve_runtime_root(read_activation_upgrade_registry(path), registry_path=path).resolve())
return sorted(str(root) for root in roots)


def authority_upgrade_registries(registry_path: Path, runtime_root_arg: str | None,
*, all_known: bool) -> list[Path]:
"""Use the same registered routes as store discovery, including the selection."""
roots = authority_upgrade_roots(registry_path, runtime_root_arg, all_known=all_known)
paths = {registry_path.expanduser().resolve()}
for root in roots:
path = global_registry_path(Path(root))
paths.add(path)
for goal in read_activation_upgrade_registry(path).get("goals", []):
source = goal.get("source_registry") if isinstance(goal, dict) else None
if source:
source_path = Path(str(source)).expanduser()
if not source_path.is_absolute():
raise ValueError("Registered source_registry must be absolute for automatic upgrade")
paths.add(source_path.resolve())
return sorted(path for path in paths if path.exists())
1 change: 1 addition & 0 deletions loopx/control_plane/effect_runtime_handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,7 @@ export function createEffectRuntimeHandlers(
["goal.vision_wait.project", lazyHandler(() => import("./goals/vision_wait_coverage.ts"), ({projectGoalVisionWaitState}) => projectGoalVisionWaitState)],
["goal.shared_goal_alignment.project", lazyHandler(() => import("./goals/shared_goal_alignment.ts"), ({projectSharedGoalAlignment}) => projectSharedGoalAlignment)],
["goal.operator_actions.project", lazyHandler(() => import("./goals/operator_actions.ts"), ({projectGoalOperatorActions}) => projectGoalOperatorActions)],
["goal.activation.migrate", lazyHandler(() => import("./goals/activation_migration.ts"), ({migrateGoalActivationMetadata}) => migrateGoalActivationMetadata)],
["goal.amendment.execute", lazyHandler(() => import("./goals/goal_amendment_authority.ts"), ({goalAmendmentLocal}) => goalAmendmentLocal)],
["goal.direction.execute", lazyHandler(() => import("./goals/direction_authority.ts"), ({directionLocal}) => directionLocal)],
["goal.amendment_proposal.admit", lazyHandler(() => import("./goals/goal_amendment_proposal.ts"), ({admitGoalAmendmentProposal}) => admitGoalAmendmentProposal)],
Expand Down
2 changes: 1 addition & 1 deletion loopx/control_plane/goals/acceptance.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ def refine_goal_outcome(
registry_path, goal, route = _routing(registry_path, goal_id, runtime_root, agent_id)
if "owner_agent_id" in refinement and refinement["owner_agent_id"] not in registered_agent_ids_for_goal(goal):
raise ValueError("Outcome owner must be a registered Agent")
# The registry's legacy omitted-status form denotes an active Goal too.
# Registration eligibility is independent of the typed activation gate.
if goal.get("status", "active") not in {"active", "ready"} or goal_is_stopped(goal):
raise ValueError("Outcome refinement requires an active Goal")
return _result("goal.acceptance.refine", {**route, "actor_agent_id": agent_id,
Expand Down
17 changes: 16 additions & 1 deletion loopx/control_plane/goals/activation.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,29 @@ def normalize_goal_activation_state(value: Any) -> GoalActivationState:
raise ValueError("goal activation state must be active or stopped") from exc


def require_current_registry_goal_activation(goal: Mapping[str, Any]) -> None:
"""Reject retired storage fields, without interpreting them as live authority."""
if "activation_state" in goal or goal.get("status") in ("paused", "stopped"):
raise ValueError(
"Goal activation metadata requires upgrade; run "
"loopx authority-archive upgrade --all-known --execute "
"to back up and migrate the registry before activation"
)


def goal_activation_state(goal: Mapping[str, Any] | None) -> GoalActivationState:
"""Return the typed Goal activation state; older registries default active."""
"""Read canonical activation or its derived read-model projection.

Registry IO rejects stored activation_state aliases and retired pause values;
only the explicit upgrade decoder may consume them. Omitted activation is active.
"""

if not isinstance(goal, Mapping):
return GoalActivationState.ACTIVE
projected = goal.get("activation_state")
if projected is not None:
return normalize_goal_activation_state(projected)
require_current_registry_goal_activation(goal)
activation = goal.get("activation")
if activation is None:
return GoalActivationState.ACTIVE
Expand Down
75 changes: 75 additions & 0 deletions loopx/control_plane/goals/activation_migration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/** Upgrade-only decoder for retired registry pause values and stored readback aliases.
* Normal readers and writers require current metadata; this never grants a Resume. */
import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts";
import type {JsonObject} from "../effect_program.ts";
import {requireJsonObject, requireNonEmptyString} from "../runtime_decode.ts";
import {SOURCE_SESSION_PROFILE_ID} from "./source_session_lifetime.ts";

const SCHEMA = "loopx_goal_activation_v1";

function state(value: unknown): "active" | "stopped" {
if (value === "active" || value === "stopped") return value;
throw new EffectRuntimeRequestError("Goal activation state must be active or stopped");
}

export function migrateGoalActivationMetadata(params: JsonObject): JsonObject {
if (!Array.isArray(params.registries)) throw new EffectRuntimeRequestError("registries must be an array");
const registries = params.registries.map(raw => {
const row = requireJsonObject(raw, "registry snapshot");
const path = requireNonEmptyString(row.path, "registry path");
const payload = requireJsonObject(row.payload, "registry payload");
if (payload.goals !== undefined && !Array.isArray(payload.goals)) throw new EffectRuntimeRequestError("registry goals must be an array");
return {path, payload, goals: (payload.goals ?? []) as unknown[]};
});
if (new Set(registries.map(row => row.path)).size !== registries.length) throw new EffectRuntimeRequestError("duplicate registry path");
const nodes = registries.flatMap(registry => registry.goals.map((raw, index) => {
const goal = requireJsonObject(raw, "registry Goal");
const activation = goal.activation == null ? null : requireJsonObject(goal.activation, "Goal activation");
if (activation?.schema_version && activation.schema_version !== SCHEMA) {
throw new EffectRuntimeRequestError("unsupported Goal activation schema");
}
const current = activation === null ? "active" : state(activation.state);
const hasAlias = Object.hasOwn(goal, "activation_state");
const alias = goal.activation_state == null ? null : state(goal.activation_state);
const legacyPause = goal.status === "paused" || goal.status === "stopped";
return {registry, index, goal, activation, current, legacyPause,
legacy: legacyPause || hasAlias,
stopped: legacyPause || alias === "stopped" || current === "stopped"};
}));
// Only an exact source route and the same Goal lifetime join a projection to
// its source. A stale projection cannot stop a recreated Goal of the same name.
const components = new Map<typeof nodes[number], Set<typeof nodes[number]>>();
for (const node of nodes) components.set(node, new Set([node]));
for (const node of nodes) {
if (!node.goal.source_registry) continue;
const matches = nodes.filter(candidate => candidate.registry.path === node.goal.source_registry
&& candidate.goal.id === node.goal.id
&& (candidate.goal.goal_instance_id ?? null) === (node.goal.goal_instance_id ?? null));
if (matches.length > 1) throw new EffectRuntimeRequestError("ambiguous source Goal during activation upgrade");
const source = matches[0];
if (!source) continue;
const merged = new Set([...components.get(node)!, ...components.get(source)!]);
for (const member of merged) components.set(member, merged);
}
const replacements = new Map<typeof nodes[number], JsonObject>();
for (const node of nodes) {
const component = [...components.get(node)!];
if (!component.some(member => member.legacy)) continue;
// Conflicting historical representations never silently resume execution.
// Resume remains an explicit owner operation after the backup and upgrade.
const nextState = component.some(member => member.stopped) ? "stopped" : "active";
if (nextState === "stopped" && node.registry.payload.profile_id === SOURCE_SESSION_PROFILE_ID
&& (node.legacyPause || node.current !== "stopped")) {
throw new EffectRuntimeRequestError("Lifecycle-only source_session_v1 cannot migrate an execution pause before its lifecycle owner supports Resume");
}
if (!node.legacy && node.current === nextState) continue;
replacements.set(node, {...node.activation, schema_version: SCHEMA, state: nextState});
}
return {registries: registries.map(registry => ({path: registry.path,
changes: nodes.filter(node => node.registry === registry && replacements.has(node)).map(node => ({
index: node.index, goal_id: requireNonEmptyString(node.goal.id, "Goal id"),
activation: replacements.get(node)!,
...(node.legacyPause ? {status: "active"} : {}),
})),
}))};
}
Loading
Loading