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
6 changes: 6 additions & 0 deletions docs/operations/attention-queue.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ Status treats these as user/controller attention:
- `needs_user_relay`
- `ready_for_controller_opt_in`
- `ready_for_user_relay`
- `state_incomplete`

Status treats these as Codex-ready action:

Expand All @@ -122,6 +123,11 @@ Status treats these as Codex-ready action:
docs without running a project adapter. The next Codex action is to inspect the
refreshed state and continue one bounded progress segment.

`state_incomplete` means a readable legacy Markdown active state lacks a
required Todo source heading, currently `## Agent Todo` (or an accepted alias).
Status treats the missing projection as unknown work, not as "no work", and
asks the user or controller to restore the section before relying on the queue.

A registry entry can explicitly override first-screen attention with
`waiting_on`, `attention_status`, `recommended_action`, `operator_question`, and
`next_handoff_condition`. This lets a controller keep a refreshed goal in the
Expand Down
18 changes: 14 additions & 4 deletions docs/status-data-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -955,10 +955,12 @@ Item fields:
`project_asset.user_todos` projection keeps legacy `next` / `next_index`
fields and may also include up to three unfinished `items` for thin workers
that need more than the first open todo.
- `agent_todos`: optional checkbox summary parsed from `## Agent Todo`,
`## Codex Todo`, or `## Project Agent Todo`. Agent-facing consumers may use
it to choose implementation work after gates and quota allow execution; it is
not a user approval signal. Status, quota, and review-packet projections
- `agent_todos`: checkbox summary parsed from `## Agent Todo`,
`## Codex Todo`, or `## Project Agent Todo`. The section may be empty, but a
readable legacy state must include its heading; absence is
`state_incomplete`. Agent-facing consumers may use it to choose
implementation work after gates and quota allow execution; it is not a user
approval signal. Status, quota, and review-packet projections
should preserve up to three unfinished agent todo items so short heartbeats do
not confuse "first visible item" with the whole backlog. Todo summaries also
expose `first_executable_items` for advancement-class work and
Expand Down Expand Up @@ -1120,6 +1122,14 @@ as controller work: register the goal if it is active, or archive the runtime
record if it is old. Watch-only legacy records remain visible in run history
without entering the queue.

`status=state_incomplete` is emitted when a legacy Markdown active state is
readable but lacks its required `## Agent Todo` heading (or an accepted alias).
The item carries `state_incomplete` with the missing sections and repair
guidance. The same condition makes `loopx todo list` return `ok=false` with
`error_code=state_incomplete`; consumers must not interpret its zero Todos as a
goal with no open work. Canonical provider authority is unaffected because this
check applies only to the legacy Markdown fallback.

