From 9eb51c208c19de0d7af23fb582a4a763ac9813bd Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:18:43 +0800 Subject: [PATCH] fix(quota): retain runnable admission timers on reentry Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../rfcs/loopx-overall-roadmap-v0.md | 10 +- .../rfcs/loopx-overall-roadmap-v0.zh-CN.md | 8 +- docs/quota-allocation.md | 15 +++ loopx/control_plane/quota/blocked_wait.ts | 27 ++++- .../quota/heartbeat_receipt_identity.ts | 8 +- loopx/control_plane/quota/settlement_cli.py | 7 ++ .../references/repair-patterns.md | 1 + tests/control_plane/test_heartbeat_receipt.py | 103 +++++++++++++++++- .../receipt_bound_wait.test.ts | 62 +++++++++++ 9 files changed, 234 insertions(+), 7 deletions(-) diff --git a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md index 45e08f5ad5..d7e9069b61 100644 --- a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md +++ b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md @@ -129,7 +129,7 @@ visibility limits, ordinary notices, current cadence floors and unchanged canonical waits/monitor clocks. Installed adoption and live host readback remain separate from this source repair. -**S3/S7 administrative closeout proposal.** [#7117](https://github.com/loopx-project/loopx/pull/7117) +**S3/S7 administrative closeout checkpoint.** [#7117](https://github.com/loopx-project/loopx/pull/7117) also separates an accepted blocked replan from the preceding real outcome checkpoint. File/SQLite CLI readback and long/compacted history regressions retain the previous milestone or genuine gap without creating a new outcome obligation @@ -150,6 +150,14 @@ packet, preserving all clauses and standalone reads. Full-repository qualification, comparable full caller cost, installed adoption and measured long-horizon benefit remain distinct and incomplete. +The follow-on timer repair retains readiness at positive CLI work admission. +File/SQLite reentry distinguishes an unchanged timer already due at admission +from a later-authored wait, including one already due when read. Both preserve +the original binding and exactly-once result settlement. The shared TypeScript +owner checks microseconds; missing legacy facts retain conservative recovery. +This source qualification adds no scheduler, historical receipt rewrite or +installed/live-host qualification. + **S5/S11 long-horizon progress design.** The [research measurement refinement](long-horizon-harness-benchmark-research-program-v0.md#evidence-based-episode-evaluation-proposed-refinement) and [product reading journey](../../product/use-cases/auto-research/long-horizon-progress-design.md) connect native outcomes to research episodes, evidence and actual adoption. diff --git a/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md b/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md index 21d2b82af6..e32a57836e 100644 --- a/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md +++ b/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md @@ -104,7 +104,7 @@ managed 与 attached 的工作对话都应能持续在 LoopX 中进行:沿用 | **S12 发布、开发体验与社区治理 · P0 卫生/P1** | 安装、源码验证、扩展注册、DCO/PR、测试层级、contributor route 与双语文档已存在 | 从干净机器/发布包验一条首次工作和一次升级/回滚;host/OS 支持以 release contract 为准。缩短合理改动的定位、测试和 review 成本;公开精确 head、可重复 fixture、兼容窗口、维护者路由和贡献归属,退休重复协议及过时证据 | | **S13 采用、生态与商业可持续性 · P1 发现/P2 试点** | 公开 adoption loop、showcase、license/governance、observer-first 产品合同已存在;付费 PMF 未证明 | 先收集真实独立首次使用/重复使用/退出原因,做可复现案例和有固定预算/验收/回滚的试点;沉淀 reusable adapter 与交付手册。核算模型/计算/存储/支持成本及维护负担;满足重复需求后再决策商业托管边界、支持等级和分发,不承诺 SLA 或擅改开源条款 | -**S3/S7 行政收尾提案。** [#7117](https://github.com/loopx-project/loopx/pull/7117) +**S3/S7 行政收尾检查点。** [#7117](https://github.com/loopx-project/loopx/pull/7117) 进一步区分已验收的阻塞重规划与前一个真实成果 checkpoint。File/SQLite CLI 回读和 长期/压缩历史回归保留原里程碑或真实缺口,避免行政收尾制造新的成果义务。源码 覆盖已包括通知/真门禁叠加、原截止恢复,以及各自持有 Vision 和时钟时的原生取消 @@ -117,6 +117,12 @@ managed 与 attached 的工作对话都应能持续在 LoopX 中进行:沿用 完全相同的 Explore 指引改为同一 CLI 包内引用,保留全部条款及独立读取。整仓 资格、可比完整调用成本、安装采用与长程实测收益仍是分别验证的未完成项。 +后续定时等待修复保留 CLI 正向工作准入时的就绪事实。File/SQLite 重入区分准入时 +已经到期且未改变的等待与后来设置的等待,后者即使在回读时已到期也保留原结果 +收尾义务。两条路径均保留原绑定及单次结果结算;共享 TypeScript owner 核对微秒, +旧回执缺失事实时保守进入恢复。这一源码资格不新增调度器、不改写历史回执,也不 +证明安装或真实 Host 采用。 + **S5/S11 长程进展设计。** [研究计量细化](long-horizon-harness-benchmark-research-program-v0.zh-CN.md#基于证据的研究片段评估拟议细化) 与[产品阅读旅程](../../product/use-cases/auto-research/long-horizon-progress-design.zh-CN.md) 把原生结果关联到研究片段、证据与实际采用。研究侧牵头方法、基线/窗口/成本口径及独立 diff --git a/docs/quota-allocation.md b/docs/quota-allocation.md index 2fd96419ec..47b5804644 100644 --- a/docs/quota-allocation.md +++ b/docs/quota-allocation.md @@ -307,6 +307,21 @@ become progress through recovery. Promoted authority's existing typed blocked closeout can use a Turn-owned retry for an open Todo without a resume condition, without inventing an external dependency or restarting the trial's budget. +Positive CLI work admission retains the selected Todo's `resume_when` and +`resume_ready` as scalar receipt facts. On same-Turn reentry, an unchanged +`resume_at` already satisfied at that admission can continue under the original +binding. The typed recovery owner checks the original receipt timestamp with +microsecond precision and current readiness. A newly authored or changed wait, +even one already due, still requires the original result's closeout. Missing or +malformed historical admission facts retain conservative recovery. No receipt, +wait, deadline or debit is rewritten; these facts grant no new work authority. + +CLI 正向工作准入将所选 Todo 的 `resume_when` 和 `resume_ready` 作为标量事实保留 +在回执中。同 Turn 重入时,若仍为准入时已经满足的同一个 `resume_at`,可沿原绑定 +继续;typed recovery owner 用微秒精度核对原回执时间及当前就绪状态。后来新设或 +改变的等待,即使已经到期,仍须结清原结果。历史准入事实缺失或畸形时保守进入恢复, +不改写回执、等待、期限或扣额,也不产生新的执行权限。 + A Todo-bound path replan can be qualified during execution even when the initial guard selected no replan obligation. Its exact durable writeback must carry a recorded `autonomous_replan_ack_v0` with an accepted semantic delta or diff --git a/loopx/control_plane/quota/blocked_wait.ts b/loopx/control_plane/quota/blocked_wait.ts index 0539546a1c..bef0c55fba 100644 --- a/loopx/control_plane/quota/blocked_wait.ts +++ b/loopx/control_plane/quota/blocked_wait.ts @@ -1,6 +1,7 @@ import {settlementIdentity, type JsonObject} from "../effect_program.ts"; import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts"; import { jsonObject, requireJsonObject } from "../runtime_decode.ts"; +import {parseTodoTimestampMicros} from "../runtime_timestamp.ts"; import {nativeChildReportAdmission} from "../capabilities/native_child_admission.ts"; import {heartbeatReceiptBinding, heartbeatReceiptDetails, heartbeatReceiptFactFromEvent} from "./heartbeat_receipt_identity.ts"; import { @@ -231,6 +232,29 @@ function admittedBusinessResult(request: JsonObject): boolean { .report_permission === "new_operation"; } +/** A timer that was already satisfied at the original positive admission has + * not removed executable work. Missing historical facts retain recovery; the + * deadline alone cannot prove when a wait was authored. */ +function runnableTimerAtAdmission(request: JsonObject, todo: JsonObject, resumeWhen: string): boolean { + const receipt = jsonObject(request.heartbeat_receipt); + const admitted = receipt && heartbeatReceiptDetails(receipt); + const condition = jsonObject(todo.resume_condition); + if (!receipt || !admitted || admitted.admission_resume_ready !== true || + todo.status !== "open" || todo.resume_ready !== true || + condition?.satisfied !== true || !resumeWhen.startsWith("resume_at:") || + typeof receipt.recorded_at !== "string") return false; + try { + const original = normalizeTodoResumeWhen({schema_version: TODO_RESUME_NORMALIZE_REQUEST_SCHEMA_VERSION, + resume_when: admitted.admission_resume_when}); + if (original !== resumeWhen) return false; + const due = parseTodoTimestampMicros(resumeWhen.slice("resume_at:".length), true); + const recorded = parseTodoTimestampMicros(receipt.recorded_at, true); + return due !== null && recorded !== null && due <= recorded; + } catch { + return false; + } +} + /** A pending dependency changes executable work, never the Turn's receipts. * settlement_progress is the existing settlement owner's exact-Turn readback, * transported by the live guard, not an author-declared progress observation. @@ -295,7 +319,8 @@ export function projectReceiptBoundWait(value: unknown): JsonObject { if (["open", "deferred"].includes(String(todo.status)) && todo.task_class === "advancement_task" && writebackRequired && request.replay_phase === "open" && - admittedBusinessResult(request) && externalWait) { + admittedBusinessResult(request) && externalWait && + !runnableTimerAtAdmission(request, todo, resumeWhen!)) { const reason = "The original admitted Turn still owes durable writeback. Its later external wait cannot discard that identity. Inspect existing effects and record only the validated original result, then settle once under the original identity. Keep the wait unchanged; do not repeat business work, select a successor, or invent a retry clock."; return boundRecovery(request, "durable_writeback", reason); } diff --git a/loopx/control_plane/quota/heartbeat_receipt_identity.ts b/loopx/control_plane/quota/heartbeat_receipt_identity.ts index d4e2441e1f..193a47b06e 100644 --- a/loopx/control_plane/quota/heartbeat_receipt_identity.ts +++ b/loopx/control_plane/quota/heartbeat_receipt_identity.ts @@ -218,8 +218,12 @@ export function heartbeatWorkRequalification( throw receiptIdentityConflict("work requalification cannot replace the original semantic replan guard"); } const qualified = {...oldDetails}; - for (const field of ["ok", "should_run", "must_attempt_work", "delivery_allowed", "quiet_noop_allowed"]) { - qualified[field] = currentDetails[field]!; + // The first positive work admission owns these facts. A repair-only + // receipt's earlier selection cannot describe the newly admitted work. + for (const field of ["ok", "should_run", "must_attempt_work", "delivery_allowed", "quiet_noop_allowed", + "admission_resume_when", "admission_resume_ready"]) { + if (currentDetails[field] !== undefined) qualified[field] = currentDetails[field]!; + else delete qualified[field]; } // Preserve workspace causality, semantic guards, pending selection and all // original identities. Work admission makes this same Todo owe closeout. diff --git a/loopx/control_plane/quota/settlement_cli.py b/loopx/control_plane/quota/settlement_cli.py index 2404ce74c8..a6ab19c5cf 100644 --- a/loopx/control_plane/quota/settlement_cli.py +++ b/loopx/control_plane/quota/settlement_cli.py @@ -395,6 +395,13 @@ def quota_rollout_details( "quiet_noop_allowed": bool(agent_channel.get("quiet_noop_allowed")), "closeout_required": closeout_required, } + if closeout_required and selected_todo and selected_todo.get("todo_id") == todo_id: + # Freeze canonical selection facts at positive admission. Reentry must + # not mistake this already-runnable timer for a newly authored wait. + # The TypeScript recovery owner validates the comparison; unknown + # readiness and an absent timer remain explicit null facts here. + details["admission_resume_when"] = selected_todo.get("resume_when") + details["admission_resume_ready"] = selected_todo.get("resume_ready") replan_packet = payload.get("replan_action_packet") receipt = payload.get("heartbeat_receipt") capability_guard = (replan_packet.get("capability_guard") if isinstance(replan_packet, Mapping) else None) or ( diff --git a/skills/loopx-self-repair/references/repair-patterns.md b/skills/loopx-self-repair/references/repair-patterns.md index 186ed398f4..1a3426d93c 100644 --- a/skills/loopx-self-repair/references/repair-patterns.md +++ b/skills/loopx-self-repair/references/repair-patterns.md @@ -59,6 +59,7 @@ teaches a reusable control-plane lesson. | `pending_capability_intent_projection_gap` | A durable post-writeback sidecar says `intent_recorded`, but quota remains `monitor_quiet_skip` or `terminal_no_followup`; no governed executor wakes, so no local artifact or exact approval Todo appears. | Exact Goal/Agent sidecar, intent schema and authority, consumption receipt, quota interaction contract, generated-artifact count, approval-Todo count, and external-effect count. | The producer journal was durable but no read model projected unconsumed capability intents into quota arbitration. Terminal or quiet lifecycle state therefore hid required capability work forever. | Add a provider-neutral TypeScript-validated pending-intent interaction slot, let the opted-in capability read only exact eligible sidecars, and give the pending action precedence over quiet/terminal routes. Consume it through an idempotent local executor that freezes checked artifacts and one digest-bound user gate; keep external delivery unauthorized and suppress consumed intents on replay. | | `approved_capability_delivery_successor_gap` | A governed report or other capability payload is frozen and its exact user gate is approved, but quota returns quiet terminal/monitor state and no provider action runs. A later caller may also improvise a destination or default sender because the approval receipt contains no executable route. | Frozen generation/consumption receipt, completed gate decision and scope, linked agent Todo, required decision scopes before/after approval, Goal Channel binding, selected Todo, provider effect and exact sender/destination readback. | The consumer created only a user gate. Approval recorded authority but had no explicitly linked, typed agent successor for Todo/quota selection, so the external effect existed only in chat memory or caller convention. | Before the gate, create one blocked agent successor bound to the frozen generation, exact decision scope, provider capability, and safe write scope; link the gate with `unblocks_todo_id`. Approval must atomically consume only that scope and resume the successor. The provider then resolves route and sender from the durable project binding, rejects caller overrides/default fallbacks, and records success only after native effect readback. | | `host_closeout_presentation_lookup_gap` | A prior heartbeat remains in closeout recovery after a successful blocked/deferred/completed Todo writeback; adding unrelated Todos changes recovery. | Receipt-bound Todo id, exact `todo list --todo-id` readback, compact quota visibility lanes, same-Turn retry and quota-spend count. | Recovery treated absence from a bounded presentation list as absence of a durable lifecycle transition. | Resolve the exact receipt-bound Todo through the existing provider-aware read path before evaluating closeout; preserve provider errors, identity binding and no-spend recovery. Cover crowded versus small inventories and real legacy/File/SQLite CLI writes; never raise display limits or fabricate settlement receipts. | +| `admission_timer_reentry_gap` | A positively admitted Todo with an unchanged, already-due timer immediately becomes writeback-only on same-Turn reentry before producing a result. | The original positive admission receipt, its retained resume/readiness facts and timestamp, current canonical Todo, exact binding, and File/SQLite replay. | Recovery classified every external wait as introduced after admission; today's due flag alone cannot establish when a wait was authored. | Freeze scalar resume/readiness facts at the first positive admission and compare them in the existing typed wait owner with microsecond timestamps. Continue only the unchanged runnable timer; later waits and missing/malformed legacy facts retain original closeout. Preserve identity, clocks, gates and one debit. Never clear a legitimate wait, rewrite receipts, fabricate progress or reset a retry clock to escape recovery. | | `blocked_turn_retry_history_gap` | An exact blocked Turn can close without spending, but its open Todo is immediately selected again or loses its wait when other runs crowd the status display; a peer hard lease prevents the blocked agent from updating the Todo. | Exact Goal/Agent/Todo/Turn receipt, peer lease, canonical Todo validator, persisted retry due time, full run index versus compact recent runs and cached index digest, next-Turn selection and spend count. | The settlement had no bounded retry authority, or quota selection reconstructed it from truncated or stale status projection and tried to mutate a peer-gated Todo. | Persist one bounded retry on the blocked Turn receipt, retain it in the full-index semantic projection, and fence cached scheduler status against the current Run index before projecting it for the same agent. Keep the canonical Todo and terminal validator intact; prove replay, no spend, crowded history, stale cache, and real File/SQLite hard-lease readback. | | `host_closeout_history_scan_amplification` | Quota entry approaches or exceeds its runtime timeout only on a long-lived Goal, especially when many prior Turns already have valid closeouts. | Turn and receipt counts, rollout-log and runs-index sizes, candidate count, per-stage elapsed time, declared runtime budget, and the same synthetic settled history before and after repair. | Preflight rebuilt append-order recency with repeated list searches, then each candidate reread, reparsed, and rescanned the complete event and run history. A larger timeout hid the multiplicative work without bounding it. | Parse each persisted source once, retain strict-versus-tolerant read semantics in the shared snapshot, build append-order and exact Turn/effect indexes, and reduce every candidate from that snapshot. Restore the ordinary runtime budget and cover a sufficiently large all-settled history that must traverse every candidate; never truncate history, skip identity validation, or raise the timeout as the repair. | | `turn_replay_recovery_semantic_gap` | A real Turn resumes from a safe Journal prefix, but `inspect-journal` presents `replay_legal=false` as if recovery were forbidden; scheduler-only or saved-Host-result recovery is especially misleading. | Journal integrity fields, replay decision, executor-adopted recovery decision, completed phase prefix, complete typed settlement identity, authoritative selected-Todo lineage, conditional Host Session Binding check, prepared-effect presence, and bounded recovery outcome. | Effect-free terminal replay and effectful executor recovery were collapsed into one user-facing decision even though the executor used separate status/phase rules; initial repairs also omitted either the settlement identity or its binding-to-Turn cross-check, allowing a drifted goal, agent, or canonical Todo to reach a later effect. | Keep replay legality and Journal consistency distinct. Make one typed recovery decision the source used by both executor and inspection; validate and cross-bind the canonical settlement goal, agent, Turn instance, binding, and effect id before authorization, including the selected Todo or adaptive primary Todo; project continue/resume phase/Host reinvocation/reason plus only participating checks, and persist a bounded planned-versus-actual audit. Cover identity and binding drift with zero-provider-call regressions. Preserve the existing prepared-effect readback owner and do not claim general exactly-once semantics. | diff --git a/tests/control_plane/test_heartbeat_receipt.py b/tests/control_plane/test_heartbeat_receipt.py index c2a1e5b5cc..a127f1002e 100644 --- a/tests/control_plane/test_heartbeat_receipt.py +++ b/tests/control_plane/test_heartbeat_receipt.py @@ -159,11 +159,13 @@ def test_concurrent_identityless_receipt_upgrades_append_one_correction( def test_concurrent_work_requalification_appends_one_same_binding_receipt(tmp_path: Path) -> None: proof = {"ok": True, "should_run": True, "must_attempt_work": True, "delivery_allowed": True, "quiet_noop_allowed": False, - "todo_id": TODO_ID, "selected_todo_id": TODO_ID, "settlement_effect_id": EFFECT_ID} + "todo_id": TODO_ID, "selected_todo_id": TODO_ID, "settlement_effect_id": EFFECT_ID, + "admission_resume_when": "resume_at:2000-01-01T00:00:00Z", "admission_resume_ready": True} original = build_rollout_event(goal_id=GOAL_ID, event_kind="quota_should_run", agent_id=AGENT_ID, todo_id=TODO_ID, run_id=TURN_ID, status="successor_replan_required", summary="delivery denied", - details={**proof, "delivery_allowed": False}) + details={**proof, "delivery_allowed": False, + "admission_resume_when": None, "admission_resume_ready": None}) log_path = rollout_event_log_path(tmp_path, GOAL_ID) append_rollout_event(log_path, original) @@ -179,7 +181,15 @@ def qualify(_: int) -> tuple[dict[str, object], bool]: events = load_rollout_events(log_path) assert len(events) == 2 and events[0] == original assert events[1]["details"]["settlement_effect_id"] == EFFECT_ID + assert events[1]["details"]["admission_resume_when"] == proof["admission_resume_when"] + assert events[1]["details"]["admission_resume_ready"] is True assert events[1]["causality"]["caused_by"] == original["event_id"] + replay, appended = requalify_bound_heartbeat_receipt(tmp_path, goal_id=GOAL_ID, agent_id=AGENT_ID, + turn_instance_id=TURN_ID, todo_id=TODO_ID, replan_obligation_id=None, + status="normal_run", details={**proof, "admission_resume_when": "resume_at:2099-01-01T00:00:00Z"}, + registry_path=None, goal_ref=None) + assert appended is False + assert replay == events[1] def test_legacy_todo_only_receipt_upgrades_derived_effect_identity( @@ -227,3 +237,92 @@ def test_effective_receipt_does_not_downgrade_after_identity_correction( assert effective is not None assert effective["event_id"] == corrected["event_id"] + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("preexisting", [True, False], ids=["already-due", "introduced-later"]) +def test_cli_reentry_distinguishes_admission_timer_from_later_wait( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, provider: str, preexisting: bool, +) -> None: + from tests.control_plane.canonical_authority_fixture import isolate_sqlite_runtime + from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted + from tests.control_plane.test_quota_settlement_cli import ( + AGENT_ID as actor, GOAL_ID as goal, TODO_ID as work, ALTERNATIVE_TODO_ID as successor, + _configure_selectable_alternative, _initialize_fixture_authority, _run_cli, + _run_generated_cli, _spend_run_count, _write_fixture, + ) + + if provider == "sqlite": + isolate_sqlite_runtime(tmp_path, monkeypatch) + project, runtime, registry = _write_fixture(tmp_path) + _configure_selectable_alternative(project) + state = project / f".codex/goals/{goal}/ACTIVE_GOAL_STATE.md" + resume = "resume_at:2000-01-01T00:00:00Z" + if preexisting: + state.write_text(state.read_text().replace( + f"todo_id={work} status=open", + f"todo_id={work} status=open resume_when={resume} successor_todo_ids={successor}", + )) + _initialize_fixture_authority(project, runtime, provider=provider) + turn = "admission-timer-reentry" + binding = ("--goal-id", goal, "--agent-id", actor, "--todo-id", work, + "--turn-instance-id", turn) + guard = ("quota", "should-run", "--codex-app", *binding) + rc, admitted = _run_cli(registry, runtime, *guard) + assert rc == 0, admitted + assert admitted["interaction_contract"]["agent_channel"]["delivery_allowed"] is True + identity = admitted["heartbeat_receipt"]["settlement_identity"] + if not preexisting: + # The new wait is already due too. Its authoring after admission, not + # the deadline alone, requires closing the original admitted result. + rc, updated = _run_cli(registry, runtime, "todo", "update", "--goal-id", goal, + "--agent-id", actor, "--todo-id", work, "--resume-when", resume, + "--successor-todo-id", successor, "--reason", "Return the verified original result.") + assert rc == 0, updated + + def rows(): + return read_canonical_todos_if_promoted(runtime_root=runtime, goal_id=goal)["todos"] + + before = rows() + rc, reentry = _run_cli(registry, runtime, *guard) + assert rc == 0, reentry + assert reentry["heartbeat_receipt"]["settlement_identity"] == identity + if preexisting: + assert reentry["interaction_contract"]["agent_channel"]["delivery_allowed"] is True + assert reentry["selected_todo"]["todo_id"] == work + else: + assert reentry["unsettled_host_turn_recovery"]["repair"] == "durable_writeback" + assert reentry["interaction_contract"]["agent_channel"]["delivery_allowed"] is False + receipt = find_heartbeat_receipt(runtime, goal_id=goal, agent_id=actor, turn_instance_id=turn) + assert receipt is not None + assert receipt["details"]["admission_resume_when"] == (resume if preexisting else None) + if preexisting: + assert receipt["details"]["admission_resume_ready"] is True + assert rows() == before + assert _spend_run_count(runtime) == 0 + + # Both routes still owe the same result, writeback and exactly one debit. + (project / "validated-result.txt").write_text("Synthetic result validated before closeout.\n") + plan = reentry["interaction_contract"]["cli_channel"]["settlement_plan"] + command = next(s["command_template"] for s in plan["ordered_steps"] if s["kind"] == "durable_writeback") + command = command.replace("", "admission_timer_validated").replace( + "", "single_surface").replace("", "outcome_progress") + command += (" --delivery-boundary in_flight_continuation --progress-result-class advanced" + f" --progress-work-item-id {work} --progress-evidence-id evidence:synthetic-result" + " --no-global-sync --suppress-external-sinks") + rc, written = _run_generated_cli(command, registry_path=registry, cwd=project) + assert rc == 0, written + spend = written["settlement_owed"]["command"] + for attempt in range(2): + rc, settled = _run_generated_cli(spend, registry_path=registry, cwd=project) + assert rc == 0, settled + assert settled["settlement_result"]["ok"] is True + if attempt: + assert settled["idempotent_replay"] is True + assert settled["appended"] is False + assert _spend_run_count(runtime) == 1 + rc, replay = _run_cli(registry, runtime, *guard) + assert rc == 0, replay + assert replay["effective_action"] == "heartbeat_settled_skip" + assert replay["heartbeat_receipt"]["settlement_identity"] == identity + assert rows() == before diff --git a/tests/control_plane_ts/receipt_bound_wait.test.ts b/tests/control_plane_ts/receipt_bound_wait.test.ts index 70559bd420..30571c292d 100644 --- a/tests/control_plane_ts/receipt_bound_wait.test.ts +++ b/tests/control_plane_ts/receipt_bound_wait.test.ts @@ -88,6 +88,68 @@ function externalWaitRequest(resume_when = "pr_merged:example/project#1") { state: "writeback_required", next_step: "durable_writeback"}}; } +function runnableAtAdmissionRequest() { + const input = externalWaitRequest("resume_at:2026-01-01T00:00:00Z"); + input.observed_at = "2026-01-01T00:10:00Z"; + input.todos[0]!.resume_ready = true; + Object.assign(input.todos[0]!.resume_condition, {satisfied: true}); + return {...input, heartbeat_receipt: {...input.heartbeat_receipt, + recorded_at: "2026-01-01T00:01:00Z", details: {...input.heartbeat_receipt.details, + admission_resume_when: input.todos[0]!.resume_when, admission_resume_ready: true}}}; +} + +test("an unchanged timer already satisfied at work admission permits same-Turn continuation", () => { + const input = runnableAtAdmissionRequest(), before = structuredClone(input); + assert.deepEqual(projectReceiptBoundWait(input), {status: "none"}); + assert.deepEqual(input, before); +}); + +for (const patch of [{admission_resume_when: null}, {admission_resume_when: undefined}, + {admission_resume_when: "invalid"}, {admission_resume_when: 1}, + {admission_resume_when: "resume_at:2025-12-31T23:59:00Z"}, + {admission_resume_ready: false}, {admission_resume_ready: "true"}, {admission_resume_ready: undefined}]) { + test(`missing or incompatible admission facts retain conservative recovery: ${JSON.stringify(patch)}`, () => { + const input = runnableAtAdmissionRequest(); + Object.assign(input.heartbeat_receipt.details, patch); + assert.equal((projectReceiptBoundWait(input).recovery as Record).repair, + "durable_writeback"); + }); +} + +for (const patch of [{status: "deferred"}, {resume_ready: false}, + {resume_condition: {kind: "resume_at", resume_when: "resume_at:2026-01-01T00:00:00Z", satisfied: false}}]) { + test(`a changed runnable state cannot reuse admission facts: ${JSON.stringify(patch)}`, () => { + const input = runnableAtAdmissionRequest(); + Object.assign(input.todos[0]!, patch); + assert.equal((projectReceiptBoundWait(input).recovery as Record).repair, + "durable_writeback"); + }); +} + +for (const recorded_at of [undefined, "invalid", "2026-01-01T00:00:00.123455Z", + "2025-12-31T23:59:59Z"]) { + test(`a timer not proven due at original admission retains recovery: ${recorded_at}`, () => { + const input = runnableAtAdmissionRequest(); + const due = "resume_at:2026-01-01T00:00:00.123456Z"; + input.todos[0]!.resume_when = due; + Object.assign(input.todos[0]!.resume_condition, {resume_when: due}); + input.heartbeat_receipt.details.admission_resume_when = due; + assert.equal((projectReceiptBoundWait({...input, heartbeat_receipt: { + ...input.heartbeat_receipt, recorded_at}}).recovery as Record).repair, + "durable_writeback"); + }); +} + +test("admission timer comparison preserves exact microseconds and equivalent offsets", () => { + const input = runnableAtAdmissionRequest(); + const due = "resume_at:2026-01-01T00:00:00.123456Z"; + input.todos[0]!.resume_when = due; + Object.assign(input.todos[0]!.resume_condition, {resume_when: due}); + input.heartbeat_receipt.details.admission_resume_when = due; + input.heartbeat_receipt.recorded_at = "2026-01-01T08:00:00.123456+08:00"; + assert.deepEqual(projectReceiptBoundWait(input), {status: "none"}); +}); + for (const resume_when of ["resume_at:2026-01-01T00:10:00Z", "resume_at:2026-01-01T02:00:00Z", "pr_merged:example/project#1", "capacity_available:git_push"]) { for (const status of ["open", "deferred"]) {