Skip to content

Commit d05c2a9

Browse files
authored
Keep deferred todos out of completed archive (#3452)
* Keep deferred todos out of completed archive Signed-off-by: huangruiteng <huangrt01@163.com> * refactor(todos): isolate archive completion semantics Signed-off-by: huangruiteng <huangrt01@163.com> --------- Signed-off-by: huangruiteng <huangrt01@163.com>
1 parent 9d6b4cc commit d05c2a9

4 files changed

Lines changed: 119 additions & 10 deletions

File tree

‎examples/control_plane/todo-archive-completed-smoke.py‎

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,11 @@
1313
if str(REPO_ROOT) not in sys.path:
1414
sys.path.insert(0, str(REPO_ROOT))
1515

16+
from loopx.control_plane.todos.completed_archive import ( # noqa: E402
17+
completed_todo_archive_warning,
18+
completed_todo_count,
19+
)
1620
from loopx.status import parse_active_state_todos # noqa: E402
17-
from loopx.control_plane.todos.completed_archive import completed_todo_archive_warning # noqa: E402
18-
1921

2022
GOAL_ID = "todo-archive-completed-goal"
2123

@@ -39,6 +41,9 @@ def state_text() -> str:
3941
"## Agent Todo\n\n"
4042
"- [ ] [P1] Keep current open benchmark validation work visible.\n"
4143
+ "".join(done_lines)
44+
+ "- [-] [P0] Resume deferred acceptance after its dependency.\n"
45+
+ " <!-- loopx:todo todo_id=todo_deferred_acceptance status=deferred "
46+
+ "task_class=advancement_task resume_when=todo_done:todo_dependency -->\n"
4247
+ "- [ ] [P2] Keep monitor work after completed items visible.\n\n"
4348
"## Completed Work Archive\n\n"
4449
"- [x] [P1] Previously archived completed work.\n"
@@ -114,7 +119,8 @@ def main() -> int:
114119
registry_path, runtime, state_path = write_fixture(Path(tmp))
115120
original = state_path.read_text(encoding="utf-8")
116121
parsed = parse_active_state_todos(original)
117-
assert parsed["agent_todos"]["done_count"] == 14, parsed
122+
assert parsed["agent_todos"]["done_count"] == 15, parsed
123+
assert parsed["agent_todos"]["deferred_count"] == 1, parsed
118124
assert parsed["agent_todos"]["open_count"] == 2, parsed
119125
warning = completed_todo_archive_warning(parsed["agent_todos"])
120126
assert warning is not None, parsed
@@ -181,7 +187,8 @@ def main() -> int:
181187

182188
updated = state_path.read_text(encoding="utf-8")
183189
parsed_after = parse_active_state_todos(updated)
184-
assert parsed_after["agent_todos"]["done_count"] == 12, parsed_after
190+
assert parsed_after["agent_todos"]["done_count"] == 13, parsed_after
191+
assert parsed_after["agent_todos"]["deferred_count"] == 1, parsed_after
185192
assert parsed_after["agent_todos"]["open_count"] == 2, parsed_after
186193
assert completed_todo_archive_warning(parsed_after["agent_todos"]) is None, parsed_after
187194
assert updated.index("## Completed Work Archive") < updated.index("[P1] Completed implementation lane 1.")
@@ -195,7 +202,7 @@ def main() -> int:
195202
assert status_after["ok"] is True, status_after
196203
assert status_after["contract_errors"] == [], status_after
197204

198-
done_before_completion = parsed_after["agent_todos"]["done_count"]
205+
done_before_completion = completed_todo_count(parsed_after["agent_todos"])
199206
lineage_source_text = "Complete work whose successor lineage must survive archiving."
200207
lineage_successor_text = "Continue the public archive interplay validation."
201208
lineage_source = run_cli(
@@ -286,6 +293,29 @@ def main() -> int:
286293
)
287294
assert successor_item["status"] == "open", successor_item
288295
assert successor_item["claimed_by"] == "codex-reviewer", successor_item
296+
deferred_item = next(
297+
item
298+
for item in active_after_archive["items"]
299+
if item["todo_id"] == "todo_deferred_acceptance"
300+
)
301+
assert deferred_item["status"] == "deferred", deferred_item
302+
assert deferred_item["archive_state"] == "active", deferred_item
303+
deferred_update = run_cli(
304+
registry_path,
305+
runtime,
306+
"todo",
307+
"update",
308+
"--goal-id",
309+
GOAL_ID,
310+
"--todo-id",
311+
"todo_deferred_acceptance",
312+
"--agent-id",
313+
"codex-reviewer",
314+
"--note",
315+
"Deferred acceptance remains lifecycle-addressable after archive compaction.",
316+
)
317+
assert deferred_update["ok"] is True, deferred_update
318+
assert deferred_update["changed"] is True, deferred_update
289319
assert completed_todo_archive_warning(active_after_archive) is None, active_after_archive
290320

291321
lineage_status = run_cli(registry_path, runtime, "status")

‎loopx/control_plane/todos/completed_archive.py‎

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
section_bounds,
1010
todo_blocks,
1111
)
12+
from .contract import TODO_STATUS_DONE, normalize_todo_status
1213
from .decision_scope import is_standing_decision_receipt_item
1314

