diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md index 07a37654a1..c9652402da 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md @@ -1034,9 +1034,11 @@ lineage. Ambiguous, stale, truncated or unrelated material changes cannot close the current obligation. This closes one T3 rule group, not the remaining consumers or T1/T2/D1–D3. -Long-chain scope correction (#4667): Agent lanes count 15 claimed advancement -Todos or 20 claimed open Todos with claimed advancement work. Shared candidates -remain selectable but no longer impose this duty; unscoped Goal observations +Long-chain scope corrections (#4667, #5001): Agent lanes require at least 15 claimed +open advancement Todos. Continuous monitors and shared candidates do not count; +shared candidates remain selectable. The former 20-claimed-open trigger is +retired for new Agent-lane obligations, while historical checkpoints and their +predecessor recovery remain readable. Unscoped Goal observations retain the selectable-pool thresholds. Full material revisions include terminal advancement rows; timestamp-only maintenance does not rearm them. A complete agent-owned identity also keeps an accepted long-chain ACK valid when peers change shared unclaimed @@ -1047,9 +1049,9 @@ an identity without a revision or an explicitly incomplete checkpoint cannot suppress replanning. Other trigger kinds cannot borrow long-chain identity matching. The same TS owner now supplies `obligation_identity_revision` from the owned basis for the existing Python identity codec and predecessor proof: peer -churn cannot invalidate an open Turn before its ACK. Numeric thresholds and -write authority remain unchanged. `replan_semantics.ts` accepts and projects an -evidence-linked vision path for long-chain review, preserving existing progress +churn cannot invalidate an open Turn before its ACK. Monitor due selection, +no-change replan rules and write authority remain unchanged. `replan_semantics.ts` +accepts and projects an evidence-linked vision path for long-chain review, preserving existing progress exits and stricter vision obligations. The real CLI regression follows the projected binding through durable ACK, satisfied checkpoint, one spend and next Turn readback; maintenance stays quiet and an owned material edit rearms. diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md index a695d9c93d..f280565d55 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md @@ -781,9 +781,10 @@ Python 验证前置 obligation id。压缩保留实质字段 `done`,历史保 多后继歧义、过期、来源截断或无关实质变化都不能关闭当前 obligation。 这闭合一个 T3 规则组,不代表其余 consumer 或 T1/T2/D1–D3 完成。 -长链口径修正(#4667):Agent lane 统计 15 项已认领 advancement,或存在已认领 -advancement 时的 20 项已认领 open Todo。共享候选仍可选,但不再计入本 lane 的 -义务;无 Agent 的 Goal 总览保留原可选池口径。 +长链口径修正(#4667、#5001):Agent lane 仅在已认领的开放 advancement 达到 15 项时触发。 +持续监控和共享候选均不计入该阈值;共享候选仍可选。新义务不再使用原 20 项已认领 +open Todo 的触发分支,历史 checkpoint 的读取与前置义务恢复保持兼容;无 Agent 的 +Goal 总览保留原可选池口径。 完整实质 revision 包含终态 advancement;仅更新时间不重新触发。完整的 Agent-owned identity 还能在同伴改变共享 unclaimed 工作时保持既有 long-chain ACK 有效。 自己的实质工作变化仍重新触发;没有认领工作的 lane 不产生长链义务。 @@ -792,7 +793,7 @@ owned identity;只有 identity 而没有 revision、或明确不完整的 chec 压制 replan;其他 trigger kind 不能借用长链身份匹配。同一 TS owner 现在提供 基于 owned 实质内容的 `obligation_identity_revision`,供既有 Python 身份 codec 及 predecessor 校验使用;同伴修改共享池不能在 ACK 前让本 Turn 的义务换 ID。 -阈值数值和写权限不变。`replan_semantics.ts` 为长链 review 接受并投影带证据的 +监控到期选择、无变化重规划规则和写权限不变。`replan_semantics.ts` 为长链 review 接受并投影带证据的 vision path,保留既有 progress 出口和严格 vision 义务。真实 CLI 回归沿投影绑定 验证持久 ACK、checkpoint、一次 spend 和下一 Turn 回读;维护不触发,自己任务的 实质修改重新触发。本次推进总路线 S2/S3 已有 T3 owner,不新增 provider、迁移存储 diff --git a/docs/product/core-control-plane/state-machine.md b/docs/product/core-control-plane/state-machine.md index b88511b548..92d487df7b 100644 --- a/docs/product/core-control-plane/state-machine.md +++ b/docs/product/core-control-plane/state-machine.md @@ -475,14 +475,16 @@ as a goal-frontier `acceptance_gaps[]` entry. If no advancement frontier remains the gap becomes a replan trigger before the lane can quietly back off. Long runnable lanes also pass through this machine. When the current agent owns -15 open advancement todos, or 20 claimed open todos with claimed advancement work -still present, quota should trigger a bounded vision replan before continuing -linearly. The replan reads the agent-scoped evidence log, uses bounded public +at least 15 open advancement todos, quota should trigger a bounded vision replan +before continuing linearly. The replan reads the agent-scoped evidence log, uses bounded public research when local evidence is insufficient for a public claim, then groups, prunes, or reprioritizes the chain into the next high-value runnable slice. Shared unclaimed candidates remain selectable but do not count toward this lane -threshold. A valid evidence-linked vision path can retain existing runnable work -and settle the projected Turn without adding another planning Todo. Shared-pool +threshold. Continuous monitors also do not count: their due schedules and +no-change review rules remain independent, and the former 20-claimed-open +threshold no longer creates new Agent-lane obligations. Historical checkpoints +remain readable. A valid evidence-linked vision path can retain existing runnable +work and settle the projected Turn without adding another planning Todo. Shared-pool churn preserves its obligation identity; owned material changes rearm it. The same ordering also applies to `vision_checkpoint_v0`: if a role records diff --git a/docs/reference/protocols/goal-vision-replan-contract-v0.md b/docs/reference/protocols/goal-vision-replan-contract-v0.md index 3c783c08e7..40a7a22a59 100644 --- a/docs/reference/protocols/goal-vision-replan-contract-v0.md +++ b/docs/reference/protocols/goal-vision-replan-contract-v0.md @@ -601,8 +601,7 @@ or agent-scope wait decisions: - normalized progress shows no remaining advancement frontier; - monitor-only lanes have no material transition and acceptance remains open; - a cleared handoff has no successor or no-follow-up rationale; -- the current agent lane owns at least 15 open advancement Todos, or 20 claimed - open Todos with claimed advancement work still present; +- the current agent lane owns at least 15 open advancement Todos; - a periodic autonomous replan obligation is due; - the user objective or acceptance contract changed; - an approved dreaming proposal requires a delivery route. @@ -611,19 +610,23 @@ The replan decision must not be disturbed by monitor quiet skip, scoped gate waiting, or a single agent having no runnable todo. Those may explain local lane state, but they cannot erase a required goal-level replan. -Long-chain scope correction (#4667): Agent-scoped counts now exclude shared -unclaimed candidates. They remain selectable, but do not create a replan duty -for a lane that has not claimed them. Unscoped Goal observations retain the -selectable-pool thresholds. Numeric thresholds and other replan sources are -unchanged. The typed frontier owner supplies `obligation_identity_revision` -from the owned material identity, keeping an open obligation stable across +Long-chain scope corrections (#4667, #5001): Agent-scoped counts exclude shared +unclaimed candidates and continuous monitors. Shared candidates remain selectable, +but a new long-chain duty requires at least 15 claimed advancement Todos. The former +20-claimed-open threshold no longer triggers an Agent lane. Unscoped Goal +observations retain the selectable-pool thresholds; monitor due selection and +no-change replan rules are unchanged. The typed frontier owner supplies +`obligation_identity_revision` from the owned material identity, keeping an open obligation stable across peer/shared-pool churn; `frontier_revision` retains the full selectable-source checkpoint for diagnostics and historical ACK matching. Owned material changes still rearm. Timestamp/evidence bookkeeping does not. Existing accepted ACKs -remain readable; an outstanding pre-upgrade Turn should refresh its guard. +remain readable, including predecessor recovery for historical open-count +obligations; an outstanding pre-upgrade Turn should refresh its guard. -长链触发范围修正:Agent lane 只统计自己已认领的任务;共享未认领任务仍可选取, -但不计入本 lane 的长链阈值。无 Agent 的 Goal 总览保留原可选池口径。 +长链触发范围修正:Agent lane 只在自己已认领的开放推进任务达到 15 项时触发; +持续监控和共享未认领任务不计入该阈值,移除原 20 项已认领开放任务的触发分支。 +共享任务仍可选取;无 Agent 的 Goal 总览保留原可选池口径,监控到期和无变化重规划 +规则不变。历史开放任务计数 checkpoint 的读取与前置义务恢复保持兼容。 义务身份使用 typed owner 给出的 owned 实质 revision,同伴修改共享池不会让正在 处理的义务换 ID;自己任务的实质修改仍重新触发。证据补充或更新时间不重新触发。 diff --git a/loopx/control_plane/todos/frontier_revision.ts b/loopx/control_plane/todos/frontier_revision.ts index d8d7a0f37b..638989af27 100644 --- a/loopx/control_plane/todos/frontier_revision.ts +++ b/loopx/control_plane/todos/frontier_revision.ts @@ -25,7 +25,7 @@ type Row = { type LongChainObservation = { trigger_count: number; count_kind: "selectable_advancement_todos" | "selectable_open_todos" | - "claimed_advancement_todos" | "claimed_open_todos"; + "claimed_advancement_todos"; selectable_open_count: number; selectable_advancement_count: number; current_agent_claimed_open_count: number; current_agent_claimed_advancement_count: number; unclaimed_advancement_count: number; @@ -168,6 +168,8 @@ function successorCheckpoints(request: JsonObject, agent: string | null): JsonOb : trigger.current_agent_claimed_advancement_count) - 1; const priorOpen = count(agent === null ? trigger.selectable_open_count : trigger.current_agent_claimed_open_count) - 1; + // Retain the former open-count threshold when reading historical obligations. + // New Agent-lane observations below count only advancement commitments. if (bindings.length === 0 && candidates.length === 1 && triggers.length === 1 && trigger.kind === TRIGGER && trigger.frontier_revision === source.frontier_revision && (priorAdvancement >= 15 || priorOpen >= 20 && priorAdvancement > 0)) { const completeSource = indexed === null ? source : checkpoint(rows, agent); @@ -244,15 +246,15 @@ export function evaluateLongTodoChain(value: unknown): JsonObject { // A lane replans commitments it owns. Shared candidates remain selectable, // but must not impose a chain obligation with no owned ACK fence. const measuredAdvancement = agent === null ? advancement : current; - const measuredOpen = agent === null ? open : claimedOpen; - const threshold = measuredAdvancement >= 15 ? 15 : measuredOpen >= 20 && measuredAdvancement > 0 ? 20 : null; + const threshold = measuredAdvancement >= 15 ? 15 + : agent === null && open >= 20 && measuredAdvancement > 0 ? 20 : null; if (threshold === null) return {observation: null, decision: null}; const revision = readIndex(summary.advancement_frontier_revision_index, agent) ?? checkpoint(decodeRows(request.rows), agent); - const observation: LongChainObservation = {trigger_count: threshold === 15 ? measuredAdvancement : measuredOpen, + const observation: LongChainObservation = {trigger_count: threshold === 15 ? measuredAdvancement : open, count_kind: agent === null ? threshold === 15 ? "selectable_advancement_todos" : "selectable_open_todos" - : threshold === 15 ? "claimed_advancement_todos" : "claimed_open_todos", + : "claimed_advancement_todos", selectable_open_count: open, selectable_advancement_count: advancement, current_agent_claimed_open_count: claimedOpen, current_agent_claimed_advancement_count: current, unclaimed_advancement_count: unclaimed, diff --git a/tests/control_plane/test_effect_turn_live_quota_decision.py b/tests/control_plane/test_effect_turn_live_quota_decision.py index ffbdeb375b..4698ecc88e 100644 --- a/tests/control_plane/test_effect_turn_live_quota_decision.py +++ b/tests/control_plane/test_effect_turn_live_quota_decision.py @@ -1160,11 +1160,17 @@ def test_retained_selection_reentry_stays_packet_free_and_signed( assert envelope["writeback"]["spend_after_validation"] is False +@pytest.mark.parametrize( + ("advancement_count", "requires_replan"), + [(1, False), (14, False), (15, True)], +) def test_retained_selection_reentry_refreshes_provider_todos_before_replan( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, + advancement_count: int, + requires_replan: bool, ) -> None: - """A retained choice must see the same fresh frontier that deferred it.""" + """Refresh retained choices before applying the claimed advancement threshold.""" agent_id = "agent-provider-frontier" selected_todo_id = "todo_provider_selected" @@ -1199,12 +1205,14 @@ def test_retained_selection_reentry_refreshes_provider_todos_before_replan( "text": ( "[P1] Continue the selected delivery." if index == 0 - else f"[P1] Preserve blocker context {index}." + else f"[P1] Provider frontier item {index}." ), "role": "agent", "status": "open", "priority": "P1", - "task_class": "advancement_task" if index == 0 else "blocker", + "task_class": ( + "advancement_task" if index < advancement_count else "blocker" + ), "claimed_by": agent_id, "updated_at": f"2026-09-{(index % 9) + 1:02d}T00:00:00Z", } @@ -1247,9 +1255,14 @@ def fresh_todos(**_kwargs: object) -> dict[str, object]: ) assert reads == ["provider"] - assert payload["decision"] == "autonomous_replan_required" + assert payload["selected_todo"]["todo_id"] == selected_todo_id assert payload["retained_action_selection"]["disposition"] == ( "preserve_retained_todo" ) - assert payload["replan_action_packet"]["obligation_id"] assert payload["interaction_contract"]["agent_channel"]["must_attempt"] is True + if requires_replan: + assert payload["decision"] == "autonomous_replan_required" + assert payload["replan_action_packet"]["obligation_id"] + else: + assert payload["decision"] == "run" + assert not payload.get("replan_action_packet") diff --git a/tests/control_plane/test_long_chain_projected_closeout.py b/tests/control_plane/test_long_chain_projected_closeout.py index c97ebce7c0..6889382442 100644 --- a/tests/control_plane/test_long_chain_projected_closeout.py +++ b/tests/control_plane/test_long_chain_projected_closeout.py @@ -4,6 +4,8 @@ import json from pathlib import Path +import pytest + from tests.control_plane.test_quota_settlement_cli import ( AGENT_ID, GOAL_ID, SELECTED_REPLAN_TODO_ID, TURN_ID, _configure_selected_todo_replan_fixture, _projected_cli_args, @@ -11,6 +13,59 @@ ) +@pytest.mark.parametrize("advancement,monitors,replan_required", [ + (9, 19, False), (14, 6, False), (15, 19, True), +]) +def test_managed_turn_counts_advancement_without_monitor_backlog( + tmp_path: Path, advancement: int, monitors: int, replan_required: bool, +) -> None: + project, runtime, registry = _write_fixture(tmp_path) + _configure_selected_todo_replan_fixture(project, registry) + state = project / f".codex/goals/{GOAL_ID}/ACTIVE_GOAL_STATE.md" + header = state.read_text(encoding="utf-8").split("## Agent Todo\n\n")[0] + rows = [ + f"- [ ] [P1] Validate bounded slice {index}.\n" + f" \n" + for index in range(advancement) + ] + rows.extend( + f"- [ ] [P2-monitor] Watch independent target {index}.\n" + f" \n" + for index in range(monitors) + ) + state.write_text(header + "## Agent Todo\n\n" + "".join(rows), encoding="utf-8") + + # The public managed-Turn entrypoint must keep both the first read and replay + # free of a long-chain duty introduced solely by independent monitors. + for _ in range(2): + rc, result = _run_cli( + registry, runtime, "quota", "should-run", "--codex-app", + "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, + "--turn-instance-id", TURN_ID, "--scan-path", str(project), cwd=project, + ) + assert rc == 0, result + frontier = result["goal_frontier_projection"] + assert frontier["remaining_advancement_frontier"][ + "current_agent_claimed_advancement_count" + ] == advancement + assert frontier["replan_required"] is replan_required + assert result["selected_todo"]["todo_id"] == SELECTED_REPLAN_TODO_ID + obligation = result["heartbeat_recommendation"].get("replan_obligation") + if replan_required: + assert [trigger["kind"] for trigger in obligation["triggers"]] == ["long_todo_chain"] + assert obligation["triggers"][0]["count_kind"] == "claimed_advancement_todos" + assert obligation["triggers"][0]["trigger_count"] == advancement + else: + assert obligation is None + assert _spend_run_count(runtime) == 0 + + def test_projected_vision_replan_settles_without_a_meta_successor(tmp_path: Path) -> None: project, runtime, registry = _write_fixture(tmp_path) _configure_selected_todo_replan_fixture(project, registry) diff --git a/tests/control_plane_ts/frontier_revision.test.ts b/tests/control_plane_ts/frontier_revision.test.ts index 5bd61be309..3f141a6902 100644 --- a/tests/control_plane_ts/frontier_revision.test.ts +++ b/tests/control_plane_ts/frontier_revision.test.ts @@ -67,13 +67,12 @@ test("duplicate identities and malformed or absent revision facts cannot authori assert.deepEqual(result.decision, {acknowledged: false, rearmed_after_obligation_id: null}); }); -test("lane thresholds count 15 claimed advancement or 20 claimed open with advancement", () => { +test("lane thresholds count only 15 claimed advancement commitments", () => { assert.notEqual(observe().observation, null); assert.equal(observe({frontier_counts: {current_agent_claimed_advancement_count: 14}}).observation, null); const open = {current_agent_claimed_open_count: 20, unclaimed_open_count: 0}; const result = observe({summary: open, frontier_counts: {current_agent_claimed_advancement_count: 1}}); - assert.equal((result.observation as Record).count_kind, "claimed_open_todos"); - assert.equal((result.observation as Record).trigger_count, 20); + assert.equal(result.observation, null); assert.equal(observe({summary: open, frontier_counts: {}}).observation, null); assert.equal(observe({summary: open, frontier_counts: {unclaimed_advancement_count: 100}}).observation, null); for (const current of [0, 14]) { @@ -85,6 +84,32 @@ test("lane thresholds count 15 claimed advancement or 20 claimed open with advan frontier_counts: {unclaimed_advancement_count: 1}}).observation, null); }); +test("monitor growth neither creates a lane long-chain obligation nor rearms an accepted ACK", () => { + function mixed(advancement: number, monitors: number, ack?: object) { + const rows = [ + ...Array.from({length: advancement}, (_, i) => row(`todo_work_${i}`, "worker-a")), + ...Array.from({length: monitors}, (_, i) => ({...row(`todo_monitor_${i}`, "worker-a"), + advancement: false, serialized: JSON.stringify({task_class: "continuous_monitor", todo_id: `todo_monitor_${i}`})})), + ]; + return observe({rows, ack, + summary: {current_agent_claimed_open_count: advancement + monitors}, + frontier_counts: {current_agent_claimed_advancement_count: advancement}}); + } + for (const [advancement, monitors] of [[9, 19], [14, 6], [0, 20]]) { + assert.deepEqual(mixed(advancement, monitors), {observation: null, decision: null}); + } + const observation = mixed(15, 0).observation as Record; + assert.equal(observation.threshold, 15); + assert.equal(observation.count_kind, "claimed_advancement_todos"); + const ack = {recorded: true, semantic_delta: {accepted: true, obligation_id: "replan-0123456789abcdef", + trigger_kinds: ["long_todo_chain"], trigger_checkpoints: [observation.trigger]}}; + for (const monitors of [0, 19, 30]) { + const result = mixed(15, monitors, ack); + assert.equal((result.observation as Record).frontier_revision, observation.frontier_revision); + assert.deepEqual(result.decision, {acknowledged: true, rearmed_after_obligation_id: null}); + } +}); + test("only exact accepted checkpoint suppresses a repeated trigger; material change rearms", () => { const observation = observe().observation as Record; const ack = {recorded: true, semantic_delta: {accepted: true, obligation_id: "replan-0123456789abcdef", @@ -224,3 +249,18 @@ test("successor reconstruction requires a complete matching source and an alread assert.deepEqual((projectAdvancementFrontier({...request, triggers}).source_checkpoint as Record).bindings, []); } }); + +test("historical open-count long-chain checkpoints retain predecessor recovery", () => { + const rows = Array.from({length: 10}, (_, i) => row(`todo_${i}`, "worker-a")); + rows[9].updated = "2026-09-02T00:00:00Z"; + const result = projectAdvancementFrontier({schema_version: "todo_frontier_revision_request_v0", + operation: "successor_checkpoints", agent_id: "worker-a", rows, obligation_id: "replan-current", + candidates: [{todo_id: "todo_9", updated_at: rows[9].updated, origin_obligation_id: "replan-prior"}], + triggers: [{kind: "long_todo_chain", ...project(rows, "worker-a"), + count_kind: "claimed_open_todos", threshold: 20, + current_agent_claimed_advancement_count: 10, current_agent_claimed_open_count: 29}], + }).source_checkpoint as Record; + assert.deepEqual(result.bindings, [{kind: "predecessor", todo_id: "todo_9", + frontier_revision: project(rows.slice(0, 9), "worker-a").frontier_revision, + obligation_identity_revision: project(rows.slice(0, 9), "worker-a").frontier_owned_identity}]); +});