Skip to content
Merged
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
8 changes: 8 additions & 0 deletions loopx/chat_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -1162,6 +1162,14 @@ def preview(self, request: Mapping[str, Any]) -> dict[str, Any]:
)
evidence = ["The recoverable Goal and Agent Chat Session is available."]
permission = "scoped_correction"
elif action_kind == "goal.lifecycle":
lifecycle_preview = self._goal_lifecycle_preview(normalized)
fingerprint = str(lifecycle_preview["state_fingerprint"])
canonical_update_basis = lifecycle_preview.get("source_basis")
evidence = [
"The lifecycle transition is bound to the current authoritative Goal source."
]
permission = "durable_write"
elif action_kind == "team.plan":
# A plan is reviewed against this Goal's registration facts *and*
# the intent its lanes would advance, so both are bound here and
Expand Down
81 changes: 78 additions & 3 deletions loopx/chat_goal_lifecycle_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,48 @@
from .control_plane.goals.deletion_service import delete_stopped_goal


GOAL_LIFECYCLE_SOURCE_BASIS_SCHEMA_VERSION = "loopx_goal_lifecycle_source_basis_v1"


class ChatGoalLifecycleActionMixin:
"""Keep Goal activation policy separate from general action orchestration."""

def _goal_lifecycle_preview(
self,
parameters: dict[str, Any],
) -> dict[str, Any]:
operation = str(parameters["operation"])
if operation == "delete":
return {"state_fingerprint": self._registry_fingerprint()}
target_state = (
GoalActivationState.STOPPED
if operation == "stop"
else GoalActivationState.ACTIVE
)
preview = set_goal_activation_state(
registry_path=self.registry_path,
goal_id=str(parameters["goal_id"]),
state=target_state,
reason=parameters.get("reason"),
execute=False,
)
fingerprint = str(preview.get("observed_state_fingerprint") or "")
source_identity = str(preview.get("source_identity") or "")
if not preview.get("ok") or not fingerprint or not source_identity:
raise ValueError(
str(
preview.get("error")
or "Goal lifecycle source identity is unavailable"
)
)
return {
"state_fingerprint": fingerprint,
"source_basis": {
"schema_version": GOAL_LIFECYCLE_SOURCE_BASIS_SCHEMA_VERSION,
"source_identity": source_identity,
},
}

def _normalize_goal_lifecycle(
self, parameters: dict[str, Any]
) -> dict[str, Any]:
Expand Down Expand Up @@ -94,10 +133,10 @@ def _apply_goal_lifecycle(
) -> dict[str, Any]:
from .chat_actions import _digest

current_fingerprint = self._registry_fingerprint()
goal_id = str(parameters["goal_id"])
operation = str(parameters["operation"])
if operation == "delete":
current_fingerprint = self._registry_fingerprint()
return self._apply_goal_delete(
proposal_id, proposal, goal_id, current_fingerprint
)
Expand All @@ -107,10 +146,31 @@ def _apply_goal_lifecycle(
if operation == "stop"
else GoalActivationState.ACTIVE
)
current_state = goal_activation_state(self._goal(goal_id))
expected_fingerprint = str(proposal.get("expected_state_fingerprint") or "")
current = set_goal_activation_state(
registry_path=self.registry_path,
goal_id=goal_id,
state=target_state,
reason=parameters.get("reason"),
execute=False,
)
current_fingerprint = str(current.get("observed_state_fingerprint") or "")
current_state = GoalActivationState(str(current.get("before_state") or ""))
source_basis = proposal.get("canonical_update_basis")
expected_source_identity = (
str(source_basis.get("source_identity") or "")
if isinstance(source_basis, dict)
and source_basis.get("schema_version")
== GOAL_LIFECYCLE_SOURCE_BASIS_SCHEMA_VERSION
else ""
)
source_route_matches = bool(
expected_source_identity
and expected_source_identity == current.get("source_identity")
)
idempotent_reapply = (
current_state is target_state
source_route_matches
and current_state is target_state
and current_fingerprint != expected_fingerprint
)
if current_fingerprint != expected_fingerprint and not idempotent_reapply:
Expand All @@ -126,8 +186,23 @@ def _apply_goal_lifecycle(
state=target_state,
reason=parameters.get("reason"),
actor_kind="owner",
expected_state_fingerprint=(
current_fingerprint
if idempotent_reapply
else expected_fingerprint
),
execute=True,
)
if result.get("error_kind") == "goal_action_stale":
stale = self.store.apply(
proposal_id,
current_state_fingerprint=str(
result.get("observed_state_fingerprint")
or current_fingerprint
),
receipt={},
)
return {"proposal": stale, "turn": None}
if not result.get("ok") or not (result.get("readback") or {}).get(
"verified"
):
Expand Down
2 changes: 1 addition & 1 deletion loopx/cli_commands/goal_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ def register_goal_lifecycle_command(
)
parser.add_argument(
"--expected-state-fingerprint",
help="SHA-256 registry fingerprint from a fresh goal-actions projection.",
help="Source-bound SHA-256 fingerprint from a fresh goal-actions projection.",
)
parser.add_argument(
"--execute",
Expand Down
Loading
Loading