1415
DEFAULT_MAX_ACTIVE_DONE_TODOS_BEFORE_ARCHIVE = 12
@@ -23,17 +24,37 @@ def completed_todo_archive_command_template(max_active_done: int) -> str:
2324
return COMPLETED_TODO_ARCHIVE_COMMAND_TEMPLATE.format(max_active_done=max_active_done)
2425

2526

27+
def completed_todo_count(todo_summary: dict[str, Any] | None) -> int:
28+
"""Decode explicit completions from the v0 terminal-count projection.
29+
30+
``todo_summary_v0.done_count`` intentionally includes deferred Todos because
31+
both states are terminal for scheduling. Completed-work compaction has a
32+
narrower contract and must remove the deferred lane before applying archive
33+
pressure.
34+
"""
35+
36+
if not isinstance(todo_summary, dict):
37+
return 0
38+
try:
39+
terminal_count = int(todo_summary["done_count"])
40+
deferred_count = int(todo_summary["deferred_count"])
41+
except (KeyError, TypeError, ValueError):
42+
return 0
43+
return max(0, terminal_count - deferred_count)
44+
45+
46+
def _is_explicitly_completed_todo(block: dict[str, Any]) -> bool:
47+
return bool(normalize_todo_status(block.get("status")) == TODO_STATUS_DONE)
48+
49+
2650
def completed_todo_archive_warning(
2751
agent_todos: dict[str, Any] | None,
2852
*,
2953
max_active_done_todos: int = DEFAULT_MAX_ACTIVE_DONE_TODOS_BEFORE_ARCHIVE,
3054
) -> dict[str, Any] | None:
3155
if not isinstance(agent_todos, dict):
3256
return None
33-
try:
34-
done_count = int(agent_todos.get("done_count") or 0)
35-
except (TypeError, ValueError):
36-
done_count = 0
57+
done_count = completed_todo_count(agent_todos)
3758
if done_count <= max_active_done_todos:
3859
return None
3960
try:
@@ -82,7 +103,11 @@ def archive_completed_todo_lines(
82103

83104
if bounds:
84105
blocks = todo_blocks(updated_lines, bounds[0], bounds[1], role=role, source_section=section)
85-
done_blocks = [block for block in blocks if block.get("done") is True]
106+
done_blocks = [
107+
block
108+
for block in blocks
109+
if _is_explicitly_completed_todo(block)
110+
]
86111
active_done_count = len(done_blocks)
87112
standing_receipts = (
88113
[block for block in done_blocks if is_standing_decision_receipt_item(block)]

‎skills/loopx-self-repair/references/repair-patterns.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ teaches a reusable control-plane lesson.
1010
| `task_session_projection_gap` | The UI says work is running while the linked Session has no active Turn, or a monitor opens an unrelated task Session. | Goal state, Todo id, Session `channel_id`, active Turn, and typed-action receipt resource ids. | Generic Goal activity was counted as active execution, or the UI joined Sessions by Goal id without the Todo id. | Count execution only from an active Turn, join task Sessions by Todo id, and create or resume a dedicated `task.<todo_id>` Session before an immediate monitor run. Cover idle resumable Sessions and multiple Todos under one Goal. |
1111
| `author_owned_blocking_review_unrepresentable` | An author-owned PR receives a complete exact-head blocking review, but GitHub rejects self-`REQUEST_CHANGES` and the PR queue continues to classify the `COMMENTED` fallback as missing or malformed. | Published review state/body/title, exact review commit, machine English verdict, five required Chinese headings, and `review_conclusion.invalid_reasons`. | The review contract defined an author-owned no-blocker fallback only, so the GitHub identity restriction made a blocking conclusion impossible to encode; loose English verdict prose could also disagree with formal state. | Define separate exact `COMMENTED` titles for author-owned approval and request-changes outcomes, require a line-starting typed English verdict, validate title/verdict/state alignment, read the rendered review back at the exact head, and cover approval, blocker, untitled, and mismatched-state cases. |
1212
| `state_projection_gap` | `quota should-run` says `should_run=true` or `must_attempt=true`, but user and agent open todo counts are both zero while `Next Action` still contains executable work. | quota payload, active state `## Agent Todo`, `## User Todo`, `Next Action`, `refresh-state` warnings. | Active state prose has drifted away from machine todo projection. | Expand executable prose into `- [ ]` agent todos, add refresh-state warning/smoke if projection missed it. |
13+
| `deferred_todo_archive_projection_gap` | A deferred Todo appears under `Completed Work Archive`; `todo list` still projects it as deferred, but lifecycle update or resume reports that the Todo does not exist. | Active Todo and archive sections, marker plus explicit status, archive command receipt, `done_count` and `deferred_count`, lifecycle lookup result. | Completed-work compaction used the shared terminal/done projection, which intentionally includes deferred Todos, instead of selecting explicit `status=done`. | Make archive warnings subtract deferred from terminal counts, move only explicit done blocks, and prove a deferred Todo remains in the active section and lifecycle-addressable after archive compaction. |
1314
| `selected_todo_continuation_projection_gap` | Quota selects the correct runnable Todo, but a later turn repeats completed implementation or follows a stale global `Next Action` instead of the Todo's latest bounded continuation. | `selected_todo`, selected Todo metadata, active-state `Next Action`, evaluator feedback, next-turn tool trace. | Compact Todo projections retained identity and routing fields but discarded the current note before selection, or kept stale durable action prose beside the selected Todo on the hot path. | Project one bounded, secret-screened `continuation_hint` from the selected open advancement Todo's current note or explicit suggested action. When that selection supersedes durable action prose, omit the shadowed prose from the default CLI packet and retain it behind the explicit detail path; do not copy raw evidence or require every intermediate step to write state. |
1415
| `user_todo_projection_gap` | `interaction_contract.user_channel.action_required=true` or open user todos exist, but heartbeat/final message only says "owner gate" or does not name the concrete decision. | quota payload `interaction_contract`, `user_todo_summary`, active state user todos, recent final messages. | User-channel projection or agent final-response rule is too vague. | Fix prompt/projection so required user todo/question is surfaced; only say "具体 user todo 未投影" when action is required but absent. |
1516
| `lark_inbox_priority_projection_gap` | A configured bot has a pending direct question or mention, but quota/status exposes only a static drain command and ordinary monitor/advancement work runs first. | local-private inbox counts, content-free oldest-pending age, `goal_boundary.capabilities.lark_event_inbox`, `work_lane_contract`, source-thread reply/readback receipt. | Inbox availability was modeled as a prompt reminder instead of a schedulable urgency read model and priority lane. | Project content-free pending/question/mention counts plus oldest age; make `reply_due` preempt ordinary work, then require durable effect, exactly one idempotent source-thread reply with readback, and ACK. Never project message bodies, ids, senders, or private paths into quota/status. |
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
from __future__ import annotations
2+
3+
from loopx.control_plane.todos.completed_archive import (
4+
archive_completed_todo_lines,
5+
completed_todo_archive_warning,
6+
completed_todo_count,
7+
)
8+
from loopx.status import parse_active_state_todos
9+
10+
11+
def _fixture_text() -> str:
12+
return (
13+
"# Goal\n\n"
14+
"## Agent Todo\n\n"
15+
"- [x] Completed work.\n"
16+
" <!-- loopx:todo todo_id=todo_done status=done -->\n"
17+
"- [-] Deferred work.\n"
18+
" <!-- loopx:todo todo_id=todo_deferred status=deferred "
19+
"resume_when=todo_done:todo_done -->\n\n"
20+
"## Completed Work Archive\n"
21+
)
22+
23+
24+
def test_completed_archive_keeps_deferred_todo_active() -> None:
25+
original = _fixture_text()
26+
parsed = parse_active_state_todos(original)["agent_todos"]
27+
28+
assert parsed["done_count"] == 2
29+
assert parsed["deferred_count"] == 1
30+
assert completed_todo_count(parsed) == 1
31+
warning = completed_todo_archive_warning(parsed, max_active_done_todos=0)
32+
assert warning is not None
33+
assert warning["active_done_count"] == 1
34+
35+
result = archive_completed_todo_lines(original.splitlines(), max_active_done=0)
36+
updated = "\n".join(result["lines"])
37+
38+
assert result["moved_count"] == 1
39+
assert updated.index("Deferred work.") < updated.index("## Completed Work Archive")
40+
assert updated.index("## Completed Work Archive") < updated.index("Completed work.")
41+
projected = parse_active_state_todos(updated)["agent_todos"]
42+
deferred = next(
43+
item for item in projected["items"] if item["todo_id"] == "todo_deferred"
44+
)
45+
assert deferred["status"] == "deferred"
46+
assert deferred["archive_state"] == "active"
47+
48+
49+
def test_completed_todo_count_fails_closed_for_invalid_summary_counts() -> None:
50+
assert completed_todo_count(None) == 0
51+
assert completed_todo_count({"done_count": 1}) == 0
52+
assert completed_todo_count({"done_count": "invalid", "deferred_count": 1}) == 0
53+
assert completed_todo_count({"done_count": 1, "deferred_count": 2}) == 0

0 commit comments

Comments
 (0)