`status=state_refreshed` is emitted for registered goals when the latest compact
run came from `loopx refresh-state`. Dashboard consumers should show it
as Codex-ready work: the controller state changed, and the next agent turn
Expand Down
20 changes: 19 additions & 1 deletion loopx/contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
)
from .control_plane.runtime.file_reads import iter_utf8_file_reads
from .control_plane.todos.active_state_editing import COMPLETED_WORK_ARCHIVE_HEADING
from .control_plane.todos.active_state_todo_parser import active_state_incomplete_warning
from .control_plane.todos.authoring_scope import todo_contract_diagnostics
from .history import (
RunHistoryAudit,
Expand Down Expand Up @@ -528,13 +529,30 @@ def add_error(code: str, message: str) -> None:
if not state_file or not state_file.exists():
continue
try:
lines = state_file.read_text(encoding="utf-8").splitlines()
state_text = state_file.read_text(encoding="utf-8")
lines = state_text.splitlines()
except (OSError, UnicodeError) as exc:
add_error(
"active_state_read_failed",
f"{goal_id}: cannot read active state for todo contract check: {exc}",
)
continue
try:
state_incomplete = active_state_incomplete_warning(state_text)
except ValueError as exc:
add_error(
"active_state_read_failed",
f"{goal_id}: cannot parse active state for todo contract check: {exc}",
)
continue
if state_incomplete:
add_error(
"state_incomplete",
f"{goal_id}: active state is structurally incomplete; missing required "
f"Todo section(s): {', '.join(state_incomplete['missing_sections'])} "
f"(state_file={state_file}); {state_incomplete['recommended_action']}",
)
continue

role: str | None = None
in_completed_work_archive = False
Expand Down
36 changes: 36 additions & 0 deletions loopx/control_plane/todos/active_state_todo_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,42 @@
from ..runtime.time import now_utc_iso


REQUIRED_TODO_SOURCE_SECTIONS: tuple[tuple[str, str], ...] = (("agent", "Agent Todo"),)


def active_state_incomplete_warning(state_text: str) -> dict[str, Any] | None:
"""Identify required Todo source headings absent from legacy Markdown.

A present-but-empty heading is a valid empty projection. A missing heading
is not: callers cannot distinguish a truncated source from a goal with no
work, so repairs stay visible to status and Todo readback.
"""
lines = state_text.splitlines()
visible = visible_markdown_lines(lines)
present_roles = {
region.role
for region in find_todo_source_regions(lines, visible=visible)
}
missing_sections = [
(role, heading)
for role, heading in REQUIRED_TODO_SOURCE_SECTIONS
if role not in present_roles
]
if not missing_sections:
return None
return {
"kind": "state_incomplete",
"source": "markdown_active_state",
"roles": [role for role, _heading in missing_sections],
"required_roles": [role for role, _heading in REQUIRED_TODO_SOURCE_SECTIONS],
"missing_sections": [heading for _role, heading in missing_sections],
"recommended_action": (
"restore the missing Agent Todo section from the last known good "
"active state, then rerun `loopx status`"
),
}


def parse_todo_source(
state_text: str,
*,
Expand Down
4 changes: 4 additions & 0 deletions loopx/control_plane/todos/active_state_todos.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ def active_state_todo_fields(
rollout_event_log_path: Callable[[Path, str], Path],
max_todo_index_rollout_events_per_goal: int,
parse_active_state_todos: Callable[..., dict[str, Any]],
active_state_incomplete_warning: Callable[[str], dict[str, Any] | None],
parse_issue_meta_surface: Callable[[str], dict[str, Any] | None],
backlog_hygiene_warning: Callable[..., dict[str, Any] | None],
completed_todo_archive_warning: Callable[[dict[str, Any] | None], dict[str, Any] | None],
Expand Down Expand Up @@ -150,6 +151,9 @@ def active_state_todo_fields(
preferred_todo_ids=preferred_todo_ids,
rollout_events=events,
)
state_incomplete = active_state_incomplete_warning(state_text)
if state_incomplete:
fields["state_incomplete"] = state_incomplete
# An explicit canonical objective revision owns current intent. A scoped
# check objective still cannot substitute for Goal intent. Keep this status
# field as display content; admission uses the native revision and guard.
Expand Down
16 changes: 15 additions & 1 deletion loopx/control_plane/todos/list_readback.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@
canonical_todo_summary_fields,
read_canonical_todos_if_promoted,
)
from .active_state_todo_parser import parse_todo_source
from .active_state_todo_parser import (
active_state_incomplete_warning,
parse_todo_source,
)
from .contract import normalize_todo_claimed_by, normalize_todo_id, normalize_todo_status
from .goal_todo_projection import (
exact_archived_todo_summaries,
Expand Down Expand Up @@ -106,6 +109,7 @@ def list_goal_todos(
runtime_root=runtime_root,
goal_id=goal_id,
)
state_incomplete: dict[str, Any] | None = None
if canonical_read is not None:
projected = todo_summaries_from_fields(
fields=(retained_todo_summary_fields(
Expand All @@ -128,6 +132,7 @@ def list_goal_todos(
if not resolved_state_file.exists():
raise ValueError(f"active state file does not exist: {resolved_state_file}")
state_text = resolved_state_file.read_text(encoding="utf-8")
state_incomplete = active_state_incomplete_warning(state_text)
projected = goal_todo_summaries(
goal,
state_text=state_text,
Expand Down Expand Up @@ -220,6 +225,15 @@ def list_goal_todos(
"state_file": str(resolved_state_file),
"project": str(resolved_project) if resolved_project else None,
}
if state_incomplete:
payload["ok"] = False
payload["error_code"] = "state_incomplete"
payload["error"] = (
"active state is structurally incomplete; missing required Todo "
f"section(s): {', '.join(state_incomplete['missing_sections'])}; "
f"{state_incomplete['recommended_action']}"
)
payload["state_incomplete"] = state_incomplete
if canonical_read is not None:
payload["authority_read"] = {
"source_authority": canonical_read["source_authority"],
Expand Down
17 changes: 17 additions & 0 deletions loopx/control_plane/todos/todo_summary.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,23 @@ def active_state_todo_attention_item(
lifecycle_fields = goal_lifecycle_fields(goal, current_run)
goal_id = str(goal.get("id") or "unknown-goal")

state_incomplete = fields.get("state_incomplete")
if isinstance(state_incomplete, dict):
item = attention_item(
goal_id=goal_id,
status="state_incomplete",
waiting_on="user_or_controller",
severity="high",
recommended_action=str(
state_incomplete.get("recommended_action")
or "restore the missing active-state Agent Todo section"
),
source="active_state",
**lifecycle_fields,
)
item["state_incomplete"] = state_incomplete
return item

if user_gate_action or user_gate_items:
return attention_item(
goal_id=goal_id,
Expand Down
1 change: 1 addition & 0 deletions loopx/control_plane/work_items/project_asset.py
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,7 @@ def attach_active_state_project_asset_fields(
"backlog_hygiene_warning",
"state_projection_gap",
"completed_todo_archive_warning",
"state_incomplete",
):
value = item.get(key) if isinstance(item.get(key), dict) else None
if value:
Expand Down
2 changes: 2 additions & 0 deletions loopx/status.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@
active_state_todo_fields as _active_state_todo_fields_read_model,
)
from .control_plane.todos.active_state_todo_parser import (
active_state_incomplete_warning,
parse_active_state_todos,
)
from .control_plane.work_items.attention_item import (
Expand Down Expand Up @@ -731,6 +732,7 @@ def active_state_todo_fields(
rollout_event_log_path=rollout_event_log_path,
max_todo_index_rollout_events_per_goal=MAX_TODO_INDEX_ROLLOUT_EVENTS_PER_GOAL,
parse_active_state_todos=parse_active_state_todos,
active_state_incomplete_warning=active_state_incomplete_warning,
parse_issue_meta_surface=parse_issue_meta_surface,
backlog_hygiene_warning=backlog_hygiene_warning,
completed_todo_archive_warning=completed_todo_archive_warning,
Expand Down
10 changes: 8 additions & 2 deletions tests/control_plane/test_canonical_status_todos.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,11 +94,13 @@ def promoted_goal(tmp_path: Path, record_format, provider):
return goal, runtime, state


@pytest.mark.parametrize("display", ["stale", "missing", "invalid_utf8"])
@pytest.mark.parametrize("display", ["stale", "missing", "invalid_utf8", "truncated"])
def test_status_uses_provider_and_allows_native_monitor_writeback_without_display(promoted_goal, display, monkeypatch):
goal, runtime, state = promoted_goal
if display == "invalid_utf8":
state.write_bytes(b"\xff")
elif display == "truncated":
state.write_text("# Goal\n\n## Next Action\n\nKeep the human narrative.\n")
original = state.read_bytes()
if display == "missing":
state.unlink()
Expand Down Expand Up @@ -159,13 +161,17 @@ def test_unpromoted_status_retains_markdown_without_starting_authority(tmp_path,
assert fields["agent_todos"]["items"][0]["text"] == "Legacy work"


@pytest.mark.parametrize("display", ["missing", "invalid_utf8", "invalid_user_todo"])
@pytest.mark.parametrize(
"display", ["missing", "invalid_utf8", "invalid_user_todo", "truncated"]
)
def test_public_status_cli_uses_canonical_contract_and_attention(promoted_goal, tmp_path, display):
goal, runtime, state = promoted_goal
if display == "missing":
state.unlink()
elif display == "invalid_utf8":
state.write_bytes(b"\xff")
elif display == "truncated":
state.write_text("# Goal\n\n## Next Action\n\nKeep the human narrative.\n")
else:
state.write_text(
"# Goal\n\n## User Todo\n\n- [ ] Obsolete display-only gate\n"
Expand Down
Loading
Loading