Skip to content

Commit dfd04bd

Browse files
loopx-agenthuangruiteng
authored andcommitted
fix(control-plane): recover receipt-bound lifecycle reviews safely
Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com>
1 parent 547766f commit dfd04bd

14 files changed

Lines changed: 218 additions & 13 deletions

‎loopx/control_plane/goals/checkpoint_context_io.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,9 @@ def read_checkpoint_context(
160160
identity = readback.identity.value
161161
with _source_guard(root, goal_id, path):
162162
result = _resolve(root, phase="read", identity=identity.as_dict(), prior=readback.writeback_run,
163+
writeback_committed=readback.writeback.failure is None,
164+
quota_spend_committed=readback.spend_run is not None or readback.spend_event is not None,
165+
semantic_replan_guard=readback.semantic_replan_guard,
163166
read_context_id=uuid4().hex, dependency_todo_ids=dependency_todo_ids or [],
164167
facts=_local_source_facts(root, registry_path, path, identity, goal_ref))
165168
receipt = result.pop("receipt")

‎loopx/control_plane/goals/checkpoint_read_context.ts‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1-
/** Read basis for a missing-checkpoint supplement, not an execution/permission lease.
1+
/** Read basis for a missing decision/qualification checkpoint, not an execution/permission lease.
22
* The commit effect holds source/index claims and the real provider fence. */
33
import type {JsonObject} from "../effect_program.ts";
4+
import {settlementIdentity} from "../effect_program.ts";
45
import {jsonObject, requireJsonObject, requireNonEmptyString} from "../runtime_decode.ts";
56
import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts";
67
import {canonicalAuthoritySha256} from "../coordination/authority_store_codec.ts";
8+
import {isUnqualifiedBlockedReplanWriteback} from "../quota/settlement_phase.ts";
79

810
const RECEIPT_SCHEMA = "checkpoint_read_context_v1";
911
// Exact presentation fields only. Unknown future fields remain part of the basis.
@@ -88,7 +90,16 @@ export function evaluateCheckpointReadContext(value: unknown): JsonObject {
8890
if (request.phase === "read") {
8991
const prior = requireJsonObject(request.prior, "committed writeback");
9092
const checkpoint = jsonObject(prior.vision_checkpoint);
91-
if (checkpoint?.decision !== "missing_required" || checkpoint.satisfied !== false) {
93+
const missingQualification = request.writeback_committed === true && request.quota_spend_committed === false &&
94+
typeof identity.replan_obligation_id === "string" &&
95+
jsonObject(request.semantic_replan_guard)?.selected_obligation_id === identity.replan_obligation_id &&
96+
isUnqualifiedBlockedReplanWriteback(prior, settlementIdentity({
97+
goal_id: requireNonEmptyString(identity.goal_id, "goal_id"),
98+
agent_id: requireNonEmptyString(identity.agent_id, "agent_id"),
99+
turn_instance_id: requireNonEmptyString(identity.turn_instance_id, "turn_instance_id"),
100+
replan_obligation_id: identity.replan_obligation_id,
101+
}));
102+
if ((checkpoint?.decision !== "missing_required" || checkpoint.satisfied !== false) && !missingQualification) {
92103
return rejected("checkpoint_context_not_missing");
93104
}
94105
const projected = snapshot(request);

‎loopx/control_plane/quota/refresh_recovery.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ export function refreshRecovery(
7373
receiptPresent: boolean,
7474
workspaceRequirement: string | undefined,
7575
laterAgentVision: boolean,
76+
missingBlockedReplanQualification = false,
7677
): JsonObject {
7778
const wantsVision = request.vision !== null || Boolean(request.unchanged_reason);
7879
const digest = wantsVision ? createHash("sha256").update(canonical({
@@ -129,6 +130,20 @@ export function refreshRecovery(
129130
(changesMutation && mutationDigest !== jsonObject(prior.refresh_recovery)?.mutation_digest)) {
130131
return result("reject", "committed_writeback_payload_conflict");
131132
}
133+
// Older callers could commit a blocked replan without qualifying its exact
134+
// admitted source. Re-run that owner through the existing additive checkpoint
135+
// path, preserving the original payload/receipt and executing no mutations.
136+
// The readback owner proves that this identity has no committed debit.
137+
if (missingBlockedReplanQualification && wantsVision) {
138+
const judgmentDigest = createHash("sha256").update(canonical({
139+
vision: request.vision, unchanged_reason: request.unchanged_reason, merge_patch: request.merge_patch,
140+
})).digest("hex");
141+
if (judgmentDigest === priorDigest) {
142+
if (laterAgentVision) return result("reject", "checkpoint_superseded_by_later_vision");
143+
if (changesMutation) return result("reject", "checkpoint_supplement_must_not_repeat_mutations");
144+
return result("supplement_checkpoint", "complete_missing_replan_qualification_on_original_turn");
145+
}
146+
}
132147
if (wantsVision && digest !== priorDigest) {
133148
if (checkpoint?.decision !== "missing_required" || checkpoint.satisfied !== false) {
134149
return result("reject", "committed_vision_decision_conflict");

‎loopx/control_plane/quota/settlement_phase.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,18 @@ export function isAcceptedBlockedReplanWriteback(
6060
});
6161
}
6262

63+
/** A legacy observation may need qualification, never acceptance by inference.
64+
* Callers separately prove its guard/writeback receipts and absence of debit. */
65+
export function isUnqualifiedBlockedReplanWriteback(value: unknown, identity: SettlementIdentity): boolean {
66+
const run = jsonObject(value);
67+
return identity.binding_kind === "autonomous_replan" && identity.replan_obligation_id !== null &&
68+
!!run && run.goal_id === identity.goal_id && run.agent_id === identity.agent_id &&
69+
run.turn_instance_id === identity.turn_instance_id && run.replan_obligation_id === identity.replan_obligation_id &&
70+
run.delivery_outcome === "outcome_gap" &&
71+
isTurnScopedSettlementOutcome(run.delivery_outcome, run.progress_observation, identity.replan_obligation_id) &&
72+
jsonObject(run.autonomous_replan_ack)?.recorded !== true;
73+
}
74+
6375
/** Classify persisted Run facts for history selection, without granting a
6476
* settlement, debit or execution right. Reuse the settlement owner's exact
6577
* semantic qualification; a caller-supplied "administrative" flag is no proof.

‎loopx/control_plane/quota/settlement_readback.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
import {
3535
isBoundedBlockedRetry,
3636
isAcceptedBlockedReplanWriteback,
37+
isUnqualifiedBlockedReplanWriteback,
3738
qualifySettlementHistory,
3839
isCommittedMonitorPollEffect,
3940
isAcceptedProgressWriteback,
@@ -1321,6 +1322,9 @@ function readQuotaSettlementFromRequest(
13211322
run.goal_id === identity.goal_id && run.agent_id === identity.agent_id &&
13221323
(jsonObject(run.agent_vision) !== null || jsonObject(run.vision_checkpoint)?.required === true)
13231324
),
1325+
writeback.failure === null && spendRun === null && spendEvent === null &&
1326+
semanticReplanGuard.selected_obligation_id === identity.replan_obligation_id &&
1327+
isUnqualifiedBlockedReplanWriteback(writebackRun, identity),
13241328
);
13251329
// A non-material poll need not appear as findWriteback's advancement run.
13261330
// Its exact committed observation still fences an incompatible unavailable

‎loopx/control_plane/work_items/replan_semantics.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts";
44
import { optionalNonEmptyString, requireJsonObject, requireNonEmptyString } from "../runtime_decode.ts";
55
import { visionAuthoringContract } from "../goals/vision_checkpoint.ts";
66
import { parseTodoTimestampMicros } from "../runtime_timestamp.ts";
7+
import { TODO_COMPLETION_RECOVERIES } from "../todos/completion_state.ts";
78
import {normalizeStrideDecision, normalizeStrideStrategy, STRIDE_DECISION_SCHEMA, strideControlContract, strideReviewMismatch} from "./stride_policy.ts";
89
import { projectProgressDelta } from "./progress_delta.ts";
910

@@ -141,7 +142,9 @@ function lifecycleSource(request: JsonObject): JsonObject {
141142
item.claimed_by === agent && item.last_actor_agent_id === agent &&
142143
completed !== null && completed <= admitted &&
143144
strings(item.successor_todo_ids).length === 0 && !item.superseded_by &&
144-
(item.no_followup !== true || (item.completion_recovery === "lifecycle_reentry_terminal_closeout" &&
145+
// Either canonical closeout path can finish after this review was
146+
// admitted. The exact trigger/identity checks below still own reentry.
147+
(item.no_followup !== true || (TODO_COMPLETION_RECOVERIES.some(recovery => recovery === item.completion_recovery) &&
145148
updated !== null && updated >= admissionSecond));
146149
}).sort((left, right) => {
147150
// Terminal reentry changes updated_at, never the original completed_at.
@@ -154,7 +157,7 @@ function lifecycleSource(request: JsonObject): JsonObject {
154157
item.no_followup === true && item.completion_continuation === "no_followup" &&
155158
typeof item.todo_id === "string" && !!item.todo_id &&
156159
typeof item.completion_turn_key === "string" && !!item.completion_turn_key &&
157-
item.completion_recovery === "lifecycle_reentry_terminal_closeout");
160+
TODO_COMPLETION_RECOVERIES.some(recovery => recovery === item.completion_recovery));
158161
return {items, settled};
159162
}
160163

‎loopx/control_plane/work_items/semantic_replan_writeback.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -374,7 +374,11 @@ def qualify_replan_writeback(
374374
context.get("replan_transition_candidates", [])
375375
if isinstance(candidate.get("obligation"), dict) and
376376
candidate["obligation"].get("obligation_id") == guard_semantic_replan_obligation_id), None)
377-
if guard_scoped and receipt_bound_replan_review is not None:
377+
# Every selected replan retains its exact receipt-bound source. A missing
378+
# reconstruction must not fall through as an unselected future obligation
379+
# and commit an unqualified writeback. Plain outcome reviews additionally
380+
# carry a scalar source; lifecycle/transition reviews need the same owner.
381+
if guard_scoped and guard_semantic_replan_obligation_id:
378382
obligation = project_replan_semantics({
379383
"operation": "receipt_bound_obligation", "guard_scoped": True,
380384
"selected_obligation_id": guard_semantic_replan_obligation_id,

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ teaches a reusable control-plane lesson.
103103
| `normal_no_user_todo_misflagged` | `action_required=false` and user open count is zero, but the agent reports a state projection fault. | quota payload and user todo summary. | Rule over-applied the "todo not projected" fallback. | Allow "无用户待办/无需通知"; update heartbeat/prompt smoke if needed. |
104104
| `todo_no_followup_global_terminal_overreach` | A completed scoped Todo records `done + no_followup`, but `refresh-state --repair-delta-kind no_followup` becomes `replan_noop` because unrelated advancement or long-running monitor Todos remain open. | rejected repair-delta claim, complete Agent Todo projection, scoped Todo metadata, and the latest local no-follow-up completion. | The repair-delta validator reused Goal-level terminal-frontier completeness to validate a Todo-local closure. | Keep Goal `terminal_no_followup` strict, but validate repair-delta no-follow-up against the latest scoped completed Todo in a complete parser projection; reject other-agent, continuation-required, malformed, or truncated evidence and keep the emitted Todo evidence bounded. Cover the real CLI path with an open monitor regression. |
105105
| `terminal_settlement_ordering_gap` | Final Todo completion succeeds, then the matching refresh or spend is rejected because the Goal is already terminal; different hosts may expose different closeout orders or retry the material effect. | Typed settlement plan and effect identity, Todo completion event, accountable refresh and spend receipts, Turn journal phase prefix, and host task-body projection. | Final `no_followup` was modeled like ordinary pre-settlement Todo completion even though it mutates Goal terminal state and activates the strict no-more-spend guard. | Keep Goal terminal guards strict. Treat ordinary successor completion as Todo lifecycle work, but make final `no_followup` a conditional post-spend terminal closeout under the same effect identity. Require matching writeback and spend receipts at the mutation boundary, prove the closeout event itself carries `no_followup`, and journal it so closeout-only retry cannot repeat writeback or spend. Cover CLI, visible Goal, Codex App, Turn driver, adapter order, failure short-circuit, and replay. |
106+
| `receipt_bound_lifecycle_source_gap` | A lifecycle review is admitted, then closing the original Todo through its settled Turn makes same-review reentry fail with an identity conflict; a blocked refresh may commit without a qualified replan ACK. | Exact original completion identity and closeout receipt, admitted review identity/time, canonical Todo readback, typed blocked observation and writeback/debit receipts. | Lifecycle reconstruction recognized only the completion-key reentry form; writeback could treat a missing selected source like an unrelated future obligation. | Reuse the canonical completion-recovery vocabulary for both legal paths and retain exact actor, identity and timestamp checks. Revalidate every selected replan through the shared owner before writeback. For an already committed unqualified blocked observation with no debit, permit only an exact additive checkpoint requalification; preserve the original run and all receipt history, reject payload changes or later superseding vision, and never infer acceptance or erase a committed debit. |
106107
| `host_settlement_source_projection_gap` | A visible Goal host packet requires `source=visible-goal` without an explicit Turn receipt, but the nested interaction contract tells the agent to spend as `heartbeat` and binds the currently selected Todo. The command can fail after Todo completion or misattribute interactive usage. | Host runtime profile, canonical host quota commands, interaction-contract CLI actions, selected Todo lifecycle order, and the failed spend preview. | The generic interaction projection hard-coded heartbeat accounting and selected-Todo binding instead of deriving the accounting route from the scheduler execution context. | Route spend actions from the typed runtime profile. Native Goal runtimes preserve `source=visible-goal` and host-level attribution; heartbeat automation keeps its Todo- and Turn-bound settlement plan. Cover the exact CLI actions for each native Goal profile and retain the heartbeat regression. |
107108
| `visible_goal_settlement_replan_attribution_gap` | An accountable visible-Goal delivery writeback moves the frontier into capability or control-plane repair; the following unbound spend is recorded against the new repair state with no Todo, Turn, effect, or delivery-workspace causality, and an exact retry cannot replay the committed effect. | Newest same-Agent accountable run, its complete nested settlement identity, top-level Todo/Turn fields, original heartbeat receipt, delivery-workspace causality, current quota frontier, spend classification, and retry run count. | The unbound visible-Goal path used the mutable current frontier as settlement authority and did not recover the completed delivery identity; commit classification also represented eligible, repair, and delivery semantics as overlapping booleans, allowing a later repair projection to overwrite attribution. | Allow unbound recovery only for visible-Goal spend from the newest fully typed same-Agent accountable run; require its top-level and nested identities to match the TypeScript-generated settlement payload and revalidate the original heartbeat receipt. Preserve Todo/Turn/effect/workspace causality, write the CLI receipt under the recovered Turn, and reduce commit semantics to one closed TypeScript disposition where proven delivery completion precedes later frontier repair. Cover missing/legacy identity, capability and control-plane replan, and exact one-run replay. |
108109
| `runtime_capability_user_gate_overreach` | A missing launcher/runtime capability such as `network` becomes `capability_gate.action=ask_owner`, `requires_user_action=true`, or a user notification even though no credential, protected access, or production authority is needed. | quota `capability_gate`, heartbeat `--available-capability` inputs, launcher/runtime observations, interaction contract user channel. | Capability availability and permission authority were collapsed into one owner-held class. | Keep runtime provisioning gaps in the agent repair lane, require truthful runtime observation before declaring availability, and reserve `ask_owner` for credentials or protected/production authority. Cover all-blocked and mixed-capability cases with capability-gate and monitor smokes. |

0 commit comments

Comments
 (0)