diff --git a/docs/reference/goal-acceptance-observations.md b/docs/reference/goal-acceptance-observations.md index a82e3b90ae..16fddbf6ab 100644 --- a/docs/reference/goal-acceptance-observations.md +++ b/docs/reference/goal-acceptance-observations.md @@ -30,8 +30,36 @@ The proposed [Goal direction baseline](../architecture/rfcs/goal-direction-basel material declarations and revision-bound usage receipts are not implemented here; historical progress does not establish that current direction materials were read. +Status also exposes a separate `run_history.goals[].artifact_lifecycle` readout +and Markdown summary: observed phase, evidence milestones, guards and next steps. +It consumes the current Goal's session-runtime work observation before display +trimming. Outstanding required work keeps the phase `qualifying` even when +Todos are complete and historical progress is reached. An absent work observation +does not invent a work requirement. This v0 readout **never recommends a terminal +transition**: `closing` stays a verification step with the machine-readable +`next_transitions[].reason_codes` value `acceptance_unverified`, both when sources +are missing and when all bounded sources were observed. Consumers use that code, +not English `precondition` text. A Goal already recorded as terminal still displays +`lifecycle_phase: closed` with no next transitions. Adding acceptance-driven +terminal advice requires a separate contract change and producer-side validation. +This readout grants no execution or completion authority; the Dashboard card +above keeps its separate acceptance-observation contract. + +Work observation coverage follows the supplied projection, not `adapter.kind`: + +| Source delivered to status | Work observation coverage | +| --- | --- | +| Session-runtime adapter, or another adapter emitting the same session-runtime projection | Available only with `session_runtime_readonly_projection_v0`, a matching Goal id, and work facts | +| Adapter without that projection, or a projection with a mismatched schema/Goal id | Unavailable; this readout does not query quota or other lane owners | + +Without that source a Goal may read `closing` from Todo/history even when a lane +outside the observation still requires work. Neither `closing` nor absence of +`work_lane_selected` proves all work is complete; the lane and completion owners +retain their decisions. + Validation: `python -m pytest tests/control_plane/test_goal_acceptance_observation.py` -and `node examples/dashboard-goal-acceptance-browser-smoke.mjs`. The browser +and `python -m pytest tests/control_plane/test_goal_artifact_work_observation.py`, +plus `node examples/dashboard-goal-acceptance-browser-smoke.mjs`. The browser check consumes real status collection over a disposable synthetic Goal; set `LOOPX_GOAL_ACCEPTANCE_PACKAGED=1` after the Dashboard build to check shipped assets. @@ -54,5 +82,24 @@ check consumes real status collection over a disposable synthetic Goal; set 不保留把完整协议误认作该观察结构的别名。此切片不增加里程碑声明入口、统一阶段序列、完整证据审计、 新的合法迁移或完成判定。Goal direction baseline 提案中的材料声明和绑定版本的阅读回执 不在此次实现范围;历史进展不能证明已阅读当前方向材料。旧来源不提供投影时显示不可用。 +status 同时在独立的 `run_history.goals[].artifact_lifecycle` 和 Markdown 摘要中展示 +观测阶段、证据里程碑、门禁和下一步。它在展示截断前读取当前 Goal 的 session-runtime +工作观察:即使 Todo 全部完成且历史进展已达成,只要仍有必须执行的工作,阶段就保持 +`qualifying`。缺少工作观察不会凭空产生执行要求。此 v0 读出**始终不建议终态迁移**: +`closing` 保持为验收核验步骤,无论是否缺少来源,均通过 +`next_transitions[].reason_codes` 中的 `acceptance_unverified` 表达本读出未验证验收。 +机器消费者读取该 code,无需匹配英文 `precondition`。已记录为终态的 Goal 仍展示 +`lifecycle_phase: closed`,下一步列表为空。未来若增加基于验收的终态建议,必须另行变更合同并验证产出侧。 +此读出不授予执行或完成权威,Dashboard 卡片仍使用独立的验收观察合同。 + +工作观察覆盖取决于实际提供的投影,不按 `adapter.kind` 名称判断: + +| status 接收的来源 | 工作观察覆盖 | +| --- | --- | +| session-runtime adapter,或提供相同 session-runtime 投影的其它 adapter | schema 为 `session_runtime_readonly_projection_v0`、Goal id 匹配且包含工作事实时可用 | +| 不提供该投影的 adapter,或 schema/Goal id 不匹配的投影 | 不可用;此读出不会额外查询 quota 或其它工作通道权威 | + +缺少该来源时,即使观察范围外仍有必须执行的工作,Todo/历史也可能让 Goal 显示 `closing`。 +`closing` 或缺少 `work_lane_selected` 均不证明所有工作完成,工作通道与完成权威仍保留各自的判断。 上面的测试命令覆盖合成 Goal 的生产 refresh-state 写入、 真实 status 收集和浏览器入口;打包验证使用 `LOOPX_GOAL_ACCEPTANCE_PACKAGED=1`。 diff --git a/examples/control_plane/goal-artifact-lifecycle-projection-smoke.py b/examples/control_plane/goal-artifact-lifecycle-projection-smoke.py new file mode 100644 index 0000000000..b4cd405408 --- /dev/null +++ b/examples/control_plane/goal-artifact-lifecycle-projection-smoke.py @@ -0,0 +1,498 @@ +"""Fixture smoke for the derived Goal artifact lifecycle projection. + +Proves milestone, guard and next-transition derivation from synthetic goal +payloads, including the negative cases the RFC requires: an unreached +milestone, and a blocking guard with no legal transition. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from loopx.control_plane.goals.artifact_lifecycle import ( # noqa: E402 - source-checkout entrypoint + GOAL_ARTIFACT_LIFECYCLE_PROJECTION_SCHEMA_VERSION, + GUARD_KIND_EVIDENCE, + GUARD_KIND_OWNER_DECISION, + PHASE_CLOSED, + PHASE_CLOSING, + PHASE_QUALIFYING, + PHASE_STARTING, + PHASE_WAITING_OWNER, + _compact_text, + build_goal_artifact_lifecycle_projection, +) +from loopx.control_plane.work_items.work_lane import WorkLaneObservation # noqa: E402 + +GOAL_ID = "artifact-lifecycle-fixture" + +# Substrings that must never appear in a public projection. +FORBIDDEN_LEAKS = ( + "/Users/", + "/private/", + "sk-", + "ghp_", + "BEGIN PRIVATE KEY", + "raw_evidence_body", +) + + +def assert_no_public_leak(projection: dict) -> None: + rendered = json.dumps(projection, ensure_ascii=False) + for token in FORBIDDEN_LEAKS: + assert token not in rendered, (token, rendered) + + +def assert_starting_phase_without_work() -> None: + projection = build_goal_artifact_lifecycle_projection( + goal_id=GOAL_ID, + goal={"id": GOAL_ID, "status": "active"}, + user_todo_summary={"gate_open_items": [], "open_count": 0}, + agent_todo_summary={"open_count": 0}, + run_history={"latest_runs": []}, + ) + assert projection["schema_version"] == ( + GOAL_ARTIFACT_LIFECYCLE_PROJECTION_SCHEMA_VERSION + ), projection + assert projection["lifecycle_phase"] == PHASE_STARTING, projection + assert projection["milestones"] == [], projection + assert projection["guards"] == [], projection + assert projection["next_transitions"] == [], projection + assert_no_public_leak(projection) + + +def assert_outcome_gap_is_material_but_not_reached() -> None: + """A recorded gap is retained history, never a reached marker. + + `MATERIAL_DELIVERY_OUTCOMES` decides what history keeps; the canonical + progress outcomes decide what the Goal advanced, and they deliberately + exclude `outcome_gap`. + """ + + projection = build_goal_artifact_lifecycle_projection( + goal_id=GOAL_ID, + goal={"id": GOAL_ID, "status": "active"}, + agent_todo_summary={"open_count": 2}, + run_history={"latest_runs": [{"delivery_outcome": "outcome_gap"}]}, + ) + reached = {item["id"]: item["reached"] for item in projection["milestones"]} + assert reached == {"outcome_gap": False}, projection + assert projection["lifecycle_phase"] == PHASE_QUALIFYING, projection + assert_no_public_leak(projection) + + +def assert_gap_only_evidence_blocks_closeout() -> None: + """The reproduced defect: zero open agent Todos over an `outcome_gap` run. + + This previously read as closing / next: closed because the material set + marked the gap reached. A gap is an unreached marker, so the Goal stays + qualifying and is told what is still missing. + """ + + projection = build_goal_artifact_lifecycle_projection( + goal_id=GOAL_ID, + goal={"id": GOAL_ID, "status": "active"}, + user_todo_summary={"gate_open_items": []}, + agent_todo_summary={"open_count": 0}, + run_history={"latest_runs": [{"delivery_outcome": "outcome_gap"}]}, + ) + assert projection["lifecycle_phase"] == PHASE_QUALIFYING, projection + transition = projection["next_transitions"][0] + assert transition["target_phase"] == PHASE_QUALIFYING, projection + assert transition["reason_codes"] == ["milestone_unreached"], projection + assert_no_public_leak(projection) + + +def assert_evidence_milestone_reached_from_run_history() -> None: + projection = build_goal_artifact_lifecycle_projection( + goal_id=GOAL_ID, + goal={"id": GOAL_ID, "status": "active"}, + agent_todo_summary={"open_count": 1}, + run_history={ + "latest_runs": [ + { + "delivery_outcome": "primary_goal_outcome", + "delivery_batch_scale": "multi_surface", + "evidence_ref": "run:abc123", + }, + {"delivery_outcome": "surface_only"}, + ] + }, + ) + assert len(projection["milestones"]) == 1, projection + milestone = projection["milestones"][0] + assert milestone["reached"] is True, projection + assert milestone["reached_evidence_refs"] == ["run:abc123"], projection + assert milestone["source"] == "evidence", projection + assert_no_public_leak(projection) + + +def assert_open_owner_gate_blocks_the_next_transition() -> None: + """The RFC's example: a milestone is reached but the owner gate is open.""" + + projection = build_goal_artifact_lifecycle_projection( + goal_id=GOAL_ID, + goal={"id": GOAL_ID, "status": "active"}, + user_todo_summary={ + "open_count": 1, + "gate_open_items": [ + { + "todo_id": "todo_gate_baseline", + "task_class": "user_gate", + "action_kind": "approve_baseline", + "blocks_agent": "agent-a", + } + ], + }, + agent_todo_summary={"open_count": 2}, + run_history={ + "latest_runs": [ + { + "delivery_outcome": "primary_goal_outcome", + "delivery_batch_scale": "multi_surface", + "evidence_ref": "run:baseline", + } + ] + }, + work_observation=WorkLaneObservation( + lane="advancement_task", must_attempt=True, next_action="advance_one_bounded_segment", + ), + ) + assert projection["lifecycle_phase"] == PHASE_WAITING_OWNER, projection + guard = projection["guards"][0] + assert guard["kind"] == GUARD_KIND_OWNER_DECISION, projection + assert guard["blocked"] is True, projection + assert guard["owner"] == "user", projection + assert guard["decision_scope"] is None, projection + # A blocking guard admits no other transition, even with a selected lane. + transitions = projection["next_transitions"] + assert len(transitions) == 1, projection + assert transitions[0]["reason_codes"] == ["guard_open"], projection + assert transitions[0]["target_phase"] == PHASE_WAITING_OWNER, projection + assert_no_public_leak(projection) + + +def assert_evidence_guard_is_required_and_owned_by_the_agent() -> None: + projection = build_goal_artifact_lifecycle_projection( + goal_id=GOAL_ID, + goal={"id": GOAL_ID, "status": "active"}, + user_todo_summary={"open_count": 0}, + agent_todo_summary={"open_count": 1}, + run_history={"latest_runs": []}, + acceptance_gaps=[{"kind": "vision_acceptance_gap", "agent_id": "agent-a"}], + ) + guard = projection["guards"][0] + assert guard["kind"] == GUARD_KIND_EVIDENCE, projection + assert guard["evidence_required"] is True, projection + assert guard["owner"] == "agent", projection + assert projection["lifecycle_phase"] == PHASE_QUALIFYING, projection + assert_no_public_leak(projection) + + +def assert_closing_advice_and_observed_terminal_status_stay_distinct() -> None: + closing = build_goal_artifact_lifecycle_projection( + goal_id=GOAL_ID, + goal={"id": GOAL_ID, "status": "active"}, + user_todo_summary={"gate_open_items": []}, + agent_todo_summary={"open_count": 0}, + run_history={ + "latest_runs": [ + { + "delivery_outcome": "primary_goal_outcome", + "delivery_batch_scale": "multi_surface", + } + ] + }, + ) + assert closing["lifecycle_phase"] == PHASE_CLOSING, closing + transition = closing["next_transitions"][0] + assert transition["target_phase"] == PHASE_CLOSING, closing + assert transition["target_phase"] != PHASE_CLOSED, closing + # Closing carries verification advice, never a terminal recommendation. + # Already-closed Goal status is still displayed without recommending a step. + assert "no_open_agent_work" in transition["reason_codes"], closing + assert "acceptance_unverified" in transition["reason_codes"], closing + assert "agent_vision" in transition["precondition"], closing + assert_no_public_leak(closing) + + closed = build_goal_artifact_lifecycle_projection( + goal_id=GOAL_ID, + goal={"id": GOAL_ID, "status": "closed"}, + agent_todo_summary={"open_count": 0}, + ) + assert closed["lifecycle_phase"] == PHASE_CLOSED, closed + assert closed["next_transitions"] == [], closed + assert_no_public_leak(closed) + + +def assert_projection_is_pure_and_reads_no_state() -> None: + """Same inputs must produce the same projection, with no side effects.""" + + kwargs = { + "goal_id": GOAL_ID, + "goal": {"id": GOAL_ID, "status": "active"}, + "user_todo_summary": {"open_count": 0, "gate_open_items": []}, + "agent_todo_summary": {"open_count": 3}, + "run_history": {"latest_runs": []}, + } + first = build_goal_artifact_lifecycle_projection(**kwargs) + second = build_goal_artifact_lifecycle_projection(**kwargs) + assert first == second, (first, second) + assert first["lifecycle_phase"] == PHASE_QUALIFYING, first + + +def assert_progress_evidence_alone_does_not_authorize_closeout() -> None: + """Evidence reaches closing; v0 never recommends a terminal transition. + + The canonical progress outcomes prove the Goal advanced. They do not prove + the declared acceptance was assessed. The step stays inside closing. + """ + + projection = build_goal_artifact_lifecycle_projection( + goal_id=GOAL_ID, + goal={"id": GOAL_ID, "status": "active"}, + user_todo_summary={"gate_open_items": []}, + agent_todo_summary={"open_count": 0}, + run_history={ + "latest_runs": [ + { + "delivery_outcome": "primary_goal_outcome", + "delivery_batch_scale": "multi_surface", + } + ] + }, + ) + reached = {item["id"]: item["reached"] for item in projection["milestones"]} + assert reached == {"primary_goal_outcome": True}, projection + assert projection["lifecycle_phase"] == PHASE_CLOSING, projection + transition = projection["next_transitions"][0] + assert transition["target_phase"] == PHASE_CLOSING, projection + assert transition["target_phase"] != PHASE_CLOSED, projection + assert "acceptance_unverified" in transition["reason_codes"], projection + + +def assert_private_values_are_redacted_or_dropped() -> None: + """A run-history reference is free text; private values must not survive.""" + + projection = build_goal_artifact_lifecycle_projection( + goal_id=GOAL_ID, + goal={"id": GOAL_ID, "status": "active"}, + agent_todo_summary={"open_count": 1}, + run_history={ + "latest_runs": [ + { + "delivery_outcome": "primary_goal_outcome", + "delivery_batch_scale": "multi_surface", + "evidence_ref": "/Users/private-owner/.ssh/id_rsa", + "recommended_action": ( + "publish ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ012345 and " + "see /private/var/folders/secret/notes.md" + ), + } + ] + }, + ) + rendered = json.dumps(projection, ensure_ascii=False) + for leaked in ( + "/Users/private-owner", + "/private/var/folders", + "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ012345", + ): + assert leaked not in rendered, (leaked, rendered) + + # Unsafe references are withheld by the same validator as other status + # projections; no local path is retained even as a truncated fragment. + assert _compact_text("/Users/private-owner/notes.md") is None + assert _compact_text("token ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ012345") is None + assert _compact_text("/private/var/folders/x/secret.md") is None + + +def assert_batch_scale_never_promotes_an_outcome() -> None: + """The batch scale describes delivery width; it is not Goal evidence. + + Regression for the counterexample the review raised: a `surface_only` run + also carrying `delivery_batch_scale=multi_surface` must not become a reached + milestone, because the canonical typed rule + (`MATERIAL_DELIVERY_OUTCOMES`) excludes `surface_only`. + """ + + for scale in ("single_surface", "multi_surface"): + for outcome in ("surface_only", "", "bogus", "multi_surface"): + projection = build_goal_artifact_lifecycle_projection( + goal_id=GOAL_ID, + goal={"id": GOAL_ID, "status": "active"}, + run_history={ + "latest_runs": [ + { + "delivery_outcome": outcome, + "delivery_batch_scale": scale, + "run_id": "run-1", + } + ] + }, + ) + assert projection["milestones"] == [], (outcome, scale, projection["milestones"]) + # Every canonical material outcome is still retained as a marker at any + # scale, but only the accountable progress outcomes are reached. Scale + # never moves a marker in either direction. + accountable = {"outcome_progress", "primary_goal_outcome"} + for scale in ("single_surface", "multi_surface"): + for outcome in ("outcome_gap", "outcome_progress", "primary_goal_outcome"): + projection = build_goal_artifact_lifecycle_projection( + goal_id=GOAL_ID, + goal={"id": GOAL_ID, "status": "active"}, + run_history={ + "latest_runs": [ + { + "delivery_outcome": outcome, + "delivery_batch_scale": scale, + "run_id": "run-1", + } + ] + }, + ) + markers = {item["id"]: item["reached"] for item in projection["milestones"]} + assert markers == {outcome: outcome in accountable}, (outcome, scale, markers) + + +def assert_status_collection_attaches_a_readable_readout() -> None: + """The RFC's smallest slice includes one readout in status markdown. + + Drives the same seam `loopx status` uses: collection attaches the + projection, the presentation renderer prints it. The projection is derived + from already-collected payloads, so this asserts no extra IO is required. + """ + + from loopx.control_plane.goals.artifact_lifecycle import ( + attach_goal_artifact_lifecycle_projections, + ) + from loopx.presentation.renderers.status_markdown import render_status_markdown + + payload = { + "run_history": { + "goals": [ + { + "id": GOAL_ID, + "status": "active", + } + ] + }, + "attention_queue": { + "items": [ + { + "goal_id": GOAL_ID, + "user_todos": { + "items": [ + { + "todo_id": "todo_gate", + "task_class": "user_gate", + "text": "approve the release", + "status": "open", + "action_kind": "publish", + } + ] + }, + "agent_todos": {"open_count": 0}, + } + ] + }, + } + attach_goal_artifact_lifecycle_projections(payload, history={"goals": []}) + goal = payload["run_history"]["goals"][0] + projection = goal["artifact_lifecycle"] + assert projection["schema_version"] == GOAL_ARTIFACT_LIFECYCLE_PROJECTION_SCHEMA_VERSION + assert projection["lifecycle_phase"] == PHASE_WAITING_OWNER, projection + assert [guard["id"] for guard in projection["guards"]] == ["todo_gate"] + markdown = render_status_markdown(payload) + assert "artifact lifecycle: phase=waiting_owner" in markdown, markdown + assert "blocked by owner_decision (user): todo_gate" in markdown, markdown + assert_no_public_leak(projection) + + +def assert_the_two_goal_projections_stay_distinct() -> None: + """The lifecycle contract and the narrow acceptance observation must not mix. + + `#4248` shipped `goal_acceptance_observation_projection_v0` as bounded + historical evidence and guarded that it is not the full lifecycle contract. + Both now ship, so each renderer must refuse the other's schema rather than + print a half-understood payload under its own heading. + """ + + from loopx.presentation.renderers.goal_acceptance_observation_markdown import ( + append_goal_acceptance_observation_markdown, + ) + from loopx.presentation.renderers.goal_artifact_lifecycle_markdown import ( + append_goal_artifact_lifecycle_markdown, + ) + + lifecycle = build_goal_artifact_lifecycle_projection( + goal_id=GOAL_ID, goal={"id": GOAL_ID, "status": "active"} + ) + narrow = { + "schema_version": "goal_acceptance_observation_projection_v0", + "guards": [], + "acceptance_gaps": [], + "historical_progress": [], + } + # Each renderer prints only its own contract, whichever key carries it. + for goal in ({"artifact_lifecycle": narrow}, {"artifact_lifecycle": {}}): + lines: list[str] = [] + append_goal_artifact_lifecycle_markdown(lines, goal) + assert lines == [], (goal, lines) + for goal in ({"acceptance_observation": lifecycle}, {"acceptance_observation": {}}): + lines = [] + append_goal_acceptance_observation_markdown(lines, goal) + assert lines == [], (goal, lines) + # And the lifecycle renderer does print its own contract. + lines = [] + append_goal_artifact_lifecycle_markdown(lines, {"artifact_lifecycle": lifecycle}) + assert any("artifact lifecycle" in line for line in lines), lines + # The phase/milestone/transition vocabulary belongs to the lifecycle alone. + assert {"lifecycle_phase", "milestones", "next_transitions"} <= set(lifecycle) + assert {"lifecycle_phase", "milestones", "next_transitions"}.isdisjoint(narrow) + + +def assert_control_plane_imports_no_presentation_module() -> None: + """The projection may not depend outward on the presentation layer.""" + + import ast + + source = ( + REPO_ROOT / "loopx" / "control_plane" / "goals" / "artifact_lifecycle.py" + ).read_text(encoding="utf-8") + imported: list[str] = [] + for node in ast.walk(ast.parse(source)): + if isinstance(node, ast.ImportFrom) and node.module: + imported.append(node.module) + elif isinstance(node, ast.Import): + imported.extend(alias.name for alias in node.names) + offenders = [name for name in imported if "presentation" in name] + assert offenders == [], offenders + + +def main() -> int: + assert_starting_phase_without_work() + assert_outcome_gap_is_material_but_not_reached() + assert_gap_only_evidence_blocks_closeout() + assert_evidence_milestone_reached_from_run_history() + assert_open_owner_gate_blocks_the_next_transition() + assert_evidence_guard_is_required_and_owned_by_the_agent() + assert_closing_advice_and_observed_terminal_status_stay_distinct() + assert_projection_is_pure_and_reads_no_state() + assert_progress_evidence_alone_does_not_authorize_closeout() + assert_private_values_are_redacted_or_dropped() + assert_batch_scale_never_promotes_an_outcome() + assert_status_collection_attaches_a_readable_readout() + assert_control_plane_imports_no_presentation_module() + assert_the_two_goal_projections_stay_distinct() + print("goal-artifact-lifecycle-projection-smoke ok") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/loopx/control_plane/goals/artifact_lifecycle.py b/loopx/control_plane/goals/artifact_lifecycle.py new file mode 100644 index 0000000000..6b731c3445 --- /dev/null +++ b/loopx/control_plane/goals/artifact_lifecycle.py @@ -0,0 +1,414 @@ +"""Derived, read-only Goal artifact lifecycle projection. + +An operator looking at a long-running Goal can see todo counts, quota state and +the latest run classification, but still has to reconstruct three answers from +them: where the Goal is in its lifecycle, which milestones it has reached, and +which guard blocks the next step and who owns it. + +This module derives those answers from state LoopX already owns. It is a pure +function over an already-collected status/goal payload: it reads no files, +writes no state, and creates no new authority. Milestones and lifecycle phases +are projections, never stored fields. + +Milestone reachability uses evidence the run history already recorded. Guards +are open owner decisions and unmet evidence preconditions. Next transitions come from the existing +frontier/lane derivation instead of a second state machine. +""" + +from __future__ import annotations + +import re +from typing import Any + +from ...public_safe_text import find_private_text_match +from ..runtime.public_safety import public_safe_compact_text, validate_public_safe_value +from ..runtime.session_runtime import session_runtime_work_observation +from .acceptance_observation import build_goal_acceptance_observation +from ..work_items.work_lane import WorkLaneObservation +from ..work_items.delivery_outcome import ( + MATERIAL_DELIVERY_OUTCOMES, + PROGRESS_DELIVERY_OUTCOMES, + normalize_delivery_outcome, +) + +GOAL_ARTIFACT_LIFECYCLE_PROJECTION_SCHEMA_VERSION = ( + "goal_artifact_lifecycle_projection_v0" +) + +# Derived labels, not a stored enum. They name the operator's question, and a +# Goal may move between them without any durable transition. +PHASE_STARTING = "starting" +PHASE_QUALIFYING = "qualifying" +PHASE_WAITING_OWNER = "waiting_owner" +PHASE_CLOSING = "closing" +PHASE_CLOSED = "closed" + +GUARD_KIND_OWNER_DECISION = "owner_decision" +GUARD_KIND_EVIDENCE = "evidence_precondition" + +_TERMINAL_GOAL_STATUSES = {"closed", "retired", "archived", "done", "complete"} + +# Provider token shapes the shared private-text rules do not cover. A run +# history reference is free text, so a leaked token there must never reach a +# public projection just because the shared corpus did not list its prefix. +_TOKEN_SHAPES = re.compile( + r"\b(?:gh[pousr]_[A-Za-z0-9]{16,}|sk-[A-Za-z0-9_-]{16,}|AKIA[0-9A-Z]{16})\b" +) + +def _compact_text(value: Any, *, limit: int = 240) -> str | None: + """Validate the complete source before bounding any public label/ref.""" + if not isinstance(value, str): + return None + try: + validate_public_safe_value(value) + except ValueError: + return None + # Preserve the stricter existing private-text/provider-token contract too; + # these checks supplement, never replace, the shared public-safety owner. + if find_private_text_match(value) or _TOKEN_SHAPES.search(value): + return None + return public_safe_compact_text(value, limit=limit) + + +def _mapping(value: Any) -> dict[str, Any]: + return value if isinstance(value, dict) else {} + + +def _list(value: Any) -> list[Any]: + return value if isinstance(value, list) else [] + + +def _goal_status(goal: dict[str, Any]) -> str: + return str(goal.get("status") or "").strip().lower() + + +def _is_closed(goal: dict[str, Any]) -> bool: + return _goal_status(goal) in _TERMINAL_GOAL_STATUSES + + +def _evidence_milestones(run_history: dict[str, Any]) -> list[dict[str, Any]]: + """Markers this readout can evidence from material run history. + + Goal-declared acceptance markers are deliberately not read: no authoring + or collection path records `goal.acceptance.milestones` or + `goal.milestones`, so reading them promised a readout that no user + declaration could reach, while carrying the only guard able to hold back a + closeout. Add them back together with the producer that writes them. + """ + + milestones: list[dict[str, Any]] = [] + seen: set[str] = set() + for run in _list(run_history.get("latest_runs")): + record = _mapping(run) + if not record: + continue + outcome = normalize_delivery_outcome(record.get("delivery_outcome")) + # Only a canonical material delivery outcome is Goal evidence. The batch + # scale describes how wide a delivery was, never whether it advanced the + # Goal, so it cannot promote `surface_only` into a reached milestone. + if outcome is None or outcome not in MATERIAL_DELIVERY_OUTCOMES: + continue + milestone_id = outcome.value + if milestone_id in seen: + continue + seen.add(milestone_id) + # Collected history exposes a timestamp, not always a public run id. + # Keep that existing locator; never publish its JSON/Markdown path. + reference = _compact_text( + record.get("evidence_ref") or record.get("run_id") or record.get("generated_at"), + limit=120, + ) + milestones.append( + { + "id": milestone_id, + "label": _compact_text(record.get("recommended_action"), limit=160) + or milestone_id, + # Materiality decides what history retains; only the canonical + # progress outcomes decide what the Goal actually advanced. + # `outcome_gap` is material evidence of a recorded gap, so it + # stays visible here as an unreached marker. + "reached": outcome in PROGRESS_DELIVERY_OUTCOMES, + "reached_evidence_refs": [reference] if reference else [], + "source": "evidence", + } + ) + return milestones + + +def _guards( + observation: dict[str, Any], + acceptance_gaps: list[Any], + *, + agent_id: str | None, +) -> list[dict[str, Any]]: + """Open owner decisions and unmet evidence preconditions.""" + + guards: list[dict[str, Any]] = [] + for record in _list(observation.get("guards")): + if not _mapping(record): + continue + guards.append({ + "id": _compact_text(record.get("todo_id"), limit=120) or "operator_gate", + "kind": GUARD_KIND_OWNER_DECISION, + "blocked": True, + "owner": _compact_text(record.get("owner"), limit=120) + or ("user" if record.get("kind") == "user_gate" else "controller"), + "decision_scope": _compact_text(record.get("decision_scope")), + "evidence_required": bool(record.get("evidence_required")), + "blocks_agent": _compact_text(record.get("blocks_agent"), limit=120), + }) + for gap in acceptance_gaps: + record = _mapping(gap) + if not record: + continue + guards.append( + { + "id": _compact_text(record.get("kind"), limit=120) or "acceptance_gap", + "kind": GUARD_KIND_EVIDENCE, + "blocked": True, + "owner": "agent", + "decision_scope": None, + "evidence_required": True, + "agent_id": _compact_text(record.get("agent_id") or record.get("owner"), limit=120) + or _compact_text(agent_id, limit=120), + } + ) + return guards + + +def _unobserved_acceptance_sources(observation: dict[str, Any]) -> list[str]: + """Name the acceptance sources the bounded observation could not read. + + `goal_acceptance_observation_projection_v0` reports what it could not + observe rather than an acceptance verdict, so this projection surfaces + those sources on the verification step instead of deriving a second completion + rule from the same runs. + """ + + return [ + text for text in ( + _compact_text(source, limit=60) + for source in _list(observation.get("missing_sources")) + ) if text + ] + + +def _lifecycle_phase( + goal: dict[str, Any], + *, + guards: list[dict[str, Any]], + milestones: list[dict[str, Any]], + agent_summary: dict[str, Any], + work: WorkLaneObservation | None, +) -> str: + if _is_closed(goal): + return PHASE_CLOSED + if any(guard["kind"] == GUARD_KIND_OWNER_DECISION for guard in guards): + return PHASE_WAITING_OWNER + if guards or (work is not None and work.must_attempt): + return PHASE_QUALIFYING + open_count = agent_summary.get("open_count") + if not isinstance(open_count, int) or isinstance(open_count, bool): + # An omitted/bounded source is not evidence that no work remains. + return PHASE_QUALIFYING if milestones or guards else PHASE_STARTING + total_open = open_count + if not milestones and total_open == 0: + return PHASE_STARTING + # No open work and reached progress markers suggest closing, not accepted + # completion. A recorded gap is still an unreached marker. + unreached = any(milestone["reached"] is not True for milestone in milestones) + if total_open == 0 and not unreached: + return PHASE_CLOSING + return PHASE_QUALIFYING + + +def _next_transitions( + goal: dict[str, Any], + *, + phase: str, + guards: list[dict[str, Any]], + milestones: list[dict[str, Any]], + work: WorkLaneObservation | None, + observation: dict[str, Any], +) -> list[dict[str, Any]]: + """Reuse the existing lane/frontier derivation instead of a second machine.""" + + blocking = [guard for guard in guards if guard["blocked"]] + if _is_closed(goal): + return [] + lane = _compact_text(work.lane, limit=120) if work else None + obligation = _compact_text(work.next_action) if work else None + if blocking: + return [ + { + "target_phase": phase, + "precondition": ( + "resolve the open owner decision" + if any(guard["kind"] == GUARD_KIND_OWNER_DECISION for guard in blocking) + else "produce the required evidence" + ), + "reason_codes": ["guard_open"], + } + ] + # An existing work-lane constraint outranks this projection's own reading + # of open work: the lane owner decides what runs next. + if lane: + return [ + { + "target_phase": PHASE_QUALIFYING, + "precondition": obligation or "advance the selected lane", + "reason_codes": ["work_lane_selected"], + } + ] + unreached = [ + milestone["id"] for milestone in milestones if milestone["reached"] is not True + ] + if unreached: + return [ + { + "target_phase": PHASE_QUALIFYING, + "precondition": "reach the unreached acceptance milestones with evidence", + "reason_codes": ["milestone_unreached"], + } + ] + if phase == PHASE_CLOSING: + # This v0 readout never recommends a terminal transition. Its bounded + # acceptance input cannot provide a verdict, and a future producer + # expansion must not silently add completion advice to this contract. + # Machine consumers use acceptance_unverified; prose only explains the + # next verification step and any sources this observation did not read. + unobserved = _unobserved_acceptance_sources(observation) + precondition = "verify the declared acceptance with its existing owner" + if unobserved: + precondition += "; this readout could not observe " + ", ".join(unobserved) + return [ + { + "target_phase": PHASE_CLOSING, + "precondition": precondition, + "reason_codes": ["no_open_agent_work", "acceptance_unverified"], + } + ] + return [] + + +def build_goal_artifact_lifecycle_projection( + *, + goal_id: str, + goal: dict[str, Any] | None, + user_todo_summary: dict[str, Any] | None = None, + agent_todo_summary: dict[str, Any] | None = None, + run_history: dict[str, Any] | None = None, + work_observation: WorkLaneObservation | None = None, + acceptance_gaps: list[Any] | None = None, + agent_id: str | None = None, + attention_item: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Derive the read-only lifecycle projection for one Goal. + + Every input is a payload the caller already collected; this function reads + nothing itself and grants no authority. + """ + + goal_record = _mapping(goal) + user_summary = _mapping(user_todo_summary) + agent_summary = _mapping(agent_todo_summary) + raw_history = _mapping(run_history) + runs = list(_list(raw_history.get("latest_runs"))) + # The collector already preserves semantic evidence beyond the display/ + # recent-run window. Reuse that retained state instead of widening a limit. + for context in _list(_mapping(raw_history.get("semantic_history")).get("agents")): + for key in ("latest_material_milestone_run", "latest_agent_vision_run"): + retained = _mapping(context).get(key) + if _mapping(retained) and retained not in runs: + runs.append(retained) + history = {"latest_runs": [ + record for record in runs + if _mapping(record) and record.get("goal_id") in (None, goal_id) + ]} + observation = build_goal_acceptance_observation( + {**goal_record, "id": goal_id, **history}, + attention_item if attention_item is not None else {"user_todos": user_summary}, + ) + gaps = [gap for gap in _list(acceptance_gaps) if _mapping(gap)] + if acceptance_gaps is None: + gaps = _list(observation.get("acceptance_gaps")) + milestones = _evidence_milestones(history) + guards = _guards(observation, gaps, agent_id=agent_id) + phase = _lifecycle_phase( + goal_record, guards=guards, milestones=milestones, agent_summary=agent_summary, + work=work_observation, + ) + return { + "schema_version": GOAL_ARTIFACT_LIFECYCLE_PROJECTION_SCHEMA_VERSION, + "goal_id": _compact_text(goal_id, limit=120) or "unknown", + "lifecycle_phase": phase, + "milestones": milestones, + "guards": guards, + "next_transitions": _next_transitions( + goal_record, + phase=phase, + guards=guards, + milestones=milestones, + work=work_observation, + observation=observation, + ), + } + + +def attach_goal_artifact_lifecycle_projections( + payload: dict[str, Any], *, history: dict[str, Any] +) -> None: + """Attach one bounded lifecycle projection to every projected Goal. + + Reads only payloads status collection already gathered, so the operator + readout costs no extra IO and grants no authority. A Goal the projection + cannot derive from is left without the key rather than given a placeholder. + """ + + sources = {str(goal.get("id")): goal for goal in _list(history.get("goals")) if _mapping(goal)} + run_history = _mapping(payload.get("run_history")) + items = _list(_mapping(payload.get("attention_queue")).get("items")) + for goal in _list(run_history.get("goals")): + record = _mapping(goal) + goal_id = str(record.get("id") or "").strip() + if not goal_id: + continue + source = {**record, **sources.get(goal_id, {})} + item = next( + (row for row in items if _mapping(row).get("goal_id") == goal_id), None + ) + attention = _mapping(item) + asset = _mapping(attention.get("project_asset")) + frontier = _mapping(attention.get("goal_frontier_projection") or asset.get("goal_frontier_projection")) + projection = build_goal_artifact_lifecycle_projection( + goal_id=goal_id, + goal=source, + user_todo_summary=_mapping( + attention.get("user_todos") or asset.get("user_todos") + ), + agent_todo_summary=_mapping( + attention.get("agent_todos") or asset.get("agent_todos") + ), + run_history=source, + work_observation=session_runtime_work_observation( + attention.get("session_runtime_projection") or asset.get("session_runtime_projection"), + goal_id=goal_id, + ), + acceptance_gaps=frontier.get("acceptance_gaps") if "acceptance_gaps" in frontier else None, + attention_item=attention, + ) + record["artifact_lifecycle"] = projection + + +__all__ = [ + "GOAL_ARTIFACT_LIFECYCLE_PROJECTION_SCHEMA_VERSION", + "GUARD_KIND_EVIDENCE", + "GUARD_KIND_OWNER_DECISION", + "PHASE_CLOSED", + "PHASE_CLOSING", + "PHASE_QUALIFYING", + "PHASE_STARTING", + "PHASE_WAITING_OWNER", + "attach_goal_artifact_lifecycle_projections", + "build_goal_artifact_lifecycle_projection", +] diff --git a/loopx/control_plane/runtime/session_runtime.py b/loopx/control_plane/runtime/session_runtime.py index 6fea895bfe..205b3136ee 100644 --- a/loopx/control_plane/runtime/session_runtime.py +++ b/loopx/control_plane/runtime/session_runtime.py @@ -3,6 +3,7 @@ from typing import AbstractSet, Any, Callable, Optional from ...session_runtime import SESSION_RUNTIME_READONLY_PROJECTION_SCHEMA_VERSION +from ..work_items.work_lane import WorkLaneObservation, observe_work_lane from .public_safety import ( public_safe_compact_list as _default_public_safe_compact_list, public_safe_compact_text as _default_public_safe_compact_text, @@ -267,6 +268,27 @@ def compact_session_runtime_projection_from_run( ) +def session_runtime_work_observation( + projection: Any, *, goal_id: str, +) -> WorkLaneObservation | None: + """Read work facts from this Goal's existing session-runtime projection. + + The adapter owns the legacy payload shape. Callers receive only the facts + needed for a readout, with no copy of the protocol or new decision rule. + """ + if ( + not isinstance(projection, dict) + or projection.get("schema_version") != SESSION_RUNTIME_READONLY_PROJECTION_SCHEMA_VERSION + or projection.get("goal_id") != goal_id + ): + return None + first_screen = projection.get("first_screen") + return observe_work_lane( + projection.get("work_lane_contract"), + next_action=first_screen.get("recommended_action") if isinstance(first_screen, dict) else None, + ) + + def session_runtime_status_waiting_on( value: Any, *, diff --git a/loopx/control_plane/status/collection.py b/loopx/control_plane/status/collection.py index 76ba2e4849..d126325a7f 100644 --- a/loopx/control_plane/status/collection.py +++ b/loopx/control_plane/status/collection.py @@ -9,6 +9,7 @@ from typing import Any, Callable from ..goals.acceptance_observation import attach_goal_acceptance_observations +from ..goals.artifact_lifecycle import attach_goal_artifact_lifecycle_projections from ..goals.contract_health import project_contract_health_for_goal from ..goals.activation import ( GoalActivationState, @@ -243,4 +244,5 @@ def collect_status( goal_channel_notification_projection ) attach_goal_acceptance_observations(payload, history=history) + attach_goal_artifact_lifecycle_projections(payload, history=history) return payload diff --git a/loopx/control_plane/work_items/work_lane.py b/loopx/control_plane/work_items/work_lane.py index b01e41c79b..3f64e88fd0 100644 --- a/loopx/control_plane/work_items/work_lane.py +++ b/loopx/control_plane/work_items/work_lane.py @@ -1,11 +1,37 @@ from __future__ import annotations +from dataclasses import dataclass from typing import Any from ..effect_program import ReceiptBoundMonitorPhase from ..todos.contract import TODO_TASK_CLASS_MONITOR, normalize_todo_id from ..todos.todo_semantics import todo_priority_label, todo_priority_rank + +@dataclass(frozen=True) +class WorkLaneObservation: + """Read-only work facts; neither a scheduling decision nor execution authority.""" + + lane: str | None + must_attempt: bool + next_action: str | None + + +def observe_work_lane( + contract: Any, *, next_action: Any = None, +) -> WorkLaneObservation | None: + """Keep legacy field decoding with the lane owner, outside new consumers.""" + if not isinstance(contract, dict): + return None + lane = contract.get("lane") + action = contract.get("obligation") or next_action + return WorkLaneObservation( + lane=lane if isinstance(lane, str) else None, + must_attempt=contract.get("must_attempt_work") is True, + next_action=action if isinstance(action, str) else None, + ) + + WORK_LANE_CONTRACT_SCHEMA_VERSION = "work_lane_contract_v1" WORK_LANE_RECEIPT_BOUND_MONITOR_SETTLEMENT_OBLIGATION = ( "settle_receipt_bound_monitor" diff --git a/loopx/presentation/renderers/goal_artifact_lifecycle_markdown.py b/loopx/presentation/renderers/goal_artifact_lifecycle_markdown.py new file mode 100644 index 0000000000..e7ff11f7d7 --- /dev/null +++ b/loopx/presentation/renderers/goal_artifact_lifecycle_markdown.py @@ -0,0 +1,47 @@ +"""Render the derived Goal artifact lifecycle projection owned by status collection.""" + +from __future__ import annotations + +from typing import Any + +from ...control_plane.goals.artifact_lifecycle import ( + GOAL_ARTIFACT_LIFECYCLE_PROJECTION_SCHEMA_VERSION, +) +from ..markdown import as_dict, as_list, markdown_scalar + + +def append_goal_artifact_lifecycle_markdown( + lines: list[str], goal: dict[str, Any] +) -> None: + projection = as_dict(goal.get("artifact_lifecycle")) + if ( + projection.get("schema_version") + != GOAL_ARTIFACT_LIFECYCLE_PROJECTION_SCHEMA_VERSION + ): + return + milestones = as_list(projection.get("milestones")) + reached = [ + milestone + for milestone in milestones + if isinstance(milestone, dict) and milestone.get("reached") is True + ] + lines.append( + " - artifact lifecycle: " + f"phase={markdown_scalar(projection.get('lifecycle_phase') or 'unknown')} " + f"milestones={len(reached)}/{len(milestones)} " + f"guards={len(as_list(projection.get('guards')))}" + ) + for guard in as_list(projection.get("guards")): + if not isinstance(guard, dict) or guard.get("blocked") is not True: + continue + lines.append( + f" - blocked by {markdown_scalar(guard.get('kind') or 'unknown')} " + f"({markdown_scalar(guard.get('owner') or 'unowned')}): " + f"{markdown_scalar(guard.get('id') or 'unknown')}" + ) + for transition in as_list(projection.get("next_transitions")): + if isinstance(transition, dict): + lines.append( + f" - next: {markdown_scalar(transition.get('target_phase') or 'unknown')} " + f"({markdown_scalar(transition.get('precondition') or 'unknown')})" + ) diff --git a/loopx/presentation/renderers/status_markdown.py b/loopx/presentation/renderers/status_markdown.py index 63506f9a1c..e21d22af90 100644 --- a/loopx/presentation/renderers/status_markdown.py +++ b/loopx/presentation/renderers/status_markdown.py @@ -10,6 +10,7 @@ from ...orchestration import orchestration_policy_summary from ..markdown import as_dict, as_list, markdown_scalar from .goal_acceptance_observation_markdown import append_goal_acceptance_observation_markdown +from .goal_artifact_lifecycle_markdown import append_goal_artifact_lifecycle_markdown from .reward_memory_markdown import append_agent_reward_memory_markdown @@ -246,6 +247,7 @@ def append_run_history_markdown(lines: list[str], run_history: dict[str, Any]) - f"unique_runs={goal.get('unique_runs')}" ) append_goal_acceptance_observation_markdown(lines, goal) + append_goal_artifact_lifecycle_markdown(lines, goal) quota = goal.get("quota") if isinstance(goal.get("quota"), dict) else {} if quota: lines.append( diff --git a/tests/control_plane/test_goal_acceptance_observation.py b/tests/control_plane/test_goal_acceptance_observation.py index c1c926f718..c617807d52 100644 --- a/tests/control_plane/test_goal_acceptance_observation.py +++ b/tests/control_plane/test_goal_acceptance_observation.py @@ -144,7 +144,13 @@ def test_redaction_precedes_truncation_and_bounded_output(): assert many["truncated"] and len(many["acceptance_gaps"]) == 12 -def collect_fixture(root: Path, *, missing_claim: bool = False) -> dict: +def collect_fixture( + root: Path, + *, + missing_claim: bool = False, + display_limit: int = 0, + delivery_outcome: str | None = None, +) -> dict: project, runtime = root / "project", root / "runtime" project.mkdir(parents=True) state = project / "ACTIVE_GOAL_STATE.md" @@ -182,6 +188,7 @@ def collect_fixture(root: Path, *, missing_claim: bool = False) -> dict: project=project, state_file=state, classification="state_refreshed", + delivery_outcome=delivery_outcome, recommended_action=None, agent_id="agent-a", agent_vision_packet={ @@ -213,7 +220,7 @@ def collect_fixture(root: Path, *, missing_claim: bool = False) -> dict: registry_path=registry, runtime_root_override=str(runtime), scan_roots=[], - limit=0, + limit=display_limit, include_public_boundary_scan=False, ) @@ -222,9 +229,17 @@ def test_real_collection_preserves_acceptance_before_display_run_trimming(tmp_pa result = collect_fixture(tmp_path) goal = result["run_history"]["goals"][0] assert goal["latest_runs"] == [] - assert "artifact_lifecycle" not in goal projection = goal["acceptance_observation"] assert projection["schema_version"] == "goal_acceptance_observation_projection_v0" + # The full lifecycle contract now ships beside this one. They stay distinct + # projections under distinct keys and schema versions: this observation is + # bounded historical evidence, never the lifecycle's phase/milestone answer. + lifecycle = goal["artifact_lifecycle"] + assert lifecycle["schema_version"] == "goal_artifact_lifecycle_projection_v0" + assert lifecycle["schema_version"] != projection["schema_version"] + # Only the lifecycle projection answers phase, milestones and transitions. + assert {"lifecycle_phase", "milestones", "next_transitions"} <= set(lifecycle) + assert {"lifecycle_phase", "milestones", "next_transitions"}.isdisjoint(projection) assert ( projection["acceptance_gaps"][0]["evidence_required"] == "Independent verification report" @@ -232,6 +247,16 @@ def test_real_collection_preserves_acceptance_before_display_run_trimming(tmp_pa assert projection["acceptance_gaps"][0]["owner"] == "agent-a" assert projection["guards"][0]["blocks_agent"] == "agent-a" validate_public_safe_value(projection) + assert lifecycle["lifecycle_phase"] == "waiting_owner" + assert {(g["id"], g["kind"]) for g in lifecycle["guards"]} == { + ("todo_review", "owner_decision"), ("vision_acceptance_gap", "evidence_precondition"), + } + validate_public_safe_value(lifecycle) + from loopx.presentation.renderers.goal_artifact_lifecycle_markdown import append_goal_artifact_lifecycle_markdown + lines = [] + append_goal_artifact_lifecycle_markdown(lines, goal) + assert "phase=waiting_owner" in "\n".join(lines) + assert "vision_acceptance_gap" in "\n".join(lines) def test_closed_stage_retains_canonical_successor_requirement(): @@ -289,3 +314,205 @@ def test_markdown_rejects_the_distinct_full_lifecycle_contract(): lines, {"acceptance_observation": observation} ) assert "Independent verification report" in "\n".join(lines) + + +def test_lifecycle_evidence_survives_real_display_trimming(tmp_path): + for limit in (0, 5): + result = collect_fixture(tmp_path / str(limit), display_limit=limit, delivery_outcome="outcome_progress") + goal = result["run_history"]["goals"][0] + if limit == 0: + assert goal["latest_runs"] == [] + milestones = goal["artifact_lifecycle"]["milestones"] + assert [m["id"] for m in milestones] == ["outcome_progress"] + assert milestones[0]["reached_evidence_refs"] + + +def test_lifecycle_cannot_close_over_canonical_mandatory_lane(): + from loopx.control_plane.goals.artifact_lifecycle import build_goal_artifact_lifecycle_projection + from loopx.control_plane.work_items.work_lane import ( + lark_inbox_reply_due_work_lane_contract, observe_work_lane, + ) + lane = lark_inbox_reply_due_work_lane_contract( + {"capabilities": {"lark_event_inbox": {"urgency": {"reply_due": True}}}}, + current_contract=None, + ) + projection = build_goal_artifact_lifecycle_projection( + goal_id="demo", goal={"status": "active"}, + agent_todo_summary={"open_count": 0}, + run_history={"latest_runs": [{"delivery_outcome": "primary_goal_outcome", "run_id": "run-evidence"}]}, + work_observation=observe_work_lane(lane), + ) + assert projection["lifecycle_phase"] == "qualifying" + assert projection["next_transitions"][0]["target_phase"] == "qualifying" + assert projection["next_transitions"][0]["precondition"] == lane["obligation"] + + +def test_lifecycle_public_safety_covers_all_emitted_text(): + from loopx.control_plane.goals.artifact_lifecycle import build_goal_artifact_lifecycle_projection + unsafe_values = [ + "C:" + chr(92) + "Users" + chr(92) + "fixture" + chr(92) + "evidence.txt", + "/" + "etc/service/config.json", "/" + "workspace/fixture/result.json", + "access_key=" + "synthetic" * 4, "token:" + "synthetic" * 4, + "x" * 500 + " access_key=" + "synthetic" * 4, + ] + for value in unsafe_values: + projection = build_goal_artifact_lifecycle_projection( + goal_id="demo", goal={}, + agent_id=value, acceptance_gaps=[{"kind": "gap"}], + run_history={"latest_runs": [{"delivery_outcome": "outcome_progress", "recommended_action": value, "evidence_ref": value}]}, + ) + validate_public_safe_value(projection) + # An unsafe label and locator are both dropped, so the marker falls + # back to its canonical outcome id and publishes no evidence ref. + evidence = projection["milestones"][0] + assert evidence["label"] == "outcome_progress" + assert evidence["reached_evidence_refs"] == [] + assert projection["guards"][0]["agent_id"] is None + safe = build_goal_artifact_lifecycle_projection( + goal_id="demo", goal={}, run_history={"latest_runs": [{ + "delivery_outcome": "outcome_progress", "recommended_action": "Review evidence", + "evidence_ref": "https://example.org/evidence/42", + }]}, + ) + assert safe["milestones"][0]["label"] == "Review evidence" + assert safe["milestones"][0]["reached_evidence_refs"] == ["https://example.org/evidence/42"] + validate_public_safe_value(safe) + + +def test_lifecycle_filters_foreign_runs_and_inactive_gates(): + from loopx.control_plane.goals.artifact_lifecycle import build_goal_artifact_lifecycle_projection + projection = build_goal_artifact_lifecycle_projection( + goal_id="demo", goal={}, + user_todo_summary={"items": [ + {"todo_id": "gate-" + status, "task_class": "user_gate", "status": status} + for status in ("open", "deferred", "done", "superseded") + ]}, + run_history={"latest_runs": [{"goal_id": "other", "delivery_outcome": "outcome_progress"}]}, + ) + assert projection["milestones"] == [] + assert [g["id"] for g in projection["guards"]] == ["gate-open"] + + +def test_lifecycle_retains_controller_gate_without_a_todo(): + from loopx.control_plane.goals.artifact_lifecycle import attach_goal_artifact_lifecycle_projections + payload = { + "run_history": {"goals": [{"id": "demo"}]}, + "attention_queue": {"items": [{ + "goal_id": "demo", "waiting_on": "controller", + "operator_question": "Approve release", "agent_todos": {"open_count": 0}, + }]}, + } + history = {"goals": [{"id": "demo", "status": "active", "latest_runs": [ + {"delivery_outcome": "outcome_progress", "run_id": "proof"}, + ]}]} + attach_goal_artifact_lifecycle_projections(payload, history=history) + projection = payload["run_history"]["goals"][0]["artifact_lifecycle"] + assert projection["lifecycle_phase"] == "waiting_owner" + assert projection["guards"][0]["owner"] == "controller" + assert projection["next_transitions"][0]["reason_codes"] == ["guard_open"] + + +def test_lifecycle_uses_evidence_retained_beyond_recent_run_window(): + from loopx.control_plane.goals.artifact_lifecycle import attach_goal_artifact_lifecycle_projections + from loopx.control_plane.runtime.run_context_retention import goal_semantic_history_from_runs, latest_runs_with_agent_context + proof = {"goal_id": "demo", "agent_id": "agent-a", "classification": "state_refreshed", + "delivery_outcome": "outcome_progress", "run_id": "retained-proof"} + runs = [{"goal_id": "demo", "agent_id": "agent-a", "classification": "monitor_poll"} + for _ in range(20)] + [proof] + source = {"id": "demo", "status": "active", + "latest_runs": latest_runs_with_agent_context(runs, limit=20), + "semantic_history": goal_semantic_history_from_runs(runs)} + assert proof not in source["latest_runs"] + payload = {"run_history": {"goals": [{"id": "demo", "latest_runs": []}]}} + attach_goal_artifact_lifecycle_projections(payload, history={"goals": [source]}) + projection = payload["run_history"]["goals"][0]["artifact_lifecycle"] + assert [m["id"] for m in projection["milestones"]] == ["outcome_progress"] + assert projection["milestones"][0]["reached_evidence_refs"] == ["retained-proof"] + assert projection["lifecycle_phase"] != "closing" # missing Todo source is not zero work + + +def test_shared_public_safety_preserves_public_uris_and_relative_paths(): + from loopx.control_plane.runtime.public_safety import public_safe_compact_text + for value in ("https://example.org/data/report", "docs/evidence.md", "owner authorization", "access key rotation guide"): + validate_public_safe_value(value) + assert public_safe_compact_text(value) == value + + +def test_lifecycle_gap_only_evidence_never_reads_as_closing(): + from loopx.control_plane.goals.artifact_lifecycle import build_goal_artifact_lifecycle_projection + projection = build_goal_artifact_lifecycle_projection( + goal_id="demo", goal={"status": "active"}, + user_todo_summary={"gate_open_items": []}, + agent_todo_summary={"open_count": 0}, + run_history={"latest_runs": [{"delivery_outcome": "outcome_gap"}]}, + ) + # `outcome_gap` is material history, not a progress outcome: it stays a + # visible unreached marker and never satisfies the closeout reading. + assert [(m["id"], m["reached"]) for m in projection["milestones"]] == [("outcome_gap", False)] + assert projection["lifecycle_phase"] == "qualifying" + assert projection["next_transitions"][0]["reason_codes"] == ["milestone_unreached"] + + +def test_lifecycle_closeout_names_unobserved_acceptance_sources(): + from loopx.control_plane.goals.artifact_lifecycle import build_goal_artifact_lifecycle_projection + projection = build_goal_artifact_lifecycle_projection( + goal_id="demo", goal={"status": "active"}, + user_todo_summary={"gate_open_items": []}, + agent_todo_summary={"open_count": 0}, + run_history={"latest_runs": [{"delivery_outcome": "outcome_progress"}]}, + ) + # Closing is the todo-completion reading. Without an acceptance verdict the + # step stays inside closing and names what was not observed, rather than + # recommending the terminal outcome with a caveat attached. + assert projection["lifecycle_phase"] == "closing" + transition = projection["next_transitions"][0] + assert transition["target_phase"] == "closing" + assert transition["reason_codes"] == ["no_open_agent_work", "acceptance_unverified"] + assert transition["precondition"].endswith("this readout could not observe agent_vision") + + +def test_lifecycle_fully_observed_goal_still_needs_an_acceptance_verdict(): + """An empty `missing_sources` is not an acceptance verdict. + + With an attention item and agent vision both present the observation has + nothing left to name, but it still reports `acceptance_assessed=False` and + a `partial` coverage. Reading "nothing missing" as "acceptance verified" + would recommend the terminal outcome with no acceptance behind it and no + disclosure attached. + """ + + from loopx.control_plane.goals.acceptance_observation import ( + build_goal_acceptance_observation, + ) + from loopx.control_plane.goals.artifact_lifecycle import ( + build_goal_artifact_lifecycle_projection, + ) + + runs = [{ + "delivery_outcome": "outcome_progress", + "agent_id": "agent-a", + "agent_vision": {"agent_id": "agent-a", "acceptance_met": True}, + }] + attention = { + "goal_id": "demo", + "user_todos": {"gate_open_items": []}, + "agent_todos": {"open_count": 0}, + } + observation = build_goal_acceptance_observation( + {"id": "demo", "status": "active", "latest_runs": runs}, attention + ) + assert observation["missing_sources"] == [] + assert observation["acceptance_assessed"] is False + assert observation["coverage"] != "complete" + + projection = build_goal_artifact_lifecycle_projection( + goal_id="demo", goal={"id": "demo", "status": "active"}, + user_todo_summary={"gate_open_items": []}, + agent_todo_summary={"open_count": 0}, + run_history={"latest_runs": runs}, + attention_item=attention, + ) + transition = projection["next_transitions"][0] + assert transition["target_phase"] == "closing" + assert transition["reason_codes"] == ["no_open_agent_work", "acceptance_unverified"] + assert "could not observe" not in transition["precondition"] diff --git a/tests/control_plane/test_goal_artifact_work_observation.py b/tests/control_plane/test_goal_artifact_work_observation.py new file mode 100644 index 0000000000..b22d62dbf8 --- /dev/null +++ b/tests/control_plane/test_goal_artifact_work_observation.py @@ -0,0 +1,168 @@ +"""Lifecycle work precedence through the persisted session-runtime status path.""" + +from __future__ import annotations + +import copy +import json + +import pytest + +from loopx.session_runtime import build_session_runtime_readonly_projection +from loopx.status import collect_status + + +@pytest.mark.parametrize("display_limit", [0, 5]) +@pytest.mark.parametrize("adapter_kind,include_work_projection", [ + ("session_runtime", True), ("session_runtime", False), + ("harness_self_improvement", True), ("harness_self_improvement", False), +]) +def test_persisted_work_observation_coverage_before_display_trimming( + tmp_path, display_limit, adapter_kind, include_work_projection, +): + from loopx.presentation.renderers.status_markdown import render_status_markdown + + project = tmp_path / "project" + runtime = tmp_path / "runtime" + project.mkdir() + state = project / "ACTIVE_GOAL_STATE.md" + state.write_text( + "---\nstatus: active\n---\n\n# Goal\n\n## Agent Todo\n\n" + "- [x] Implement the change\n" + " \n\n## User Todo\n" + ) + registry = project / "registry.json" + registry.write_text(json.dumps({ + "schema_version": 1, "common_runtime_root": str(runtime), + "goals": [{ + "id": "demo", "status": "active", "domain": "software", "repo": str(project), + "state_file": state.name, + "adapter": {"kind": adapter_kind, "status": "connected-read-only"}, + }], + })) + projection = build_session_runtime_readonly_projection( + goal_id="demo", + decision_results=[{"recommended_action": "Verify the remaining evidence"}], + ) + runs = runtime / "goals" / "demo" / "runs" + runs.mkdir(parents=True) + run_path, markdown_path = runs / "run.json", runs / "run.md" + record = { + "goal_id": "demo", "generated_at": "2026-09-01T00:00:00+00:00", + "classification": "state_refreshed", + "delivery_outcome": "outcome_progress", + "json_path": str(run_path), "markdown_path": str(markdown_path), + } + if include_work_projection: + record["session_runtime_readonly_projection"] = projection + run_path.write_text(json.dumps(record)) + markdown_path.write_text("# Compact session-runtime observation\n") + (runs / "index.jsonl").write_text(json.dumps(record) + "\n") + before = {path: path.read_bytes() for path in (state, registry, run_path, markdown_path, runs / "index.jsonl")} + result = collect_status( + registry_path=registry, runtime_root_override=str(runtime), + scan_roots=[], limit=display_limit, include_public_boundary_scan=False, + ) + assert result["ok"] is True + goal = result["run_history"]["goals"][0] + item = next(item for item in result["attention_queue"]["items"] if item["goal_id"] == "demo") + assert item["agent_todos"]["open_count"] == 0 + assert "work_lane_contract" not in item + if display_limit == 0: + assert goal["latest_runs"] == [] + lifecycle = goal["artifact_lifecycle"] + assert lifecycle["guards"] == [] + assert all(marker["reached"] for marker in lifecycle["milestones"]) + if include_work_projection: + assert item["session_runtime_projection"]["work_lane_contract"]["must_attempt_work"] is True + assert lifecycle["lifecycle_phase"] == "qualifying" + assert lifecycle["next_transitions"] == [{ + "target_phase": "qualifying", "precondition": "Verify the remaining evidence", + "reason_codes": ["work_lane_selected"], + }] + assert "Verify the remaining evidence" in render_status_markdown(result) + else: + # Adapter naming alone is not a work observation or proof of no work. + assert "session_runtime_projection" not in item + assert lifecycle["lifecycle_phase"] == "closing" + assert lifecycle["next_transitions"][0]["target_phase"] == "closing" + assert lifecycle["next_transitions"][0]["reason_codes"] == [ + "no_open_agent_work", "acceptance_unverified", + ] + assert "next: closed" not in render_status_markdown(result) + assert {path: path.read_bytes() for path in before} == before + + +@pytest.mark.parametrize("field,value", [ + ("goal_id", "other-goal"), ("goal_id", None), ("schema_version", "unknown_v0"), +]) +def test_work_observation_rejects_foreign_or_untyped_projection(field, value): + from loopx.control_plane.runtime.session_runtime import session_runtime_work_observation + + projection = build_session_runtime_readonly_projection( + goal_id="demo", decision_results=[{"recommended_action": "Continue verification"}], + ) + projection[field] = value + before = copy.deepcopy(projection) + assert session_runtime_work_observation(projection, goal_id="demo") is None + assert projection == before + + +@pytest.mark.parametrize("required", [False, True, "false"]) +def test_lane_owner_exposes_facts_without_creating_a_work_requirement(required): + from loopx.control_plane.work_items.work_lane import observe_work_lane + + contract = {"lane": "continuous_monitor", "must_attempt_work": required, + "obligation": "Inspect the current monitor receipt"} + before = copy.deepcopy(contract) + observation = observe_work_lane(contract, next_action="Fallback description") + assert observation.lane == "continuous_monitor" + assert observation.must_attempt is (required is True) + assert observation.next_action == "Inspect the current monitor receipt" + assert contract == before + + +def test_lifecycle_validates_work_observation_text_before_rendering(): + from loopx.control_plane.goals.artifact_lifecycle import build_goal_artifact_lifecycle_projection + from loopx.control_plane.runtime.public_safety import validate_public_safe_value + from loopx.control_plane.work_items.work_lane import WorkLaneObservation + + projection = build_goal_artifact_lifecycle_projection( + goal_id="demo", goal={"status": "active"}, + work_observation=WorkLaneObservation( + lane="advancement_task", must_attempt=True, + next_action="x" * 500 + " token=" + "synthetic" * 4, + ), + ) + assert projection["lifecycle_phase"] == "qualifying" + assert projection["next_transitions"][0]["precondition"] == "advance the selected lane" + validate_public_safe_value(projection) + + +@pytest.mark.parametrize("status", ["active", "closed"]) +def test_lifecycle_v0_never_acquires_terminal_advice_from_acceptance_owner(monkeypatch, status): + """A future producer change must not silently expand this v0 readout's scope.""" + from loopx.control_plane.goals import artifact_lifecycle + + original = artifact_lifecycle.build_goal_acceptance_observation + + def future_observation(*args, **kwargs): + return {**original(*args, **kwargs), "acceptance_assessed": True, + "coverage": "complete", "missing_sources": []} + + # Deliberately inject a verdict the bounded owner cannot currently produce. + # This is a counterfactual, not a new supported acceptance contract. + monkeypatch.setattr(artifact_lifecycle, "build_goal_acceptance_observation", future_observation) + projection = artifact_lifecycle.build_goal_artifact_lifecycle_projection( + goal_id="demo", goal={"status": status}, + user_todo_summary={"gate_open_items": []}, agent_todo_summary={"open_count": 0}, + run_history={"latest_runs": [{"delivery_outcome": "outcome_progress"}]}, + ) + if status == "closed": + assert projection["lifecycle_phase"] == "closed" + assert projection["next_transitions"] == [] + else: + assert projection["lifecycle_phase"] == "closing" + transition = projection["next_transitions"][0] + assert transition["target_phase"] == "closing" + assert transition["reason_codes"] == ["no_open_agent_work", "acceptance_unverified"]