From 72295c69749381036df7f7ab8c1bda96e60c02be Mon Sep 17 00:00:00 2001 From: "lusendong.6789" Date: Sat, 31 Oct 2026 19:56:10 +0800 Subject: [PATCH] fix(replan): exclude inferred-successor rows from lifecycle reconstruction A receipt-bound lifecycle review reconstructs the completed rows that the admitted summary reported without a successor. That summary also closes a gap through inferred resume/unblock edges, but the reconstruction only checked declared successor ids. An older row that was resumed by a later Todo re-entered the source, its trigger list no longer matched the admitted obligation digest, and the same Turn's guard failed closed with heartbeat_receipt_identity_conflict after a valid no-follow-up closeout. Reuse the succession owner's inferred-edge index with its active-generation identity rule so the reconstruction matches the admitted summary. Signed-off-by: lusendong.6789 Co-authored-by: TRAE CLI --- .../work_items/replan_semantics.ts | 18 ++++++++++ .../control_plane_ts/replan_semantics.test.ts | 35 +++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/loopx/control_plane/work_items/replan_semantics.ts b/loopx/control_plane/work_items/replan_semantics.ts index 96aef39ef1..ab12ab0509 100644 --- a/loopx/control_plane/work_items/replan_semantics.ts +++ b/loopx/control_plane/work_items/replan_semantics.ts @@ -5,6 +5,8 @@ import { optionalNonEmptyString, requireJsonObject, requireNonEmptyString } from import { visionAuthoringContract } from "../goals/vision_checkpoint.ts"; import { parseTodoTimestampMicros } from "../runtime_timestamp.ts"; import { TODO_COMPLETION_RECOVERIES } from "../todos/completion_state.ts"; +import { id as todoId, readResume, taskClass } from "../todos/read_codec.ts"; +import { indexInferredSuccessors } from "../todos/succession.ts"; import {normalizeStrideDecision, normalizeStrideStrategy, STRIDE_DECISION_SCHEMA, strideControlContract, strideReviewMismatch} from "./stride_policy.ts"; import { projectProgressDelta } from "./progress_delta.ts"; @@ -134,6 +136,21 @@ function lifecycleSource(request: JsonObject): JsonObject { // second is possible; the exact obligation digest must still prove identity. // Older terminal rows were already absent from the admitted frontier. const admissionSecond = admitted / 1_000_000n * 1_000_000n; + // The admitted summary also closes a gap through inferred resume/unblock + // edges. Reuse the succession owner's index, with its active-generation + // identity rule, so a row which already had a successor cannot re-enter the + // reconstructed source and break the admitted obligation digest. + const generations = new Map(); + for (const item of request.items.map(object)) { + const key = todoId(item.todo_id); + if (key && ((item.archive_state ?? "active") === "active" || !generations.has(key))) generations.set(key, item); + } + const inferred = indexInferredSuccessors([...generations.values()].map(item => { + const resume = readResume(item.resume_when) ?? ""; + return {id: todoId(item.todo_id), advancement: taskClass(item) === "advancement_task", + unblocks: todoId(item.unblocks_todo_id), + resumes: resume.startsWith("todo_done:") ? todoId(resume.slice(10)) : null}; + })); const items = request.items.map(object).filter(item => { const completed = typeof item.completed_at === "string" ? parseTodoTimestampMicros(item.completed_at) : null; const updated = typeof item.updated_at === "string" ? parseTodoTimestampMicros(item.updated_at) : null; @@ -142,6 +159,7 @@ function lifecycleSource(request: JsonObject): JsonObject { item.claimed_by === agent && item.last_actor_agent_id === agent && completed !== null && completed <= admitted && strings(item.successor_todo_ids).length === 0 && !item.superseded_by && + !inferred.get(todoId(item.todo_id) ?? "")?.length && // Either canonical closeout path can finish after this review was // admitted. The exact trigger/identity checks below still own reentry. (item.no_followup !== true || (TODO_COMPLETION_RECOVERIES.some(recovery => recovery === item.completion_recovery) && diff --git a/tests/control_plane_ts/replan_semantics.test.ts b/tests/control_plane_ts/replan_semantics.test.ts index 7d5674dd28..93e3a4a55d 100644 --- a/tests/control_plane_ts/replan_semantics.test.ts +++ b/tests/control_plane_ts/replan_semantics.test.ts @@ -624,6 +624,41 @@ test("lifecycle reconstruction restores completion order before its trigger boun .map(item => item.todo_id), ["todo_4", "todo_3", "todo_2", "todo_1"]); }); +test("lifecycle reconstruction excludes rows closed by an inferred successor", () => { + const agent = "synthetic-agent", id = "replan-1111111111111111"; + const row = {todo_id: "todo_settled", status: "done", done: true, task_class: "advancement_task", + claimed_by: agent, last_actor_agent_id: agent, completed_at: "2026-08-01T00:00:00Z", + updated_at: "2026-08-01T00:00:10Z", completion_turn_key: "settled-completion", + no_followup: true, completion_continuation: "no_followup", + completion_recovery: "lifecycle_reentry_terminal_closeout"}; + // The admitted summary had no gap for this older row: a later row resumes on it. + const resumed = {...row, todo_id: "todo_resumed", completed_at: "2026-07-31T00:00:00Z", + updated_at: "2026-07-31T00:00:00Z", completion_turn_key: "resumed-completion", + no_followup: null, completion_continuation: "active_goal", completion_recovery: null}; + const resumer = {...resumed, todo_id: "todo_resumer", completion_turn_key: "resumer-completion", + successor_todo_ids: [resumed.todo_id], resume_when: `todo_done:${resumed.todo_id}`}; + const original = {required: true, obligation_id: id, resolution_mode: "todo_lifecycle_settlement", + triggers: [{kind: "completed_advancement_without_successor", todo_id: row.todo_id, + completion_turn_key: row.completion_turn_key, agent_id: agent}]}; + const candidate = {obligation: original, source_obligation: original, lifecycle_settled: true, + guard_recorded_at: "2026-08-01T00:00:05.123456Z", items: [row, resumed, resumer]}; + const request = {operation: "receipt_bound_obligation", guard_scoped: true, replay_phase: "open", + selected_obligation_id: id, agent_id: agent, current_obligation: null, transition_candidates: [candidate]}; + assert.deepEqual((projectReplanSemantics({operation: "lifecycle_source", agent_id: agent, + guard_recorded_at: candidate.guard_recorded_at, items: candidate.items}).items as JsonObject[]) + .map(item => item.todo_id), [row.todo_id]); + const result = projectReplanSemantics(request).obligation as JsonObject; + assert.equal(result.obligation_id, id); + assert.equal(result.resolution_mode, "receipt_bound_replan_settlement"); + // An unblock edge closes the gap the same way. A non-advancement resumer + // infers no edge, so the row stays and the exact trigger check still fails. + const unblocker = {...resumer, resume_when: null, unblocks_todo_id: resumed.todo_id}; + assert.equal((projectReplanSemantics({...request, transition_candidates: [{...candidate, + items: [row, resumed, unblocker]}]}).obligation as JsonObject).obligation_id, id); + assert.throws(() => projectReplanSemantics({...request, transition_candidates: [{...candidate, + items: [row, resumed, {...resumer, task_class: "continuous_monitor"}]}]}), /identity conflicts/); +}); + test("pending lifecycle survives a new frontier without granting settlement or successor work", () => { const agent = "synthetic-agent", id = "replan-1111111111111111";