From 5860235b251cbd5887a2280f811ea0bb7de58a00 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Wed, 11 Nov 2026 20:18:27 +0800 Subject: [PATCH 01/31] fix(refresh): retain original settlement recovery through maintenance Signed-off-by: huangruiteng --- docs/quota-allocation.md | 28 ++++ .../project_lifecycle_refresh_state.py | 8 ++ loopx/cli_commands/refresh_recovery.py | 70 ++++++++++ loopx/control_plane/quota/settlement.py | 11 ++ .../quota/settlement_readback.ts | 41 +++++- .../references/autonomous-execution.md | 9 ++ .../test_effective_turn_replan.py | 15 +- .../test_refresh_settlement_recovery.py | 130 ++++++++++++++++++ .../quota_settlement_readback.test.ts | 80 ++++++++++- 9 files changed, 389 insertions(+), 3 deletions(-) create mode 100644 loopx/cli_commands/refresh_recovery.py create mode 100644 tests/control_plane/test_refresh_settlement_recovery.py diff --git a/docs/quota-allocation.md b/docs/quota-allocation.md index 20e25c1b01..6193c456e1 100644 --- a/docs/quota-allocation.md +++ b/docs/quota-allocation.md @@ -344,6 +344,34 @@ caller appends the refresh receipt and rereads the chain; it must not offer a spend command before that point. Older guards without a persisted spend source retain the existing heartbeat default. +A rejected `refresh-state`, or an Agent's state-only maintenance update, also +returns `settlement_recovery` when a persisted Turn still owes closeout. Its +identity and progress come from the same typed receipt reader as guard recovery. +The TS `pending_closeout` read selects the candidate, validates the original +binding and evaluates receipts under one admitted owner and immutable snapshot; +Python only adapts CLI input and renders the returned recovery. This read mode +cannot be combined with settlement admission or latest-Run inference. +`ok: true` on a state update describes that update only; it does not settle the +nested original Turn. Follow `settlement_recovery.next_cli_actions`: before a +verified writeback this reenters the original bound guard; afterwards it offers +only the remaining spend. A stale replan basis still requires reading the +returned `replan_transition.context_recovery` and correcting the evidence-linked +packet before retry. No recovery read creates a Turn or debits quota. Missing +or conflicting receipt authority is reported as `settlement_recovery_error`, +never inferred as successful settlement. Setup with no admitted Turn, other +Agents' Turns, and already settled work do not produce pending recovery. + +`refresh-state` 被拒绝,或 Agent 只更新升级/维护状态时,若已有 Turn 尚欠结算, +响应会返回 `settlement_recovery`,身份与进度沿用 guard 恢复的 typed receipt reader。 +TS `pending_closeout` 在同一准入 owner、同一不可变快照下完成候选选择、原绑定校验 +及收据判断;Python 只适配 CLI 参数和展示,不再分两次读取决定恢复对象。这个只读 +模式不能与结算准入或最新 Run 推断混用。 +状态更新的 `ok: true` 只表示该更新成功,不代表嵌套的原 Turn 已结清。执行其中的 +`next_cli_actions`:写回验证前回到原绑定 guard,验证后只补剩余扣账。重规划依据 +过期仍须读取 `replan_transition.context_recovery`,修正证据关联的 packet 后再试。 +恢复读取不新建 Turn、不扣额;收据不可读或身份冲突会显示 `settlement_recovery_error`, +不能视为已结算。没有已准入 Turn 的设置工作、其它 Agent 和已结清工作不产生待恢复项。 + ## Minimal Contract The compact status shape can start with a small object: diff --git a/loopx/cli_commands/project_lifecycle_refresh_state.py b/loopx/cli_commands/project_lifecycle_refresh_state.py index e535120fbd..5d35857339 100644 --- a/loopx/cli_commands/project_lifecycle_refresh_state.py +++ b/loopx/cli_commands/project_lifecycle_refresh_state.py @@ -82,6 +82,7 @@ ) from ..control_plane.quota.cli_projection import compact_refresh_state_cli_payload from .refresh_settlement import settle_requested_refresh +from .refresh_recovery import attach_refresh_settlement_recovery def register_refresh_state_command( @@ -668,6 +669,7 @@ def handle_refresh_state_command( "error": str(exc), **({"error_code": exc.code, **getattr(exc, "payload", {})} if isinstance(getattr(exc, "code", None), str) else {}), } + attach_refresh_settlement_recovery(payload, args, registry_path, goal_ref) if isinstance(exc, ReplanWritebackRejected): transition = project_replan_writeback_rejection( exc, @@ -686,6 +688,10 @@ def handle_refresh_state_command( "current_context_id": recovery["replan_context"]["context_id"], "instruction": recovery["instruction"], **reference, } + settlement_recovery = payload.get("settlement_recovery") + if isinstance(settlement_recovery, dict) and transition["host_action"] == "write_typed_semantic_delta": + transition["host_action"] = "read_current_replan_context" + transition["next_cli_actions"] = settlement_recovery["next_cli_actions"] payload["replan_transition"] = transition if transition["next_cli_actions"]: payload["error"] += " Required transition: " + "; ".join( @@ -884,6 +890,7 @@ def handle_refresh_state_command( ) if not material_refresh_ready: settle_requested_refresh(payload, args, registry_path, goal_ref) + attach_refresh_settlement_recovery(payload, args, registry_path, goal_ref) print_payload(compact_refresh_state_cli_payload(payload) if fmt == "json" else payload, fmt, render_state_refresh_markdown) return 0 if payload.get("ok") else 1 if payload.get("explore_result") is not None: @@ -974,5 +981,6 @@ def handle_refresh_state_command( ), ) settle_requested_refresh(payload, args, registry_path, goal_ref) + attach_refresh_settlement_recovery(payload, args, registry_path, goal_ref) print_payload(compact_refresh_state_cli_payload(payload) if fmt == "json" else payload, fmt, render_state_refresh_markdown) return 0 if payload.get("ok") else 1 diff --git a/loopx/cli_commands/refresh_recovery.py b/loopx/cli_commands/refresh_recovery.py new file mode 100644 index 0000000000..5d664836cf --- /dev/null +++ b/loopx/cli_commands/refresh_recovery.py @@ -0,0 +1,70 @@ +"""Keep a rejected write or maintenance refresh attached to its owed Turn. + +The shared TS closeout reader selects persisted debt; this adapter only exposes +its exact readback and CLI continuation. It never admits or settles a Turn. +""" +from __future__ import annotations + +import argparse +import shlex +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +from ..control_plane.quota.settlement import attach_settlement_progress, read_heartbeat_settlement +from ..control_plane.work_items.action_selection_contract import render_cli_command_prefix +from ..history import load_registry +from ..paths import resolve_runtime_root + + +def attach_refresh_settlement_recovery( + payload: dict[str, Any], args: argparse.Namespace, registry_path: Path, + goal_ref: Mapping[str, Any] | None, +) -> None: + """Expose existing debt without turning a state update into a write receipt.""" + if (args.dry_run or not args.agent_id or isinstance(payload.get("settlement_progress"), dict) + or "settlement_recovery" in payload or "settlement_recovery_error" in payload): + return + try: + runtime_root = resolve_runtime_root(load_registry(registry_path), args.runtime_root, + registry_path=registry_path) + # TS selects persisted debt and validates every caller-supplied binding + # against one immutable snapshot; Python supplies no latest-Run guess. + readback = read_heartbeat_settlement(runtime_root, goal_id=args.goal_id, + agent_id=args.agent_id, todo_id=args.todo_id, + replan_obligation_id=args.replan_obligation_id, turn_instance_id=args.turn_instance_id, + pending_closeout=True, include_record_bodies=False, + registry_path=registry_path, goal_ref=goal_ref) + if readback is None: + return + if readback.identity.failure is not None: + raise ValueError(readback.identity.failure.reason) + identity = readback.identity.value + if identity is None: + raise RuntimeError("original Turn settlement identity is unavailable") + recovery: dict[str, Any] = {"ok": True} + attach_settlement_progress(recovery, readback, registry_path=registry_path, + runtime_root=runtime_root, goal_ref=goal_ref) + recovery.pop("ok") + owed = recovery.get("settlement_owed") + prefix = render_cli_command_prefix(registry_path=str(registry_path), runtime_root=str(runtime_root)) + # Select the complete CLI consumer explicitly: an unprofiled guard can + # return legacy unbound refresh templates. Replan guards recover their + # obligation from the Turn receipt (that flag is spend-only on quota). + command = prefix + " --format json quota should-run " + shlex.join([ + "--complete-turn", "--runtime-profile", "generic_cli", + "--goal-id", identity.goal_id, "--agent-id", identity.agent_id, + "--turn-instance-id", identity.turn_instance_id, + *(["--todo-id", identity.todo_id] if identity.todo_id else []), + *(["--goal-instance-id", str(goal_ref["goal_instance_id"])] if goal_ref is not None else []), + *[word for capability in args.available_capabilities or [] for word in ("--available-capability", capability)], + ]) + recovery.update(required=True, settlement_identity=identity.as_dict(), + next_cli_actions=[owed["command"] if isinstance(owed, dict) else command], + instruction="This response does not settle the original Turn. Follow its recovery with the same identity; " + "state-only updates and installation maintenance cannot replace accountable writeback.") + payload["settlement_recovery"] = recovery + except (OSError, RuntimeError, ValueError) as error: + # Preserve the original write result, including an already committed + # state update. An unavailable read is not evidence of no pending debt. + payload["settlement_recovery_error"] = str(error) diff --git a/loopx/control_plane/quota/settlement.py b/loopx/control_plane/quota/settlement.py index 4ea55d529d..cbf8a4d27a 100644 --- a/loopx/control_plane/quota/settlement.py +++ b/loopx/control_plane/quota/settlement.py @@ -226,6 +226,15 @@ def attach_settlement_progress( def render_settlement_progress_markdown(payload: dict[str, Any]) -> list[str]: + recovery = payload.get("settlement_recovery") + if isinstance(recovery, dict): + identity = recovery["settlement_identity"] + lines = [f"- Pending settlement: `{identity['turn_instance_id']}`", recovery["instruction"]] + for command in recovery["next_cli_actions"]: + lines.extend(["", "```sh", command, "```"]) + return lines + if payload.get("settlement_recovery_error"): + return [f"- Settlement recovery could not be read: {payload['settlement_recovery_error']}"] progress = payload.get("settlement_progress") if not isinstance(progress, dict): return [] @@ -443,6 +452,7 @@ def read_heartbeat_settlement( infer_turn_instance_id: bool = False, allow_unbound_binding: bool = False, resolve_original_binding: bool = False, + pending_closeout: bool = False, refresh_retry: dict[str, Any] | None = None, registry_path: Path | None = None, goal_ref: Mapping[str, Any] | None = None, @@ -469,6 +479,7 @@ def read_heartbeat_settlement( else {} ), **({"resolve_original_binding": True} if resolve_original_binding else {}), + **({"pending_closeout": True} if pending_closeout else {}), **({"refresh_retry": refresh_retry} if refresh_retry is not None else {}), **({"borrow_source_admission": True} if borrow_source_admission else {}), **( diff --git a/loopx/control_plane/quota/settlement_readback.ts b/loopx/control_plane/quota/settlement_readback.ts index d77a381bc3..87c8b617ce 100644 --- a/loopx/control_plane/quota/settlement_readback.ts +++ b/loopx/control_plane/quota/settlement_readback.ts @@ -108,6 +108,7 @@ interface ReadbackRequest { infer_turn_instance_id: boolean; allow_unbound_binding: boolean; resolve_original_binding: boolean; + pending_closeout: boolean; borrow_source_admission: boolean; include_record_bodies: boolean; refresh_retry: RefreshRetryRequest | null; @@ -227,6 +228,13 @@ function decodeRequest( if (request.resolve_original_binding === true && request.infer_turn_instance_id) { throw new EffectRuntimeRequestError("original binding requires an explicit Turn identity"); } + if (request.pending_closeout !== undefined && typeof request.pending_closeout !== "boolean") { + throw new EffectRuntimeRequestError("pending_closeout must be a boolean"); + } + if (request.pending_closeout === true && (request.infer_turn_instance_id || request.allow_unbound_binding + || request.refresh_retry != null || request.heartbeat_reentry_guard != null || request.spend_precheck === true)) { + throw new EffectRuntimeRequestError("pending closeout is a read-only receipt recovery, not inference or settlement admission"); + } if ( request.borrow_source_admission !== undefined && typeof request.borrow_source_admission !== "boolean" @@ -281,6 +289,7 @@ function decodeRequest( infer_turn_instance_id: request.infer_turn_instance_id, allow_unbound_binding: request.allow_unbound_binding, resolve_original_binding: request.resolve_original_binding === true, + pending_closeout: request.pending_closeout === true, borrow_source_admission: request.borrow_source_admission === true, include_record_bodies: request.include_record_bodies !== false, refresh_retry: decodeRefreshRetry(request.refresh_retry), @@ -1100,7 +1109,7 @@ function owedPriorTurnFromSnapshot( request: ReadbackRequest, snapshot: QuotaSettlementReadbackSnapshot, agentId: string, - turnInstanceId: string, + turnInstanceId: string | null, ): OwedPriorTurn | null { const receipts = heartbeatReceiptsFromEvents(snapshot.events, request.goal_id, agentId) .filter((event) => quotaOwnerOwnsProjection(request.owner, event.goal_ref)); @@ -1132,6 +1141,7 @@ function owedPriorTurnFromSnapshot( infer_turn_instance_id: false, allow_unbound_binding: false, resolve_original_binding: false, + pending_closeout: false, refresh_retry: null, heartbeat_reentry_guard: null, }, @@ -1146,6 +1156,34 @@ function owedPriorTurnFromSnapshot( return null; } +/** Resolve pending recovery without another provider read or a latest-Run heuristic. */ +function readPendingCloseoutFromRequest( + request: ReadbackRequest, + snapshot: QuotaSettlementReadbackSnapshot, +): JsonObject { + const explicitAgentId = normalizeAgentId(request.agent_id); + if (explicitAgentId === null) { + throw new EffectRuntimeRequestError("pending closeout requires an explicit Agent"); + } + const notFound = {schema_version: QUOTA_SETTLEMENT_READBACK_RESULT_SCHEMA, found: false}; + const turn = request.turn_instance_id ?? owedPriorTurnFromSnapshot( + request, snapshot, explicitAgentId, null, + )?.prior_turn_instance_id; + if (!turn) return notFound; + // Selection, original binding validation and receipt progress share this + // immutable snapshot and admitted owner. Maintenance Run classifications + // cannot conceal the guard's debt or select another Agent's work. + const result = readQuotaSettlementFromRequest({...request, turn_instance_id: turn, + pending_closeout: false, resolve_original_binding: true}, snapshot, false); + const identity = jsonObject(jsonObject(jsonObject(result.identity)?.result)?.value); + if (jsonObject(jsonObject(result.settlement)?.payload)?.ok === true || + (identity?.todo_id && committedMonitorPollFromSnapshot(snapshot, { + goal_id: request.goal_id, agent_id: explicitAgentId, turn_instance_id: turn, + todo_id: String(identity.todo_id), + }, request.owner) !== null)) return notFound; + return result; +} + function readQuotaSettlementFromRequest( request: ReadbackRequest, snapshot: QuotaSettlementReadbackSnapshot, @@ -1161,6 +1199,7 @@ function readQuotaSettlementFromRequest( ); } const explicitAgentId = normalizeAgentId(request.agent_id); + if (request.pending_closeout) return readPendingCloseoutFromRequest(request, snapshot); // Only latest-Turn inference needs the full run history. Explicit readback // must use the existing Turn indexes before applying the Goal owner fence; // otherwise a batch over N Turns repeatedly scans all N receipts. diff --git a/skills/loopx-project/references/autonomous-execution.md b/skills/loopx-project/references/autonomous-execution.md index 06d9079736..44612dac19 100644 --- a/skills/loopx-project/references/autonomous-execution.md +++ b/skills/loopx-project/references/autonomous-execution.md @@ -174,6 +174,15 @@ returns. Do not reconstruct static refresh/spend commands from this skill. Accountable progress precedes its spend; uncertain effects require readback and recovery before retry. State-only refresh cannot replace accountable writeback. +After a rejected refresh or installation maintenance, inspect +`settlement_recovery` even when the state update reports `ok: true`. Execute its +`next_cli_actions` with the original Agent/Todo or replan/Turn identity. If the +rejection also returns `replan_transition.context_recovery`, read that current +basis and revise the evidence-linked packet before retrying its writeback. Do +not repeat the upgrade or treat a subsequent `surface_only` success as closing +the admitted work. A `settlement_recovery_error` leaves settlement unknown; +preserve the original write result and resolve the reported read failure. + The absence of an adapter run or an unchanged artifact does not waive settlement. Only setup metadata work with no admitted delivery may use a state-only refresh when needed. After admitted delivery, an optional state-only refresh follows its diff --git a/tests/control_plane/test_effective_turn_replan.py b/tests/control_plane/test_effective_turn_replan.py index abbbd002b9..f1446f733a 100644 --- a/tests/control_plane/test_effective_turn_replan.py +++ b/tests/control_plane/test_effective_turn_replan.py @@ -479,11 +479,24 @@ def call(*args: str, expected_code: int = 0) -> dict: assert transition["reason_code"] == "stride_review_basis_mismatch" assert transition["host_action"] == "read_current_replan_context" assert transition["next_cli_actions"] == [] # No unbound repeat of the rejected write. + owed_recovery = foreign["settlement_recovery"] + assert owed_recovery["settlement_identity"] == reread["heartbeat_receipt"]["settlement_identity"] + from tests.control_plane.test_quota_settlement_cli import _run_generated_cli + recovered_code, recovered_turn = _run_generated_cli(owed_recovery["next_cli_actions"][0], + registry_path=registry, cwd=project) + assert recovered_code == 0, json.dumps(recovered_turn, indent=2) + assert recovered_turn["heartbeat_receipt"]["settlement_identity"] == owed_recovery["settlement_identity"] + assert _heartbeat_receipt_count(runtime, "stride-due-review") == receipt_count + assert _spend_run_count(runtime) == spend_count reference = transition["context_recovery"] # A rejected write must not eagerly import historical acceptance/evidence # into the model. Its exact write-time basis remains independently readable. if os.name == "posix": - assert len(json.dumps(foreign, ensure_ascii=False).encode()) < 4096 + # Preserve the old diagnostic budget; the additive original-Turn + # recovery has its own bounded allowance, not historical context. + assert len(json.dumps({key: value for key, value in foreign.items() if key != "settlement_recovery"}, ensure_ascii=False).encode()) < 4096 + assert len(json.dumps(owed_recovery, ensure_ascii=False).encode()) < 2048 + assert len(json.dumps(foreign, ensure_ascii=False).encode()) < 6144 from loopx.control_plane.turn_driver.decision_capture import read_context_capture_file saved, _, recovery = read_context_capture_file(reference["path"], reference["sha256"], private=True) assert saved.stat().st_mode & 0o077 == 0 diff --git a/tests/control_plane/test_refresh_settlement_recovery.py b/tests/control_plane/test_refresh_settlement_recovery.py new file mode 100644 index 0000000000..6abdad73bc --- /dev/null +++ b/tests/control_plane/test_refresh_settlement_recovery.py @@ -0,0 +1,130 @@ +"""Maintenance and rejected writes cannot conceal an admitted Turn's debt.""" +from __future__ import annotations + +import shlex +import json +import subprocess +from contextlib import chdir, redirect_stdout +from io import StringIO +from types import SimpleNamespace + +import pytest + +from canonical_authority_fixture import isolate_sqlite_runtime +from test_quota_settlement_cli import ( + AGENT_ID, GOAL_ID, TODO_ID, TURN_ID, ALTERNATIVE_TODO_ID, + _heartbeat_receipt_count, _initialize_fixture_authority, _run_cli, + _run_generated_cli, _spend_run_count, _write_fixture, +) + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("profile,spend_source", [("codex_app_heartbeat", "heartbeat"), ("codex_app_ssh_goal", "visible-goal")]) +def test_rejected_write_and_upgrade_state_recover_original_turn_once(tmp_path, monkeypatch, provider, profile, spend_source): + isolate_sqlite_runtime(tmp_path, monkeypatch) + project, runtime, registry = _write_fixture(tmp_path) + _initialize_fixture_authority(project, runtime, provider=provider) + binding = ["--goal-id", GOAL_ID, "--agent-id", AGENT_ID] + refresh = ["refresh-state", *binding, "--no-global-sync", "--suppress-external-sinks"] + maintenance = [*refresh, "--classification", "local_upgrade_status_updated", "--delivery-outcome", "surface_only"] + rc, setup = _run_cli(registry, runtime, *maintenance, cwd=project) + assert rc == 0 and setup["ok"] and "settlement_recovery" not in setup, setup + rc, guard = _run_cli(registry, runtime, "quota", "should-run", "--runtime-profile", profile, *binding, + "--todo-id", TODO_ID, "--turn-instance-id", TURN_ID, cwd=project) + assert rc == 0 and guard["normal_delivery_allowed"], guard + identity = guard["heartbeat_receipt"]["settlement_identity"] + receipts = _heartbeat_receipt_count(runtime, TURN_ID) + + # The original incident omitted --turn-instance-id, then wrote surface_only. + rc, refused = _run_cli(registry, runtime, *refresh, "--todo-id", TODO_ID, + "--classification", "verified_delivery", "--delivery-outcome", "outcome_progress", cwd=project) + assert rc == 1 and not refused["appended"], refused + assert "turn_instance_id" in refused["error"] + recovery = refused["settlement_recovery"] + assert recovery["required"] and recovery["settlement_identity"] == identity + assert recovery["settlement_progress"]["next_step"] == "durable_writeback" + assert "settlement_owed" not in recovery + rc, surface = _run_cli(registry, runtime, *maintenance, cwd=project) + assert rc == 0 and surface["appended"], surface + assert surface["settlement_recovery"] == recovery + assert "settlement_identity" not in surface # This update is not a Turn writeback. + assert _spend_run_count(runtime) == 0 and _heartbeat_receipt_count(runtime, TURN_ID) == receipts + from loopx.state_refresh import render_state_refresh_markdown + assert TURN_ID in render_state_refresh_markdown(surface) + assert recovery["next_cli_actions"][0] in render_state_refresh_markdown(surface) + + # A conflicting selection is never silently repaired to a different work item. + rc, foreign = _run_cli(registry, runtime, *refresh, "--todo-id", ALTERNATIVE_TODO_ID, + "--turn-instance-id", TURN_ID, "--delivery-outcome", "outcome_progress", cwd=project) + assert rc == 1 and "settlement_recovery" not in foreign, foreign + assert "binding" in foreign["settlement_recovery_error"] + rc, other_agent = _run_cli(registry, runtime, "refresh-state", "--goal-id", GOAL_ID, + "--agent-id", "another-agent", "--classification", "other_lane_status", "--delivery-outcome", "surface_only", + "--no-global-sync", "--suppress-external-sinks", cwd=project) + assert "settlement_recovery" not in other_agent + + # Execute the emitted recovery, not a hand-constructed replacement identity. + rc, recovered = _run_generated_cli(recovery["next_cli_actions"][0], registry_path=registry, cwd=project) + assert rc == 0 and recovered["normal_delivery_allowed"], recovered + assert recovered["heartbeat_receipt"]["settlement_identity"] == identity + assert _heartbeat_receipt_count(runtime, TURN_ID) == receipts + command = next(action for action in recovered["interaction_contract"]["cli_channel"]["next_cli_actions"] + if "refresh-state" in shlex.split(action)) + command = command.replace("", "verified_delivery").replace("", "implementation").replace("", "outcome_progress") + command += " --format json --no-global-sync --suppress-external-sinks" + assert "--turn-instance-id" in shlex.split(command), command + assert "<" not in shlex.join(shlex.split(command)), command + # Interrupt the generated combined operation between writeback and debit. + # Maintenance must recover this partial commit without rewriting progress. + from loopx.cli import main + from loopx.cli_commands import refresh_settlement + def unavailable_child(argv, **kwargs): + assert "spend-slot" in argv and TURN_ID in argv + raise OSError("fixture interrupt before quota debit") + stdout = StringIO() + with monkeypatch.context() as fault, chdir(project), redirect_stdout(stdout): + fault.setattr(refresh_settlement, "subprocess", SimpleNamespace( + run=unavailable_child, TimeoutExpired=subprocess.TimeoutExpired)) + rc = main(shlex.split(command)[1:]) + written = json.loads(stdout.getvalue()) + assert rc == 1 and written["settlement_identity"] == identity, written + assert written["settlement_progress"]["next_step"] == "quota_spend" + + # Maintenance after a committed write offers only the owed debit, no replan. + rc, after_write = _run_cli(registry, runtime, *maintenance, cwd=project) + assert rc == 0, after_write + owed = after_write["settlement_recovery"]["settlement_owed"] + assert owed["effect_id"] == identity["effect_id"] + assert after_write["settlement_recovery"]["settlement_progress"]["quota_spend_source"] == spend_source + spend = after_write["settlement_recovery"]["next_cli_actions"][0] + assert spend == owed["command"] and "spend-slot" in spend + for _ in range(2): + rc, settled = _run_generated_cli(spend, registry_path=registry, cwd=project) + assert rc == 0 and settled["settlement_result"]["ok"], settled + assert {receipt["step_kind"] for receipt in settled["settlement_result"]["receipts"]} >= {"durable_writeback", "quota_spend"} + assert _spend_run_count(runtime) == 1 + rc, after_settlement = _run_cli(registry, runtime, *maintenance, cwd=project) + assert rc == 0 and "settlement_recovery" not in after_settlement, after_settlement + + +def test_unavailable_recovery_read_preserves_committed_state_result(tmp_path, monkeypatch): + isolate_sqlite_runtime(tmp_path, monkeypatch) + project, runtime, registry = _write_fixture(tmp_path) + _initialize_fixture_authority(project, runtime) + from loopx.cli import main + from loopx.cli_commands import refresh_recovery + def unavailable_read(*args, **kwargs): + raise RuntimeError("fixture closeout reader unavailable") + monkeypatch.setattr(refresh_recovery, "read_heartbeat_settlement", unavailable_read) + stdout = StringIO() + with chdir(project), redirect_stdout(stdout): + rc = main(["--registry", str(registry), "--runtime-root", str(runtime), "--format", "json", + "refresh-state", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, + "--classification", "local_upgrade_status_updated", "--delivery-outcome", "surface_only", + "--no-global-sync", "--suppress-external-sinks"]) + result = json.loads(stdout.getvalue()) + assert rc == 0 and result["appended"], result + assert result["settlement_recovery_error"] == "fixture closeout reader unavailable" + assert "settlement_recovery" not in result and _spend_run_count(runtime) == 0 + from pathlib import Path + assert json.loads(Path(result["json_path"]).read_text())["classification"] == "local_upgrade_status_updated" diff --git a/tests/control_plane_ts/quota_settlement_readback.test.ts b/tests/control_plane_ts/quota_settlement_readback.test.ts index bcea7eb4c7..32e74d95ec 100644 --- a/tests/control_plane_ts/quota_settlement_readback.test.ts +++ b/tests/control_plane_ts/quota_settlement_readback.test.ts @@ -12,7 +12,7 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import test from "node:test"; -import { settlementIdentity } from "../../loopx/control_plane/effect_program.ts"; +import { settlementIdentity, settlementIdentityPayload } from "../../loopx/control_plane/effect_program.ts"; import { createEffectRuntimeHandlers, dispatchEffectRuntimeMethod, @@ -28,6 +28,7 @@ import { QUOTA_SETTLEMENT_READBACK_REQUEST_SCHEMA, readQuotaSettlement, readQuotaSettlementSnapshot, + readQuotaSettlementFromSnapshot, } from "../../loopx/control_plane/quota/settlement_readback.ts"; import { PRIOR_HOST_TURN_CLOSEOUT_PREFLIGHT_REQUEST_SCHEMA, @@ -462,6 +463,83 @@ function request(runtimeRoot: string, overrides: Record = {}) { }; } +test("pending closeout survives maintenance and reads one original receipt snapshot", async t => { + for (const entry of [ + {state: "writeback_required"}, + {writeback: true, state: "spend_required"}, + {writeback: true, spend: true, remove: "quota_spend", state: "spend_receipt_required"}, + {writeback: true, spend: true, state: null}, + {monitor: true, state: null}, + {guard: false, state: null}, + {legacy: true, state: null}, + ]) await t.test(entry.state ?? "no debt", async () => { + const root = await fixture(entry); + try { + const log = join(root, "goals", goalId, "rollout-event-log.jsonl"); + const index = join(root, "goals", goalId, "runs", "index.jsonl"); + const events = (await readFile(log, "utf8")).trim().split("\n").filter(Boolean).map(line => JSON.parse(line)); + for (const event of events) if (event.event_kind === "quota_should_run") event.details.closeout_required = entry.legacy !== true; + await writeFile(log, events.filter(event => event.event_kind !== entry.remove).map(event => JSON.stringify(event)).join("\n") + "\n"); + await appendFile(index, JSON.stringify({goal_id: goalId, agent_id: agentId, + classification: "local_upgrade_status_updated", delivery_outcome: "surface_only"}) + "\n"); + const input = request(root, {pending_closeout: true, turn_instance_id: null, todo_id: null}); + const snapshot = await readQuotaSettlementSnapshot(root, goalId); + const result = readQuotaSettlementFromSnapshot(input, snapshot); + assert.equal(result.found, entry.state !== null); + if (entry.state !== null) { + assert.deepEqual((result.identity as any).result.value, settlementIdentityPayload(identity)); + assert.equal((result.progress as any).state, entry.state); + const mismatch = readQuotaSettlementFromSnapshot({...input, todo_id: "todo_foreign"}, snapshot); + assert.equal((mismatch.identity as any).payload.ok, false); + assert.match((mismatch.identity as any).result.failure.reason, /binding differs/); + } + assert.equal(readQuotaSettlementFromSnapshot({...input, agent_id: "another-agent"}, snapshot).found, false); + assert.deepEqual(await readQuotaSettlement(input), result); + // Candidate selection and exact readback use the already acquired snapshot. + await writeFile(log, "corrupt live history\n"); + assert.deepEqual(readQuotaSettlementFromSnapshot(input, snapshot), result); + await assert.rejects(readQuotaSettlement(input), /malformed/); + } finally {await rm(root, {recursive: true, force: true});} + }); +}); + +test("pending closeout keeps replan identity and rejects query/admission mixing", async () => { + const root = await fixture(); + try { + const log = join(root, "goals", goalId, "rollout-event-log.jsonl"); + const events = (await readFile(log, "utf8")).trim().split("\n").map(line => JSON.parse(line)); + const obligation = "replan-0000000000000001"; + const bound = settlementIdentity({goal_id: goalId, agent_id: agentId, todo_id: null, + replan_obligation_id: obligation, turn_instance_id: turnId}); + events[0].details = {closeout_required: true, replan_obligation_id: obligation, + semantic_replan_obligation_id: obligation, settlement_effect_id: bound.effect_id}; + await writeFile(log, events.map(event => JSON.stringify(event)).join("\n") + "\n"); + const input = request(root, {pending_closeout: true, turn_instance_id: null, todo_id: null}); + assert.deepEqual(((await readQuotaSettlement(input)).identity as any).result.value, settlementIdentityPayload(bound)); + for (const patch of [{infer_turn_instance_id: true}, {allow_unbound_binding: true}, + {refresh_retry: {}}, {heartbeat_reentry_guard: {}}, {spend_precheck: true}]) { + await assert.rejects(readQuotaSettlement({...input, ...patch}), /read-only receipt recovery/); + } + await assert.rejects(readQuotaSettlement({...input, pending_closeout: "yes"}), /must be a boolean/); + await assert.rejects(readQuotaSettlement({...input, agent_id: null}), /explicit Agent/); + } finally {await rm(root, {recursive: true, force: true});} +}); + +test("pending closeout cannot adopt another Goal instance's receipts", async () => { + const root = await fixture({goalRef: {goal_id: goalId, goal_instance_id: instanceA}}); + try { + const log = join(root, "goals", goalId, "rollout-event-log.jsonl"); + const event = JSON.parse((await readFile(log, "utf8")).trim()); + event.details.closeout_required = true; + await writeFile(log, JSON.stringify(event) + "\n"); + await withSourceAdmission(root, instanceB, instanceB, async binding => { + const result = await readQuotaSettlement(request(root, {...binding, pending_closeout: true, + turn_instance_id: null, todo_id: null})); + assert.equal(result.found, false); + }); + } finally {await rm(root, {recursive: true, force: true});} +}); + function sourceGuardPath(registryPath: string): string { const digest = createHash("sha256").update(goalId, "utf8").digest("hex"); return join( From 6a42539a807138ecf2de89a82abefdcf087166fb Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Wed, 11 Nov 2026 22:08:33 +0800 Subject: [PATCH 02/31] fix(checkpoint): classify occupied destinations Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../runtime/checkpoint_artifacts.ts | 25 ++++++++++++++++--- .../test_checkpoint_artifact_transport.py | 7 ++++++ .../checkpoint_artifacts.test.ts | 10 +++++--- 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/loopx/control_plane/runtime/checkpoint_artifacts.ts b/loopx/control_plane/runtime/checkpoint_artifacts.ts index 66a6d02067..2712159c7b 100644 --- a/loopx/control_plane/runtime/checkpoint_artifacts.ts +++ b/loopx/control_plane/runtime/checkpoint_artifacts.ts @@ -134,8 +134,25 @@ function unchanged(a: unknown, b: unknown, message = "checkpoint source moved; r } async function absentDestination(path: string): Promise { await physical(dirname(path)); - try {await lstat(path); throw new Error("checkpoint destination already exists");} - catch (error) {if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;} + try {await lstat(path);} + catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return; + throw error; + } + throw new EffectRuntimeConflictError("checkpoint destination already exists"); +} +async function publishCheckpointDirectory(staging: string, destination: string): Promise { + try {await publishNewDirectory(staging, destination);} + catch (error) { + const code = (error as NodeJS.ErrnoException).code; + const permissionFailure = code === "EACCES" || code === "EPERM"; + // A directory rename can report a permission error for an occupied target. + // Confirm the entry exists before changing the meaning of that error. + const occupied = code === "EEXIST" || code === "ENOTEMPTY" || + (permissionFailure && await lstat(destination).then(() => true, () => false)); + if (occupied) throw new EffectRuntimeConflictError("checkpoint destination already exists"); + throw error; + } } async function sqliteFile(path: string): Promise { const handle = await open(path, "r"); @@ -427,7 +444,7 @@ export async function checkpointArtifacts(value: unknown): Promise { await copyComponent(root, join(staging, "unit"), false); await verify(join(staging, "unit"), request.expected_sha256); withinDeadline(); - await publishNewDirectory(join(staging, "unit"), destination); + await publishCheckpointDirectory(join(staging, "unit"), destination); return {...verification, materialized: true, activation_performed: false}; } const sources = components(request.components); @@ -462,7 +479,7 @@ export async function checkpointArtifacts(value: unknown): Promise { await verify(staging, manifest.sha256); unchanged(before, await witness(sources)); withinDeadline(); - await publishNewDirectory(staging, destination); + await publishCheckpointDirectory(staging, destination); return {...await verify(destination, manifest.sha256), source_stability_verified: true}; } finally {await rm(staging, {recursive: true, force: true});} } diff --git a/tests/control_plane/test_checkpoint_artifact_transport.py b/tests/control_plane/test_checkpoint_artifact_transport.py index 0de71f48e3..295381e207 100644 --- a/tests/control_plane/test_checkpoint_artifact_transport.py +++ b/tests/control_plane/test_checkpoint_artifact_transport.py @@ -9,6 +9,7 @@ from loopx.control_plane.effect_runtime import ( MAX_REQUEST_BYTES, MAX_RESPONSE_BYTES, + EffectRuntimeConflict, EffectRuntimeRejected, EffectRuntimeRemoteError, effect_runtime_result, @@ -68,6 +69,12 @@ def test_large_member_transport_with_explicit_rpc_budget_preserves_inventory(tmp assert sorted(p.name for p in (destination / "components/research").iterdir()) == [ row["path"] for row in expected[1:]] member = destination / "components/research" / expected[-1]["path"] + with pytest.raises(EffectRuntimeConflict, match="^checkpoint destination already exists$") as rejected: + effect_runtime_result("runtime.checkpoint_artifacts", { + "action": "materialize", "root": str(unit), "expected_sha256": captured["sha256"], + "destination": str(destination), + }, timeout=LARGE_ARTIFACT_RPC_SECONDS, retry_safe=False, large_local_snapshot=True) + assert rejected.value.diagnostic_code == "state_conflict" assert member.read_bytes() == content member.unlink() with pytest.raises(EffectRuntimeRemoteError): diff --git a/tests/control_plane_ts/checkpoint_artifacts.test.ts b/tests/control_plane_ts/checkpoint_artifacts.test.ts index 1c466387e4..97ad736ab8 100644 --- a/tests/control_plane_ts/checkpoint_artifacts.test.ts +++ b/tests/control_plane_ts/checkpoint_artifacts.test.ts @@ -7,7 +7,7 @@ import {chmod, mkdir, mkdtemp, readFile, readdir, realpath, rm, stat, symlink, w import {tmpdir} from "node:os"; import {join} from "node:path"; import {checkpointArtifacts} from "../../loopx/control_plane/runtime/checkpoint_artifacts.ts"; -import {EffectRuntimeRequestError} from "../../loopx/control_plane/effect_runtime_errors.ts"; +import {EffectRuntimeConflictError, EffectRuntimeRequestError} from "../../loopx/control_plane/effect_runtime_errors.ts"; import {sqliteAuthorityRuntime} from "../../loopx/control_plane/coordination/sqlite_runtime.ts"; async function fixture(t: test.TestContext) { @@ -40,7 +40,9 @@ test("capture, verify and inert materialization preserve executable code and emp assert.deepEqual(await readdir(join(root, "restored/components/code/empty")), []); assert.deepEqual(await readFile(join(root, "restored/checkpoint.json")), await readFile(join(root, "captured/checkpoint.json"))); await assert.rejects(checkpointArtifacts({action: "materialize", root: request.destination, - expected_sha256: captured.sha256, destination: join(root, "restored")}), /already exists/); + expected_sha256: captured.sha256, destination: join(root, "restored")}), { + name: "EffectRuntimeConflictError", code: "state_conflict", message: "checkpoint destination already exists", + }); }); test("a read-only participant's copied mode survives the durability flush and materialization", async t => { @@ -179,7 +181,9 @@ test("concurrent capture reserves one destination and cannot replace an existing const {root, request} = await fixture(t); const results = await Promise.allSettled([checkpointArtifacts(request), checkpointArtifacts(request)]); assert.equal(results.filter(r => r.status === "fulfilled").length, 1); - assert.equal(results.filter(r => r.status === "rejected").length, 1); + const rejected = results.find(result => result.status === "rejected"); + assert.ok(rejected?.reason instanceof EffectRuntimeConflictError, rejected?.reason); + assert.equal(rejected.reason.code, "state_conflict"); assert.deepEqual((await readdir(root)).sort(), ["captured", "code", "conversation"]); const bytes = await readFile(join(root, "captured/checkpoint.json")); await assert.rejects(checkpointArtifacts(request), /already exists/); From 7d810cc069e760d7f7fcd4debb734ecf57207ad7 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Wed, 11 Nov 2026 22:33:51 +0800 Subject: [PATCH 03/31] Recover admitted replan settlement when refresh omits the Turn Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- docs/quota-allocation.md | 5 ++ .../quota/prior_turn_closeout.ts | 36 +++++++++------ .../quota/settlement_readback.ts | 4 +- .../test_effective_turn_replan.py | 46 +++++++++++++++---- .../quota_settlement_readback.test.ts | 38 +++++++++++++-- 5 files changed, 99 insertions(+), 30 deletions(-) diff --git a/docs/quota-allocation.md b/docs/quota-allocation.md index 6193c456e1..870ab8698f 100644 --- a/docs/quota-allocation.md +++ b/docs/quota-allocation.md @@ -347,6 +347,9 @@ retain the existing heartbeat default. A rejected `refresh-state`, or an Agent's state-only maintenance update, also returns `settlement_recovery` when a persisted Turn still owes closeout. Its identity and progress come from the same typed receipt reader as guard recovery. +If the caller omits the Turn ID, the refresh can discover an autonomous replan +whose guard positively admitted work and recorded the original obligation and +effect. Later Turn admission still follows the guard's explicit closeout marker. The TS `pending_closeout` read selects the candidate, validates the original binding and evaluates receipts under one admitted owner and immutable snapshot; Python only adapts CLI input and renders the returned recovery. This read mode @@ -363,6 +366,8 @@ Agents' Turns, and already settled work do not produce pending recovery. `refresh-state` 被拒绝,或 Agent 只更新升级/维护状态时,若已有 Turn 尚欠结算, 响应会返回 `settlement_recovery`,身份与进度沿用 guard 恢复的 typed receipt reader。 +即使调用者漏传 Turn ID,只要原 guard 已明确准入自主重规划并记录义务和 effect, +刷新也能找回原轮次。后续 Turn 的准入仍依据 guard 明确记录的 closeout 标记。 TS `pending_closeout` 在同一准入 owner、同一不可变快照下完成候选选择、原绑定校验 及收据判断;Python 只适配 CLI 参数和展示,不再分两次读取决定恢复对象。这个只读 模式不能与结算准入或最新 Run 推断混用。 diff --git a/loopx/control_plane/quota/prior_turn_closeout.ts b/loopx/control_plane/quota/prior_turn_closeout.ts index e468e2755e..723f548e78 100644 --- a/loopx/control_plane/quota/prior_turn_closeout.ts +++ b/loopx/control_plane/quota/prior_turn_closeout.ts @@ -12,6 +12,7 @@ import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts"; import { jsonObject } from "../runtime_decode.ts"; import { heartbeatReceiptBinding, + heartbeatReceiptDetails, heartbeatReceiptFactFromEvent, selectEffectiveHeartbeatReceipt, type HeartbeatReceiptBinding, @@ -45,8 +46,18 @@ function candidate( }; } +function admitsAutonomousReplan(event: JsonObject, fact: HeartbeatReceiptFact): boolean { + if (fact.replan_obligation_id === null || fact.settlement_effect_id === null) return false; + const guard = heartbeatReceiptDetails(event); + return guard.semantic_replan_obligation_id === fact.replan_obligation_id + && guard.interaction_mode === "autonomous_replan" && guard.ok === true && guard.should_run === true + && guard.must_attempt_work === true && guard.delivery_allowed === false && guard.quiet_noop_allowed === false; +} + /** - * Select the prior must-attempt Turns that still require a host closeout. + * Select prior Turns eligible for closeout readback. Guard preflight requires + * an explicit closeout marker. Refresh recovery can also discover an admitted + * replan-only binding without making it a later Turn's admission prerequisite. * * Every prior Turn is identity-validated, so a conflicting receipt fails the * read instead of being hidden behind a newer Turn. Results are newest first @@ -63,8 +74,9 @@ export function selectCloseoutCandidates( goalId: string, agentId: string, excludeTurnInstanceId: string | null, + { includeAdmittedReplans } = { includeAdmittedReplans: false }, ): { candidates: PriorHostTurnCloseoutCandidate[]; turnsValidated: number } { - const perTurn = new Map(); + const perTurn = new Map(); const firstRecorded = new Map(); for (const event of receipts) { const fact = heartbeatReceiptFactFromEvent(event); @@ -74,16 +86,17 @@ export function selectCloseoutCandidates( firstRecorded.set(turnInstanceId, firstRecorded.size); } if (turnInstanceId === excludeTurnInstanceId) continue; + const entry = { fact, value: event }; const existing = perTurn.get(turnInstanceId); if (existing) { - existing.push(fact); + existing.push(entry); // Map insertion order is the append-order recency index. Moving an // existing Turn to the tail avoids an O(T) indexOf/splice for every // receipt while preserving "last receipt wins recency" exactly. perTurn.delete(turnInstanceId); perTurn.set(turnInstanceId, existing); } else { - perTurn.set(turnInstanceId, [fact]); + perTurn.set(turnInstanceId, [entry]); } } @@ -92,22 +105,17 @@ export function selectCloseoutCandidates( ? undefined : firstRecorded.get(excludeTurnInstanceId); let validated = 0; for (const turnInstanceId of [...perTurn.keys()].reverse()) { - const entries = (perTurn.get(turnInstanceId) ?? []).map((fact) => ({ - fact, - value: fact, - })); - const effective = selectEffectiveHeartbeatReceipt(goalId, agentId, entries); + const effective = selectEffectiveHeartbeatReceipt(goalId, agentId, perTurn.get(turnInstanceId)!); validated += 1; if (effective === null) continue; // Validate identities even outside the causal candidate set: filtering // must not conceal a conflicting persisted settlement binding. if (currentPosition !== undefined && firstRecorded.get(turnInstanceId)! >= currentPosition) continue; - // Closeout is opt-in on the persisted receipt, so older receipts from - // before this contract cannot become new recovery obligations. - if (!effective.closeout_required) continue; - const binding = heartbeatReceiptBinding(goalId, agentId, effective); + const fact = heartbeatReceiptFactFromEvent(effective); + if (!fact.closeout_required && !(includeAdmittedReplans && admitsAutonomousReplan(effective, fact))) continue; + const binding = heartbeatReceiptBinding(goalId, agentId, fact); if (binding === null) continue; - candidates.push(candidate(binding, turnInstanceId, effective)); + candidates.push(candidate(binding, turnInstanceId, fact)); } return {candidates, turnsValidated: validated}; } diff --git a/loopx/control_plane/quota/settlement_readback.ts b/loopx/control_plane/quota/settlement_readback.ts index 87c8b617ce..0d155bf6d5 100644 --- a/loopx/control_plane/quota/settlement_readback.ts +++ b/loopx/control_plane/quota/settlement_readback.ts @@ -1110,6 +1110,7 @@ function owedPriorTurnFromSnapshot( snapshot: QuotaSettlementReadbackSnapshot, agentId: string, turnInstanceId: string | null, + options?: { includeAdmittedReplans: boolean }, ): OwedPriorTurn | null { const receipts = heartbeatReceiptsFromEvents(snapshot.events, request.goal_id, agentId) .filter((event) => quotaOwnerOwnsProjection(request.owner, event.goal_ref)); @@ -1118,6 +1119,7 @@ function owedPriorTurnFromSnapshot( request.goal_id, agentId, turnInstanceId, + options, ); for (const selected of candidates) { // A committed monitor poll is its own exact no-spend closeout. This is the @@ -1167,7 +1169,7 @@ function readPendingCloseoutFromRequest( } const notFound = {schema_version: QUOTA_SETTLEMENT_READBACK_RESULT_SCHEMA, found: false}; const turn = request.turn_instance_id ?? owedPriorTurnFromSnapshot( - request, snapshot, explicitAgentId, null, + request, snapshot, explicitAgentId, null, { includeAdmittedReplans: true }, )?.prior_turn_instance_id; if (!turn) return notFound; // Selection, original binding validation and receipt progress share this diff --git a/tests/control_plane/test_effective_turn_replan.py b/tests/control_plane/test_effective_turn_replan.py index f1446f733a..0e449180e8 100644 --- a/tests/control_plane/test_effective_turn_replan.py +++ b/tests/control_plane/test_effective_turn_replan.py @@ -20,7 +20,10 @@ GOAL_ID, TODO_ID, _configure_selectable_alternative, + _heartbeat_receipt_count, _initialize_fixture_authority, + _run_generated_cli, + _spend_run_count, ALTERNATIVE_TODO_ID, ) from canonical_authority_fixture import isolate_sqlite_runtime @@ -407,6 +410,29 @@ def call(*args: str, expected_code: int = 0) -> dict: assert reread["autonomous_replan_obligation"].get("resolution_mode") != "receipt_bound_replan_settlement" assert reread["replan_action_packet"]["stride_control"]["required_on_periodic_review"] is True assert reread["replan_action_packet"]["stride_control"]["decision_value_review"] == value_review + replan_identity = reread["heartbeat_receipt"]["settlement_identity"] + receipt_count = _heartbeat_receipt_count(runtime, "stride-due-review") + spend_count = _spend_run_count(runtime) + refresh = ("refresh-state", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, + "--no-global-sync", "--suppress-external-sinks") + maintenance = (*refresh, "--classification", "local_upgrade_status_updated", + "--delivery-outcome", "surface_only") + missing_turn = call(*refresh, "--replan-obligation-id", obligation["obligation_id"], + "--classification", "bounded_replan_progress", "--delivery-outcome", "outcome_progress", + expected_code=1) + assert "turn_instance_id" in missing_turn["error"] + surface = call(*maintenance, expected_code=1) + assert not surface["appended"] and "settlement_identity" not in surface + assert surface["replan_transition"]["reason_code"] == "stride_decision_required" + assert "settlement_recovery" in missing_turn, missing_turn + assert "settlement_recovery" in surface, surface + pending = missing_turn["settlement_recovery"] + assert pending == surface["settlement_recovery"] + assert surface["replan_transition"]["next_cli_actions"] == pending["next_cli_actions"] + assert pending["settlement_identity"] == replan_identity + assert pending["settlement_progress"]["next_step"] == "durable_writeback" + assert _heartbeat_receipt_count(runtime, "stride-due-review") == receipt_count + assert _spend_run_count(runtime) == spend_count rejected = call("refresh-state", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--replan-obligation-id", obligation["obligation_id"], "--turn-instance-id", "stride-due-review", @@ -461,9 +487,6 @@ def call(*args: str, expected_code: int = 0) -> dict: state_path.write_text(state_path.read_text().replace( "Settle one standard Codex App delivery.", "Settle one standard delivery and verify the newly required source.")) - from tests.control_plane.test_quota_settlement_cli import _heartbeat_receipt_count, _spend_run_count - receipt_count = _heartbeat_receipt_count(runtime, "stride-due-review") - spend_count = _spend_run_count(runtime) rejected_reference = decision["reference_context_id"] if changed_context else "replan-context-" + "b" * 24 decision_path.write_text(json.dumps({**vision_packet, "stride_decision": {**decision, "reference_context_id": rejected_reference}})) @@ -480,9 +503,8 @@ def call(*args: str, expected_code: int = 0) -> dict: assert transition["host_action"] == "read_current_replan_context" assert transition["next_cli_actions"] == [] # No unbound repeat of the rejected write. owed_recovery = foreign["settlement_recovery"] - assert owed_recovery["settlement_identity"] == reread["heartbeat_receipt"]["settlement_identity"] - from tests.control_plane.test_quota_settlement_cli import _run_generated_cli - recovered_code, recovered_turn = _run_generated_cli(owed_recovery["next_cli_actions"][0], + assert owed_recovery == pending + recovered_code, recovered_turn = _run_generated_cli(pending["next_cli_actions"][0], registry_path=registry, cwd=project) assert recovered_code == 0, json.dumps(recovered_turn, indent=2) assert recovered_turn["heartbeat_receipt"]["settlement_identity"] == owed_recovery["settlement_identity"] @@ -534,13 +556,17 @@ def call(*args: str, expected_code: int = 0) -> dict: assert persisted["agent_vision"]["stride_decision"] == decision assert persisted["settlement_identity"]["schema_version"] == "quota_settlement_identity_v1" assert persisted["replan_obligation_id"] == obligation["obligation_id"] - assert persisted["settlement_identity"] == reread["heartbeat_receipt"]["settlement_identity"] + assert persisted["settlement_identity"] == replan_identity + after_write = call(*maintenance)["settlement_recovery"] + spend = after_write["settlement_owed"] + assert after_write["next_cli_actions"] == [spend["command"]] + assert spend["effect_id"] == replan_identity["effect_id"] for _ in range(2): - call("quota", "spend-slot", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, - "--replan-obligation-id", obligation["obligation_id"], "--turn-instance-id", "stride-due-review", - "--slots", "1", "--source", "heartbeat", "--execute") + rc, spent = _run_generated_cli(spend["command"], registry_path=registry, cwd=project) + assert rc == 0 and spent["settlement_result"]["ok"], spent assert _spend_run_count(runtime) == spend_count + 1 assert _heartbeat_receipt_count(runtime, "stride-due-review") == receipt_count + assert "settlement_recovery" not in call(*maintenance) # Continue through real authority/history, rather than injecting a crafted # context. The next periodic review must see the accepted autonomous decision. diff --git a/tests/control_plane_ts/quota_settlement_readback.test.ts b/tests/control_plane_ts/quota_settlement_readback.test.ts index 32e74d95ec..59c8ebcb39 100644 --- a/tests/control_plane_ts/quota_settlement_readback.test.ts +++ b/tests/control_plane_ts/quota_settlement_readback.test.ts @@ -503,7 +503,7 @@ test("pending closeout survives maintenance and reads one original receipt snaps }); }); -test("pending closeout keeps replan identity and rejects query/admission mixing", async () => { +test("pending closeout recovers positively admitted replan work without creating a preflight obligation", async () => { const root = await fixture(); try { const log = join(root, "goals", goalId, "rollout-event-log.jsonl"); @@ -511,11 +511,39 @@ test("pending closeout keeps replan identity and rejects query/admission mixing" const obligation = "replan-0000000000000001"; const bound = settlementIdentity({goal_id: goalId, agent_id: agentId, todo_id: null, replan_obligation_id: obligation, turn_instance_id: turnId}); - events[0].details = {closeout_required: true, replan_obligation_id: obligation, - semantic_replan_obligation_id: obligation, settlement_effect_id: bound.effect_id}; - await writeFile(log, events.map(event => JSON.stringify(event)).join("\n") + "\n"); + const admitted = {closeout_required: false, replan_obligation_id: obligation, + semantic_replan_obligation_id: obligation, settlement_effect_id: bound.effect_id, + ok: true, should_run: true, must_attempt_work: true, delivery_allowed: false, + quiet_noop_allowed: false, interaction_mode: "autonomous_replan"}; + const writeGuard = async (details: Record) => { + events[0].details = details; + await writeFile(log, events.map(event => JSON.stringify(event)).join("\n") + "\n"); + }; + await writeGuard(admitted); const input = request(root, {pending_closeout: true, turn_instance_id: null, todo_id: null}); - assert.deepEqual(((await readQuotaSettlement(input)).identity as any).result.value, settlementIdentityPayload(bound)); + for (const query of [input, {...input, replan_obligation_id: obligation}]) { + const readback = await readQuotaSettlement(query); + assert.equal(readback.found, true); + assert.deepEqual((readback.identity as any).result.value, settlementIdentityPayload(bound)); + } + assert.equal((await readQuotaSettlement({...input, agent_id: "another-agent"})).found, false); + const mismatched = await readQuotaSettlement({...input, replan_obligation_id: "replan-0000000000000002"}); + assert.equal((mismatched.identity as any).result.failure.kind, "identity_mismatch"); + const preflight = await preflightPriorHostTurnCloseout({ + ...input, schema_version: PRIOR_HOST_TURN_CLOSEOUT_PREFLIGHT_REQUEST_SCHEMA, + }); + assert.equal(preflight.reason, "no_prior_turn_requires_closeout"); + + for (const patch of [{should_run: false}, {must_attempt_work: undefined}, {quiet_noop_allowed: true}, + {interaction_mode: "control_plane_self_repair"}, {semantic_replan_obligation_id: "replan-0000000000000002"}, + {settlement_effect_id: undefined}, {replan_obligation_id: undefined, settlement_effect_id: undefined}]) { + await writeGuard({...admitted, ...patch}); + assert.equal((await readQuotaSettlement(input)).found, false, JSON.stringify(patch)); + } + await writeGuard({...admitted, settlement_effect_id: "another-effect"}); + const wrongEffect = await readQuotaSettlement(input); + assert.equal((wrongEffect.identity as any).result.failure.kind, "identity_mismatch"); + for (const patch of [{infer_turn_instance_id: true}, {allow_unbound_binding: true}, {refresh_retry: {}}, {heartbeat_reentry_guard: {}}, {spend_precheck: true}]) { await assert.rejects(readQuotaSettlement({...input, ...patch}), /read-only receipt recovery/); From ee5226a9bcb14e047bd6372ed4dbabd5d661bf0d Mon Sep 17 00:00:00 2001 From: xiaobaifly7 <196511160+xiaobaifly7@users.noreply.github.com> Date: Wed, 11 Nov 2026 22:44:37 +0800 Subject: [PATCH 04/31] fix(dsh): read local scheduler cadence from scheduler hint detail The Driver looked for the local-scheduler backoff under scheduler_hint.unchanged_poll.local_scheduler, but the CLI emits the cadence under scheduler_hint.cold_path_detail.local_scheduler (the Driver already requests it with --include-detail scheduler) and the unchanged-poll limit under unchanged_poll.limits.local_scheduler. Every non-running decision therefore produced a wait without a delay, so the Driver never scheduled a re-evaluation and stayed silent until an external event. Read the cadence and limit from their actual fields and cap the delay with recheck_after_seconds when present. Co-Authored-By: Claude Opus 5.5 Signed-off-by: xiaobaifly7 <196511160+xiaobaifly7@users.noreply.github.com> --- packages/dsh-loopx-plugin/README.md | 7 +- packages/dsh-loopx-plugin/src/driver.ts | 11 +- .../dsh-loopx-plugin/tests/driver.spec.ts | 164 +++++++++++++++++- 3 files changed, 171 insertions(+), 11 deletions(-) diff --git a/packages/dsh-loopx-plugin/README.md b/packages/dsh-loopx-plugin/README.md index 56db63b2a4..fa051dc690 100644 --- a/packages/dsh-loopx-plugin/README.md +++ b/packages/dsh-loopx-plugin/README.md @@ -555,8 +555,11 @@ eight-failure breaker or plugin-owned durable activation state. The small process-local activation projection is bound to one exact Session and is reconstructed only from its typed event history. Exhausting the three attempts stops that evaluation; it does not create a periodic error-retry loop. Only a -typed LoopX local-scheduler plan can schedule another wakeup, and its limit on -unchanged polls is enforced. +typed LoopX local-scheduler plan can schedule another wakeup. The Driver reads +cadence from `scheduler_hint.cold_path_detail.local_scheduler` with +`--include-detail scheduler`, caps the wait by `recheck_after_seconds` when +present, and enforces `unchanged_poll.limits.local_scheduler`. A changed +`reset_policy.reset_token` resets the unchanged-poll streak. If DSH refuses the maintenance slot before admission starts, the Driver waits for `Agent.whenIdle()` and re-requests evaluation for that same Session. This diff --git a/packages/dsh-loopx-plugin/src/driver.ts b/packages/dsh-loopx-plugin/src/driver.ts index a9ea8927f6..5f144538cf 100644 --- a/packages/dsh-loopx-plugin/src/driver.ts +++ b/packages/dsh-loopx-plugin/src/driver.ts @@ -516,7 +516,7 @@ function schedulerWait( ): WaitPlan { const scheduler = record(payload.scheduler_hint) const unchanged = record(scheduler?.unchanged_poll) - const local = record(unchanged?.local_scheduler) + const local = record(record(scheduler?.cold_path_detail)?.local_scheduler) if (local === undefined) return { kind: 'wait' } const reset = record(scheduler?.reset_policy) @@ -524,7 +524,7 @@ function schedulerWait( const unchangedPolls = token.length > 0 && token === currentToken ? currentPolls : 0 - const rawLimit = local.unchanged_poll_limit + const rawLimit = record(unchanged?.limits)?.local_scheduler const limit = typeof rawLimit === 'number' && Number.isSafeInteger(rawLimit) && rawLimit >= 0 @@ -548,9 +548,14 @@ function schedulerWait( const minutes = progression.length > 0 ? progression[Math.min(unchangedPolls, progression.length - 1)] ?? fallback : fallback + let delayMs = minutes * 60_000 + const recheck = local.recheck_after_seconds + if (typeof recheck === 'number' && Number.isFinite(recheck) && recheck >= 0) { + delayMs = Math.min(delayMs, recheck * 1_000) + } return { kind: 'wait', - delayMs: Math.min(Math.round(minutes * 60_000), MAX_WAIT_MS), + delayMs: Math.min(Math.round(delayMs), MAX_WAIT_MS), schedulerToken: token, unchangedPolls: unchangedPolls + 1, } diff --git a/packages/dsh-loopx-plugin/tests/driver.spec.ts b/packages/dsh-loopx-plugin/tests/driver.spec.ts index 2fd7f8cfa6..19e11c9443 100644 --- a/packages/dsh-loopx-plugin/tests/driver.spec.ts +++ b/packages/dsh-loopx-plugin/tests/driver.spec.ts @@ -286,6 +286,56 @@ interface RunnerFixture { heartbeatCalls: number } +// Relevant scheduler fields emitted by hint.ts for a generic_cli human gate. +function schedulerWaitHint() { + return { + schema_version: 'scheduler_hint_v0', + source: 'quota.should-run', + action: 'backoff_waiting_for_user', + cadence_class: 'human_gate', + unchanged_poll: { + limits: { local_scheduler: 3, codex_cli_tui: 3, claude_code_loop: 3 }, + after_limits: { + local_scheduler: 'stop_tick_loop', + codex_cli_tui: 'update_goal_blocked_keep_loopx_active', + claude_code_loop: 'stop_loop', + }, + final_quota_replan_check_enabled: true, + final_quota_replan_check_action: 'rerun_quota_should_run_once', + }, + reset_policy: { + reset_token: 'scheduler-token', + host_state_key: 'scheduler_hint.reset_policy.reset_token', + }, + cold_path_detail: { + schema_version: 'scheduler_hint_detail_v0', + source: 'quota.should-run', + local_scheduler: { + recommended_interval_minutes: 30, + max_interval_minutes: 120, + unchanged_poll_backoff_multiplier: 2, + example_progression_minutes: [30, 60, 120], + unchanged_poll_limit: 3, + after_limit: 'stop_tick_loop', + no_spend_for_cadence_change: true, + }, + }, + } +} + +function schedulerClock() { + const timers: Array<{ callback: () => void; delay: number }> = [] + return { + timers, + setTimeout(callback: () => void, delay: number) { + const timer = { callback, delay } + timers.push(timer) + return timer + }, + clearTimeout() {}, + } +} + function runnerFixture(options: { readonly quotaExitFailures?: number readonly heartbeatExitFailures?: number @@ -293,6 +343,7 @@ function runnerFixture(options: { readonly waitMinutes?: number readonly schedulerMode?: 'poll' | 'stop' | 'missing' readonly unchangedPollLimit?: number + readonly schedulerHint?: () => Record readonly quotaAgentId?: string readonly quotaTurnInstanceId?: string readonly heartbeatTurnInstanceId?: string @@ -362,7 +413,7 @@ function runnerFixture(options: { : options.shouldRun ?? true const turnInstanceId = options.quotaTurnInstanceId ?? args[args.indexOf('--turn-instance-id') + 1] - const schedulerHint = shouldRun + const schedulerHint = options.schedulerHint?.() ?? (shouldRun ? { action: 'run_now' } : options.schedulerMode === 'stop' ? { @@ -372,17 +423,19 @@ function runnerFixture(options: { : options.schedulerMode === 'missing' ? { action: 'wait' } : { - action: 'wait', + schema_version: 'scheduler_hint_v0', + action: 'backoff_until_state_change', reset_policy: { reset_token: 'scheduler-token' }, unchanged_poll: { + limits: { local_scheduler: options.unchangedPollLimit ?? null }, + }, + cold_path_detail: { + schema_version: 'scheduler_hint_detail_v0', local_scheduler: { recommended_interval_minutes: options.waitMinutes ?? 5, - ...(options.unchangedPollLimit === undefined - ? {} - : { unchanged_poll_limit: options.unchangedPollLimit }), }, }, - } + }) return { exitCode: shouldRun && !options.typedQuotaFailure ? 0 : 1, stdout: JSON.stringify({ @@ -1541,6 +1594,105 @@ describe('same-session LoopX driver', () => { await driver.dispose() }) + it('schedules detailed waits, stops at the limit and resets on a new token', async () => { + const hint = schedulerWaitHint() + let runnable = false + const fixture = runnerFixture({ + shouldRun: () => runnable, + schedulerHint: () => hint, + }) + const clock = schedulerClock() + const host = fakeAgent() + const driver = new LoopXContinuationDriver({ + runner: fixture.runner, + isLiveAgent: () => true, + makeTurnInstanceId: () => 'turn-stable', + clock, + }) + try { + observeActivated(driver, host) + for (const [index, minutes] of [30, 60, 120].entries()) { + await waitFor(() => clock.timers.length === index + 1) + expect(clock.timers[index]?.delay).toBe(minutes * 60_000) + expect(fixture.quotaCalls[index]).toContain('--include-detail') + expect(fixture.quotaCalls[index]).toContain('scheduler') + clock.timers[index]?.callback() + } + await waitFor(() => fixture.quotaCalls.length === 4) + expect(clock.timers).toHaveLength(3) + expect(fixture.heartbeatCalls).toBe(0) + expect(host.nextTurn).toHaveLength(0) + + hint.reset_policy.reset_token = 'changed-token' + await driver.evaluateActivatedSession({ agent: host.agent, session: host.agent.session }) + expect(clock.timers).toHaveLength(4) + expect(clock.timers[3]?.delay).toBe(30 * 60_000) + + runnable = true + clock.timers[3]?.callback() + await waitFor(() => host.nextTurn.length === 1) + expect(fixture.heartbeatCalls).toBe(1) + expect(clock.timers).toHaveLength(4) + } finally { + await driver.dispose() + } + }) + + it.each([45, 7_200])('bounds a detailed wait by recheck_after_seconds=%s', async seconds => { + const hint = schedulerWaitHint() + const fixture = runnerFixture({ + shouldRun: false, + schedulerHint: () => ({ + ...hint, + cold_path_detail: { + ...hint.cold_path_detail, + local_scheduler: { + ...hint.cold_path_detail.local_scheduler, + recheck_after_seconds: seconds, + }, + }, + }), + }) + const clock = schedulerClock() + const host = fakeAgent() + const driver = new LoopXContinuationDriver({ runner: fixture.runner, isLiveAgent: () => true, clock }) + try { + observeActivated(driver, host) + await waitFor(() => clock.timers.length === 1) + expect(clock.timers[0]?.delay).toBe(Math.min(30 * 60_000, seconds * 1_000)) + expect(fixture.heartbeatCalls).toBe(0) + } finally { + await driver.dispose() + } + }) + + it.each([0, 1, null])('uses the compact unchanged-poll limit %s instead of the detail copy', async limit => { + const hint = schedulerWaitHint() + const fixture = runnerFixture({ + shouldRun: false, + schedulerHint: () => ({ + ...hint, + unchanged_poll: { ...hint.unchanged_poll, limits: { local_scheduler: limit } }, + }), + }) + const clock = schedulerClock() + const host = fakeAgent() + const driver = new LoopXContinuationDriver({ runner: fixture.runner, isLiveAgent: () => true, clock }) + try { + observeActivated(driver, host) + const intervals = limit === null ? [30, 60, 120, 120] : [30].slice(0, limit) + for (const [index, minutes] of intervals.entries()) { + await waitFor(() => clock.timers.length === index + 1) + expect(clock.timers[index]?.delay).toBe(minutes * 60_000) + clock.timers[index]?.callback() + } + await waitFor(() => fixture.quotaCalls.length === intervals.length + 1) + expect(clock.timers).toHaveLength(intervals.length + (limit === null ? 1 : 0)) + } finally { + await driver.dispose() + } + }) + it('honors the typed unchanged-poll limit without an indefinite timer loop', async () => { const fixture = runnerFixture({ shouldRun: false, From 661ff605e70247e725a2543d0d6679b4fbc38503 Mon Sep 17 00:00:00 2001 From: xiaobaifly7 Date: Wed, 11 Nov 2026 23:35:37 +0800 Subject: [PATCH 05/31] fix(change-window): preserve LF in managed Git scripts (#8598) --- .../repository_change_window/git_hook.py | 3 +- .../test_repository_change_window.py | 49 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/loopx/capabilities/repository_change_window/git_hook.py b/loopx/capabilities/repository_change_window/git_hook.py index 43762a696e..15b01c8c67 100644 --- a/loopx/capabilities/repository_change_window/git_hook.py +++ b/loopx/capabilities/repository_change_window/git_hook.py @@ -446,7 +446,7 @@ def _write_provider_files( if event not in managed_events: path.unlink(missing_ok=True) continue - path.write_text(_hook_script(event), encoding="utf-8") + path.write_text(_hook_script(event), encoding="utf-8", newline="\n") path.chmod(0o755) ssh_command = hooks_dir.parent / SSH_COMMAND_NAME if ssh_command.is_symlink(): @@ -459,6 +459,7 @@ def _write_provider_files( else None ), encoding="utf-8", + newline="\n", ) ssh_command.chmod(0o755) else: diff --git a/tests/capabilities/test_repository_change_window.py b/tests/capabilities/test_repository_change_window.py index e8802c41cc..8cefcff85d 100644 --- a/tests/capabilities/test_repository_change_window.py +++ b/tests/capabilities/test_repository_change_window.py @@ -1133,6 +1133,55 @@ def test_verify_detects_branch_movement( ) +@pytest.mark.parametrize("enforcement_level", list(EnforcementLevel)) +def test_cli_install_verify_round_trip_preserves_lf_scripts( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + enforcement_level: EnforcementLevel, +) -> None: + monkeypatch.setenv("GIT_CONFIG_GLOBAL", str(tmp_path / "empty-global-gitconfig")) + monkeypatch.setenv("GIT_CONFIG_NOSYSTEM", "1") + repo = tmp_path / "repo" + repo.mkdir() + _git(repo, "init", "-b", "main") + _git(repo, "config", "user.name", "LoopX Test") + _git(repo, "config", "user.email", "loopx@example.invalid") + _git(repo, "-c", "commit.gpgsign=false", "commit", "--allow-empty", "-m", "base") + args = [ + "--runtime-root", str(tmp_path / "runtime"), "--format", "json", "change-window", + ] + assert main([ + *args, + "install", "--repo-path", str(repo), + "--enforcement-level", enforcement_level.value, + "--execute", + ]) == 0 + assert json.loads(capsys.readouterr().out)["status"] == "installed" + + verify_args = [*args, "verify", "--repo-path", str(repo)] + assert main(verify_args) == 0 + verified = json.loads(capsys.readouterr().out) + assert verified["status"] == "ready" + assert verified["verified"] is True + assert verified["enabled"] is True + + hooks_dir = Path(_git(repo, "config", "--local", "--get", "core.hooksPath")) + scripts = [hooks_dir / name for name in verified["managed_hook_names"]] + if enforcement_level is EnforcementLevel.REFERENCE_GUARD: + scripts.append(hooks_dir.parent / "ssh-command") + for script in scripts: + content = script.read_bytes() + assert content.startswith(b"#!/bin/sh\n") + assert b"\r\n" not in content + + scripts[0].write_bytes(scripts[0].read_bytes() + b"# modified\n") + assert main(verify_args) == 1 + drifted = json.loads(capsys.readouterr().out) + assert drifted["status"] == "drifted" + assert drifted["verified"] is False + + def test_cli_preview_and_global_readback( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, From b092c70489dd1fe6cfa69b4f98c8c8261e7f3069 Mon Sep 17 00:00:00 2001 From: xiaobaifly7 Date: Wed, 11 Nov 2026 23:35:58 +0800 Subject: [PATCH 06/31] fix(content-ops): generate runtime report and aggregate timestamps at call time (#8600) --- loopx/capabilities/content_ops/cli.py | 9 +- loopx/capabilities/content_ops/surface.py | 15 ++- tests/test_content_ops_packet_aggregation.py | 134 +++++++++++++++++++ 3 files changed, 146 insertions(+), 12 deletions(-) create mode 100644 tests/test_content_ops_packet_aggregation.py diff --git a/loopx/capabilities/content_ops/cli.py b/loopx/capabilities/content_ops/cli.py index b6439757ee..d5f27890ef 100644 --- a/loopx/capabilities/content_ops/cli.py +++ b/loopx/capabilities/content_ops/cli.py @@ -307,8 +307,7 @@ def register_content_ops_commands( ) aggregate_parser.add_argument( "--generated-at", - default="2026-06-23T00:00:00Z", - help="Public-safe generated_at timestamp for the aggregate surface.", + help="Public-safe generated_at timestamp for the aggregate surface (default: current UTC).", ) chatview_parser = content_ops_sub.add_parser( "project-chatview-report", @@ -365,8 +364,7 @@ def register_content_ops_commands( ) chatview_parser.add_argument( "--generated-at", - default="2026-06-23T00:00:00Z", - help="Public-safe generated_at timestamp for the ChatView report.", + help="Public-safe generated_at timestamp for the ChatView report (default: current UTC).", ) walkthrough_parser = content_ops_sub.add_parser( "walkthrough-artifact", @@ -435,8 +433,7 @@ def register_content_ops_commands( ) walkthrough_parser.add_argument( "--generated-at", - default="2026-06-23T00:00:00Z", - help="Public-safe generated_at timestamp for the walkthrough artifact.", + help="Public-safe generated_at timestamp for the walkthrough artifact (default: current UTC).", ) item_create_parser = content_ops_sub.add_parser( "item-create", diff --git a/loopx/capabilities/content_ops/surface.py b/loopx/capabilities/content_ops/surface.py index 4785c36baf..e976e1f029 100644 --- a/loopx/capabilities/content_ops/surface.py +++ b/loopx/capabilities/content_ops/surface.py @@ -8,6 +8,7 @@ from urllib.parse import urljoin, urlsplit, urlunsplit from urllib.request import HTTPRedirectHandler, Request, build_opener +from loopx.control_plane.runtime.time import now_utc_iso from .connector_packets import ( source_item_and_trial_from_private_gate_packet, @@ -1461,7 +1462,7 @@ def build_content_ops_chatview_report_packet( connector_url: str = "https://chatview.zaynjarvis.com/", source_item_id: str = "source_chatview_metadata_signal_001", owner_label: str = "ChatView owner", - generated_at: str | None = "2026-06-23T00:00:00Z", + generated_at: str | None = None, ) -> dict[str, Any]: """Build a public-safe ChatView report that remains aggregation-compatible.""" @@ -1509,7 +1510,7 @@ def build_content_ops_chatview_report_packet( report = { "schema_version": CONTENT_OPS_CHATVIEW_CONNECTOR_REPORT_SCHEMA_VERSION, - "generated_at": generated_at, + "generated_at": generated_at if generated_at is not None else now_utc_iso(), "connector_url": normalised_url, "operator_card": operator_card, "observed_shape": { @@ -1558,7 +1559,7 @@ def build_content_ops_surface_from_connector_packets( public_handle_packets: Sequence[Mapping[str, Any]], private_connector_gate_packets: Sequence[Mapping[str, Any]], surface_id: str = "content_ops_connector_packet_aggregation", - generated_at: str | None = "2026-06-23T00:00:00Z", + generated_at: str | None = None, ) -> dict[str, Any]: """Aggregate connector packets into a compact content-ops state surface.""" @@ -1709,7 +1710,7 @@ def build_content_ops_surface_from_connector_packets( return { "schema_version": CONTENT_OPS_SURFACE_SCHEMA_VERSION, "surface_id": surface_id, - "generated_at": generated_at, + "generated_at": generated_at if generated_at is not None else now_utc_iso(), "mode": "compact_state_surface", "source_items": source_items, "angle_candidates": angle_candidates, @@ -1742,7 +1743,7 @@ def build_content_ops_packet_aggregation_packet( public_handle_packets: Sequence[Mapping[str, Any]], private_connector_gate_packets: Sequence[Mapping[str, Any]], surface_id: str = "content_ops_connector_packet_aggregation", - generated_at: str | None = "2026-06-23T00:00:00Z", + generated_at: str | None = None, ) -> dict[str, Any]: surface = build_content_ops_surface_from_connector_packets( public_handle_packets=public_handle_packets, @@ -1794,10 +1795,12 @@ def build_content_ops_walkthrough_artifact_packet( api_path_counts: Mapping[str, Any] | None = None, private_preview_item_count: int = 0, theme_signals: Sequence[str] | None = None, - generated_at: str | None = "2026-06-23T00:00:00Z", + generated_at: str | None = None, ) -> dict[str, Any]: """Build the public-safe operator artifact for the content-ops chain.""" + if generated_at is None: + generated_at = now_utc_iso() preview_count = _non_negative_int( private_preview_item_count, "private_preview_item_count", diff --git a/tests/test_content_ops_packet_aggregation.py b/tests/test_content_ops_packet_aggregation.py new file mode 100644 index 0000000000..3e93207eab --- /dev/null +++ b/tests/test_content_ops_packet_aggregation.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +import json +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path +from unittest.mock import Mock + +import pytest + +from loopx.capabilities.content_ops import surface +from loopx.control_plane.runtime import time as runtime_time + + +@pytest.fixture +def connector_packets(): + return { + "public_handle_packets": [ + surface.build_content_ops_public_handle_observation_packet( + url="https://x.com/OpenAI", + source_item_id="source_public_timestamp_test", + fetch=False, + ) + ], + "private_connector_gate_packets": [ + surface.build_content_ops_private_connector_gate_packet() + ], + } + + +@pytest.mark.parametrize( + "builder", + [ + surface.build_content_ops_surface_from_connector_packets, + surface.build_content_ops_packet_aggregation_packet, + ], +) +@pytest.mark.parametrize( + "timestamp_args", + [{}, {"generated_at": None}, {"generated_at": "2026-06-23T00:00:00Z"}], + ids=["omitted", "none", "explicit"], +) +def test_aggregation_timestamp_resolves_at_call_time( + connector_packets, monkeypatch, builder, timestamp_args +): + for hour in (10, 11): + now = datetime(2030, 1, 2, hour, tzinfo=timezone.utc) + monkeypatch.setattr(runtime_time, "now_utc", lambda: now) + payload = builder(**connector_packets, **timestamp_args) + aggregate_surface = payload.get("surface", payload) + expected = timestamp_args.get("generated_at") or f"2030-01-02T{hour}:00:00Z" + assert aggregate_surface["generated_at"] == expected + + +@pytest.mark.parametrize( + "builder, extra_args, report_key", + [ + (surface.build_content_ops_chatview_report_packet, {}, "chatview_report"), + ( + surface.build_content_ops_walkthrough_artifact_packet, + {"public_handle_url": "https://x.com/OpenAI"}, + None, + ), + ], +) +@pytest.mark.parametrize( + "timestamp_args", + [{}, {"generated_at": None}, {"generated_at": "2024-02-03T04:05:06+02:00"}], + ids=["omitted", "none", "explicit"], +) +def test_report_timestamp_resolves_at_call_time( + monkeypatch, builder, extra_args, report_key, timestamp_args +): + for hour in (10, 11): + clock = Mock(return_value=datetime(2030, 1, 2, hour, tzinfo=timezone.utc)) + monkeypatch.setattr(runtime_time, "now_utc", clock) + payload = builder( + channel_count=2, recent_record_count=50, report_count=5, api_request_count=54, + **extra_args, **timestamp_args, + ) + report = payload[report_key] if report_key else payload + expected = timestamp_args.get("generated_at") or f"2030-01-02T{hour}:00:00Z" + assert report["generated_at"] == expected + # The walkthrough must share one timestamp with its nested report and aggregation. + assert clock.call_count == (timestamp_args.get("generated_at") is None) + + +@pytest.mark.parametrize( + "command", ["aggregate-packets", "project-chatview-report", "walkthrough-artifact"] +) +@pytest.mark.parametrize("generated_at", [None, "2026-06-23T00:00:00Z"]) +def test_content_ops_cli_timestamp(connector_packets, tmp_path, generated_at, command): + argv = [ + sys.executable, "-m", "loopx.cli", "--format", "json", + "content-ops", command, + ] + if command == "aggregate-packets": + for key, option in ( + ("public_handle_packets", "--public-packet-json"), + ("private_connector_gate_packets", "--private-gate-packet-json"), + ): + packet_path = tmp_path / f"{key}.json" + packet_path.write_text(json.dumps(connector_packets[key][0]), encoding="utf-8") + argv.extend([option, str(packet_path)]) + else: + argv.extend([ + "--channel-count", "2", "--recent-record-count", "50", + "--report-count", "5", "--api-request-count", "54", + ]) + if generated_at is not None: + argv.extend(["--generated-at", generated_at]) + + before = datetime.now(timezone.utc).replace(microsecond=0) + result = subprocess.run( + argv, cwd=Path(__file__).resolve().parents[1], capture_output=True, text=True, check=True + ) + after = datetime.now(timezone.utc).replace(microsecond=0) + payload = json.loads(result.stdout) + assert payload["ok"] is True + if command == "aggregate-packets": + actual = payload["surface"]["generated_at"] + elif command == "project-chatview-report": + actual = payload["chatview_report"]["generated_at"] + else: + actual = payload["generated_at"] + if generated_at is None: + assert actual.endswith("Z") + assert before <= datetime.fromisoformat(actual.replace("Z", "+00:00")) <= after + else: + assert actual == generated_at + assert payload["external_reads_performed"] is False + assert payload["private_source_content_read"] is False + assert payload["autopublish_allowed"] is False From 938673b950203d6f62de1c58befa711010333b2a Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Wed, 11 Nov 2026 23:45:18 +0800 Subject: [PATCH 07/31] fix(chat): retain steward admission across executor publication Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../native_chat/external_conversations.py | 25 +++--- loopx/chat_ingress.py | 48 ++++++++++- tests/test_lark_private_conversations.py | 82 +++++++++++++++++++ 3 files changed, 140 insertions(+), 15 deletions(-) diff --git a/loopx/capabilities/native_chat/external_conversations.py b/loopx/capabilities/native_chat/external_conversations.py index c49ce6c592..8468d7d2e8 100644 --- a/loopx/capabilities/native_chat/external_conversations.py +++ b/loopx/capabilities/native_chat/external_conversations.py @@ -16,6 +16,7 @@ from typing import Any from ...chat_actions import goal_workspace_ref +from ...chat_ingress import ensure_external_admission_scope, resolve_external_admission_source from ...chat_store import _atomic_write_json, _read_json, RESUMABLE_SESSION_STATES from ...chat_attachments import normalize_chat_image_attachments from ...control_plane.quota.automation_cadence_readback import ( @@ -1397,17 +1398,9 @@ def admit(self, *, binding_id: str, source: dict[str, Any], request_ref: str, # it must not hold up other messages while native cancellation runs. with exclusive_file_lock(path, operation="admit_external_chat_request"): with exclusive_file_lock(self.root / "source-fences" / f"{binding_id}.{source['source_ref']}.json", operation="route_external_chat_request"): - # Resolve after waiting for both fences, including exact replay. - # A lock-external provider probe cannot authorize the write and - # would duplicate this fresh authority check on every request. - selected = self.bindings.resolve(binding_id=binding_id, **source, - **({"include_observation": True} if source_preflight is not None else {})) - # Earlier provider IO and App observations cannot authorize - # admission. Validate against this fresh selection inside both fences, - # before creating or replaying any canonical request. - if source_preflight is not None: - if source_preflight(deepcopy(selected)) is not None: - raise ValueError("source preflight must complete verification or raise") + # Resolve and verify the source inside both fences before + # creating or replaying any canonical request. + selected = resolve_external_admission_source(self.bindings, binding_id, source, source_preflight) expected = {"binding_id": binding_id, "source": source, "message": message, "command": command, "attachments": normalize_chat_image_attachments(attachments) or None, "origin": origin} recovering_request = path.exists() @@ -1427,7 +1420,8 @@ def admit(self, *, binding_id: str, source: dict[str, Any], request_ref: str, "created_at": datetime.now(timezone.utc).isoformat()} _atomic_write_json(path, row) try: - row = self._admit_prepared(path, row, selected, recovering_request=recovering_request) + row = self._admit_prepared(path, row, selected, recovering_request=recovering_request, + source_preflight=source_preflight) except ValueError: self._reject_prepared(path, row) raise @@ -1449,12 +1443,15 @@ def _reject_prepared(path: Path, row: dict[str, Any]) -> None: _atomic_write_json(path, row) def _admit_prepared(self, path: Path, row: dict[str, Any], selected: dict[str, Any], - *, recovering_request: bool = False) -> dict[str, Any]: + *, recovering_request: bool = False, + source_preflight: Callable[[dict[str, Any]], None] | None = None) -> dict[str, Any]: controller = self.controller held_for_binding = row.get("response_code") == STEWARD_BINDING_ADOPTION_PENDING if held_for_binding: row.pop("response_code") - self.bindings.ensure_delivery_scope(selected) + selected = ensure_external_admission_scope( + self.bindings, selected, row["source"], source_preflight=source_preflight, + refresh_steward_executor=row["command"] is None and row.get("agent_target") is None) if not row.get("routing_recorded"): choices = [item for item in self.pending() if item.get("binding_id") == row["binding_id"] and item["source"]["source_ref"] == row["source"]["source_ref"] diff --git a/loopx/chat_ingress.py b/loopx/chat_ingress.py index 4c97dca82f..3fe859d953 100644 --- a/loopx/chat_ingress.py +++ b/loopx/chat_ingress.py @@ -2,22 +2,68 @@ from __future__ import annotations from collections.abc import Callable, Iterator +from copy import deepcopy from datetime import datetime, timedelta, timezone from functools import cached_property import os import json from pathlib import Path import uuid -from typing import Any +from typing import TYPE_CHECKING, Any from .chat import require_matching_message_replay, require_matching_replay from .file_lock import exclusive_file_lock from .capabilities.native_chat.file_discovery import FileChatDiscovery +if TYPE_CHECKING: + from .capabilities.native_chat.conversation_bindings import ChatConversationBindings + SESSION_QUEUE_MAX_PENDING = 20 SESSION_QUEUE_TTL_SECONDS: int | None = None +def resolve_external_admission_source( + bindings: ChatConversationBindings, binding_id: str, source: dict[str, Any], + source_preflight: Callable[[dict[str, Any]], None] | None, +) -> dict[str, Any]: + selected = bindings.resolve(binding_id=binding_id, **source, include_observation=source_preflight is not None) + if source_preflight is not None and source_preflight(deepcopy(selected)) is not None: + raise ValueError("source preflight must complete verification or raise") + return selected + + +def ensure_external_admission_scope( + bindings: ChatConversationBindings, selected: dict[str, Any], source: dict[str, Any], *, + refresh_steward_executor: bool, source_preflight: Callable[[dict[str, Any]], None] | None, +) -> dict[str, Any]: + previous = selected["binding"] + if (not refresh_steward_executor or source.get("private_human_message") is not True + or previous["context_kind"] != "steward" or previous.get("audience", "owner") != "owner" + or previous.get("goal_scope") not in {"all_registered", "selected"} or bindings.controller is None): + bindings.ensure_delivery_scope(selected) + return selected + + with exclusive_file_lock(bindings.path, operation="configure_bound_delivery_scope"): + current = next((binding for binding in bindings.read()["bindings"] + if binding["binding_id"] == previous["binding_id"]), None) + if current == previous: + bindings._ensure_delivery_scope(selected) # This caller retains the binding fence. + return selected + if (current is None + or {previous.get("executor_endpoint_id"), current.get("executor_endpoint_id")} != {"antigravity", "codex"} + or current != {**previous, "executor_endpoint_id": current["executor_endpoint_id"]}): + raise ValueError("conversation binding is no longer authorized") + + # No delivery effect has run. Reverify the same original source outside + # the binding fence, then require the exact refreshed binding at the fence. + fresh = resolve_external_admission_source(bindings, previous["binding_id"], source, source_preflight) + if (fresh["binding"] != current or fresh["context"] != selected["context"] + or fresh["channel_id"] != selected["channel_id"]): + raise ValueError("conversation binding changed during identity verification") + bindings.ensure_delivery_scope(fresh) + return fresh + + def chat_runtime_activation_lock(runtime_root: Path, *, timeout_seconds: float | None = None): """Fence new durable input against a managed-service runtime switch.""" return exclusive_file_lock( diff --git a/tests/test_lark_private_conversations.py b/tests/test_lark_private_conversations.py index 9814201a59..6d1cb47a01 100644 --- a/tests/test_lark_private_conversations.py +++ b/tests/test_lark_private_conversations.py @@ -104,6 +104,88 @@ def interrupt_admission_projection(*args, **kwargs): fixture.close() +@pytest.mark.parametrize("change", ["executor", "scope", "revoked", "second_publication"]) +def test_private_steward_admission_rechecks_same_owner_executor_publication(tmp_path, monkeypatch, change): + from test_native_steward_private import finish, steward + from loopx import chat_ingress + from loopx.capabilities.native_chat import conversation_bindings + from loopx.file_lock import exclusive_file_lock + + fixture = steward.__wrapped__(tmp_path) + store, runtime, provider, transport, binding, capture, _ = next(fixture) + try: + original_event = provider.event("steward-app", "original-before-adoption", "Read the original conversation") + assert transport.admit("steward-app", original_event)["status"] == "durably_accepted" + original_ref = identity_ref(binding["provider_ref"], original_event["message_id"]) + original = transport.core.read_request(original_ref) + assert finish(runtime, original)["status"] == "completed" + original_path = transport.root / f"{original_ref}.json" + assert transport.reconcile_request(original_path) == 1 + original_delivery = original_path.read_bytes() + sid = original["session_id"] + + bindings = transport.bindings + bindings.select_app_steward(binding["binding_id"], expected_revision=bindings.read()["revision"]) + adopted = bindings.read() + stale = {**adopted, "bindings": [ + {**row, "executor_endpoint_id": "antigravity"} if row["binding_id"] == binding["binding_id"] else row + for row in adopted["bindings"]]} + bindings.path.write_text(json.dumps(stale)) + published = {**adopted, "revision": adopted["revision"] + 1} + if change == "scope": + published["bindings"] = [ + {**row, "goal_scope": "selected"} if row["binding_id"] == binding["binding_id"] else row + for row in adopted["bindings"]] + if change == "revoked": + published = {**published, "app_steward_binding_id": None, "bindings": [ + row for row in adopted["bindings"] if row["binding_id"] != binding["binding_id"]]} + observed_endpoints = [] + + def publish_at_scope_fence(path, **kwargs): + if path == bindings.path and kwargs.get("operation") == "configure_bound_delivery_scope": + previous = next(row for row in bindings.read()["bindings"] if row["binding_id"] == binding["binding_id"]) + observed_endpoints.append(previous["executor_endpoint_id"]) + if len(observed_endpoints) == 1: + bindings.path.write_text(json.dumps(published)) + elif change == "second_publication": + bindings.path.write_text(json.dumps({**stale, "revision": adopted["revision"] + 2})) + return exclusive_file_lock(path, **kwargs) + + monkeypatch.setattr(chat_ingress, "exclusive_file_lock", publish_at_scope_fence) + monkeypatch.setattr(conversation_bindings, "exclusive_file_lock", publish_at_scope_fence) + pending_event = provider.event("steward-app", "original-during-adoption", "Continue in this conversation") + pending_ref = identity_ref(binding["provider_ref"], pending_event["message_id"]) + pending_path = transport.root / f"{pending_ref}.json" + calls_before = capture.read_bytes() + writes_before = list(provider.writes) + admission = transport.admit("steward-app", pending_event) + request = transport.core.read_request(pending_ref) + if change != "executor": + assert admission["status"] == "command_rejected" + assert request["status"] == "rejected" and request["turn_id"] is None + assert not store.turn_for_client(sid, f"external-{pending_ref}") + assert capture.read_bytes() == calls_before and provider.writes == writes_before + assert observed_endpoints == (["antigravity", "codex"] if change == "second_publication" else ["antigravity"]) + else: + assert admission["status"] == "durably_accepted" + assert observed_endpoints == ["antigravity", "codex"] + assert request["status"] == "accepted" and request["session_id"] == sid + assert request["binding_id"] == binding["binding_id"] and request["source"] == original["source"] + assert finish(runtime, request)["status"] == "completed" + assert transport.reconcile_request(pending_path) == 1 + delivered = json.loads(pending_path.read_text()) + assert delivered["status"] == "delivered" and delivered["deliveries"]["terminal"]["verified"] + assert delivered["event"]["message_id"] == pending_event["message_id"] + assert store.turn_for_client(sid, f"external-{pending_ref}")["turn_id"] == request["turn_id"] + writes_after = list(provider.writes) + assert transport.reconcile_request(pending_path) == 0 and provider.writes == writes_after + assert sum(json.loads(line)["method"] == "turn/start" for line in capture.read_text().splitlines()) == 2 + assert [row["session_id"] for row in store.list_sessions()] == [sid] + assert original_path.read_bytes() == original_delivery + finally: + fixture.close() + + def test_historical_context_is_explicitly_bounded_and_never_decodes_unread_attachments(): rows = [{"message_id": f"m-{i}", "created_at": f"2026-01-01T00:00:{i:02}+00:00", "role": "agent", "text": f"Complete original answer {i}"} for i in range(14)] From fb6ea1d603915eaf1382a58f1c1bfac3fb0dce17 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Wed, 11 Nov 2026 23:58:21 +0800 Subject: [PATCH 08/31] fix(chat): pin the original steward Session during scope refresh Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../native_chat/external_conversations.py | 9 ++--- loopx/chat_ingress.py | 37 ++++++++++++++++--- tests/test_lark_private_conversations.py | 8 +++- 3 files changed, 41 insertions(+), 13 deletions(-) diff --git a/loopx/capabilities/native_chat/external_conversations.py b/loopx/capabilities/native_chat/external_conversations.py index 8468d7d2e8..1903219e23 100644 --- a/loopx/capabilities/native_chat/external_conversations.py +++ b/loopx/capabilities/native_chat/external_conversations.py @@ -16,7 +16,7 @@ from typing import Any from ...chat_actions import goal_workspace_ref -from ...chat_ingress import ensure_external_admission_scope, resolve_external_admission_source +from ...chat_ingress import STEWARD_BINDING_ADOPTION_PENDING, ensure_external_admission_scope, resolve_external_admission_source from ...chat_store import _atomic_write_json, _read_json, RESUMABLE_SESSION_STATES from ...chat_attachments import normalize_chat_image_attachments from ...control_plane.quota.automation_cadence_readback import ( @@ -33,7 +33,6 @@ _REQUEST_JOURNAL_STEM = re.compile(r"[a-f0-9]{24}") QUOTA_BACKLOG_BATCH_SIZE = 64 _QUOTA_BACKLOG_BATCH_BYTES = 1024 * 1024 -STEWARD_BINDING_ADOPTION_PENDING = "steward_executor_binding_pending" def _preparation_image_identity(value: Any) -> dict[str, Any] | None: @@ -1439,6 +1438,8 @@ def admit(self, *, binding_id: str, source: dict[str, Any], request_ref: str, @staticmethod def _reject_prepared(path: Path, row: dict[str, Any]) -> None: # Definitive command/grant denials must not retry indefinitely. + if row.get("response_code") == STEWARD_BINDING_ADOPTION_PENDING: + row.pop("response_code") row.update(status="rejected", response="原授权或命令格式不可用,本条未进入执行队列;请用 /agents 或 /project 查看可用入口。") _atomic_write_json(path, row) @@ -1446,12 +1447,10 @@ def _admit_prepared(self, path: Path, row: dict[str, Any], selected: dict[str, A *, recovering_request: bool = False, source_preflight: Callable[[dict[str, Any]], None] | None = None) -> dict[str, Any]: controller = self.controller + selected = ensure_external_admission_scope(self.bindings, selected, path, row, source_preflight=source_preflight) held_for_binding = row.get("response_code") == STEWARD_BINDING_ADOPTION_PENDING if held_for_binding: row.pop("response_code") - selected = ensure_external_admission_scope( - self.bindings, selected, row["source"], source_preflight=source_preflight, - refresh_steward_executor=row["command"] is None and row.get("agent_target") is None) if not row.get("routing_recorded"): choices = [item for item in self.pending() if item.get("binding_id") == row["binding_id"] and item["source"]["source_ref"] == row["source"]["source_ref"] diff --git a/loopx/chat_ingress.py b/loopx/chat_ingress.py index 3fe859d953..7052737e04 100644 --- a/loopx/chat_ingress.py +++ b/loopx/chat_ingress.py @@ -20,24 +20,33 @@ SESSION_QUEUE_MAX_PENDING = 20 SESSION_QUEUE_TTL_SECONDS: int | None = None +STEWARD_BINDING_ADOPTION_PENDING = "steward_executor_binding_pending" def resolve_external_admission_source( bindings: ChatConversationBindings, binding_id: str, source: dict[str, Any], - source_preflight: Callable[[dict[str, Any]], None] | None, + source_preflight: Callable[[dict[str, Any]], None] | None, *, + session: dict[str, Any] | None = None, ) -> dict[str, Any]: - selected = bindings.resolve(binding_id=binding_id, **source, include_observation=source_preflight is not None) + observation_args = {**source} + if source_preflight is not None: + observation_args["include_observation"] = True + if session is not None: + observation_args.update(session=session, session_context=session["steward_context"]) + selected = bindings.resolve(binding_id=binding_id, **observation_args) if source_preflight is not None and source_preflight(deepcopy(selected)) is not None: raise ValueError("source preflight must complete verification or raise") return selected def ensure_external_admission_scope( - bindings: ChatConversationBindings, selected: dict[str, Any], source: dict[str, Any], *, - refresh_steward_executor: bool, source_preflight: Callable[[dict[str, Any]], None] | None, + bindings: ChatConversationBindings, selected: dict[str, Any], path: Path, request: dict[str, Any], *, + source_preflight: Callable[[dict[str, Any]], None] | None, ) -> dict[str, Any]: + source = request["source"] previous = selected["binding"] - if (not refresh_steward_executor or source.get("private_human_message") is not True + if (request["command"] is not None or request.get("agent_target") is not None + or source.get("private_human_message") is not True or previous["context_kind"] != "steward" or previous.get("audience", "owner") != "owner" or previous.get("goal_scope") not in {"all_registered", "selected"} or bindings.controller is None): bindings.ensure_delivery_scope(selected) @@ -53,10 +62,26 @@ def ensure_external_admission_scope( or {previous.get("executor_endpoint_id"), current.get("executor_endpoint_id")} != {"antigravity", "codex"} or current != {**previous, "executor_endpoint_id": current["executor_endpoint_id"]}): raise ValueError("conversation binding is no longer authorized") + endpoint = current["executor_endpoint_id"] + candidates = bindings.controller.store.resumable_session_candidates( + goal_id="loopx-manager", agent_id=endpoint, channel_id=selected["channel_id"]) + if len(candidates) != 1: + raise ValueError("the original steward Session is unavailable for admission") + original = candidates[0] + session_id = original["session_id"] + if (original.get("executor_endpoint_id") != endpoint + or not isinstance(original.get("steward_context"), dict) + or request.get("session_id") not in (None, session_id)): + raise ValueError("the original steward Session changed during admission") + # Preserve the original Session across provider IO, service interruption + # or Close. The existing prepared-request path owns subsequent admission. + from .chat_store import _atomic_write_json + request.update(session_id=session_id, response_code=STEWARD_BINDING_ADOPTION_PENDING) + _atomic_write_json(path, request) # No delivery effect has run. Reverify the same original source outside # the binding fence, then require the exact refreshed binding at the fence. - fresh = resolve_external_admission_source(bindings, previous["binding_id"], source, source_preflight) + fresh = resolve_external_admission_source(bindings, previous["binding_id"], source, source_preflight, session=original) if (fresh["binding"] != current or fresh["context"] != selected["context"] or fresh["channel_id"] != selected["channel_id"]): raise ValueError("conversation binding changed during identity verification") diff --git a/tests/test_lark_private_conversations.py b/tests/test_lark_private_conversations.py index 6d1cb47a01..8cd5965410 100644 --- a/tests/test_lark_private_conversations.py +++ b/tests/test_lark_private_conversations.py @@ -104,7 +104,7 @@ def interrupt_admission_projection(*args, **kwargs): fixture.close() -@pytest.mark.parametrize("change", ["executor", "scope", "revoked", "second_publication"]) +@pytest.mark.parametrize("change", ["executor", "scope", "revoked", "second_publication", "closed"]) def test_private_steward_admission_rechecks_same_owner_executor_publication(tmp_path, monkeypatch, change): from test_native_steward_private import finish, steward from loopx import chat_ingress @@ -149,6 +149,8 @@ def publish_at_scope_fence(path, **kwargs): bindings.path.write_text(json.dumps(published)) elif change == "second_publication": bindings.path.write_text(json.dumps({**stale, "revision": adopted["revision"] + 2})) + elif change == "closed": + assert runtime.close_session(sid) return exclusive_file_lock(path, **kwargs) monkeypatch.setattr(chat_ingress, "exclusive_file_lock", publish_at_scope_fence) @@ -165,7 +167,9 @@ def publish_at_scope_fence(path, **kwargs): assert request["status"] == "rejected" and request["turn_id"] is None assert not store.turn_for_client(sid, f"external-{pending_ref}") assert capture.read_bytes() == calls_before and provider.writes == writes_before - assert observed_endpoints == (["antigravity", "codex"] if change == "second_publication" else ["antigravity"]) + assert observed_endpoints == (["antigravity", "codex"] if change in {"second_publication", "closed"} else ["antigravity"]) + if change == "closed": + assert request["session_id"] == sid and "response_code" not in request else: assert admission["status"] == "durably_accepted" assert observed_endpoints == ["antigravity", "codex"] From 43bd8bd8c009cfe5afe2caa00c71534390b158ab Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Thu, 12 Nov 2026 00:40:36 +0800 Subject: [PATCH 09/31] fix(lark): verify original message after steward executor adoption Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../extensions/lark/private_conversations.py | 23 ++++----- loopx/extensions/lark/private_ingress.py | 47 +++++++++++++++++++ tests/test_lark_private_conversations.py | 38 ++++++++++++--- 3 files changed, 88 insertions(+), 20 deletions(-) diff --git a/loopx/extensions/lark/private_conversations.py b/loopx/extensions/lark/private_conversations.py index 952dfccb13..a732be14e6 100644 --- a/loopx/extensions/lark/private_conversations.py +++ b/loopx/extensions/lark/private_conversations.py @@ -42,6 +42,7 @@ from .outbound import present_lark_conversation_text as _presentation_text from .private_images import private_message_caption, private_message_images from .private_delivery_discovery import PrivateDeliveryDiscovery, prepared_steward_binding_waits +from .private_ingress import PrivateSourceUnavailable, private_source_text, steward_source_preflight from .private_ingress import project_private_reply_history as project_private_reply_history from .return_files import uploaded_chat_images, verify_chat_images from .private_progress import (ACTIVITY_UPDATE_INTERVAL_SEC, UPDATE_INTERVAL_SEC, legacy_recovery_repairs, @@ -914,19 +915,9 @@ def admit(self, profile: str, event: dict[str, Any], *, canonical_type = source_message.get("msg_type", source_message.get("message_type")) if canonical_type != "text": return {"status": "source_conflict"} - content = source_message.get("content") - if isinstance(content, str): - text = content - else: - # Raw provider envelopes are supported by the existing - # readback contract too; keep parsing isolated to body. - try: - raw = json.loads(source_message["body"]["content"]) - text = raw["text"] - except (ValueError, KeyError, TypeError): - return {"status": "invalid_text"} - if not isinstance(text, str): - return {"status": "invalid_text"} + text = private_source_text(source_message) + if text is None: + return {"status": "invalid_text"} if not text.strip(): return {"status": "empty_text"} elif message_type in {"image", "post"}: @@ -1024,10 +1015,14 @@ def read(tail: list[str]) -> Mapping[str, Any] | None: _atomic_write_json(path, record) return {"status": "history_answer_held"} else: + preflight = (steward_source_preflight(selected["binding"], source_message, + lambda fresh: self._source_message(record, selected=fresh)) if command is None else None) admitted = self.core.admit(binding_id=binding["binding_id"], source=source, request_ref=request, message=text, command=command, attachments=attachments, - defer_background_stop=defer_background_stop) + defer_background_stop=defer_background_stop, source_preflight=preflight) record_event_timing(profile, event["message_id"], "core_admission_returned") + except PrivateSourceUnavailable: + return {"status": "source_verification_failed"} except ValueError: if record.get("capture_origin") == "provider_history": return {"status": "history_answer_held"} diff --git a/loopx/extensions/lark/private_ingress.py b/loopx/extensions/lark/private_ingress.py index e3c837c93c..3450e34bd9 100644 --- a/loopx/extensions/lark/private_ingress.py +++ b/loopx/extensions/lark/private_ingress.py @@ -25,6 +25,53 @@ from .private_json import write_private_json_atomic +class PrivateSourceUnavailable(OSError): + """Keep the captured request for its existing delivery worker.""" + + +def private_source_text(message: Mapping[str, Any]) -> str | None: + content = message.get("content") + if isinstance(content, str): + return content + try: + text = json.loads(message["body"]["content"])["text"] + except (KeyError, TypeError, ValueError): + return None + return text if isinstance(text, str) else None + + +def _private_source_input(message: Mapping[str, Any]) -> tuple[Any, ...]: + kind = message.get("msg_type", message.get("message_type")) + content = private_source_text(message) if kind == "text" else message.get("content") + ancestry = tuple(message.get(field) or None for field in ("parent_id", "root_id", "thread_id")) + return kind, content, message.get("create_time"), ancestry + + +def steward_source_preflight( + binding: Mapping[str, Any], original: Mapping[str, Any], + read_source: Callable[[dict[str, Any]], Mapping[str, Any] | None], +) -> Callable[[dict[str, Any]], None] | None: + """Require fresh Lark content if Core observes a different executor.""" + if binding.get("context_kind") != "steward" or binding.get("audience", "owner") != "owner": + return None + observed_executor = binding["executor_endpoint_id"] + + def verify(fresh: dict[str, Any]) -> None: + nonlocal observed_executor + executor = fresh["binding"]["executor_endpoint_id"] + if executor == observed_executor: + return + current = read_source(fresh) + if current is None: + raise PrivateSourceUnavailable("original Lark source could not be verified") + if (current.get("deleted") is True or original.get("deleted") is True + or _private_source_input(current) != _private_source_input(original)): + raise ValueError("original Lark source changed during executor adoption") + observed_executor = executor + + return verify + + class IngressObservationStatus(str, Enum): """Lark observation vocabulary; it grants no Core action or permission.""" diff --git a/tests/test_lark_private_conversations.py b/tests/test_lark_private_conversations.py index 8cd5965410..6771b93483 100644 --- a/tests/test_lark_private_conversations.py +++ b/tests/test_lark_private_conversations.py @@ -104,7 +104,8 @@ def interrupt_admission_projection(*args, **kwargs): fixture.close() -@pytest.mark.parametrize("change", ["executor", "scope", "revoked", "second_publication", "closed"]) +@pytest.mark.parametrize("change", ["executor", "scope", "revoked", "second_publication", "closed", + "source_unavailable", "source_changed"]) def test_private_steward_admission_rechecks_same_owner_executor_publication(tmp_path, monkeypatch, change): from test_native_steward_private import finish, steward from loopx import chat_ingress @@ -114,8 +115,12 @@ def test_private_steward_admission_rechecks_same_owner_executor_publication(tmp_ fixture = steward.__wrapped__(tmp_path) store, runtime, provider, transport, binding, capture, _ = next(fixture) try: + def source_reads(message_id): + return sum("+messages-mget" in args and message_id in args for args in provider.calls) + original_event = provider.event("steward-app", "original-before-adoption", "Read the original conversation") assert transport.admit("steward-app", original_event)["status"] == "durably_accepted" + assert source_reads(original_event["message_id"]) == 1 original_ref = identity_ref(binding["provider_ref"], original_event["message_id"]) original = transport.core.read_request(original_ref) assert finish(runtime, original)["status"] == "completed" @@ -140,6 +145,9 @@ def test_private_steward_admission_rechecks_same_owner_executor_publication(tmp_ published = {**published, "app_steward_binding_id": None, "bindings": [ row for row in adopted["bindings"] if row["binding_id"] != binding["binding_id"]]} observed_endpoints = [] + pending_event = provider.event("steward-app", "original-during-adoption", "Continue in this conversation") + pending_id = pending_event["message_id"] + pending_source = dict(provider.messages[pending_id]) def publish_at_scope_fence(path, **kwargs): if path == bindings.path and kwargs.get("operation") == "configure_bound_delivery_scope": @@ -147,6 +155,10 @@ def publish_at_scope_fence(path, **kwargs): observed_endpoints.append(previous["executor_endpoint_id"]) if len(observed_endpoints) == 1: bindings.path.write_text(json.dumps(published)) + if change == "source_unavailable": + provider.messages.pop(pending_id) + elif change == "source_changed": + provider.messages[pending_id]["content"] = "Edited after the first provider read" elif change == "second_publication": bindings.path.write_text(json.dumps({**stale, "revision": adopted["revision"] + 2})) elif change == "closed": @@ -155,28 +167,42 @@ def publish_at_scope_fence(path, **kwargs): monkeypatch.setattr(chat_ingress, "exclusive_file_lock", publish_at_scope_fence) monkeypatch.setattr(conversation_bindings, "exclusive_file_lock", publish_at_scope_fence) - pending_event = provider.event("steward-app", "original-during-adoption", "Continue in this conversation") - pending_ref = identity_ref(binding["provider_ref"], pending_event["message_id"]) + pending_ref = identity_ref(binding["provider_ref"], pending_id) pending_path = transport.root / f"{pending_ref}.json" calls_before = capture.read_bytes() writes_before = list(provider.writes) admission = transport.admit("steward-app", pending_event) request = transport.core.read_request(pending_ref) - if change != "executor": + if change == "source_unavailable": + assert admission["status"] == "source_verification_failed" + assert request["status"] == "prepared" and request["session_id"] == sid and request["turn_id"] is None + assert json.loads(pending_path.read_text())["status"] == "captured" + assert source_reads(pending_id) == 2 and observed_endpoints == ["antigravity"] + assert not store.turn_for_client(sid, f"external-{pending_ref}") + assert capture.read_bytes() == calls_before and provider.writes == writes_before + provider.messages[pending_id] = pending_source + transport.reconcile_request(pending_path) + request = transport.core.read_request(pending_ref) + if change not in {"executor", "source_unavailable"}: assert admission["status"] == "command_rejected" assert request["status"] == "rejected" and request["turn_id"] is None assert not store.turn_for_client(sid, f"external-{pending_ref}") assert capture.read_bytes() == calls_before and provider.writes == writes_before + if change == "source_changed": + assert source_reads(pending_id) == 2 assert observed_endpoints == (["antigravity", "codex"] if change in {"second_publication", "closed"} else ["antigravity"]) if change == "closed": assert request["session_id"] == sid and "response_code" not in request else: - assert admission["status"] == "durably_accepted" + if change == "executor": + assert admission["status"] == "durably_accepted" + assert source_reads(pending_id) == 2 assert observed_endpoints == ["antigravity", "codex"] assert request["status"] == "accepted" and request["session_id"] == sid assert request["binding_id"] == binding["binding_id"] and request["source"] == original["source"] assert finish(runtime, request)["status"] == "completed" - assert transport.reconcile_request(pending_path) == 1 + if json.loads(pending_path.read_text())["status"] != "delivered": + assert transport.reconcile_request(pending_path) == 1 delivered = json.loads(pending_path.read_text()) assert delivered["status"] == "delivered" and delivered["deliveries"]["terminal"]["verified"] assert delivered["event"]["message_id"] == pending_event["message_id"] From f905c49860a76aa61dfb4bc831ddbc7c6579e0cd Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Thu, 12 Nov 2026 01:47:41 +0800 Subject: [PATCH 10/31] perf(chat): skip unrelated transcript reads in bound Session discovery Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../native_chat/conversation_bindings.py | 19 ++++--- loopx/chat_store.py | 37 ++++++------- tests/test_lark_private_conversations.py | 52 +++++++++++++++++++ 3 files changed, 80 insertions(+), 28 deletions(-) diff --git a/loopx/capabilities/native_chat/conversation_bindings.py b/loopx/capabilities/native_chat/conversation_bindings.py index 0ce429598c..db9821d0ea 100644 --- a/loopx/capabilities/native_chat/conversation_bindings.py +++ b/loopx/capabilities/native_chat/conversation_bindings.py @@ -424,14 +424,19 @@ def local_session_snapshot(self, binding_id: str, *, app_steward_entry: bool = F raise ValueError("conversation binding is no longer authorized") observation = dict(self.observe(row["transport_ref"])) store = self.controller.store + + def mentions_binding(session: dict[str, Any]) -> bool: + return any(isinstance(session.get(key), dict) and session[key].get("binding_id") == binding_id + for key in ("project_context", "steward_context")) + + # Keep aliases in the candidate set even when their own metadata is + # unrelated. Core must still see duplicate canonical Session identities. + hints = (session for path, session in store.iter_session_metadata() + if path.parent.name != session["session_id"] or mentions_binding(session)) sessions: list[dict[str, Any]] = [] - for summary in store.list_sessions(): - session = store.load_session(summary["session_id"]) - # Narrow the IO payload only. Core independently validates every - # matching context, audience and canonical Session identity. - if session and any(isinstance(session.get(key), dict) - and session[key].get("binding_id") == binding_id - for key in ("project_context", "steward_context")): + for hint in sorted(hints, key=lambda item: str(item.get("updated_at") or ""), reverse=True): + session = store.load_session(hint["session_id"]) + if session and mentions_binding(session): sessions.append(session) current = self.read() if next((item for item in current["bindings"] if item["binding_id"] == binding_id), None) != row: diff --git a/loopx/chat_store.py b/loopx/chat_store.py index f85ddfc332..5d719fb771 100644 --- a/loopx/chat_store.py +++ b/loopx/chat_store.py @@ -12,7 +12,7 @@ from pathlib import Path import re import threading -from typing import Any +from typing import Any, Iterator import uuid from weakref import WeakValueDictionary @@ -825,32 +825,27 @@ def session_candidates( candidates.append(payload) return candidates - def list_sessions( - self, - *, - goal_id: str | None = None, - agent_id: str | None = None, - channel_id: str | None = None, - ) -> list[dict[str, Any]]: - rows: list[dict[str, Any]] = [] + def list_sessions(self, *, goal_id: str | None = None, agent_id: str | None = None, + channel_id: str | None = None) -> list[dict[str, Any]]: + metadata = self.iter_session_metadata(goal_id=goal_id, agent_id=agent_id, channel_id=channel_id) + rows = (self.public_session(payload) for _, payload in metadata) + return sorted(rows, key=lambda item: str(item.get("updated_at") or ""), reverse=True) + + def iter_session_metadata(self, *, goal_id: str | None = None, agent_id: str | None = None, + channel_id: str | None = None) -> Iterator[tuple[Path, dict[str, Any]]]: + """Read discoverable Session records without reading transcript versions.""" for path in self.sessions_root.glob("*/session.json"): try: payload = _read_json(path) except UnicodeDecodeError: - # Discovery can skip damaged metadata, just as malformed JSON. - # Direct Session/Turn reads stay strict: unreadable queued work - # must never become false evidence that a conversation is idle. - continue - if payload.get("schema_version") != CHAT_SESSION_SCHEMA_VERSION: - continue - if goal_id and payload.get("goal_id") != goal_id: + # Direct Session reads remain strict. Discovery skips damaged metadata. continue - if agent_id and payload.get("agent_id") != agent_id: + if (payload.get("schema_version") != CHAT_SESSION_SCHEMA_VERSION + or (goal_id and payload.get("goal_id") != goal_id) + or (agent_id and payload.get("agent_id") != agent_id) + or (channel_id and _session_channel(payload) != channel_id)): continue - if channel_id and _session_channel(payload) != channel_id: - continue - rows.append(self.public_session(payload)) - return sorted(rows, key=lambda item: str(item.get("updated_at") or ""), reverse=True) + yield path, payload def append_message( self, diff --git a/tests/test_lark_private_conversations.py b/tests/test_lark_private_conversations.py index 6771b93483..65bd275297 100644 --- a/tests/test_lark_private_conversations.py +++ b/tests/test_lark_private_conversations.py @@ -20,6 +20,58 @@ from loopx.extensions.lark.goal_topic_runtime import poll_lark_goal_topic_profile_once +def test_app_steward_discovery_only_projects_the_bound_session(tmp_path, monkeypatch): + from collections import Counter + from loopx import chat_store + from test_native_steward_private import finish, steward + + fixture = steward.__wrapped__(tmp_path) + store, runtime, provider, transport, binding, capture, _ = next(fixture) + try: + event = provider.event("steward-app", "discovery-source", "Read the fixture workspace") + assert transport.admit("steward-app", event)["status"] == "durably_accepted" + request = transport.core.read_request(identity_ref(binding["provider_ref"], event["message_id"])) + assert finish(runtime, request)["status"] == "completed" + original_id = request["session_id"] + other = store.create_session(goal_id="different-goal", agent_id="codex", adapter_kind="codex_app_server", + upstream_thread_id="other-thread", upstream_mode="chat") + bindings = transport.bindings + bindings.select_app_steward(binding["binding_id"], expected_revision=bindings.read()["revision"]) + before = capture.read_bytes(), bindings.path.read_bytes(), store.messages(original_id) + metadata, transcripts = Counter(), [] + read, revision = chat_store._read_json, store._event_revision + + def read_session(path): + if path.name == "session.json": + metadata[path.parent.name] += 1 + return read(path) + + def read_revision(path): + if path.name == "messages.jsonl": + transcripts.append(path.parent.name) + return revision(path) + + with monkeypatch.context() as observations: + observations.setattr(chat_store, "_read_json", read_session) + observations.setattr(store, "_event_revision", read_revision) + start = len(provider.calls) + assert bindings.app_steward_target() == {"binding_id": binding["binding_id"], "session_id": original_id} + assert sum("auth" in args and "--verify" in args for args in provider.calls[start:]) == 1 + assert metadata == {original_id: 2, other["session_id"]: 1} + assert transcripts == [original_id] + assert before == (capture.read_bytes(), bindings.path.read_bytes(), store.messages(original_id)) + + # The alias's own context is unrelated, but its declared ID still + # resolves to the same canonical bound record. Core must see both. + alias_path = store._session_path(other["session_id"]) + alias_path.write_text(json.dumps({**other, "session_id": original_id})) + with pytest.raises(ValueError, match="canonical bound Session identity is ambiguous"): + bindings.app_steward_target() + finally: + runtime.close() + fixture.close() + + @pytest.mark.parametrize("outcome", ["adopted", "closed", "interrupted"]) def test_private_steward_holds_original_request_during_executor_adoption(tmp_path, monkeypatch, outcome): from test_native_steward_private import finish, steward From 6ab22a73b3f6cb7737c70b8e8425bc0cf905b89d Mon Sep 17 00:00:00 2001 From: psychologister Date: Thu, 12 Nov 2026 02:24:53 +0800 Subject: [PATCH 11/31] agent_turn_recall: build the canonical hash through the digest envelope owner (#8611) --- loopx/capabilities/agent_turn_recall/core.py | 4 ++-- tests/architecture/test_content_digest_production_owner.py | 7 ++++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/loopx/capabilities/agent_turn_recall/core.py b/loopx/capabilities/agent_turn_recall/core.py index bce98d2a15..7bf8c42742 100644 --- a/loopx/capabilities/agent_turn_recall/core.py +++ b/loopx/capabilities/agent_turn_recall/core.py @@ -1,10 +1,10 @@ from __future__ import annotations -import hashlib import json from collections.abc import Mapping, Sequence from typing import Any +from ...control_plane.digest_envelope import sha256_envelope from ...control_plane.runtime.public_safety import public_safe_compact_text from ..context_providers.base import ContextProvider from ..reward_memory.runtime_hooks import run_reward_memory_automatic_recall_hook @@ -32,7 +32,7 @@ def _canonical_hash(value: object) -> str: sort_keys=True, separators=(",", ":"), ).encode("utf-8") - return "sha256:" + hashlib.sha256(encoded).hexdigest() + return sha256_envelope(encoded) def _recent_outcomes(quota_decision: Mapping[str, Any]) -> list[str]: diff --git a/tests/architecture/test_content_digest_production_owner.py b/tests/architecture/test_content_digest_production_owner.py index e0f21f04d9..e42a176b75 100644 --- a/tests/architecture/test_content_digest_production_owner.py +++ b/tests/architecture/test_content_digest_production_owner.py @@ -30,6 +30,7 @@ import pytest +from loopx.capabilities.agent_turn_recall import core from loopx.capabilities.periodic_report import ( archive, audience, @@ -58,7 +59,10 @@ PRODUCTION_OWNER = "loopx/control_plane/digest_envelope.py" ENVELOPE = "sha256:" -CONVERTED_SURFACES = ("loopx/capabilities/periodic_report",) +CONVERTED_SURFACES = ( + "loopx/capabilities/agent_turn_recall", + "loopx/capabilities/periodic_report", +) def _fold_joined_prefix(node: ast.JoinedStr, scope: Any) -> str | None: @@ -297,6 +301,7 @@ def _reference(data: bytes) -> str: @pytest.mark.parametrize( "helper,expected", [ + (core._canonical_hash, _reference(_canonical(SAMPLE))), (bindings._sha256, _reference(_canonical(SAMPLE))), (audience._digest, _reference(_canonical(SAMPLE))), (machine_defaults._digest, _reference(_canonical(SAMPLE))), From 4dc8933f3fb0782e196d8326f4ebc71dd4be1b6e Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Thu, 12 Nov 2026 02:48:43 +0800 Subject: [PATCH 12/31] fix(accounts): detect stale adapter pins during provider readiness Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../loopx-codex-provider-routing/NATIVE.md | 9 +++++ .../extension.toml | 2 +- .../pyproject.toml | 2 +- .../native-extension.toml | 2 +- .../native_host.py | 27 ++++++++------ .../loopx_codex_provider_routing/provider.py | 22 ++++++++++-- .../tests/test_provider_lifecycle.py | 36 +++++++++++++++++++ 7 files changed, 83 insertions(+), 17 deletions(-) diff --git a/packages/loopx-codex-provider-routing/NATIVE.md b/packages/loopx-codex-provider-routing/NATIVE.md index 52170eca18..2662968c96 100644 --- a/packages/loopx-codex-provider-routing/NATIVE.md +++ b/packages/loopx-codex-provider-routing/NATIVE.md @@ -72,6 +72,15 @@ and steward independently. An unconfigured steward route does not block the worker. If a configured route is unavailable, or the two health responses identify different relay instances, installation verification fails and each route keeps its own status. The legacy `relay` field still reports the worker. + +Version 0.15.8 reports `adapter_readiness` alongside relay readiness. With an +enabled account policy and a saved adapter, the local probe verifies the same +owner-file pins and native route as Chat model preparation. A stale pin reports +`adapter_changed` and fails installation readiness even when both relays answer. +After adoption of the reviewed package, use the existing **Reconnect** action +with the current policy and check the live status again. For package-only +verification before adoption, pass a disposable `--runtime-root` to `--doctor`. + Keep original-message acceptance as a separate end-to-end check. For upgrades, install the reviewed target into a new immutable environment and diff --git a/packages/loopx-codex-provider-routing/extension.toml b/packages/loopx-codex-provider-routing/extension.toml index d99c6dd847..a87a17832b 100644 --- a/packages/loopx-codex-provider-routing/extension.toml +++ b/packages/loopx-codex-provider-routing/extension.toml @@ -1,6 +1,6 @@ schema_version = "loopx_extension_manifest_v0" id = "loopx-codex-provider-routing" -version = "0.15.7" +version = "0.15.8" requires_loopx_api = ">=1,<2" permissions = [] diff --git a/packages/loopx-codex-provider-routing/pyproject.toml b/packages/loopx-codex-provider-routing/pyproject.toml index b675c9268f..6f6a7fbcdd 100644 --- a/packages/loopx-codex-provider-routing/pyproject.toml +++ b/packages/loopx-codex-provider-routing/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "loopx-codex-provider-routing" -version = "0.15.7" +version = "0.15.8" description = "Native Codex account slots and exhausted-account handoff for LoopX" readme = "README.md" requires-python = ">=3.11" diff --git a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native-extension.toml b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native-extension.toml index 59e8df3cd0..24794166bf 100644 --- a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native-extension.toml +++ b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native-extension.toml @@ -1,6 +1,6 @@ schema_version = "loopx_extension_manifest_v0" id = "loopx-native-account-provider" -version = "0.15.7" +version = "0.15.8" requires_loopx_api = ">=1,<2" permissions = ["model-provider.local-read", "model-provider.transport-manage"] diff --git a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_host.py b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_host.py index 082e55652c..dec2e76a5e 100644 --- a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_host.py +++ b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_host.py @@ -129,21 +129,26 @@ def discover(*, user_home: Path | None = None) -> dict: "profile": {key: str(value) for key, value in profile.items()}} +def checked_adapter(adapter: dict, home: Path) -> tuple[Path, dict]: + if adapter.get("owner") != OWNER: + raise HostError("adapter_changed") + source = Path(__file__).parent + for name in OWNER_FILES: + if hashlib.sha256(trusted_file(source / name).read_bytes()).hexdigest() != adapter["pins"][name]: + raise HostError("adapter_changed") + codex = trusted_file(Path(adapter["codex"])) + spec = native_app.route(home, "gpt") + if {key: str(value) for key, value in spec.items()} != adapter.get("profile"): + raise HostError("adapter_changed") + return codex, spec + + class NativeHost: def __init__(self, adapter: dict, *, user_home: Path | None = None, reconcile_services: bool = True): - if adapter.get("owner") != OWNER: - raise HostError("adapter_changed") - source = Path(__file__).parent - for name in OWNER_FILES: - if hashlib.sha256(trusted_file(source / name).read_bytes()).hexdigest() != adapter["pins"][name]: - raise HostError("adapter_changed") - self.codex = trusted_file(Path(adapter["codex"])) + self.home = user_home or Path.home() + self.codex, self.spec = checked_adapter(adapter, self.home) self.apps = native_app self.accounts = native_account_store - self.home = user_home or Path.home() - self.spec = self.apps.route(self.home, "gpt") - if {key: str(value) for key, value in self.spec.items()} != adapter.get("profile"): - raise HostError("adapter_changed") self.expected = None self.not_after = None diff --git a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/provider.py b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/provider.py index a0eaf01bce..29a99f17b6 100644 --- a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/provider.py +++ b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/provider.py @@ -8,11 +8,25 @@ import sys from .native import NativeAccounts, policy -from .native_host import HostError +from .native_host import HostError, checked_adapter SCHEMA = "loopx_native_account_provider_v1" +def adapter_readiness(owner: NativeAccounts) -> dict: + state = owner._load() + adapter = state.get("adapter") + if adapter is None or state["policy"].get("enabled") is not True: + return {"state": "not_required", "ready": True} + try: + checked_adapter(adapter, Path.home()) + except HostError as error: + return {"state": error.code, "ready": False} + except (AttributeError, KeyError, OSError, TypeError, ValueError): + return {"state": "native_unavailable", "ready": False} + return {"state": "ready", "ready": True} + + def doctor(owner): from .relay_lifecycle import consumer_statuses, same_instance # Exercises the shipped TS bridge with a synthetic disabled policy; no @@ -22,10 +36,12 @@ def doctor(owner): worker, steward = consumers["worker"], consumers["steward"] worker_runtime, steward_runtime = worker.get("runtime"), steward.get("runtime") same = same_instance(worker_runtime, steward_runtime) if worker_runtime and steward_runtime else None - ready = worker["state"] in ("not_started", "ready") and ( + adapter = adapter_readiness(owner) + ready = adapter["ready"] and worker["state"] in ("not_started", "ready") and ( steward["state"] == "not_configured" or (steward["ready"] and same is True)) return {"ok": ready, "schema_version": SCHEMA, "provider_id": "loopx-native-account-provider", - "installation_ready": ready, "relay": worker, "consumer_readiness": consumers, + "installation_ready": ready, "adapter_readiness": adapter, + "relay": worker, "consumer_readiness": consumers, "same_relay_instance": same, "message_delivery_verified": False} diff --git a/packages/loopx-codex-provider-routing/tests/test_provider_lifecycle.py b/packages/loopx-codex-provider-routing/tests/test_provider_lifecycle.py index f88b5989b8..b7f59b6785 100644 --- a/packages/loopx-codex-provider-routing/tests/test_provider_lifecycle.py +++ b/packages/loopx-codex-provider-routing/tests/test_provider_lifecycle.py @@ -187,6 +187,7 @@ def test_provider_doctor_checks_real_dependencies_and_does_not_start_unconfigure before = list(owner.root.iterdir()) result = provider.doctor(owner) assert result["installation_ready"] and result["relay"]["state"] == "not_started" + assert result["adapter_readiness"] == {"state": "not_required", "ready": True} assert result["consumer_readiness"]["steward"]["state"] == "not_configured" assert result["same_relay_instance"] is None assert result["message_delivery_verified"] is False and list(owner.root.iterdir()) == before @@ -195,6 +196,41 @@ def test_provider_doctor_checks_real_dependencies_and_does_not_start_unconfigure provider.doctor(owner) +def test_provider_doctor_rejects_stale_native_adapter_with_healthy_relay(owner, tmp_path, monkeypatch): + native = importlib.import_module("loopx_codex_provider_routing.native") + host = importlib.import_module("loopx_codex_provider_routing.native_host") + life = importlib.import_module("loopx_codex_provider_routing.relay_lifecycle") + provider = importlib.import_module("loopx_codex_provider_routing.provider") + fake_codex = tmp_path / "codex" + fake_codex.write_text("") + fake_codex.chmod(0o700) + route = {"home": tmp_path / "codex-home", "frontend": tmp_path / "frontend"} + monkeypatch.setattr(host.native_app, "route", lambda *_: route) + original_which = host.shutil.which + monkeypatch.setattr(host.shutil, "which", lambda name: str(fake_codex) if name == "codex" else original_which(name)) + current = host.discover() + stale = {**current, "pins": {**current["pins"], "native_account_store.py": "0" * 64}} + state = owner._load() + native.atomic(owner.path, {**state, "adapter": stale}) + before = owner.path.read_bytes() + runtime = {"instance_id": "relay", "pid": 1, "build": "current"} + monkeypatch.setattr(life, "consumer_statuses", lambda _: { + name: {"state": "ready", "ready": True, "runtime": runtime} for name in ("worker", "steward") + }) + monkeypatch.setattr(host.NativeHost, "status", lambda *_: pytest.fail("Probe must use adapter checks only")) + + result = provider.doctor(owner) + assert result["same_relay_instance"] is True + assert result["installation_ready"] is False and result["ok"] is False + assert result["adapter_readiness"] == {"state": "adapter_changed", "ready": False} + assert owner.path.read_bytes() == before + + native.atomic(owner.path, {**state, "adapter": current}) + result = provider.doctor(owner) + assert result["adapter_readiness"] == {"state": "ready", "ready": True} + assert result["installation_ready"] and result["ok"] + + def test_installed_doctor_separates_steward_readiness_from_worker_health(owner, monkeypatch): relay = importlib.import_module("loopx_codex_provider_routing.openai_relay") native = importlib.import_module("loopx_codex_provider_routing.native") From 3ac127c24c3139f3c13de32dbc02430c18a0e591 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Thu, 12 Nov 2026 03:38:29 +0800 Subject: [PATCH 13/31] fix(accounts): preserve readiness JSON for invalid saved policy Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../loopx-codex-provider-routing/NATIVE.md | 2 + .../loopx_codex_provider_routing/provider.py | 11 +++-- .../tests/test_provider_lifecycle.py | 46 +++++++++++++++++++ 3 files changed, 55 insertions(+), 4 deletions(-) diff --git a/packages/loopx-codex-provider-routing/NATIVE.md b/packages/loopx-codex-provider-routing/NATIVE.md index 2662968c96..10fc6aba04 100644 --- a/packages/loopx-codex-provider-routing/NATIVE.md +++ b/packages/loopx-codex-provider-routing/NATIVE.md @@ -77,6 +77,8 @@ Version 0.15.8 reports `adapter_readiness` alongside relay readiness. With an enabled account policy and a saved adapter, the local probe verifies the same owner-file pins and native route as Chat model preparation. A stale pin reports `adapter_changed` and fails installation readiness even when both relays answer. +A malformed saved policy reports `state_untrusted` and retains the two relay +observations. After adoption of the reviewed package, use the existing **Reconnect** action with the current policy and check the live status again. For package-only verification before adoption, pass a disposable `--runtime-root` to `--doctor`. diff --git a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/provider.py b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/provider.py index 29a99f17b6..00fabdbd46 100644 --- a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/provider.py +++ b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/provider.py @@ -14,11 +14,14 @@ def adapter_readiness(owner: NativeAccounts) -> dict: - state = owner._load() - adapter = state.get("adapter") - if adapter is None or state["policy"].get("enabled") is not True: - return {"state": "not_required", "ready": True} try: + state = owner._load() + saved_policy = state.get("policy") + if not isinstance(saved_policy, dict) or type(saved_policy.get("enabled")) is not bool: + return {"state": "state_untrusted", "ready": False} + adapter = state.get("adapter") + if adapter is None or not saved_policy["enabled"]: + return {"state": "not_required", "ready": True} checked_adapter(adapter, Path.home()) except HostError as error: return {"state": error.code, "ready": False} diff --git a/packages/loopx-codex-provider-routing/tests/test_provider_lifecycle.py b/packages/loopx-codex-provider-routing/tests/test_provider_lifecycle.py index b7f59b6785..57994001f7 100644 --- a/packages/loopx-codex-provider-routing/tests/test_provider_lifecycle.py +++ b/packages/loopx-codex-provider-routing/tests/test_provider_lifecycle.py @@ -285,6 +285,52 @@ def doctor(credentials): thread.join(5) +@pytest.mark.parametrize("saved_policy", [ + {}, + {"policy": None}, + {"policy": []}, + {"policy": {"enabled": "true"}}, +], ids=["missing", "null", "list", "invalid-enabled"]) +@pytest.mark.parametrize("operation", ["doctor", "status"]) +def test_provider_readiness_reports_invalid_saved_policy(owner, tmp_path, monkeypatch, saved_policy, operation): + native = importlib.import_module("loopx_codex_provider_routing.native") + relay = importlib.import_module("loopx_codex_provider_routing.openai_relay") + provider = importlib.import_module("loopx_codex_provider_routing.provider") + probe = native.NativeAccounts(tmp_path / "probe") + probe.root.mkdir(mode=0o700, parents=True) + native.atomic(probe.path, {"schema_version": native.SCHEMA, "adapter": {}, **saved_policy}) + before = probe.path.read_bytes() + credentials = {"token": "worker-" + "w" * 32, "steward_token": "steward-" + "s" * 32} + server = relay.RelayServer(owner, {"port": 0, **credentials}) + native.atomic(probe.root / "api-relay.json", { + "schema_version": relay.SCHEMA, "port": server.server_port, **credentials, + }) + thread = threading.Thread(target=server.serve_forever) + thread.start() + monkeypatch.setenv("PYTHONPATH", str(SRC)) + command = [sys.executable, "-m", "loopx_codex_provider_routing.provider", + "--runtime-root", str(tmp_path / "probe")] + payload = None + if operation == "doctor": + command.append("--doctor") + else: + payload = json.dumps({"schema_version": provider.SCHEMA, "operation": "status"}) + try: + result = subprocess.run(command, input=payload, capture_output=True, text=True, timeout=8) + assert result.returncode == 1 and not result.stderr, result.stderr + status = json.loads(result.stdout) + assert status["ok"] is False and status["installation_ready"] is False + assert status["adapter_readiness"] == {"state": "state_untrusted", "ready": False} + assert status["same_relay_instance"] is True + assert all(observation["ready"] for observation in status["consumer_readiness"].values()) + assert probe.path.read_bytes() == before + assert all(token not in result.stdout for token in credentials.values()) + finally: + server.shutdown() + server.server_close() + thread.join(5) + + @pytest.mark.parametrize("invalid", ["shared", "", "invalid\r\nheader"]) def test_reconcile_refuses_invalid_steward_credential_before_drain(owner, monkeypatch, invalid): life = importlib.import_module("loopx_codex_provider_routing.relay_lifecycle") From 0f9efb31fb7310137e3203d5600540551c5f5e1d Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Thu, 12 Nov 2026 07:48:54 +0800 Subject: [PATCH 14/31] Resume Chat history after an interrupted response body Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../dashboard/smoke/bound-conversation-smoke.ts | 13 +++++++++++-- apps/presentation/dashboard/src/data/chat.ts | 6 ++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/apps/presentation/dashboard/smoke/bound-conversation-smoke.ts b/apps/presentation/dashboard/smoke/bound-conversation-smoke.ts index 6b840f5af5..b7a61c4567 100644 --- a/apps/presentation/dashboard/smoke/bound-conversation-smoke.ts +++ b/apps/presentation/dashboard/smoke/bound-conversation-smoke.ts @@ -132,6 +132,11 @@ try { assert.equal(complete.messages.length, expectedRows); const recoveredOffsets: number[] = []; const failedOffsets = new Set(); + const interruptedHistoryBody = (status: number) => new Response(new ReadableStream({ + start(controller) { + controller.error(new Error("Synthetic interrupted history response body")); + }, + }), {status}); globalThis.fetch = async (input, init) => { const response = await routedRead(input, init); if (new URL(String(input), origin).pathname !== `/api/chat/sessions/${sessionId}`) return response; @@ -140,7 +145,8 @@ try { recoveredOffsets.push(offset); if (offset > 0 && !failedOffsets.has(offset)) { failedOffsets.add(offset); - throw new Error("Synthetic interrupted transport after a verified prefix"); + if (offset === 200) throw new Error("Synthetic interrupted transport after a verified prefix"); + return interruptedHistoryBody(response.status); } return response; }; @@ -150,6 +156,8 @@ try { assert.deepEqual(interrupted.snapshots, []); const interruptedAgain = await fetchChatHistory(options, interrupted); assert.deepEqual(interruptedAgain.messages, []); + assert.equal(interruptedAgain.pendingReads?.get(sessionId)?.messages.length, 400, + "A lost body after successful response headers keeps the verified prefix private"); const recovered = await fetchChatHistory(options, interruptedAgain); assert.deepEqual(recovered.unavailableSessionIds, []); assert.deepEqual(recovered.snapshots[0].messages, complete.messages); @@ -188,11 +196,12 @@ try { child.stdin.write("recover\n"); assert.equal((await next()).source_verified, true); // Permanent denial, a changed source or malformed prefix cannot be retained // as transport progress. The following read must validate page zero again. - for (const fault of ["denied", "source", "digest"]) { + for (const fault of ["denied", "denied-body", "source", "digest"]) { globalThis.fetch = async (input, init) => { const response = await routedRead(input, init); if (!new URL(String(input), origin).searchParams.has("message_cursor")) return response; if (fault === "denied") return new Response("{}", {status: 403}); + if (fault === "denied-body") return interruptedHistoryBody(403); const value = await response.json(); if (fault === "source") value.session.goal_id = "unrelated-goal"; else value.message_page.snapshot_sha256 = "0".repeat(64); diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index d7c740c9f6..95f19d780f 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -1297,8 +1297,10 @@ function sameHistoryRevision(current: ChatSessionSummary, read: ChatSessionSumma } function historyTransportUnavailable(error: unknown) { - return error instanceof ChatApiError && error.payload.error_code === "chat_api_unavailable" - && (error.payload.http_status === undefined || Number(error.payload.http_status) >= 500); + if (!(error instanceof ChatApiError) || error.payload.error_code !== "chat_api_unavailable") return false; + const status = error.payload.http_status; + // A response body can be interrupted after successful history headers. + return status === undefined || status === 200 || Number(status) >= 500; } export type ChatHistory = { From 73f58b233674a38e43fe461d319bc096ace442a9 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Thu, 12 Nov 2026 09:51:20 +0800 Subject: [PATCH 15/31] Wait for the original steward session before showing runtime setup Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../conversation-reply-browser-smoke.mjs | 25 ++++++++++++++++++- .../personal-workspace-page.tsx | 3 ++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/apps/presentation/dashboard/smoke/conversation-reply-browser-smoke.mjs b/apps/presentation/dashboard/smoke/conversation-reply-browser-smoke.mjs index a47c2ca3b9..9192c131a2 100644 --- a/apps/presentation/dashboard/smoke/conversation-reply-browser-smoke.mjs +++ b/apps/presentation/dashboard/smoke/conversation-reply-browser-smoke.mjs @@ -29,6 +29,25 @@ function next() { if (queue.length) receive(queue.shift()); else waiting.push(receive); }); } +async function openWhileCheckingOriginalConversation(ready, page) { + const isOriginalSource = url => url.pathname === "/api/chat/sessions" + && url.searchParams.get("conversation_binding_id") === ready.bindingId; + const {promise: allowSourceRead, resolve: finishSourceRead} = Promise.withResolvers(); + const holdOriginalSource = async route => { + await allowSourceRead; + await route.continue(); + }; + await page.route(isOriginalSource, holdOriginalSource); + try { + await page.goto(ready.origin + "/chat/", {waitUntil: "domcontentloaded"}); + await page.getByText("正在核验原会话…", {exact: true}).waitFor({timeout: 4_000}); + assert.equal(await page.getByTestId("runtime-readiness").count(), 0, + "Checking the original conversation must not show machine runtime setup"); + } finally { + finishSourceRead(); + await page.unrouteAll({behavior: "wait"}); + } +} async function checkReturnedFiles(ready, page, errors, writes) { const snapshot = await (await page.request.get(`${ready.origin}/api/chat/sessions/${ready.sessionId}`)).json(); const answer = snapshot.messages.find(row => row.message_id === ready.fileMessageId); @@ -113,7 +132,11 @@ try { const snapshot = await (await page.request.get(`${ready.origin}/api/chat/sessions/${ready.sessionId}`)).json(); const source = snapshot.messages.find(row => row.role === "user" && row.turn_id); assert.ok(source.text.startsWith("Referenced message ("), "Canonical model input stays unchanged"); - await page.goto(ready.origin + "/chat/"); + if (process.env.LOOPX_REPLY_BEFORE === "1") { + await page.goto(ready.origin + "/chat/"); + } else { + await openWhileCheckingOriginalConversation(ready, page); + } await page.getByRole("navigation", {name: "管家视图"}).getByRole("button", {name: /^(Chat|对话)$/}).click(); const body = "请比较这次修改,并保留原话题中的限制。Compare this change against the original constraints."; const parent = "原要求:保留已有结果和失败记录。Original requirement: retain results and failed attempts."; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx index b78a4fe3be..72884d084e 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx @@ -3016,7 +3016,8 @@ export function PersonalWorkspacePage({ void callbacks.onRefresh?.("missing")} onRetrySource={() => void refreshWorkspace()} onSelectGoal={selectGoal} systemHealth={model.systemHealth} onboarding={model.onboarding} onRequestGoalCreate={readOnly ? undefined : requestGoalCreate} onSuggestRequest={readOnly ? undefined : suggestReply} - runtimeReadiness={readOnly ? undefined : runtimeReadiness} recheckingRuntimes={recheckingRuntimes} onRecheckRuntimes={callbacks.onRecheckRuntimes ? recheckRuntimes : undefined} + runtimeReadiness={readOnly || originalSourceUnresolved ? undefined : runtimeReadiness} + recheckingRuntimes={recheckingRuntimes} onRecheckRuntimes={callbacks.onRecheckRuntimes ? recheckRuntimes : undefined} operations={actionReadback.isError ? [] : homeOperations} onSelectOperation={proposal => setSelection({kind: "proposal", item: proposal})} /> ) : ( Date: Thu, 12 Nov 2026 11:00:57 +0800 Subject: [PATCH 16/31] issue_fix: single-source the issue-close event-type set metrics_supplement._ISSUE_CLOSE_EVENT_TYPES decides which event types count as issue-close activity (issue_close_recommended / issue_close_request_published / issue_closed_observed / issue_reopened_observed). The supplement builds and filters the activity by this set; metrics_projection only validates the compacted rows it receives against the same four types, yet restated them inline as a function-local known_types set. The projection already imports private names from the supplement (_SUPPLEMENT_FIELDS), so borrowing one more owner object adds no import edge and cannot cycle. The inline set is retired and the validation now references the owner; the nine-member _EVENT_TYPES vocabulary is left untouched. The by-name semantic inventory cannot see this: the owner is a private _-binding it does not catalogue and the restatement was an anonymous function-local under a different name. The new guard keys on the folded value -- a whole-tree census, identity for the consumer, retirement, and behavioural parity through the projection's _issue_close_activity gate parametrised over the owner's membership. Refs #4447. Signed-off-by: psychologister <230141834+psychologister@users.noreply.github.com> --- .../issue_fix/metrics_projection.py | 13 +- .../test_issue_fix_close_event_type_owner.py | 228 ++++++++++++++++++ 2 files changed, 233 insertions(+), 8 deletions(-) create mode 100644 tests/architecture/test_issue_fix_close_event_type_owner.py diff --git a/loopx/capabilities/issue_fix/metrics_projection.py b/loopx/capabilities/issue_fix/metrics_projection.py index 8568169c04..173160f34b 100644 --- a/loopx/capabilities/issue_fix/metrics_projection.py +++ b/loopx/capabilities/issue_fix/metrics_projection.py @@ -10,8 +10,11 @@ # a different document's version that happens to share a name prefix, so it stays. # The same rule runs the other way for the snapshot name: `repository_snapshot` # builds and stamps those payloads and this file only compares against them, so -# the value is imported rather than restated here. +# the value is imported rather than restated here. The supplement also owns the +# issue-close event-type set: it filters activity by `_ISSUE_CLOSE_EVENT_TYPES` +# and this projection only validates the compacted rows against that same set. from .metrics_supplement import ( + _ISSUE_CLOSE_EVENT_TYPES, _SUPPLEMENT_FIELDS, SUPPLEMENT_SCHEMA_VERSION, ) @@ -255,12 +258,6 @@ def _issue_close_activity( if not isinstance(raw_activity, list): raise ValueError("supplement.issue_close_activity must be a list") - known_types = { - "issue_close_recommended", - "issue_close_request_published", - "issue_closed_observed", - "issue_reopened_observed", - } seen_issue_refs: set[str] = set() seen_event_ids: set[str] = set() activity: list[dict[str, Any]] = [] @@ -297,7 +294,7 @@ def _issue_close_activity( if ( not event_id or event_id in seen_event_ids - or event_type not in known_types + or event_type not in _ISSUE_CLOSE_EVENT_TYPES ): raise ValueError( "issue close activity requires unique event_id values and known event types" diff --git a/tests/architecture/test_issue_fix_close_event_type_owner.py b/tests/architecture/test_issue_fix_close_event_type_owner.py new file mode 100644 index 0000000000..30eacdf7c1 --- /dev/null +++ b/tests/architecture/test_issue_fix_close_event_type_owner.py @@ -0,0 +1,228 @@ +"""Guard one owner for the issue-fix close event-type set (#4447). + +``_ISSUE_CLOSE_EVENT_TYPES`` in ``metrics_supplement.py`` decides which event +types count as issue-close activity (``issue_close_recommended`` / +``issue_close_request_published`` / ``issue_closed_observed`` / +``issue_reopened_observed``). The supplement builds and filters the activity by +this set; ``metrics_projection.py`` only validates the compacted rows it receives +against the *same* four types, yet restated them inline as a function-local +``known_types`` set. The projection *already imports* private names from the +supplement (``_SUPPLEMENT_FIELDS``), so borrowing one more owner object adds no +new import edge and cannot introduce a cycle. The inline set is retired and the +validation now references the single owner. + +The by-name semantic inventory cannot see this: the owner is a private +``_``-binding (the inventory only catalogues ``UPPER_NAME`` carriers) and the +restatement was an anonymous function-local under a different name. This guard +keys on the **folded value** instead, so a renamed or anonymous inline +restatement is caught, while the genuinely different neighbour -- the nine-member +``_EVENT_TYPES`` vocabulary that supersedes this set -- is never swept. + +Two layers: a whole-tree folded-value census (only the owner states the set), and +behavioural parity through the projection's own ``_issue_close_activity`` gate, +parametrised over the owner's actual membership, so a migration that silently +changed which event types count as close activity fails here. +""" + +from __future__ import annotations + +import ast +import pathlib + +import pytest + +from loopx.capabilities.issue_fix import ( + metrics_projection as projection_mod, + metrics_supplement as owner, +) + +REPO_ROOT = pathlib.Path(__file__).resolve().parents[2] +PACKAGE_ROOT = REPO_ROOT / "loopx" +OWNER_PATH = "loopx/capabilities/issue_fix/metrics_supplement.py" +PROJECTION_PATH = "loopx/capabilities/issue_fix/metrics_projection.py" + +ISSUE_CLOSE = frozenset( + { + "issue_close_recommended", + "issue_close_request_published", + "issue_closed_observed", + "issue_reopened_observed", + } +) +EVENT_VOCABULARY = frozenset( + { + "capability_gap", + "duplicate_external_write", + "first_push_ci", + "human_intervention", + "issue_close_recommended", + "issue_close_request_published", + "issue_closed_observed", + "issue_reopened_observed", + "useful_public_comment", + } +) + + +def _members(node: ast.expr) -> frozenset[str] | None: + """Fold a set / frozenset / list / tuple of plain string literals to members.""" + + if isinstance(node, ast.Call): + fname = getattr(node.func, "id", None) or getattr(node.func, "attr", None) + if fname in {"frozenset", "set"} and len(node.args) == 1: + return _members(node.args[0]) + return None + if isinstance(node, (ast.Set, ast.List, ast.Tuple)): + values = [ + element.value + for element in node.elts + if isinstance(element, ast.Constant) and isinstance(element.value, str) + ] + if len(values) == len(node.elts) and values: + return frozenset(values) + return None + + +def _close_type_literal_sites(source: str) -> list[int]: + """Every collection literal in the source folding to ISSUE_CLOSE, counted once. + + ``frozenset({...})`` is walked as both the call and its inner set; the inner + node is skipped so one restatement is one site, not two. + """ + + tree = ast.parse(source) + wrapped = { + id(node.args[0]) + for node in ast.walk(tree) + if isinstance(node, ast.Call) + and (getattr(node.func, "id", None) or getattr(node.func, "attr", None)) + in {"frozenset", "set"} + and len(node.args) == 1 + } + return [ + node.lineno + for node in ast.walk(tree) + if id(node) not in wrapped and _members(node) == ISSUE_CLOSE + ] + + +@pytest.fixture(scope="module") +def tree_offenders() -> dict[str, list[int]]: + offenders: dict[str, list[int]] = {} + for path in sorted(PACKAGE_ROOT.rglob("*.py")): + relative = path.relative_to(REPO_ROOT).as_posix() + if relative == OWNER_PATH: + continue + sites = _close_type_literal_sites(path.read_text(encoding="utf-8")) + if sites: + offenders[relative] = sites + return offenders + + +def test_only_the_owner_states_the_close_set(tree_offenders) -> None: + assert tree_offenders == {}, ( + f"a restatement of the issue-close event types remains: {tree_offenders}" + ) + + +def test_the_owner_states_the_recorded_members_once() -> None: + assert frozenset(owner._ISSUE_CLOSE_EVENT_TYPES) == ISSUE_CLOSE + source = (REPO_ROOT / OWNER_PATH).read_text(encoding="utf-8") + assert len(_close_type_literal_sites(source)) == 1 + + +def test_projection_borrows_the_owner_object() -> None: + assert projection_mod._ISSUE_CLOSE_EVENT_TYPES is owner._ISSUE_CLOSE_EVENT_TYPES + + +def test_projection_retired_its_inline_set() -> None: + source = (REPO_ROOT / PROJECTION_PATH).read_text(encoding="utf-8") + assert _close_type_literal_sites(source) == [] + assert "_ISSUE_CLOSE_EVENT_TYPES" in source + assert "known_types" not in source + + +def test_the_event_vocabulary_is_a_strict_superset() -> None: + # The full event vocabulary is a superset, not the close set; it stays its + # own owner in metrics_supplement.py and is deliberately left untouched. + assert frozenset(owner._EVENT_TYPES) == EVENT_VOCABULARY + assert ISSUE_CLOSE < EVENT_VOCABULARY + assert ISSUE_CLOSE != EVENT_VOCABULARY + + +# --- behavioural parity through the projection's own close-activity gate ---------------- + + +def _activity_value(event_type: str, *, evidence_url: str | None = None) -> dict: + event = { + "event_id": "evt-1", + "event_type": event_type, + "occurred_at": "2026-07-25T17:30:00+00:00", + } + if evidence_url is not None: + event["evidence_url"] = evidence_url + return { + "issue_close_activity": [ + { + "schema_version": "issue_fix_issue_close_activity_v0", + "issue_ref": "owner/repo#1", + "events": [event], + } + ] + } + + +@pytest.mark.parametrize("event_type", sorted(owner._ISSUE_CLOSE_EVENT_TYPES)) +def test_projection_accepts_every_owner_close_type(event_type: str) -> None: + url = ( + None + if event_type == "issue_close_recommended" + else "https://example.com/evidence" + ) + activity, _ = projection_mod._issue_close_activity( + _activity_value(event_type, evidence_url=url) + ) + assert len(activity) == 1 + assert activity[0]["events"][0]["event_type"] == event_type + + +@pytest.mark.parametrize( + "event_type", sorted(owner._EVENT_TYPES - owner._ISSUE_CLOSE_EVENT_TYPES) +) +def test_projection_rejects_types_outside_the_close_set(event_type: str) -> None: + # These are valid events in the broader vocabulary but not close activity: + # the projection's accept/reject boundary is exactly the owner's set, which + # it now shares by identity rather than restating. + with pytest.raises(ValueError): + projection_mod._issue_close_activity( + _activity_value(event_type, evidence_url="https://example.com/evidence") + ) + + +# --- the census bites every restatement form and leaves the others ---------------------- + +POISON_CORPUS = ( + ("bare set restatement", 'X = {"issue_close_recommended", "issue_close_request_published", "issue_closed_observed", "issue_reopened_observed"}\n', 1), + ("frozenset call", 'X = frozenset({"issue_close_recommended", "issue_close_request_published", "issue_closed_observed", "issue_reopened_observed"})\n', 1), + ("set call over a list", 'X = set(["issue_close_recommended", "issue_close_request_published", "issue_closed_observed", "issue_reopened_observed"])\n', 1), + ("list literal", 'X = ["issue_close_recommended", "issue_close_request_published", "issue_closed_observed", "issue_reopened_observed"]\n', 1), + ("tuple literal", 'X = ("issue_close_recommended", "issue_close_request_published", "issue_closed_observed", "issue_reopened_observed")\n', 1), + ("reordered members fold the same", 'X = {"issue_reopened_observed", "issue_closed_observed", "issue_close_request_published", "issue_close_recommended"}\n', 1), + ("renamed binding is still caught", '_ANY = {"issue_close_recommended", "issue_close_request_published", "issue_closed_observed", "issue_reopened_observed"}\n', 1), + ("nested inside a function", 'def f():\n return {"issue_close_recommended", "issue_close_request_published", "issue_closed_observed", "issue_reopened_observed"}\n', 1), + ("importing the owner is not a restatement", "from .m import _ISSUE_CLOSE_EVENT_TYPES\n", 0), + ("alias rebind is not a restatement", "known_types = _ISSUE_CLOSE_EVENT_TYPES\n", 0), + ("the broader event vocabulary is not this set", 'X = {"capability_gap", "duplicate_external_write", "first_push_ci", "human_intervention", "issue_close_recommended", "issue_close_request_published", "issue_closed_observed", "issue_reopened_observed", "useful_public_comment"}\n', 0), + ("the count-field pair is not this set", 'X = {"issue_close_recommendations", "issue_close_requests_published"}\n', 0), + ("a subset is not this set", 'X = {"issue_close_recommended", "issue_closed_observed"}\n', 0), + ("a single event type is not this set", 'X = {"issue_close_recommended"}\n', 0), +) + + +@pytest.mark.parametrize( + "label,source,expected", POISON_CORPUS, ids=[case[0] for case in POISON_CORPUS] +) +def test_the_census_names_every_form_and_leaves_the_others( + label: str, source: str, expected: int +) -> None: + assert len(_close_type_literal_sites(source)) == expected, label \ No newline at end of file From a777437594732539cae6975681d8f0b21c74d60b Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Thu, 12 Nov 2026 11:47:29 +0800 Subject: [PATCH 17/31] Keep oversized diagnostics out of private chat backlog reads Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../native_chat/external_conversations.py | 16 ++++++++++------ tests/test_lark_private_conversations.py | 8 ++++++-- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/loopx/capabilities/native_chat/external_conversations.py b/loopx/capabilities/native_chat/external_conversations.py index 1903219e23..8e08e0f06e 100644 --- a/loopx/capabilities/native_chat/external_conversations.py +++ b/loopx/capabilities/native_chat/external_conversations.py @@ -353,14 +353,18 @@ def flush() -> None: def _quota_pending_inputs(self, row: dict[str, Any]) -> dict[str, Any]: store = self.controller.store turn = store.load_turn(row["session_id"], row["turn_id"]) + session = store.load_session(row["session_id"]) + # Keep the full request/response evidence. The related Session and + # Turn need only identity, lifecycle and result for this read. return { "request": {key: value for key, value in row.items() if key != "attachments"}, - "current_session": store.load_session(row["session_id"]), - # Pending-work semantics use status/response, not image bytes. - # Accepted Chat images can exceed the ordinary Core message limit; - # retain them in the canonical Turn, outside this read-only view. - "pending_observation": {"turn": ({key: value for key, value in turn.items() if key != "attachments"} - if turn is not None else None), "stop_observed": False}, + "current_session": ({key: session[key] for key in ("session_id", "status") if key in session} + if session is not None else None), + "pending_observation": { + "turn": ({key: turn[key] for key in ("session_id", "turn_id", "client_turn_id", "status", + "error_code", "response") if key in turn} if turn is not None else None), + "stop_observed": False, + }, } def quota_backlog_pending(self, row: dict[str, Any], *, diff --git a/tests/test_lark_private_conversations.py b/tests/test_lark_private_conversations.py index 65bd275297..bbee4ebcc7 100644 --- a/tests/test_lark_private_conversations.py +++ b/tests/test_lark_private_conversations.py @@ -2242,6 +2242,10 @@ def quota_exhausted(*_args, **_kwargs): # does not retire the retained original work or authorize a replay. transport.reconcile() assert json.loads((transport.root / f"{row['request_ref']}.json").read_text())["status"] == "delivered" + # An old Turn can retain a large diagnostic without changing its + # structured failure. The read-only health route must still show the + # pending original, even when that diagnostic cannot fit a Core RPC. + store.update_turn(row["session_id"], row["turn_id"], error="synthetic diagnostic " * 450_000) def durable_state(): return {path: path.read_bytes() for path in store.root.rglob("*") @@ -2249,8 +2253,6 @@ def durable_state(): before = durable_state() writes = list(provider.writes) - assert transport.core.quota_backlog_pending(row) - assert transport.core.quota_backlog_observer()([row]) == [True] connection = http.client.HTTPConnection(*server.server_address, timeout=15) try: connection.request("GET", "/api/chat/lark/private-conversations") @@ -2261,6 +2263,8 @@ def durable_state(): connection.close() binding = next(item for item in result["connections"] if item["binding_id"] == row["binding_id"]) assert binding["pending_count"] == 1 and binding["recovery_count"] == 0 + assert transport.core.quota_backlog_pending(row) + assert transport.core.quota_backlog_observer()([row]) == [True] assert durable_state() == before and provider.writes == writes assert not ordinary[4].exists() or '"method": "turn/start"' not in ordinary[4].read_text() store.update_session(row["session_id"], status="closed") From 4390d593697aa3d529fccf3750b33aab538494e0 Mon Sep 17 00:00:00 2001 From: Luoluoluo0110 <158687529+Luoluoluo0110@users.noreply.github.com> Date: Thu, 12 Nov 2026 13:55:05 +0800 Subject: [PATCH 18/31] control_plane/projects: single-source the safe project-id grammar (#8637) --- loopx/control_plane/projects/registry.py | 9 +- .../test_repository_identity_safe_id_owner.py | 187 ++++++++++++++++++ 2 files changed, 192 insertions(+), 4 deletions(-) create mode 100644 tests/architecture/test_repository_identity_safe_id_owner.py diff --git a/loopx/control_plane/projects/registry.py b/loopx/control_plane/projects/registry.py index c52a8c05b2..45411b0e27 100644 --- a/loopx/control_plane/projects/registry.py +++ b/loopx/control_plane/projects/registry.py @@ -1,7 +1,6 @@ from __future__ import annotations import hashlib -import re from collections.abc import Callable from pathlib import Path from typing import Any @@ -31,7 +30,10 @@ register_fresh_source_session_project, resolve_source_session_project, ) -from ...repository_identity import normalize_repository_identity +from ...repository_identity import ( + _SAFE_PROJECT_ID, + normalize_repository_identity, +) from .contract import validate_project_record_bindings from .registration_state import ( registration_state_matches, @@ -45,12 +47,11 @@ ) PROJECT_KINDS = ("work", "personal") -_SAFE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$") def _identifier(value: str, *, field: str) -> str: compact = str(value or "").strip() - if not _SAFE_ID.fullmatch(compact): + if not _SAFE_PROJECT_ID.fullmatch(compact): raise ValueError(f"{field} must be a stable identifier") return compact diff --git a/tests/architecture/test_repository_identity_safe_id_owner.py b/tests/architecture/test_repository_identity_safe_id_owner.py new file mode 100644 index 0000000000..f37b145452 --- /dev/null +++ b/tests/architecture/test_repository_identity_safe_id_owner.py @@ -0,0 +1,187 @@ +"""Guard one owner for the safe project-id grammar (#4447). + +``_SAFE_PROJECT_ID`` in ``repository_identity.py`` is the compiled grammar for a +stable project identifier (``^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$``). The project +registry restated the *same* compiled regex under a different name (``_SAFE_ID``) +to validate its identifiers. ``registry.py`` *already imports* +``normalize_repository_identity`` from ``repository_identity`` -- a stdlib-only +leaf module -- so borrowing the owner's compiled pattern adds no new import edge +and cannot introduce a cycle. The restated regex is retired and the registry's +``_identifier`` gate now references the single owner. + +The by-name semantic inventory cannot see this: both bindings are private +``_``-names (the inventory only catalogues ``UPPER_NAME`` carriers) and they differ +by name. This guard keys on the **compiled pattern literal** instead, so a renamed +restatement is caught, while the genuinely different neighbour -- the stricter +``static_site._SAFE_ID`` grammar (no colon, 63 chars) -- is never swept. + +Two layers: a whole-tree compiled-pattern census (only the owner compiles it), and +behavioural parity through the registry's own ``_identifier`` gate plus the owner's +``resolve_project_identity`` fallback, so a migration that silently changed which +identifiers are accepted fails here. +""" + +from __future__ import annotations + +import ast +import pathlib + +import pytest + +from loopx import repository_identity as owner +from loopx.control_plane.projects import registry as registry_mod +from loopx.presentation import static_site as static_site_mod + +REPO_ROOT = pathlib.Path(__file__).resolve().parents[2] +PACKAGE_ROOT = REPO_ROOT / "loopx" +OWNER_PATH = "loopx/repository_identity.py" +REGISTRY_PATH = "loopx/control_plane/projects/registry.py" + +PATTERN = r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$" +STATIC_SITE_PATTERN = r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$" + + +def _compiled_pattern_sites(source: str, pattern: str) -> list[int]: + """Every ``re.compile()`` call in the source whose pattern matches. + + Keys on the pattern literal, so a restatement under any name (or with extra + flags) is still one site; a bare pattern string that is never compiled is not + a compiled-regex restatement and is left alone. + """ + + tree = ast.parse(source) + sites: list[int] = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + fname = getattr(node.func, "id", None) or getattr(node.func, "attr", None) + if fname != "compile": + continue + if ( + node.args + and isinstance(node.args[0], ast.Constant) + and node.args[0].value == pattern + ): + sites.append(node.lineno) + return sites + + +@pytest.fixture(scope="module") +def tree_offenders() -> dict[str, list[int]]: + offenders: dict[str, list[int]] = {} + for path in sorted(PACKAGE_ROOT.rglob("*.py")): + relative = path.relative_to(REPO_ROOT).as_posix() + if relative == OWNER_PATH: + continue + sites = _compiled_pattern_sites(path.read_text(encoding="utf-8"), PATTERN) + if sites: + offenders[relative] = sites + return offenders + + +def test_only_the_owner_compiles_the_pattern(tree_offenders) -> None: + assert tree_offenders == {}, ( + f"a restatement of the safe project-id grammar remains: {tree_offenders}" + ) + + +def test_the_owner_compiles_the_recorded_pattern_once() -> None: + assert owner._SAFE_PROJECT_ID.pattern == PATTERN + source = (REPO_ROOT / OWNER_PATH).read_text(encoding="utf-8") + assert len(_compiled_pattern_sites(source, PATTERN)) == 1 + + +def test_registry_borrows_the_owner_object() -> None: + assert registry_mod._SAFE_PROJECT_ID is owner._SAFE_PROJECT_ID + + +def test_registry_retired_its_restated_regex() -> None: + source = (REPO_ROOT / REGISTRY_PATH).read_text(encoding="utf-8") + assert _compiled_pattern_sites(source, PATTERN) == [] + assert "_SAFE_PROJECT_ID" in source + assert "_SAFE_ID" not in source + + +def test_the_static_site_id_is_a_different_grammar() -> None: + # The site-id grammar is stricter (no colon, 63 chars); it stays its own + # owner in static_site.py and is deliberately left untouched. + assert static_site_mod._SAFE_ID.pattern == STATIC_SITE_PATTERN + assert STATIC_SITE_PATTERN != PATTERN + + +# --- behavioural parity through both gates that share the owner ------------------------- + +CANDIDATES = ( + "abc", + "A1", + "a.b", + "a:b", + "a-b", + "a_b", + "a1.b:c-d_e", + "1", + "a" * 256, + "a" * 257, + "1" + "a" * 255, + "", + ".x", + "-x", + ":x", + "_x", + "a/b", + "a b", + "a@b", + " abc ", + "héllo", +) + + +@pytest.mark.parametrize("value", CANDIDATES) +def test_registry_identifier_accepts_exactly_the_owner_grammar(value: str) -> None: + compact = str(value or "").strip() + if owner._SAFE_PROJECT_ID.fullmatch(compact) is not None: + assert registry_mod._identifier(value, field="project_id") == compact + else: + with pytest.raises(ValueError): + registry_mod._identifier(value, field="project_id") + + +def test_owner_project_id_fallback_shares_the_grammar() -> None: + # An unusable remote forces the loopx project-id fallback, which the owner + # validates with the very regex the registry now borrows. + resolved = owner.resolve_project_identity( + "/nonexistent-checkout", + loopx_project_id="valid.id:1", + remote_url="::invalid::", + ) + assert resolved == "loopx:valid.id:1" + with pytest.raises(ValueError): + owner.resolve_project_identity( + "/nonexistent-checkout", + loopx_project_id="bad/id", + remote_url="::invalid::", + ) + + +# --- the census bites every restatement form and leaves the others ---------------------- + +POISON_CORPUS = ( + ("re.compile of the pattern", 'X = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$")\n', 1), + ("bare compile from re", 'from re import compile\nX = compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$")\n', 1), + ("nested in a function", 'def f():\n return re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$")\n', 1), + ("flags do not hide the restated pattern", 'X = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$", re.IGNORECASE)\n', 1), + ("the static-site grammar is different", 'X = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")\n', 0), + ("a shorter max length is different", 'X = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$")\n', 0), + ("a bare pattern string is not a compiled regex", 'X = r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$"\n', 0), + ("importing the owner is not a restatement", "from ...repository_identity import _SAFE_PROJECT_ID\n", 0), + ("alias rebind is not a restatement", "_SAFE_ID = _SAFE_PROJECT_ID\n", 0), +) + + +@pytest.mark.parametrize( + "label,source,expected", POISON_CORPUS, ids=[case[0] for case in POISON_CORPUS] +) +def test_the_census_names_every_form_and_leaves_the_others( + label: str, source: str, expected: int +) -> None: + assert len(_compiled_pattern_sites(source, PATTERN)) == expected, label \ No newline at end of file From 86ba59da702835acc64b759761a553426004b86f Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Thu, 12 Nov 2026 10:32:12 +0800 Subject: [PATCH 19/31] Resume attached chat history after a dropped response body Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../attached-agent-conversations.tsx | 18 ++++---- .../codex-attached.mjs | 29 +++++++++---- .../codex_attached_fixture.py | 41 +++++++++++++------ 3 files changed, 60 insertions(+), 28 deletions(-) diff --git a/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.tsx b/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.tsx index 16e16b06da..29de78c83c 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.tsx @@ -16,7 +16,12 @@ type LiveObservation = {kind: "records"; revision: number; records: Message[]} | {kind: "unavailable"; retry: boolean}; class ConversationRequestError extends Error { - constructor(message: string, readonly unsubmittedOperation?: string, readonly httpStatus?: number) {super(message);} + constructor( + message: string, + readonly unsubmittedOperation?: string, + readonly httpStatus?: number, + readonly interruptedSuccessfulResponse = false, + ) {super(message);} } export async function request(url: string, options?: RequestInit): Promise { @@ -30,11 +35,9 @@ export async function request(url: string, options?: RequestInit): Promise try { body = await response.text(); } catch { - // The headers arrived, so the status is already known even though the body - // did not. Drop the status here and a stalled 503 body looks like a plain - // transport failure while a truncated 403 body looks retryable; the caller - // must classify on this status, not on how the body read failed. - throw new ConversationRequestError("Conversation unavailable", undefined, response.status); + // Preserve rejected statuses even when their bodies drop. A successful + // response interrupted in transit is different from a complete invalid body. + throw new ConversationRequestError("Conversation unavailable", undefined, response.status, response.ok); } let value: Record | null = null; try {value = body ? JSON.parse(body) as Record : null;} catch {value = null;} @@ -51,7 +54,8 @@ function interruptedRead(failure: unknown): boolean { } export function retryableRead(failure: unknown): boolean { - return interruptedRead(failure) || (failure instanceof ConversationRequestError && (failure.httpStatus ?? 0) >= 500); + return interruptedRead(failure) || (failure instanceof ConversationRequestError && + (failure.interruptedSuccessfulResponse || (failure.httpStatus ?? 0) >= 500)); } export function readFailureMessage(failure: unknown, zh: boolean): string { diff --git a/examples/personal-workspace-browser/codex-attached.mjs b/examples/personal-workspace-browser/codex-attached.mjs index 0ccfa144b4..4bafe0c7c0 100644 --- a/examples/personal-workspace-browser/codex-attached.mjs +++ b/examples/personal-workspace-browser/codex-attached.mjs @@ -740,28 +740,41 @@ async function runConversation(browser, uncertain) { let releasePage, pageStarted; const heldPage = new Promise(resolve => {releasePage = resolve;}); const pagingStarted = new Promise(resolve => {pageStarted = resolve;}); - let held = false, historyOutage = true; + const olderReads = []; const holdHistory = async route => { - if (!new URL(route.request().url()).searchParams.has('before') || held) return route.continue(); - if (historyOutage) { - historyOutage = false; - return route.fulfill({status:503,contentType:'text/html',body:'Temporary history outage'}); - } - held = true; + const query = new URL(route.request().url()).searchParams; + if (!query.has('before') || olderReads.length === 2) return route.continue(); + olderReads.push([query.get('before'), query.get('source_ref'), query.get('binding_ref')]); + if (olderReads.length === 1) return route.continue(); const response = await route.fetch(); pageStarted(); await heldPage; await route.fulfill({response}); }; + await page.request.post(new URL('/fixture/history-body-drop',url).href); await page.route('**/api/chat/codex-attached?*',holdHistory); const transcript = panel.locator('.attached-conversation-messages'); + const partial = page.waitForResponse(response => + new URL(response.url()).searchParams.has('before') && response.status() === 200); await transcript.focus(); await page.keyboard.press('Home'); + await partial; await panel.getByRole('alert').waitFor(); - await Promise.race([pagingStarted, page.waitForTimeout(10_000).then(() => {throw new Error('Scrolling to earlier history did not request a source page');})]); + assert.equal(await longDialogue.count(),1,'An interrupted page keeps the saved conversation visible'); + await page.screenshot({path:resolve(outputDir,'attached-history-interrupted-desktop.png'),animations:'disabled'}); + await page.setViewportSize({width:390,height:844}); + await page.screenshot({path:resolve(outputDir,'attached-history-interrupted-narrow.png'),animations:'disabled'}); + await page.setViewportSize({width:1512,height:982}); + await Promise.race([pagingStarted, page.waitForTimeout(4_000).then(() => {throw new Error('An interrupted response with successful headers did not automatically resume history');})]); + assert.ok(olderReads[0].every(Boolean),'The request identifies the original history and position'); + assert.deepEqual(olderReads[1],olderReads[0],'Automatic recovery retries the same source and position'); await longDialogue.locator('p').first().click(); const summaryTop = (await workSummary.boundingBox()).y; releasePage(); await work.getByText('Saved output 110',{exact:true}).waitFor({state:'attached'}); await panel.getByRole('alert').waitFor({state:'hidden'}); assert.ok(Math.abs((await workSummary.boundingBox()).y - summaryTop) < 2,'Prepending a page keeps the source summary at the same reading position'); + await page.screenshot({path:resolve(outputDir,'attached-history-recovered-desktop.png'),animations:'disabled'}); + await page.setViewportSize({width:390,height:844}); + await page.screenshot({path:resolve(outputDir,'attached-history-recovered-narrow.png'),animations:'disabled'}); + await page.setViewportSize({width:1512,height:982}); await page.unroute('**/api/chat/codex-attached?*',holdHistory); await workSummary.focus(); await page.keyboard.press('Enter'); const expandedGroup = await work.elementHandle(); diff --git a/examples/personal-workspace-browser/codex_attached_fixture.py b/examples/personal-workspace-browser/codex_attached_fixture.py index a509cd1073..c3dd8f374a 100644 --- a/examples/personal-workspace-browser/codex_attached_fixture.py +++ b/examples/personal-workspace-browser/codex_attached_fixture.py @@ -8,6 +8,7 @@ import tempfile import threading import time +from urllib.parse import parse_qs, urlsplit from contextlib import ExitStack from types import SimpleNamespace @@ -90,18 +91,30 @@ def do_GET(self): elif ( body_fault and self.path.startswith("/api/chat/codex-attached?") - and "agent_id=" not in self.path + and ( + "before" in parse_qs(urlsplit(self.path).query) + if body_fault == "history" + else "agent_id" not in parse_qs(urlsplit(self.path).query) + ) ): - # Headers first, then a body that never completes: the client - # read limit aborts a stalled body, and a short close against - # the declared length surfaces as a network error. Both keep - # the status the response already carried. + # Send actual headers, then end the connection short of the + # declared body length (or let the browser's read time out). mode, body_fault = body_fault, None - self.send_response(503 if mode == "timeout" else 403) - self.send_header("Content-Type", "text/html; charset=utf-8") + status = {"timeout": 503, "drop": 403, "history": 200}[mode] + self.send_response(status) + self.send_header( + "Content-Type", + "application/json" + if status == 200 + else "text/html; charset=utf-8", + ) self.send_header("Content-Length", "200") self.end_headers() - self.wfile.write(b"Service unavailable") + self.wfile.write( + b'{"ok":' + if status == 200 + else b"Service unavailable" + ) self.wfile.flush() if mode == "timeout": time.sleep(7.5) @@ -121,11 +134,13 @@ def do_POST(self): ) self._send_json({"ok": True}) return - if self.path in { - "/fixture/discovery-body-timeout", - "/fixture/discovery-body-drop", - }: - body_fault = "timeout" if self.path.endswith("timeout") else "drop" + faults = { + "/fixture/discovery-body-timeout": "timeout", + "/fixture/discovery-body-drop": "drop", + "/fixture/history-body-drop": "history", + } + if self.path in faults: + body_fault = faults[self.path] self._send_json({"ok": True}) return if self.path == "/fixture/work-records": From fdda55ce8fe9e952725428a10a788b7edbea251d Mon Sep 17 00:00:00 2001 From: Luoluoluo0110 <158687529+Luoluoluo0110@users.noreply.github.com> Date: Thu, 12 Nov 2026 13:55:35 +0800 Subject: [PATCH 20/31] state_projection: single-source the backlog-hygiene bullet grammar (#8638) --- loopx/state_projection.py | 8 +- ...t_state_projection_bullet_pattern_owner.py | 229 ++++++++++++++++++ .../test_status_facade_vocabulary_owners.py | 5 +- 3 files changed, 238 insertions(+), 4 deletions(-) create mode 100644 tests/architecture/test_state_projection_bullet_pattern_owner.py diff --git a/loopx/state_projection.py b/loopx/state_projection.py index 4fe8b02986..6b79503330 100644 --- a/loopx/state_projection.py +++ b/loopx/state_projection.py @@ -5,7 +5,10 @@ import re from .control_plane.goals.active_state_metadata import todo_role_for_heading -from .control_plane.status.active_state_projection import SECTION_HEADING_PATTERN +from .control_plane.status.active_state_projection import ( + BACKLOG_HYGIENE_BULLET_PATTERN, + SECTION_HEADING_PATTERN, +) from .control_plane.todos.contract import ( TODO_TASK_PATTERN, build_todo_id, @@ -45,7 +48,6 @@ ACTIVE_STATE_PROJECTION_DIAGNOSTICS_SCHEMA_VERSION = "active_state_projection_diagnostics_v0" TODO_ITEM_SCHEMA_VERSION = "todo_item_v0" -BULLET_PATTERN = re.compile(r"^\s*(?:[-*]|\d+[.)])\s+(.+?)\s*$") PRIORITY_PATTERN = re.compile(r"^\[(P[0-4])\]\s+(.+)$", re.IGNORECASE) NEXT_ACTION_EXECUTABLE_PATTERN = re.compile( r"(?i)\b(?:run|repair|fix|implement|add|update|write|record|validate|" @@ -399,7 +401,7 @@ def append_entry(parts: list[str]) -> None: for line in lines: if line.strip().startswith(" - **2026-10-25 · 🤝 [Long-horizon agent research: collaborators welcome](https://loopx-project.github.io/loopx/blog/edgebench-feedback-and-memory/)** — Exploring **breakthroughs in long-horizon outcomes under equal budgets**, through feedback, replanning and reproducible evaluation on EdgeBench and real-world tasks. Academic labs and industry teams are welcome to collaborate, comparing outcomes and total cost. [Discuss collaboration](https://github.com/loopx-project/loopx/discussions). -- **2026-10-20 · 🚀 [LoopX 2.0](https://github.com/loopx-project/loopx/releases/tag/v2.0.0)** — A shared TypeScript control plane and long-horizon research workbench. [Migration guide](docs/product/releases/v2.0.0.md) · [Engineering story](https://loopx-project.github.io/loopx/blog/typescript-control-plane-refactor/). +- **2026-10-20 · 🚀 [LoopX 2.0](https://github.com/loopx-project/loopx/releases/tag/v2.0.0)** — A shared TypeScript state layer and long-horizon research workbench. [Migration guide](docs/product/releases/v2.0.0.md) · [Engineering story](https://loopx-project.github.io/loopx/blog/typescript-control-plane-refactor/). - **2026-10-14 · 🤖 [Agentic software](https://loopx-project.github.io/loopx/blog/semantic-software-development/)** — Connecting goals, evidence and human feedback to engineering delivery. - **2026-10-02 · 🧠 [Long-horizon bootstrapping & RSI](https://loopx-project.github.io/loopx/blog/long-horizon-rsi/)** — Four months of developing LoopX with LoopX: public project history and a design perspective on system-level self-improvement. -- **2026-09-18 · 🧪 [LoopX × LHTB](https://loopx-project.github.io/loopx/benchmarks/lhtb/)** — A 46-task, same-model study provides empirical support for **trading more compute for better outcomes**: LoopX Heartbeat mean reward is 17.3% above Plain Codex and 10.6% above native Goal. +- **2026-09-18 · 🧪 [LoopX × LHTB](https://loopx-project.github.io/loopx/benchmarks/lhtb/)** — A 46-task, same-model study provides empirical support for **trading more compute for better outcomes**: LoopX 1.0.3 Heartbeat continued runs reach mean reward 17.3% above Plain Codex and 10.6% above native Goal. - **2026-09-06 · 🎉 [LoopX 1.0](https://github.com/loopx-project/loopx/releases/tag/v1.0.0)** — A Personal Agent Workspace to follow agent work, act on decisions, and inspect delivered results across sessions. [Workspace guide](https://github.com/loopx-project/loopx/blob/v1.0.0/docs/guides/personal-workspace-user-guide.md). - **2026-08-02 · ⏱️ [200+ hour trajectories shared on X](https://x.com/huangruiteng/status/2083904257494024425)** — Early owner-run examples spanning 200+ elapsed hours drew broad attention on X, showing work across execution, waits, human decisions and recovery. [Trajectories and limits](#open-source-issue-fix). @@ -78,12 +79,12 @@ hand work to peers. A timer can keep a loop running without making it useful. - **Continue without starting over.** Keep the Goal, accepted requirements, decisions and evidence across sessions and restarts. -- **[Coordinate without losing ownership.](https://loopx-project.github.io/loopx/blog/agent-facing-kanban/#walkthrough)** Connect delegated work, dependent +- **[Coordinate without dropping context.](https://loopx-project.github.io/loopx/blog/agent-facing-kanban/#walkthrough)** Connect delegated work, dependent artifacts, review and returned results while keeping responsibility explicit. - **Judge progress by what changed.** Inspect deliverables, supporting and refuting evidence, costs and the next decision—not just activity. -Your Agent runtime supplies the model and tools. LoopX’s semantic control plane +Your Agent runtime supplies the model and tools. LoopX’s durable state layer preserves goals, acceptance, decisions, ownership and evidence across sessions and runtimes for engineering, research and operations. Agents and people use those facts to reassess what to do next. Human authorization still governs @@ -97,25 +98,25 @@ protected actions. | Manage personal projects, schedules and decisions in one place | [Personal Agent Workspace](#meet-the-personal-agent-workspace) | | Let agents collaborate and deliver verifiable results | [Agent collaboration guide](docs/product/use-cases/cross-runtime/README.md) | -LoopX keeps the durable control state in one compact layer: +LoopX keeps the durable work state in one compact layer: Your harness runs the turn. LoopX carries the work. -Agent runtimes execute the work. LoopX governs the state that lets engineering, +Agent runtimes execute the work. LoopX keeps the state that lets engineering, research, discovery, and operations loops continue across runs. It is not another agent framework or a provider-specific orchestration runtime. +
+Advanced: how the control layer works and where it fits + - One turn is a governed state transition—not just a timer. + Advanced view of a governed state transition, not just a timer. -
-How the control plane works and where it fits - A useful mental model is an **[agent-native Kanban for long-running work](docs/development/control-plane-course/00-concept-primer.md)**. Cards carry identity, authority, evidence, and continuation. Moves are validated diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.tsx index 1e55e0f8d5..f52ffbcfb3 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.tsx @@ -634,7 +634,7 @@ export function ResearchAnalysisPanel({analysis: a, onHistory}: {analysis: Contr return
{if (event.key === "Escape") {event.stopPropagation(); a.close();}}}>
{result?.synthesis ? <>

{zh ? "研究汇报" : "Research report"}

{scopeHelp}
{p?.title ?

{p.title}

: null}{scope}
- : <>

{zh ? "轨迹深度分析" : "Trajectory analysis"}

{p?.title ?

{p.title}

: null}}
{result?.synthesis && onHistory ? : null}{result ? : null}
+ : <>

{zh ? "轨迹深度分析" : "Trajectory analysis"}

{p?.title ?

{p.title}

: null}}
{result?.synthesis && onHistory ? : null}{result ? : null}
{a.error ?
{zh ? "分析请求未完成,已保存结果保留。" : "The analysis request did not complete; saved results are preserved."}

{a.error}

: null} {a.snapshot?.catalog.unreadable_pack_ids?.length && !p?.continuation_unavailable ?

{zh ? "部分历史分析暂不可读" : "Some historical analyses cannot be read"} · {a.snapshot.catalog.unreadable_pack_ids.length}

: null} diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx index 482bad0e80..f738472bde 100644 --- a/apps/presentation/dashboard/src/views/dashboard-page.tsx +++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx @@ -3923,18 +3923,22 @@ export function DashboardPage() { function selectGoal(goalId: string, view?: WorkspaceGoalTab) { void navigate({ hash: search.goalId === goalId ? keepCaseChapterHash : undefined, - search: (current) => ({ - ...current, - ...("goalId" in current && current.goalId === goalId ? {} : clearedResearchSearch), - goalId, - workspace: undefined, - conversationBinding: undefined, - conversationSession: undefined, - messageSession: undefined, message: undefined, - workAgent: undefined, workSession: undefined, - todo: undefined, - view: view === "chat" ? "conversation" : view, - }), + search: (current) => { + const currentSearch = current as Partial; + const keepResearch = Boolean(currentSearch.research_goal) || currentSearch.goalId === goalId; + return ({ + ...current, + ...(keepResearch ? {} : clearedResearchSearch), + goalId, + workspace: undefined, + conversationBinding: undefined, + conversationSession: undefined, + messageSession: undefined, message: undefined, + workAgent: undefined, workSession: undefined, + todo: undefined, + view: view === "chat" ? "conversation" : view, + }); + }, }); } @@ -3976,7 +3980,6 @@ export function DashboardPage() { onChangeGoalRoot={rootGoalId => void navigate({ hash: !rootGoalId || rootGoalId === search.goalId ? keepCaseChapterHash : undefined, search: current => ({...current, - ...(!rootGoalId || ("goalId" in current && current.goalId === rootGoalId) ? {} : clearedResearchSearch), workAgent: undefined, workSession: undefined, messageSession: undefined, message: undefined, rootGoalId: rootGoalId ?? undefined, goalId: rootGoalId ?? ("goalId" in current ? current.goalId : ""), workspace: undefined}), })} diff --git a/apps/presentation/site/src/App.tsx b/apps/presentation/site/src/App.tsx index 74f2c15204..deb3447a29 100644 --- a/apps/presentation/site/src/App.tsx +++ b/apps/presentation/site/src/App.tsx @@ -32,25 +32,23 @@ import mlEvidenceUrl from "../../../../docs/assets/long-running-loop-ml-experime type Language = "en" | "zh"; const setupPrompts: Record = { - en: `Help me start LoopX in the current project: https://github.com/loopx-project/loopx + en: `Connect the current project to LoopX: https://github.com/loopx-project/loopx Do not clone LoopX. Follow the README Quick Start to use the no-clone installer and \`loopx doctor\`, then reuse or \`loopx connect/bootstrap\` the current project state. After setup, first report whether the project is connected, then ask me for one natural-language goal unless I already included it. -When you receive \`/loopx \`, first provide a concise ordered plan, then write it as P0/P1/P2 todos so I can see what needs to be done, what is blocked by a gate, and what the next safe action is. +When you receive \`/loopx \`, first provide a concise ordered plan, save that plan as ordered LoopX Todos, then read back the current work, any decision that needs me, and the next safe action. Keep LoopX internal terms behind advanced or debugging explanations unless a decision requires them.`, - zh: `帮我在当前项目启动 LoopX: https://github.com/loopx-project/loopx + zh: `把当前项目接入 LoopX: https://github.com/loopx-project/loopx 不要 clone LoopX;按 README Quick Start 使用 no-clone installer、 \`loopx doctor\`,复用或 \`loopx connect/bootstrap\` 当前项目状态。 接入后先汇报项目是否已连接;如果我还没有给目标,就先问我要一个自然语言目标。 -收到 \`/loopx \` 后,先给出简洁有序计划,再把它写成 -P0/P1/P2 todos,让我能看到当前要做什么、什么被 gate 卡住、 -下一步安全动作是什么。 +收到 \`/loopx \` 后,先给出简洁有序计划,把计划按顺序保存为 LoopX 待办,再读回当前要做什么、哪里需要我判断、下一步安全动作是什么。 LoopX 内部机制术语默认放在高级或调试说明里,只有决策需要时再展开。`, }; @@ -76,7 +74,7 @@ const content = { secondPrefix: "Keep ", secondAccent: "progress visible", }, - body: "Use the agent you already trust. LoopX turns one natural-language goal into durable work, gates, and evidence behind the scenes, so the next session can continue without making you learn LoopX internals first.", + body: "Use the agent you already trust. LoopX turns one natural-language goal into durable work, needed decisions, and verifiable results behind the scenes, so the next session can continue without making you learn LoopX internals first.", copy: "Get started", mac: { download: "Download for Mac", @@ -87,7 +85,7 @@ const content = { setup: { eyebrow: "Goal-first setup", title: "Start from the outcome.", - body: "Give your current agent one setup prompt, then send the goal. Shell remains available when you want the manual path.", + body: "Give your current agent one setup prompt, then send the goal. Prefer the shell? Install LoopX manually.", agentTitle: "Guided agent start", agentBody: "Ask Codex, Claude Code, OpenCode, TraeX, Pi, or another shell-capable agent to connect this project and keep the machinery behind the scenes.", shellTitle: "Manual shell setup", @@ -98,24 +96,24 @@ const content = { }, demo: "See in action", panel: { - label: "ONE GOAL · ACROSS SESSIONS", + label: "ONE OUTCOME · ACROSS SESSIONS", title: "Ship the next release", status: "On track", - running: "Agent work", - researcher: "Research and implementation", - gate: "Human gate", - gateBody: "Merge needs your approval", - waiting: "Waiting", - evidence: "Evidence", - evidenceBody: "Validation and handoff written", - complete: "Complete", + running: "Work in progress", + researcher: "Research and implementation underway", + gate: "Your decision", + gateBody: "Approve the release when ready", + waiting: "Needs you", + evidence: "Verified result", + evidenceBody: "Checks and handoff written", + complete: "Saved", next: "Next safe action", - nextBody: "Keep independent tasks moving while review waits", + nextBody: "Continue work that does not depend on approval", }, proof: [ ["No vocabulary tax", "Start from the outcome; open LoopX details only when they matter."], ["Durable work", "The next session resumes from governed state, not memory."], - ["Reviewable evidence", "Every transition leaves a result another person can verify."], + ["Verifiable results", "Every transition leaves a result another person can check."], ], sectionEyebrow: "One goal-first lifecycle", sectionTitle: "Useful work can start before the machinery is familiar.", @@ -123,18 +121,18 @@ const content = { steps: [ ["01", "One goal", "A natural-language outcome"], ["02", "Plan", "Visible work to do"], - ["03", "Gate", "A concrete decision"], - ["04", "Evidence", "A validated result"], + ["03", "Decision", "A concrete choice"], + ["04", "Proof", "A validated result"], ["05", "Resume", "Recoverable progress"], ], capabilities: { - eyebrow: "Default simple, kernel intact", + eyebrow: "Simple by default, durable underneath", title: "A natural entry backed by durable state.", body: "Codex, Claude Code, OpenCode, TraeX, Pi, and custom runners can share one durable lifecycle. First contact starts with one goal; advanced surfaces expose ownership, scheduling, recovery, and protocols when needed.", items: [ - ["01", "Goal-first entry", "Start from a natural-language outcome; LoopX records the durable Goal and Todo structure as work becomes concrete."], + ["01", "Goal-first entry", "Start from a natural-language outcome; LoopX records durable work as the plan becomes concrete."], ["02", "Cross-runtime continuity", "Change the executor or host without reconstructing the objective from chat history."], - ["03", "Gate-aware progress", "A blocked P0 stays visible while independent P1 and P2 lanes continue safely."], + ["03", "Decision-aware progress", "A blocked decision stays visible while independent safe work can continue."], ["04", "Reviewable outcomes", "Every run writes validation, ownership, review, and handoff evidence into the next loop."], ], }, @@ -195,7 +193,7 @@ const content = { secondPrefix: "让进展", secondAccent: "持续可见", }, - body: "继续使用你已经信任的 Agent。LoopX 在背后把一个自然语言目标变成持久工作、决策门和证据,让下一轮会话可以接着推进,而不要求你先学会 LoopX 内部机制。", + body: "继续使用你已经信任的 Agent。LoopX 在背后把一个自然语言目标变成持久工作、需要你判断的决策和可验证结果,让下一轮会话可以接着推进,而不要求你先学会 LoopX 内部机制。", copy: "开始使用", mac: { download: "下载 Mac App", @@ -217,19 +215,19 @@ const content = { }, demo: "查看实战", panel: { - label: "一个目标 · 跨越多轮会话", + label: "一个结果 · 跨越多轮会话", title: "交付下一个版本", status: "进展正常", - running: "Agent 执行", - researcher: "研究与实现", - gate: "Human Gate", - gateBody: "合并前,等待你的批准", - waiting: "等待中", - evidence: "Evidence", - evidenceBody: "验证与交接已写回", - complete: "已完成", + running: "正在推进", + researcher: "研究与实现正在进行", + gate: "需要你判断", + gateBody: "版本准备好后由你批准", + waiting: "等你决定", + evidence: "已验证结果", + evidenceBody: "检查与交接已写回", + complete: "已保存", next: "下一步安全动作", - nextBody: "审批等待中,继续推进不依赖审批的任务", + nextBody: "继续推进不依赖审批的工作", }, proof: [ ["无术语门槛", "先描述结果;LoopX 细节只在需要决策或调试时展开。"], @@ -242,8 +240,8 @@ const content = { steps: [ ["01", "一个目标", "自然语言结果"], ["02", "计划", "可见的待办"], - ["03", "Gate", "一个具体判断"], - ["04", "Evidence", "一个验证结果"], + ["03", "判断", "一个具体选择"], + ["04", "证明", "一个验证结果"], ["05", "恢复", "可继续的进展"], ], capabilities: { @@ -251,9 +249,9 @@ const content = { title: "自然入口背后,是持久状态。", body: "Codex、Claude Code、OpenCode、TraeX、Pi 与自定义 Runner 可以共享同一套持久生命周期。第一次接触从一个目标开始;需要时,高级界面再展开 ownership、调度、恢复与协议。", items: [ - ["01", "目标优先入口", "从自然语言结果开始;随着工作具体化,LoopX 写入持久 Goal 与 Todo 结构。"], + ["01", "目标优先入口", "从自然语言结果开始;随着计划具体化,LoopX 记录可持续推进的工作。"], ["02", "跨运行时连续性", "更换执行器或 Host 时,无需从聊天记录重建长期目标。"], - ["03", "Gate-aware 进展", "P0 等待决策时保持可见,独立的 P1、P2 线路可以安全继续。"], + ["03", "决策可见的进展", "需要你判断的事项保持可见,不依赖它的安全工作可以继续。"], ["04", "可审查结果", "每轮运行都把验证、归属、审查和交接证据写入下一轮闭环。"], ], }, @@ -350,7 +348,7 @@ function ControlPlane({ language }: { language: Language }) {
- {language === "en" ? "ACTIVE GOAL" : "当前目标"} + {language === "en" ? "CURRENT GOAL" : "当前目标"} {panel.title}
{panel.status} @@ -365,7 +363,7 @@ function ControlPlane({ language }: { language: Language }) {
- P0 · {panel.running} + {language === "en" ? "NOW" : "当前"} · {panel.running} {panel.researcher}
@@ -378,7 +376,7 @@ function ControlPlane({ language }: { language: Language }) {
- GATE · {panel.gate} + {language === "en" ? "DECISION" : "判断"} · {panel.gate} {panel.gateBody}
{panel.waiting} @@ -458,10 +456,10 @@ const terminalCases = { }, lines: [ ["GOAL", "Ship the focused fix and preserve reusable knowledge.", "交付聚焦修复,并保留可复用知识。", "ACTIVE", "active"], - ["P0", "Focused PR delivery · bounded diff and validation", "聚焦式 PR 交付 · 有边界的改动与验证", "RUNNING", "running"], - ["GATE", "Reviewer authority · approve the focused change", "审查权限 · 批准聚焦改动", "WAITING", "waiting"], - ["P1", "Repository knowledge and terminal resume remain available", "仓库知识与终端恢复保持可用", "READY", "ready"], - ["EVIDENCE", "Merge/open result linked to the next reusable discovery", "合并 / 开放结果连接到下一轮可复用发现", "WRITTEN", "written"], + ["NOW", "Focused PR delivery · bounded diff and validation", "聚焦式 PR 交付 · 有边界的改动与验证", "RUNNING", "running"], + ["DECISION", "Reviewer authority · approve the focused change", "审查权限 · 批准聚焦改动", "WAITING", "waiting"], + ["SUPPORT", "Repository knowledge and terminal resume remain available", "仓库知识与终端恢复保持可用", "READY", "ready"], + ["PROOF", "Merge/open result linked to the next reusable discovery", "合并 / 开放结果连接到下一轮可复用发现", "WRITTEN", "written"], ["NEXT", "Review the focused diff, then continue the capability lane.", "先审查聚焦改动,再继续能力沉淀线路。", "SAFE", "safe"], ], }, @@ -473,15 +471,15 @@ const terminalCases = { command: "$ loopx status --goal-id auto-ml", image: mlEvidenceUrl, imageTitle: { - en: "Evidence branches remain visible until promotion or stop.", - zh: "证据分支保持可见,直到晋级或停止。", + en: "Result branches remain visible until promotion or stop.", + zh: "结果分支保持可见,直到晋级或停止。", }, lines: [ ["BASELINE", "Matched candidate and evaluation contract locked.", "匹配候选项与评估契约已锁定。", "MATCHED", "active"], ["BRANCHES", "Positive · Negative · Invalid lineage · Running replicate", "正向 · 负向 · 无效谱系 · 运行中复现", "VISIBLE", "ready"], - ["P1", "Replicate capacity stays bounded while evidence accumulates", "证据持续积累,同时保持复现容量有界", "RUNNING", "running"], - ["EVIDENCE", "Negative results and invalid lineages remain traceable", "负向结果和无效谱系持续可追溯", "WRITTEN", "written"], - ["P0 GATE", "Promote or stop after the running replicate completes", "运行中复现完成后,决定晋级或停止", "WAITING", "waiting"], + ["SUPPORT", "Replicate capacity stays bounded while evidence accumulates", "证据持续积累,同时保持复现容量有界", "RUNNING", "running"], + ["PROOF", "Negative results and invalid lineages remain traceable", "负向结果和无效谱系持续可追溯", "WRITTEN", "written"], + ["DECISION", "Promote or stop after the running replicate completes", "运行中复现完成后,决定晋级或停止", "WAITING", "waiting"], ["NEXT", "Compare replicate evidence, then record the decision.", "比较复现证据,再记录最终决策。", "SAFE", "safe"], ], }, diff --git a/apps/presentation/site/src/page-metadata.ts b/apps/presentation/site/src/page-metadata.ts index 24b109ba44..e0e2b28d7d 100644 --- a/apps/presentation/site/src/page-metadata.ts +++ b/apps/presentation/site/src/page-metadata.ts @@ -27,11 +27,11 @@ export const pageMetadata = { path: "", en: { title: "LoopX — Start long-running agent work with one goal", - description: "Tell Codex, Claude Code or another agent one natural-language goal. LoopX keeps the work, gates, evidence and recovery durable across sessions.", + description: "Tell Codex, Claude Code or another agent one natural-language goal. LoopX keeps the work, decisions, verification and recovery durable across sessions.", }, zh: { title: "LoopX — 从一个目标开始长程 Agent 工作", - description: "给 Codex、Claude Code 等 Agent 一个自然语言目标。LoopX 跨会话保留工作、决策门、证据与恢复状态。", + description: "给 Codex、Claude Code 等 Agent 一个自然语言目标。LoopX 跨会话保留工作、判断、验证结果与恢复状态。", }, }, sweMarathon: { diff --git a/apps/presentation/site/src/styles.css b/apps/presentation/site/src/styles.css index c492cfb874..d083f3650e 100644 --- a/apps/presentation/site/src/styles.css +++ b/apps/presentation/site/src/styles.css @@ -260,6 +260,7 @@ button { .hero-copy { position: relative; z-index: 2; + min-width: 0; max-width: 590px; } @@ -298,6 +299,8 @@ body[data-language="zh"] .hero h1 { .hero-title-line { display: block; + max-width: 100%; + overflow-wrap: break-word; } .hero-accent { @@ -1805,10 +1808,14 @@ body.dialog-open { } .site-header { + grid-template-columns: minmax(0, 1fr) auto; height: 64px; } .header-download { + justify-content: center; + width: 40px; + flex: 0 0 40px; padding: 0 12px; } @@ -1817,7 +1824,7 @@ body.dialog-open { } .header-download-compact { - display: inline; + display: none; } .hero { @@ -1825,6 +1832,18 @@ body.dialog-open { padding: 48px 0 56px; } + .hero-copy, + .hero-system { + width: 100%; + max-width: 100%; + min-width: 0; + } + + .hero-copy > *, + .hero-system > * { + max-width: 100%; + } + .hero h1 { font-size: clamp(29px, 7.5vw, 42px); } diff --git a/examples/frontstage-share-bundle-smoke.mjs b/examples/frontstage-share-bundle-smoke.mjs index 7a3644bd1d..e9304c0c33 100644 --- a/examples/frontstage-share-bundle-smoke.mjs +++ b/examples/frontstage-share-bundle-smoke.mjs @@ -333,13 +333,14 @@ for (const promptContract of [ "no-clone installer", "loopx doctor", "loopx connect/bootstrap", - "project connection status, current user gate, top agent todo", - "next safe action, and available commands", - "/loopx ", + "one natural-language goal", "/loopx ", "concise ordered plan", - "P0/P1/P2 todos", + "save that plan as ordered LoopX Todos", + "read back the current work, any decision that needs me, and the next safe action", + "Keep LoopX internal terms behind advanced or debugging explanations", "把当前项目接入 LoopX", + "把计划按顺序保存为 LoopX 待办", "下一步安全动作是什么", ]) { if (!homepageSource.includes(promptContract)) { diff --git a/examples/personal-workspace-browser/composer-session-admission.mjs b/examples/personal-workspace-browser/composer-session-admission.mjs index c7ad6a0f8f..43413d1cfd 100644 --- a/examples/personal-workspace-browser/composer-session-admission.mjs +++ b/examples/personal-workspace-browser/composer-session-admission.mjs @@ -199,9 +199,28 @@ export const composerSessionAdmissionScenario = { if (await composerInput.inputValue() !== nextManagerDraft) throw new Error("Returning lost the unsent request's text"); await openGoalChat(); if (new Set(adjustments.slice(0, 3).map(row => row.client_ingress_id)).size !== 1) throw new Error("Uncertain retries minted new ingress identities"); - await composerInput.fill(instruction); + for (let attempt = 0; attempt < 20 && await composerInput.inputValue() !== instruction; attempt += 1) { + await composerInput.fill(instruction); + await page.waitForTimeout(50); + } + if (await composerInput.inputValue() !== instruction) { + const statuses = await page.getByRole("status").allTextContents().catch(() => []); + throw new Error(`Confirmed non-delivery retry could not stage its draft: statuses=${JSON.stringify(statuses)} draft=${JSON.stringify(await composerInput.inputValue())}`); + } + if (await sendButton.isDisabled()) throw new Error("Confirmed non-delivery retry was blocked before it reached steering"); await composerInput.press("Meta+Enter"); - await page.getByRole("status").filter({ hasText: "本次未送达" }).waitFor(); + for (let attempt = 0; attempt < 100 && adjustments.length < 4; attempt += 1) await page.waitForTimeout(50); + if (adjustments.length < 4) { + const statuses = await page.getByRole("status").allTextContents().catch(() => []); + throw new Error(`Confirmed non-delivery retry did not reach steering: adjustments=${JSON.stringify(adjustments)} statuses=${JSON.stringify(statuses)} draft=${JSON.stringify(await composerInput.inputValue())}`); + } + try { + await page.getByRole("status").filter({ hasText: "本次未送达" }).waitFor(); + } catch (error) { + const statuses = await page.getByRole("status").allTextContents().catch(() => []); + const feedbacks = await page.locator(".personal-action-feedback").allTextContents().catch(() => []); + throw new Error(`Confirmed non-delivery feedback did not render: adjustments=${JSON.stringify(adjustments)} statuses=${JSON.stringify(statuses)} feedbacks=${JSON.stringify(feedbacks)} draft=${JSON.stringify(await composerInput.inputValue())}`, { cause: error }); + } await composerInput.press("Meta+Enter"); await page.getByRole("status").filter({ hasText: "执行器已接收本轮追加指令" }).waitFor(); if (adjustments[3].client_ingress_id === adjustments[4].client_ingress_id) throw new Error("Confirmed non-delivery could not retry after recovery"); diff --git a/examples/personal-workspace-browser/conversation-activity.mjs b/examples/personal-workspace-browser/conversation-activity.mjs index bf5b85bc7e..7a2794865d 100644 --- a/examples/personal-workspace-browser/conversation-activity.mjs +++ b/examples/personal-workspace-browser/conversation-activity.mjs @@ -24,7 +24,27 @@ export const conversationActivityScenario = { response.on("close", () => { if (streams.get(request.url) === response) streams.delete(request.url); }); }); await new Promise(resolveListen => server.listen(0, "127.0.0.1", resolveListen)); - const context = await openWorkspacePage(browser, url, { collectCoverage }); + const context = await openWorkspacePage(browser, url, { collectCoverage, beforeGoto: async (_api, page) => { + await page.addInitScript(() => { + window.__loopxSetSessionStorageFailure = message => { + if (!window.__loopxNativeSetItem) window.__loopxNativeSetItem = Storage.prototype.setItem; + const failForSessionStorage = function setItem(key, value) { + if (this === window.sessionStorage) throw new Error(message); + return window.__loopxNativeSetItem.call(this, key, value); + }; + Storage.prototype.setItem = failForSessionStorage; + Object.defineProperty(window.sessionStorage, "setItem", { + configurable: true, + value: () => { throw new Error(message); }, + }); + }; + window.__loopxRestoreSessionStorage = () => { + delete window.sessionStorage.setItem; + if (window.__loopxNativeSetItem) Storage.prototype.setItem = window.__loopxNativeSetItem; + delete window.__loopxNativeSetItem; + }; + }); + }}); const { page, api } = context; try { for (const pattern of ["**/events/**", "**/api/chat/sessions/*/turns/*/events"]) { @@ -103,6 +123,38 @@ export const conversationActivityScenario = { const composer = page.getByLabel("向 LoopX 发送消息"); const submit = page.getByRole("button", { name: "发送", exact: true }); const feedback = page.locator(".personal-action-feedback"); + const makeStorageUnavailable = reason => page.evaluate(message => { + window.__loopxSetSessionStorageFailure(message); + }, reason); + const restoreStorage = () => page.evaluate(() => { + window.__loopxRestoreSessionStorage(); + }); + const assertStorageUnavailable = async reason => { + const message = await page.evaluate(() => { + try { + window.sessionStorage.setItem("loopx-storage-probe", "1"); + return ""; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + }); + assert.equal(message, reason); + }; + const waitForFeedback = async (text, detail) => { + await feedback.filter({ hasText: text }).waitFor({timeout: 3000}).catch(async error => { + const storage = await page.evaluate(() => { + let probe = ""; + try { + window.sessionStorage.setItem("loopx-storage-probe-after", "1"); + probe = "ok"; + } catch (cause) { + probe = cause instanceof Error ? cause.message : String(cause); + } + return { probe, steering: window.sessionStorage.getItem("loopx-pw-composer-steering") }; + }); + throw new Error(`${detail}: ${error.message}; feedback=${JSON.stringify(await feedback.allTextContents())}; adjustments=${adjustments.length}; turn_requests=${api.turnRequests.length}; send_disabled=${await submit.isDisabled()}; composer_value=${JSON.stringify(await composer.inputValue())}; active=${await page.evaluate(() => document.activeElement?.id ?? document.activeElement?.tagName)}; storage=${JSON.stringify(storage)}`); + }); + }; await pending.getByRole("button", { name: "调整本轮", exact: true }).click(); assert.equal(await composer.evaluate(node => node === document.activeElement), true, "Adjust focuses the shared composer"); assert.equal(await page.locator("textarea:visible").count(), 1, "Only one editable conversation input is visible"); @@ -157,6 +209,40 @@ export const conversationActivityScenario = { const correctionIndex = conversation.findIndex(row => row.user && row.text.includes("先核对依赖")); assert.ok(originalIndex >= 0 && originalIndex < workIndex && workIndex < correctionIndex, "The request, ongoing work and accepted correction remain together in conversation order"); + await pending.getByRole("button", { name: "调整本轮", exact: true }).click(); + await makeStorageUnavailable("storage unavailable"); + await assertStorageUnavailable("storage unavailable"); + await composer.fill("先检查最新证据。"); + await page.getByRole("navigation", { name: "管家视图" }).getByRole("button", { name: "总览", exact: true }).click(); + await page.getByRole("navigation", { name: "管家视图" }).getByRole("button", { name: /^(Chat|对话)$/ }).click(); + assert.equal(await composer.inputValue(), "先检查最新证据。", "Blocked storage preserves the page draft across manager views"); + const dispatchedBeforeStorageFailure = adjustments.length; + const turnsBeforeStorageFailure = api.turnRequests.length; + await composer.focus(); + await page.keyboard.press("Meta+Enter"); + await waitForFeedback("调整尚未发送", "Storage failure did not report a retained steering draft"); + assert.equal(adjustments.length, dispatchedBeforeStorageFailure, "Storage failure cannot dispatch steering"); + assert.equal(api.turnRequests.length, turnsBeforeStorageFailure, "Storage failure cannot queue a new conversation Turn"); + assert.equal(await composer.inputValue(), "先检查最新证据。"); + await page.screenshot({path: resolve(outputDir, "conversation-steering-storage-unavailable.png"), animations: "disabled"}); + await restoreStorage(); + await composer.fill("先检查最新证据,再继续。"); + await composer.focus(); + await page.keyboard.press("Meta+Enter"); + await feedback.filter({ hasText: "原回合指令未确认" }).waitFor(); + const durableRequest = adjustments.at(-1); + await makeStorageUnavailable("QuotaExceededError"); + await assertStorageUnavailable("QuotaExceededError"); + await composer.fill("另一个尚未保存的调整。"); + await submit.click(); + await feedback.filter({ hasText: "上条投递尚未确认" }).waitFor(); + assert.equal(adjustments.length, dispatchedBeforeStorageFailure + 1, "A changed draft cannot replace an uncertain request"); + await composer.fill("先检查最新证据,再继续。"); + await submit.click(); + await feedback.filter({ hasText: "执行器已接收本轮追加指令" }).waitFor(); + assert.equal(adjustments.at(-1).client_ingress_id, durableRequest.client_ingress_id, "A storage failure cannot replace an existing durable retry identity"); + await restoreStorage(); + const adjustmentsAfterStorageFailure = adjustments.length; await pending.locator("summary").filter({ hasText: "最近活动" }).click(); assert.deepEqual((await pending.locator(".personal-message-activity li").allTextContents()).slice(-3), ["Agent 正在执行命令", "Agent 正在检索", "Agent 正在执行命令"]); await page.screenshot({ path: resolve(outputDir, "conversation-activity-desktop.png"), animations: "disabled" }); @@ -239,7 +325,7 @@ export const conversationActivityScenario = { await page.getByRole("navigation", { name: "管家视图" }).getByRole("button", { name: "总览", exact: true }).click(); await page.getByRole("navigation", { name: "管家视图" }).getByRole("button", { name: /^(Chat|对话)$/ }).click(); assert.equal(await composer.inputValue(), "保留我的未发送草稿。", "finished turn retains the unsent draft across unmount"); - assert.equal(adjustments.length, 4, "Completion never dispatches the retained draft"); + assert.equal(adjustments.length, adjustmentsAfterStorageFailure, "Completion never dispatches the retained draft"); await page.unroute("**/interrupt"); // The same interaction is present in Goal Chat, through the same timeline. @@ -319,7 +405,7 @@ export const conversationActivityScenario = { await page.reload({ waitUntil: "domcontentloaded" }); await page.getByRole("navigation", { name: "Goal 视图" }).getByRole("button", { name: /^(Chat|对话)$/ }).click(); await page.waitForFunction(() => document.querySelector("#personal-conversation-composer")?.value === "旧版保留的原回合草稿。"); - assert.equal(adjustments.length, 4, "Migrating an old draft never dispatches it"); + assert.equal(adjustments.length, adjustmentsAfterStorageFailure, "Migrating an old draft never dispatches it"); const migrated = await page.evaluate(() => JSON.parse(sessionStorage.getItem("loopx-pw-composer-steering")) .filter(([, request]) => request.id === "legacy-exact-ingress")); assert.equal(migrated.length, 1, "One retry owner remains after durable migration"); @@ -329,32 +415,6 @@ export const conversationActivityScenario = { await feedback.filter({ hasText: "执行器已接收本轮追加指令" }).waitFor(); assert.equal(adjustments.at(-1).client_ingress_id, "legacy-exact-ingress"); await composer.fill(""); - await page.evaluate(() => { window.sessionStorage.setItem = () => { throw new Error("storage unavailable"); }; }); - await composer.fill("先检查最新证据。"); - await page.getByRole("navigation", { name: "Goal 视图" }).getByRole("button", { name: "任务", exact: true }).click(); - await page.getByRole("navigation", { name: "Goal 视图" }).getByRole("button", { name: /^(Chat|对话)$/ }).click(); - assert.equal(await composer.inputValue(), "先检查最新证据。", "Blocked storage preserves the page draft across views"); - const dispatchedBeforeStorageFailure = adjustments.length; - await composer.press("Meta+Enter"); - await feedback.filter({ hasText: "调整尚未发送" }).waitFor(); - assert.equal(adjustments.length, dispatchedBeforeStorageFailure, "Storage failure cannot dispatch steering"); - assert.equal(await composer.inputValue(), "先检查最新证据。"); - await page.screenshot({path: resolve(outputDir, "conversation-steering-storage-unavailable.png"), animations: "disabled"}); - await page.evaluate(() => { delete window.sessionStorage.setItem; }); - await composer.fill("先检查最新证据,再继续。"); - await composer.press("Meta+Enter"); - await feedback.filter({ hasText: "原回合指令未确认" }).waitFor(); - const durableRequest = adjustments.at(-1); - await page.evaluate(() => { window.sessionStorage.setItem = () => { throw new Error("QuotaExceededError"); }; }); - await composer.fill("另一个尚未保存的调整。"); - await submit.click(); - await feedback.filter({ hasText: "上条投递尚未确认" }).waitFor(); - assert.equal(adjustments.length, dispatchedBeforeStorageFailure + 1, "A changed draft cannot replace an uncertain request"); - await composer.fill("先检查最新证据,再继续。"); - await submit.click(); - await feedback.filter({ hasText: "执行器已接收本轮追加指令" }).waitFor(); - assert.equal(adjustments.at(-1).client_ingress_id, durableRequest.client_ingress_id, "A storage failure cannot replace an existing durable retry identity"); - await page.evaluate(() => { delete window.sessionStorage.setItem; }); await page.getByRole("button", { name: "中断本轮", exact: true }).click(); await page.getByText("已中断。你可以在当前会话继续发送消息。", { exact: true }).waitFor(); await page.locator(".personal-chat-activity").waitFor({state: "hidden"}); diff --git a/examples/personal-workspace-browser/fixture.mjs b/examples/personal-workspace-browser/fixture.mjs index 046b031a2b..1b84b840e8 100644 --- a/examples/personal-workspace-browser/fixture.mjs +++ b/examples/personal-workspace-browser/fixture.mjs @@ -1466,7 +1466,11 @@ export async function installApi(page, { fixturePort = port, goalSubagentConfigu const answer = (typeof scriptedAnswer === "object" ? scriptedAnswer?.message : scriptedAnswer) || "已沿用当前 Goal 与 Agent Session。接下来会先核对状态,再继续推进。"; await new Promise((resolveWait) => setTimeout(resolveWait, /(中断控制|刷新恢复)/u.test(turnMessages.get(turnId) ?? "") ? 5000 : 1200)); - await route.fulfill({ contentType: "text/event-stream", body: finishTurn(sessionId, turnId, answer, null, scriptedAnswer?.goal_draft, scriptedAnswer?.proposals ?? []), status: 200 }); + try { + await route.fulfill({ contentType: "text/event-stream", body: finishTurn(sessionId, turnId, answer, null, scriptedAnswer?.goal_draft, scriptedAnswer?.proposals ?? []), status: 200 }); + } catch (error) { + if (!/Route is already handled/u.test(String(error?.message ?? error))) throw error; + } return; } if (url.pathname === "/api/chat/goals/contexts") { diff --git a/examples/personal-workspace-browser/goal-hierarchy.mjs b/examples/personal-workspace-browser/goal-hierarchy.mjs index ff211a1234..b8fca40394 100644 --- a/examples/personal-workspace-browser/goal-hierarchy.mjs +++ b/examples/personal-workspace-browser/goal-hierarchy.mjs @@ -143,6 +143,21 @@ export const goalHierarchyScenario = { "opening a scoped workspace reads its recursive subtree without opening unrelated Goal state"); assert.equal(new URL(page.url()).searchParams.get("rootGoalId"), "research"); assertResearchFocus(); + const scopedCalibration = page.url(); + const unscopedGraphBookmark = new URL(scopedCalibration); + unscopedGraphBookmark.searchParams.delete("research_goal"); + unscopedGraphBookmark.searchParams.delete("research_analysis_goal"); + await page.goto(unscopedGraphBookmark.href, {waitUntil: "networkidle"}); + await row("graph").locator(".personal-goal-link").click(); + await page.waitForURL(target => target.searchParams.get("goalId") === "graph"); + const unscopedQuery = new URL(page.url()).searchParams; + assert.equal(unscopedQuery.get("research_lens"), null, + "switching Goals clears an unscoped research lens instead of applying it to the destination Goal"); + assert.equal(unscopedQuery.get("research_node"), null, + "switching Goals clears unscoped research node bookmarks together with the lens"); + await page.goto(scopedCalibration, {waitUntil: "networkidle"}); + await row("calibration").locator('[aria-current="page"]').waitFor(); + assertResearchFocus(); await assertTreeLayout(); await page.screenshot({path: resolve(outputDir, "goal-hierarchy-focused.png"), animations: "disabled"}); await page.getByRole("button", {name: "调整 Goal 顺序", exact: true}).click(); diff --git a/examples/personal-workspace-browser/research-history.mjs b/examples/personal-workspace-browser/research-history.mjs index 6eb99eaf65..7923aa7192 100644 --- a/examples/personal-workspace-browser/research-history.mjs +++ b/examples/personal-workspace-browser/research-history.mjs @@ -795,7 +795,7 @@ export const researchHistoryScenario = { assert.equal(await analysis.getByRole('tab', {name: /下一步实验/}).getAttribute('aria-selected'), 'true'); await synthesisPage.setViewportSize({width: 1280, height: 900}); const packBeforeHistory = new URL(synthesisPage.url()).searchParams.get('research_pack'); - await analysis.getByRole('button', {name: 'Goal 研究历程', exact: true}).click(); + await analysis.getByRole('button', {name: '研究历程', exact: true}).click(); assert.equal(await analysis.count(), 0); assert.equal(new URL(synthesisPage.url()).searchParams.get('research_pack'), packBeforeHistory); await synthesisPage.waitForFunction(() => document.activeElement?.classList.contains('research-history-heading')); @@ -807,12 +807,13 @@ export const researchHistoryScenario = { await synthesisPage.reload({waitUntil: 'domcontentloaded'}); await synthesisPage.setViewportSize({width: 390, height: 844}); await synthesisPage.getByRole('button', {name: 'Read deeper', exact: true}).click(); - const english = synthesisPage.locator('.research-analysis'); + const english = synthesisPage.getByRole('region', {name: 'Trajectory analysis', exact: true}); + await english.waitFor(); assert.match(await english.locator('.analysis-report-meta').innerText(), /3 research episodes.*Purposive sample/); await english.getByRole('button', {name: 'Analysis scope', exact: true}).focus(); await synthesisPage.getByRole('tooltip').getByText('Agent analysis · uncalibrated', {exact: true}).waitFor(); await synthesisPage.keyboard.press('Escape'); - assert(await english.getByRole('button', {name: 'Goal research history', exact: true}).isVisible()); + assert(await english.getByRole('button', {name: 'Research history', exact: true}).isVisible()); const englishAccount = english.locator('.research-report-account').first(); assert(await englishAccount.getByRole('heading', {name: 'Observation and evidence', exact: true}).isVisible()); assert(await englishAccount.getByRole('heading', {name: 'Interpretation', exact: true}).isVisible()); diff --git a/examples/personal-workspace-browser/research-turn-costs.mjs b/examples/personal-workspace-browser/research-turn-costs.mjs index 23582e2d0a..49b13cebec 100644 --- a/examples/personal-workspace-browser/research-turn-costs.mjs +++ b/examples/personal-workspace-browser/research-turn-costs.mjs @@ -61,7 +61,10 @@ Path(sys.argv[1]).write_text(json.dumps(dict(goal_id=goal,display_name='Syntheti assert.match(await items.first().innerText(), /2,000/); await costs.getByRole('combobox').selectOption('elapsed'); assert.match(await items.first().innerText(), /第 7 轮.*未完成/s); - await items.first().getByRole('button').click(); + const turnCostEvidence = items.first().getByRole('button'); + const turnCostEvidenceHandle = await turnCostEvidence.elementHandle(); + assert(turnCostEvidenceHandle); + await turnCostEvidence.click(); const source = page.getByRole('region', {name: '引用原文', exact: true}); await source.locator('pre').waitFor(); assert.match(await source.innerText(), /turn-7/); @@ -69,7 +72,7 @@ Path(sys.argv[1]).write_text(json.dumps(dict(goal_id=goal,display_name='Syntheti assert.match(await source.innerText(), /token_count/); assert.ok(!(await source.innerText()).includes('SENSITIVE_TEXT_NOT_FOR_BROWSER')); await source.focus(); await page.keyboard.press('Escape'); - assert.equal(await items.first().getByRole('button').evaluate(element => element === document.activeElement), true); + await page.waitForFunction(element => element === document.activeElement, turnCostEvidenceHandle); await costs.getByRole('combobox').selectOption('time'); await costs.getByRole('button', {name: '下一页', exact: true}).click(); assert.equal(await items.count(), 3); diff --git a/examples/personal-workspace-browser/research-worker-readback.mjs b/examples/personal-workspace-browser/research-worker-readback.mjs index 5d75523b95..c5c8f08fb1 100644 --- a/examples/personal-workspace-browser/research-worker-readback.mjs +++ b/examples/personal-workspace-browser/research-worker-readback.mjs @@ -36,7 +36,8 @@ export async function startAuthority(mode) { lines.once("line", line => { clearTimeout(timeout); accept(JSON.parse(line)); }); }); assert.ok(Number.isSafeInteger(value.port) && value.port > 0); - return {...value, url: `http://127.0.0.1:${value.port}`, close}; + return {...value, url: `http://127.0.0.1:${value.port}`, root, + diagnostic: () => diagnostic, exited: () => ({exitCode: child.exitCode, signalCode: child.signalCode}), close}; } catch (error) { await close(); throw error; } } @@ -76,7 +77,12 @@ export const researchWorkerReadbackScenario = { writes += 1; writePaths.push(path); } - const response = await fetchWorkspaceBackend(route, authority.url); + let response; + try { + response = await fetchWorkspaceBackend(route, authority.url); + } catch (error) { + throw new Error(`Worker backend proxy failed: root=${authority.root} child=${JSON.stringify(authority.exited())} stderr=${authority.diagnostic()}`, {cause: error}); + } if (emptySession) sessionPreparations.push({status: response.status(), payload: await response.json()}); if (response.status() >= 400) failedReads.push({path: new URL(route.request().url()).pathname, status: response.status(), body: (await response.text()).slice(0, 800)}); diff --git a/examples/personal-workspace-browser/scenario-context.mjs b/examples/personal-workspace-browser/scenario-context.mjs index 93562ede9c..0f49f75046 100644 --- a/examples/personal-workspace-browser/scenario-context.mjs +++ b/examples/personal-workspace-browser/scenario-context.mjs @@ -2,7 +2,7 @@ import { installApi } from "./fixture.mjs"; // A disposable backend has a different port from the browser fixture. Rebase // same-origin requests with it; foreign origins must still reach the real guard. -export function fetchWorkspaceBackend(route, backendUrl, { pathname } = {}) { +export async function fetchWorkspaceBackend(route, backendUrl, { pathname } = {}) { const request = route.request(); const source = new URL(request.url()); const target = new URL(backendUrl); @@ -10,7 +10,17 @@ export function fetchWorkspaceBackend(route, backendUrl, { pathname } = {}) { target.search = source.search; const headers = { ...request.headers(), host: target.host }; if (headers.origin === source.origin) headers.origin = target.origin; - return route.fetch({ url: target.href, headers }); + for (let attempt = 0; ; attempt += 1) { + try { + return await route.fetch({ url: target.href, headers }); + } catch (error) { + const retryable = ["GET", "HEAD"].includes(request.method()) + && /\b(?:ECONNRESET|ECONNREFUSED)\b/u.test(String(error?.message ?? error)) + && attempt < 2; + if (!retryable) throw error; + await new Promise(resolve => setTimeout(resolve, 50 * (attempt + 1))); + } + } } export async function openWorkspacePage( From b21e7391c90261bf36d763e7b91eb511dbd231ef Mon Sep 17 00:00:00 2001 From: xiaobaifly7 Date: Thu, 12 Nov 2026 16:15:17 +0800 Subject: [PATCH 22/31] =?UTF-8?q?PR=20#8599=20=E2=80=94=20qualify=20Window?= =?UTF-8?q?s=20file=20identity=20and=20bind=20backup=20evidence=20(#8599)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(materials): ignore Windows ctime-only skew Signed-off-by: xiaobaifly7 <196511160+xiaobaifly7@users.noreply.github.com> * fix(io): ignore Windows ctime-only skew in cold backup Signed-off-by: xiaobaifly7 <196511160+xiaobaifly7@users.noreply.github.com> * fix(materials): retain ctime checks without file identity Signed-off-by: xiaobaifly7 <196511160+xiaobaifly7@users.noreply.github.com> * fix(backup): scan members from the verified private archive Signed-off-by: xiaobaifly7 <196511160+xiaobaifly7@users.noreply.github.com> * fix(io): require complete Windows identity for ctime tolerance Retain ctime checks when the volume or file index is unavailable in material and cold-backup observations. Exercise real Windows junction replacement with partial metadata and preserve complete-identity skew. Keep tar link normalization independent of host path syntax so valid first links remain accepted on Windows. Signed-off-by: xiaobaifly7 <196511160+xiaobaifly7@users.noreply.github.com> --------- Signed-off-by: xiaobaifly7 <196511160+xiaobaifly7@users.noreply.github.com> Co-authored-by: xiaobaifly7 <196511160+xiaobaifly7@users.noreply.github.com> --- .../coordination/cold_source_backup.py | 41 +++-- loopx/materials.py | 7 +- .../test_decision_context_profile.py | 152 ++++++++++++++++++ .../test_cold_source_backup_scope.py | 64 ++++++++ .../test_cold_source_import_cli.py | 72 +++++++++ 5 files changed, 320 insertions(+), 16 deletions(-) diff --git a/loopx/control_plane/coordination/cold_source_backup.py b/loopx/control_plane/coordination/cold_source_backup.py index 2d0cb1613d..4bbd3390e6 100644 --- a/loopx/control_plane/coordination/cold_source_backup.py +++ b/loopx/control_plane/coordination/cold_source_backup.py @@ -14,6 +14,7 @@ import json import os from pathlib import Path, PurePosixPath +import posixpath import shutil import sqlite3 import stat @@ -85,9 +86,13 @@ def _open_regular(path: Path) -> tuple[BinaryIO, os.stat_result]: def _same_file(before: os.stat_result, after: os.stat_result) -> bool: + identity_fields: tuple[str, ...] = ("st_dev", "st_ino", "st_size", "st_mtime_ns") + # Only a complete Windows volume/file identity can tolerate ctime skew. + if os.name != "nt" or not all(info.st_dev and info.st_ino for info in (before, after)): + identity_fields += ("st_ctime_ns",) return all( getattr(before, field) == getattr(after, field) - for field in ("st_dev", "st_ino", "st_size", "st_mtime_ns", "st_ctime_ns") + for field in identity_fields ) @@ -114,7 +119,7 @@ def _safe_link(name: str, target: str, *, hardlink: bool) -> str: raise ColdSourceBackupError("cold_import_backup_link_unsafe") if hardlink: return _member_name(target) - resolved = os.path.normpath(str(PurePosixPath(name).parent / target)) + resolved = posixpath.normpath(str(PurePosixPath(name).parent / target)) if resolved == ".." or resolved.startswith("../"): raise ColdSourceBackupError("cold_import_backup_link_unsafe") return _member_name(resolved) @@ -190,8 +195,8 @@ def _matches(name: str, prefixes: list[str] | None) -> bool: def _scan_archive( - archive: BinaryIO, *, selected: list[str] | None, workspace: Path -) -> tuple[dict[str, Any], list[dict[str, Any]], int, int, int]: + archive: BinaryIO, *, execution: dict[str, Any], selected: list[str] | None, workspace: Path +) -> tuple[dict[str, Any], list[dict[str, Any]], int, int, int, str, int]: workspace.mkdir(mode=0o700, parents=True, exist_ok=True) workspace_info = workspace.lstat() if not stat.S_ISDIR(workspace_info.st_mode): @@ -205,9 +210,20 @@ def _scan_archive( archive.seek(0) # One disposable transaction avoids per-prefix durable writes. # This scratch index is not a Goal authority or recovery store. - with closing(sqlite3.connect(database_path)) as database: + with ( + closing(sqlite3.connect(database_path)) as database, + (Path(temporary) / "archive.tar.gz").open("w+b") as snapshot, + ): + # Hash and scan the same private bytes even if the source is overwritten and restored. + shutil.copyfileobj(archive, snapshot, _CHUNK) + original_sha, archive_size = _sha(snapshot) + if original_sha != execution.get("archive_sha256"): + raise ColdSourceBackupError("cold_import_backup_archive_changed") + declared_size = execution.get("archive_size_bytes") + if declared_size is not None and declared_size != archive_size: + raise ColdSourceBackupError("cold_import_backup_archive_changed") database.execute("CREATE TABLE members (name BLOB PRIMARY KEY, marker BLOB NOT NULL) WITHOUT ROWID") - with tarfile.open(fileobj=archive, mode="r|gz") as tar: + with tarfile.open(fileobj=snapshot, mode="r|gz") as tar: # r|gz streams bytes but still caches every TarInfo on # supported Python versions. No random member lookup is # needed: hardlink coverage already lives in the disk DB. @@ -282,7 +298,7 @@ def _scan_archive( raise if embedded is None: raise ColdSourceBackupError("cold_import_backup_manifest_missing") - return embedded, members, member_count, logical_bytes, workspace_bytes + return embedded, members, member_count, logical_bytes, workspace_bytes, original_sha, archive_size def read_cold_source_backup( @@ -319,15 +335,10 @@ def read_cold_source_backup( archive_path = Path(str(execution.get("archive_path") or "")).expanduser().absolute() archive, before = _open_regular(archive_path) with archive: - original_sha, archive_size = _sha(archive) - if original_sha != execution.get("archive_sha256"): - raise ColdSourceBackupError("cold_import_backup_archive_changed") - declared_size = execution.get("archive_size_bytes") - if declared_size is not None and declared_size != archive_size: - raise ColdSourceBackupError("cold_import_backup_archive_changed") scan_workspace = (workspace or manifest_path.parent / ".cold-import-work").expanduser().absolute() - embedded, members, member_count, logical_bytes, workspace_bytes = _scan_archive( - archive, selected=selected, workspace=scan_workspace + (embedded, members, member_count, logical_bytes, workspace_bytes, + original_sha, archive_size) = _scan_archive( + archive, execution=execution, selected=selected, workspace=scan_workspace ) after = os.fstat(archive.fileno()) if not _same_file(before, after): diff --git a/loopx/materials.py b/loopx/materials.py index e4b3196f4e..dc81accc40 100644 --- a/loopx/materials.py +++ b/loopx/materials.py @@ -222,7 +222,12 @@ def read_bounded_material_file(path: Path, max_bytes: int) -> tuple[Path, bytes, after = os.fstat(handle.fileno()) resolved = path.resolve() current = resolved.stat() - identity_fields = ("st_dev", "st_ino", "st_size", "st_mtime_ns", "st_ctime_ns") + identity_fields: tuple[str, ...] = ("st_dev", "st_ino", "st_size", "st_mtime_ns") + # Tolerate Windows ctime skew only with a volume and file index in every observation. + if os.name != "nt" or not all( + observed.st_dev and observed.st_ino for observed in (before, after, current) + ): + identity_fields += ("st_ctime_ns",) identity = tuple(getattr(before, field) for field in identity_fields) if len(content) != before.st_size or any( tuple(getattr(observed, field) for field in identity_fields) != identity diff --git a/tests/capabilities/test_decision_context_profile.py b/tests/capabilities/test_decision_context_profile.py index 7a8c9ac8d0..1964d1211a 100644 --- a/tests/capabilities/test_decision_context_profile.py +++ b/tests/capabilities/test_decision_context_profile.py @@ -411,6 +411,78 @@ def stale_preflight(path, *args, **kwargs): assert grew +@pytest.mark.parametrize("platform", ["nt", "posix"]) +@pytest.mark.parametrize("descriptor_change", [False, True]) +def test_local_file_ctime_skew(tmp_path, monkeypatch, platform, descriptor_change): + import os + from types import SimpleNamespace + + from loopx import materials + + authority = tmp_path / "authority.txt" + authority.write_text("stable source", encoding="utf-8") + profile = normalize_decision_context_profile(profile_payload(authority)) + provider = build_decision_source_provider(profile.provider_binding_map()["local-authority"]) + original_fstat = os.fstat + calls = 0 + + def descriptor_stat(fd): + nonlocal calls + observed = original_fstat(fd) + calls += 1 + fields = {name: getattr(observed, name) for name in dir(observed) if name.startswith("st_")} + fields["st_ctime_ns"] = authority.stat().st_ctime_ns + 499_700 + if descriptor_change and calls == 2: + fields["st_ctime_ns"] += 1 + return SimpleNamespace(**fields) + + monkeypatch.setattr(materials, "os", SimpleNamespace(**{ + **vars(os), "name": platform, "fstat": descriptor_stat, + })) + args = dict(source=profile.sources[0], after_cursor=None, + before="2100-01-01T00:00:00+00:00", limit=8, + timeout_seconds=2, observed_at=OBSERVED_AT) + if platform == "posix": + with pytest.raises(ValueError, match="changed during bounded read"): + provider.scan(**args) + else: + scan = provider.scan(**args) + exact = provider.exact_read(source=profile.sources[0], item=scan.items[0], + timeout_seconds=2, observed_at=OBSERVED_AT) + assert scan.status == "completed" + assert exact.content == "stable source" + + +@pytest.mark.parametrize("change", ["mtime", "size"]) +def test_local_file_path_change_after_descriptor_snapshot(tmp_path, monkeypatch, change): + import os + + authority = tmp_path / "authority.txt" + authority.write_bytes(b"before") + profile = normalize_decision_context_profile(profile_payload(authority)) + provider = build_decision_source_provider(profile.provider_binding_map()["local-authority"]) + original_fstat = os.fstat + calls = 0 + + def change_after_snapshot(fd): + nonlocal calls + observed = original_fstat(fd) + calls += 1 + if calls == 2: + if change == "size": + authority.write_bytes(b"longer source") + os.utime(authority, ns=(observed.st_atime_ns, + observed.st_mtime_ns + (1_000_000_000 if change == "mtime" else 0))) + return observed + + monkeypatch.setattr(os, "fstat", change_after_snapshot) + with pytest.raises(ValueError, match="changed during bounded read"): + provider.scan(source=profile.sources[0], after_cursor=None, + before="2100-01-01T00:00:00+00:00", limit=8, + timeout_seconds=2, observed_at=OBSERVED_AT) + assert calls == 2 + + def test_local_file_growth_during_read_is_bounded(tmp_path, monkeypatch): import os @@ -460,6 +532,86 @@ def read(self, size=-1): assert read_sizes == [17] +@pytest.mark.parametrize("missing_identity", [None, "st_ino", "st_dev"]) +def test_local_file_same_metadata_replacement_has_no_verified_read(tmp_path, monkeypatch, missing_identity): + import os + import subprocess + from types import SimpleNamespace + + from loopx import materials + + original_dir, replacement_dir = tmp_path / "original", tmp_path / "replacement" + original_dir.mkdir() + replacement_dir.mkdir() + original = original_dir / "authority.txt" + original.write_bytes(b"original") + os.utime(original, (1_700_000_000, 1_700_000_000)) + before = original.stat() + replacement = replacement_dir / "authority.txt" + replacement.write_bytes(b"replaced") + os.utime(replacement, ns=(before.st_atime_ns, before.st_mtime_ns)) + assert replacement.stat().st_ino != before.st_ino + assert replacement.stat().st_size == before.st_size + assert replacement.stat().st_mtime_ns == before.st_mtime_ns + alias = tmp_path / "alias" + + def link_to(target): + if os.name == "nt": + created = subprocess.run( + ["cmd.exe", "/d", "/c", "mklink", "/J", str(alias), str(target)], + capture_output=True, + ) + assert created.returncode == 0, created.stderr or created.stdout + else: + alias.symlink_to(target, target_is_directory=True) + + link_to(original_dir) + authority = alias / "authority.txt" + profile = normalize_decision_context_profile(profile_payload(authority)) + provider = build_decision_source_provider(profile.provider_binding_map()["local-authority"]) + original_fstat, original_stat = os.fstat, Path.stat + observations = 0 + + def windows_stat(observed): + fields = {name: getattr(observed, name) for name in dir(observed) if name.startswith("st_")} + fields["st_ctime_ns"] = before.st_ctime_ns + (observed.st_ino != before.st_ino) + if missing_identity: + fields[missing_identity] = 0 + if missing_identity == "st_dev": + # File indexes may collide across volumes; inject only the OS metadata. + fields["st_ino"] = before.st_ino + return SimpleNamespace(**fields) + + def descriptor_stat(fd): + nonlocal observations + observed = original_fstat(fd) + observations += 1 + if observations == 2: + # Retarget the directory while the original file is open on Windows. + if os.name == "nt": + alias.rmdir() + else: + alias.unlink() + link_to(replacement_dir) + assert authority.read_bytes() == b"replaced" + return windows_stat(observed) + + def path_stat(path, *args, **kwargs): + observed = original_stat(path, *args, **kwargs) + return windows_stat(observed) if path in (authority, original, replacement) else observed + + monkeypatch.setattr(materials, "os", SimpleNamespace(**{ + **vars(os), "name": "nt", "fstat": descriptor_stat, + })) + monkeypatch.setattr(Path, "stat", path_stat) + with pytest.raises(ValueError, match="changed during bounded read"): + provider.scan(source=profile.sources[0], after_cursor=None, + before="2100-01-01T00:00:00+00:00", limit=8, + timeout_seconds=2, observed_at=OBSERVED_AT) + assert observations == 2 + assert authority.read_bytes() == b"replaced" + + def test_local_file_replacement_after_open_has_no_verified_read(tmp_path, monkeypatch): import os diff --git a/tests/control_plane/test_cold_source_backup_scope.py b/tests/control_plane/test_cold_source_backup_scope.py index d34f33cc17..61ddb94e4c 100644 --- a/tests/control_plane/test_cold_source_backup_scope.py +++ b/tests/control_plane/test_cold_source_backup_scope.py @@ -4,9 +4,11 @@ import hashlib import io import json +import os from pathlib import Path import sqlite3 import tarfile +from types import SimpleNamespace import pytest @@ -140,6 +142,66 @@ def test_scoping_does_not_hide_unsafe_unrelated_members_or_changed_artifact(tmp_ read_cold_source_backup(manifest, source_paths=[tmp_path / "runtime/goals/example"]) +@pytest.mark.parametrize("unavailable_identity", [None, "st_dev", "st_ino"]) +@pytest.mark.parametrize("platform", ["nt", "posix"]) +@pytest.mark.parametrize("field", ["st_dev", "st_ino", "st_size", "st_mtime_ns", "st_ctime_ns"]) +def test_archive_identity_change_policy(tmp_path, monkeypatch, platform, field, unavailable_identity): + from loopx.control_plane.coordination import cold_source_backup + + manifest = backup(tmp_path) + archive = tmp_path / "state.tar.gz" + archive_stat = archive.stat() + assert archive_stat.st_dev and archive_stat.st_ino + original_fstat = os.fstat + original_lstat = Path.lstat + observations = 0 + + def partial_identity(observed): + if unavailable_identity is None or (observed.st_dev, observed.st_ino) != ( + archive_stat.st_dev, archive_stat.st_ino + ): + return observed + # Unavailable volume/file indexes are injected only at the stat boundary. + fields = {name: getattr(observed, name) for name in dir(observed) if name.startswith("st_")} + fields[unavailable_identity] = 0 + return SimpleNamespace(**fields) + + def path_stat(path, *args, **kwargs): + return partial_identity(original_lstat(path, *args, **kwargs)) + + def descriptor_stat(fd): + nonlocal observations + observed = original_fstat(fd) + if (observed.st_dev, observed.st_ino) != (archive_stat.st_dev, archive_stat.st_ino): + return observed + observations += 1 + observed = partial_identity(observed) + if observations != 2: + return observed + fields = {name: getattr(observed, name) for name in dir(observed) if name.startswith("st_")} + fields[field] += 1 + return SimpleNamespace(**fields) + + monkeypatch.setattr(Path, "lstat", path_stat) + monkeypatch.setattr(cold_source_backup, "os", SimpleNamespace(**{ + **vars(os), "name": platform, "fstat": descriptor_stat, + })) + if platform == "nt" and field == "st_ctime_ns" and unavailable_identity is None: + witness = read_cold_source_backup(manifest) + assert witness["archive_sha256"] == hashlib.sha256(archive.read_bytes()).hexdigest() + assert witness["archive_size_bytes"] == archive_stat.st_size + assert witness["members"] == [ + {"archive_path": "runtime/goals/example/state.md", + "sha256": hashlib.sha256(b"retained source").hexdigest()}, + {"archive_path": "runtime/goals/example/task-leases/settled.json", + "sha256": hashlib.sha256(b"retained lease").hexdigest()}, + ] + else: + with pytest.raises(ColdSourceBackupError, match="cold_import_backup_changed_retry"): + read_cold_source_backup(manifest) + assert observations == 2 + + def test_streaming_witness_has_no_old_expanded_byte_ceiling(tmp_path): archive = tmp_path / "large-expanded.tar.gz" source = tmp_path / "source.bin" @@ -200,6 +262,8 @@ def unsafe_manifest(name: str, members: list[tarfile.TarInfo]) -> Path: link = tarfile.TarInfo("root/link") link.type, link.linkname = tarfile.SYMTYPE, "target" + assert read_cold_source_backup(unsafe_manifest("first-link", [link]), + source_paths=[tmp_path])["members"] == [] descendant = tarfile.TarInfo("root/link/file") descendant.size = 1 with pytest.raises(ValueError, match="member_unsafe"): diff --git a/tests/control_plane/test_cold_source_import_cli.py b/tests/control_plane/test_cold_source_import_cli.py index 5071d6bbd7..c49eaf5d30 100644 --- a/tests/control_plane/test_cold_source_import_cli.py +++ b/tests/control_plane/test_cold_source_import_cli.py @@ -6,6 +6,8 @@ """ from __future__ import annotations +from contextlib import contextmanager +import gzip import hashlib import io import json @@ -15,6 +17,7 @@ import subprocess import sys import tarfile +from types import SimpleNamespace import pytest @@ -94,6 +97,75 @@ def cli(action, *args, success=True, output_format="json", runtime_override=None return cli, state, backup, body, runtime, receiver, env +def test_prepare_rejects_archive_swap_and_restore_during_member_scan(tmp_path, monkeypatch, capsys): + from loopx.cli import main + from loopx.control_plane.coordination import cold_source_backup + + _, state, backup, _, runtime, _, _ = workspace(tmp_path, monkeypatch) + manifest_path = Path(backup["manifest_path"]) + manifest = json.loads(manifest_path.read_text()) + archive = Path(manifest["execution"]["archive_path"]) + original = archive.read_bytes() + source_a = state.read_bytes() + source_b = source_a.replace(b"Complete cold source requirement", b"Tampered cold source requirement") + assert source_a != source_b and len(source_a) == len(source_b) + expanded = gzip.decompress(original) + assert source_a in expanded + variant = gzip.compress(expanded.replace(source_a, source_b), mtime=0) + size = max(len(original), len(variant)) + original, variant = original.ljust(size, b"\0"), variant.ljust(size, b"\0") + archive.write_bytes(original) + manifest["execution"].update(archive_sha256=hashlib.sha256(original).hexdigest(), archive_size_bytes=size) + manifest_path.write_text(json.dumps(manifest)) + state.write_bytes(source_b) + os.utime(archive, (1_700_000_000, 1_700_000_000)) + before = archive.stat() + original_open, original_fstat = tarfile.open, os.fstat + scans = 0 + + def overwrite(data): + with archive.open("r+b") as handle: + handle.write(data) + os.utime(archive, ns=(before.st_atime_ns, before.st_mtime_ns)) + observed = archive.stat() + assert (observed.st_dev, observed.st_ino, observed.st_size, observed.st_mtime_ns) == ( + before.st_dev, before.st_ino, before.st_size, before.st_mtime_ns) + + @contextmanager + def swap_during_scan(*args, **kwargs): + nonlocal scans + scans += 1 + overwrite(variant) + try: + with original_open(*args, **kwargs) as tar: + yield tar + finally: + overwrite(original) + + def descriptor_stat(fd): + observed = original_fstat(fd) + if (observed.st_dev, observed.st_ino) != (before.st_dev, before.st_ino): + return observed + fields = {name: getattr(observed, name) for name in dir(observed) if name.startswith("st_")} + fields["st_ctime_ns"] = before.st_ctime_ns + scans + return SimpleNamespace(**fields) + + monkeypatch.setattr(tarfile, "open", swap_during_scan) + monkeypatch.setattr(cold_source_backup, "os", SimpleNamespace(**{ + **vars(os), "name": "nt", "fstat": descriptor_stat, + })) + code = main(["--registry", str(tmp_path / "project/.loopx/registry.json"), + "--runtime-root", str(runtime), "--format", "json", "coordination-shadow", "prepare-import", + "--goal-id", "cold", "--operation-id", "cold-original", "--backup-manifest", str(manifest_path), + "--provider", "file", "--target-handoff-mode", "hard_lease"]) + result = json.loads(capsys.readouterr().out)["cold_import"] + assert scans == 1 and archive.read_bytes() == original + assert code == 1, result + assert result["reason_code"] == "cold_import_backup_source_missing_or_changed" + assert result.get("coordination_source_backup_verified") is not True + assert not list((runtime / "authority-transition").rglob("writer-fence.json")) + + def test_coordination_command_help_and_rejections_match_full_cli(tmp_path): # Independently compare the existing compatibility parser/handler with the # selected dispatcher, including diagnostics that deliberately fall back. From 8bed1e516eedc6373e40366f8886038441e45605 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Thu, 12 Nov 2026 16:15:51 +0800 Subject: [PATCH 23/31] Show the bound Yardstick reversal in replan opening (#8622) * Show the bound Yardstick reversal in replan opening Use one scoped cue for the complete first read and staged review. Expose the authoritative comparison candidate and source finding only for a Todo bound to that replan, without another read or a new decision. Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> * Keep bound Yardstick evidence in the replan opening Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --------- Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Co-authored-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- docs/reference/protocols/turn-envelope-v0.md | 13 ++ loopx/capabilities/explore/turn_context.py | 11 +- .../capabilities/explore_turn_context.ts | 16 +- loopx/control_plane/quota/live_decision.py | 1 + .../quota/progressive_context.ts | 41 ++++- loopx/control_plane/quota/turn_envelope.ts | 9 +- .../work_items/yardstick_decision.ts | 5 +- loopx/quota.py | 10 +- .../control_plane/test_work_yardstick_cli.py | 145 ++++++++++++++++++ tests/control_plane_ts/turn_envelope.test.ts | 69 ++++++++- 10 files changed, 307 insertions(+), 13 deletions(-) diff --git a/docs/reference/protocols/turn-envelope-v0.md b/docs/reference/protocols/turn-envelope-v0.md index 9474a88164..ef7488f0b6 100644 --- a/docs/reference/protocols/turn-envelope-v0.md +++ b/docs/reference/protocols/turn-envelope-v0.md @@ -250,6 +250,19 @@ reader; a missing source is never replaced by a fresh guard. The existing generi followthrough is now available with stride off; its result-class counts and observations remain evidence, not successful-work or goal-gain verdicts. +When the bound Todo's captured Yardstick shows a proxy ordering opposite to the +authoritative measurement, the first complete Turn read and staged `review` +show the conflict, the preferred candidate, the proxy's comparison scope, and +the finding IDs for the measured pair alongside the Yardstick declaration. +The selected Todo's card takes precedence within the bounded read. The pinned +Explore read retains the full evidence. Unbound reviews keep navigation until +the Todo is selected. The read grants only evidence access. + +已绑定 Todo 的标尺若与正式测量反向,完整 Turn 的首读和分段 `review` 会显示 +分歧、正式测量更优的候选、代理指标的比较范围,以及实际测量发现和原标尺声明各自的 ID。 +已选 Todo 的提示在有上限的读取中优先交付,原捕获的 Explore 读取保留完整证据。 +待选择的复盘保留证据入口,绑定 Todo 后才交付该任务的提示。读取只提供证据访问。 + Captured replans also reference Explore attachment syntax already present in the same captured writer instead of repeating it in the entry work context. This applies to default CLI/full recovery as well as optional delta delivery. Evidence, diff --git a/loopx/capabilities/explore/turn_context.py b/loopx/capabilities/explore/turn_context.py index 51a3f50995..639f9981f3 100644 --- a/loopx/capabilities/explore/turn_context.py +++ b/loopx/capabilities/explore/turn_context.py @@ -49,7 +49,7 @@ def _policy(registry_path: Path, goal_id: str, runtime_root: Path | None = None) def read_replan_explore_context(*, source_status: Mapping[str, Any], goal_id: str, agent_id: str | None, - review_pages: Any = None) -> dict[str, Any] | None: + review_pages: Any = None, preferred_yardstick_todo_id: str | None = None) -> dict[str, Any] | None: """Outer read adapter; the replan owner invokes it only for enabled reviews. Unavailable IO is distinct from an empty or disabled evidence source. No @@ -61,6 +61,7 @@ def read_replan_explore_context(*, source_status: Mapping[str, Any], goal_id: st return explore_turn_context( registry_path=Path(source_status["registry"]), runtime_root=Path(source_status["runtime_root"]), goal_id=goal_id, agent_id=agent_id, include_finding_digests=True, + preferred_yardstick_todo_id=preferred_yardstick_todo_id, **({"review_pages": review_pages} if review_pages is not None else {})) except (OSError, ValueError, RuntimeError): return {"ok": False, "goal_id": goal_id, "agent_id": agent_id} @@ -70,6 +71,7 @@ def project_work_yardstick_sources( *, events: list[dict[str, Any]], projection: Mapping[str, Any], goal_id: str, agent_id: str, work_todos: list[dict[str, Any]], enabled: bool, todo_id: str | None = None, result_node: str | None = None, + preferred_yardstick_todo_id: str | None = None, ) -> list[dict[str, Any]]: """Replay typed-owner-selected prefixes from one already-read canonical log. @@ -82,6 +84,7 @@ def project_work_yardstick_sources( requests = effect_runtime_result("work_item.yardstick.sources", { "yardstick_enabled": enabled, "work_todos": work_todos, "goal_id": goal_id, "agent_id": agent_id, "todo_id": todo_id, "result_node": result_node, + "preferred_yardstick_todo_id": preferred_yardstick_todo_id, "findings": projection.get("findings", []), "source_events": [{"goal_id": row["goal_id"], "event_id": row["event_id"]} for row in events], }) @@ -108,6 +111,7 @@ def explore_turn_context( result_node: str | None = None, result_revision: str | None = None, include_finding_digests: bool = False, review_pages: Any = None, todo_id: str | None = None, + preferred_yardstick_todo_id: str | None = None, ) -> dict[str, Any]: if type(include_finding_digests) is not bool: raise ValueError("include_finding_digests must be an explicit boolean") @@ -165,7 +169,7 @@ def explore_turn_context( if yardstick and graph: sources = project_work_yardstick_sources(events=events, projection=projection, goal_id=goal_id, agent_id=agent_id, work_todos=work_todos, enabled=yardstick, - todo_id=todo_id, result_node=result_node) + todo_id=todo_id, result_node=result_node, preferred_yardstick_todo_id=preferred_yardstick_todo_id) return cast(dict[str, Any], effect_runtime_result( "explore.turn_context", { @@ -175,7 +179,8 @@ def explore_turn_context( "harness_gate": gate, "projection": projection, "plan": plan, - **({"yardstick_enabled": True, "work_todos": work_todos, "yardstick_sources": sources} if yardstick else {}), + **({"yardstick_enabled": True, "work_todos": work_todos, "yardstick_sources": sources, + "preferred_yardstick_todo_id": preferred_yardstick_todo_id} if yardstick else {}), **focus, **({"review_todos": [scopes[key] for key in sorted(page_tasks) if key in scopes]} if page_tasks else {}), "route": route, diff --git a/loopx/control_plane/capabilities/explore_turn_context.ts b/loopx/control_plane/capabilities/explore_turn_context.ts index 1dd47d1f55..edf8e85c3d 100644 --- a/loopx/control_plane/capabilities/explore_turn_context.ts +++ b/loopx/control_plane/capabilities/explore_turn_context.ts @@ -10,7 +10,7 @@ import {PLANNING_TODO_ID} from "../work_items/planning_relations.ts"; import {EXPLORE_WRITEBACK_SUMMARY_LIMIT, exploreBeforeWorkGuidance, exploreInTurnFeedbackGuidance, exploreResultWritebackAffordance} from "./explore_result_writeback.ts"; import {EXPLORE_RESULT_PAGE_MAX, normalizeExploreResultPages} from "./explore_result_page.ts"; import {projectWorkMeasurementFacts, projectWorkYardstickCue, workYardstickTodos, WORK_YARDSTICK_DECISION_SCHEMA} from "../work_items/yardstick_decision.ts"; -import {projectExploreMeasurementFeedback} from "./explore_measurements.ts"; +import {projectExploreMeasurementFeedback, type ExploreMeasurementPoint} from "./explore_measurements.ts"; /** Reusable evidence and instructions in this owner's read model. Current * selection, eligibility, source revisions and pagination stay outside this @@ -374,13 +374,23 @@ export function projectExploreTurnContext(params: JsonObject): JsonObject { ...(workCues.length ? {work_yardsticks: workCues.slice(0, 3).map(cue => { if (resultNode !== null) return cue; const comparison = cue.search_comparison as JsonObject | undefined; + const goalPair = comparison?.goal as JsonObject | undefined; + const proxyPair = comparison?.proxy as JsonObject | undefined; + const supportingFindings = comparison?.proxy_order_reversed === true && goalPair && proxyPair + ? [...new Set([goalPair.previous_best, goalPair.latest, proxyPair.previous_best, proxyPair.latest] + .map(point => (point as ExploreMeasurementPoint).finding_id))] + : undefined; return {source_finding_id: cue.source_finding_id, node_id: cue.node_id, todo_id: (cue.binding as JsonObject).todo_id, target: (cue.target as JsonObject).summary, cue: (cue.next_action as JsonObject).reason, counterevidence_refs: cue.counterevidence_refs, ...(comparison ? {comparison_cue: comparison.guidance, - comparison_candidate_ref: (comparison.goal as JsonObject).preferred_candidate_ref, + comparison_candidate_ref: goalPair?.preferred_candidate_ref, ...(typeof comparison.proxy_order_reversed === "boolean" - ? {proxy_order_reversed: comparison.proxy_order_reversed} : {})} : {}), + ? {proxy_order_reversed: comparison.proxy_order_reversed} : {}), + ...(supportingFindings ? { + comparison_finding_ids: supportingFindings, + proxy_comparison_scope: (cue.proxy as JsonObject).comparison_scope, + } : {})} : {}), details_command: resultRead("--result-node", String(cue.node_id))}; }), omitted_work_yardsticks: Math.max(0, workCues.length - 3), work_yardstick_command: command("turn-context", "--agent-id", agent, "--todo-id", "")} : {}), diff --git a/loopx/control_plane/quota/live_decision.py b/loopx/control_plane/quota/live_decision.py index c4b598c681..5498b6ea28 100644 --- a/loopx/control_plane/quota/live_decision.py +++ b/loopx/control_plane/quota/live_decision.py @@ -825,6 +825,7 @@ def build_live_quota_should_run_decision( ), receipt_bound_todo_id=receipt_bound_todo_id, requested_action_todo_id=requested_action_todo_id, + retained_action_selection_todo_id=retained_action_selection_todo_id, receipt_bound_monitor_phase=receipt_bound_monitor_phase, receipt_bound_replay_phase=receipt_bound_replay_phase, receipt_bound_replan_obligation_id=receipt_bound_replan_obligation_id, diff --git a/loopx/control_plane/quota/progressive_context.ts b/loopx/control_plane/quota/progressive_context.ts index 2997fdd69f..2d3bedadac 100644 --- a/loopx/control_plane/quota/progressive_context.ts +++ b/loopx/control_plane/quota/progressive_context.ts @@ -8,7 +8,7 @@ import {completeGoalNarrative} from "../work_items/goal_context_source.ts"; import type {JsonObject} from "../effect_program.ts"; import {jsonObject, requireBoolean, requireJsonObject} from "../runtime_decode.ts"; import {GOAL_OUTCOMES_SCHEMA, OUTCOME_CONTEXT_NAVIGATION_SCHEMA} from "../goals/outcome_requirements.ts"; -import {FULL_OUTCOME_READ_GUIDANCE, PROGRESSIVE_OUTCOME_READ_GUIDANCE} +import {FULL_OUTCOME_READ_GUIDANCE, PROGRESSIVE_OUTCOME_READ_GUIDANCE, selectedWorkTodoId} from "../work_items/interaction_contract.ts"; import {quotaSummaryReferences, contextSourceIdentity, contextProjectionChanges, type ProjectionSourceValues} from "./diagnostic_projection.ts"; @@ -27,6 +27,7 @@ type ReusableReadField = { export type ReusableExactBody = {path: Path; sourceIdentity: string}; const MIN_REUSABLE_BODY_BYTES = 1024; const SETTLEMENT_PLAN_PATH = ["interaction_contract", "cli_channel", "settlement_plan"]; +const REPLAN_CONTEXT_PATH = ["autonomous_replan_obligation", "replan_context"]; const pointer = (path: Path): string => path.length ? "/" + path.map(key => String(key).replaceAll("~", "~0").replaceAll("/", "~1")).join("/") : ""; const at = (root: unknown, path: Path): unknown => path.reduce((row, key) => @@ -833,6 +834,39 @@ export function initialContextReadPointers(context: JsonObject, decisionFields: return [...opening, ...remaining]; } +/** A suggested Todo in an unbound guard cannot supply the next decision's cue. */ +export function scopedReplanYardstickReversal(decision: JsonObject) { + const packet = jsonObject(decision.replan_action_packet); + const review = jsonObject(at(decision, REPLAN_CONTEXT_PATH)); + const explore = jsonObject(review?.explore_basis); + const cli = jsonObject(jsonObject(decision.interaction_contract)?.cli_channel); + const identity = jsonObject(jsonObject(cli?.settlement_plan)?.identity); + const todo = selectedWorkTodoId(decision); + const agent = decision.agent_id ?? jsonObject(decision.agent_identity)?.agent_id; + if (typeof packet?.obligation_id !== "string" || packet.obligation_id !== review?.obligation_id + || packet.settlement_only === true || explore?.availability !== "available" + || todo === null || cli?.selection_required === true || identity?.todo_id !== todo + || typeof decision.goal_id !== "string" || identity.goal_id !== decision.goal_id + || typeof agent !== "string" || identity.agent_id !== agent + || !Array.isArray(explore.work_yardsticks)) return undefined; + + const index = explore.work_yardsticks.findIndex(value => { + const cue = jsonObject(value); + return cue?.todo_id === todo && cue.proxy_order_reversed === true + && typeof cue.source_finding_id === "string" && typeof cue.comparison_candidate_ref === "string"; + }); + if (index < 0) return undefined; + const cue = explore.work_yardsticks[index] as JsonObject; + const fields = ["todo_id", "source_finding_id", "comparison_candidate_ref", "proxy_order_reversed", "proxy_comparison_scope", + "fidelity_status", "paired_candidates"].filter(field => + ["string", "boolean", "number"].includes(typeof cue[field])); + if (Array.isArray(cue.comparison_finding_ids) && cue.comparison_finding_ids.length > 0 + && cue.comparison_finding_ids.length <= 4 && cue.comparison_finding_ids.every(id => typeof id === "string")) { + fields.push("comparison_finding_ids"); + } + return {cue, fields, path: [...REPLAN_CONTEXT_PATH, "explore_basis", "work_yardsticks", index]}; +} + /** A pending Manager Inbox read already delivers these scoped request rows. */ function managerAgendaOpeningFields(value: unknown, requiredReads: unknown[]): string[] | undefined { const {content, ...read} = jsonObject(value) ?? {}; @@ -893,6 +927,7 @@ function initialContextReadGroups(context: JsonObject, decisionFields: readonly // A staged replan has already replaced inline acceptance with a pinned // review reader. Deliver its first action and ordering without writeback. const replan = object(context.replan_action_packet); + let replanReversal: ReturnType; if (context.effective_action === EffectiveAction.AUTONOMOUS_REPLAN_REQUIRED && replan?.schema_version === "replan_action_packet_v0" && replan.decision === "replan_required" && replan.settlement_only !== true && typeof replan.review === "string" && replan.review) { @@ -918,6 +953,7 @@ function initialContextReadGroups(context: JsonObject, decisionFields: readonly include("autonomous_replan_obligation", "replan_context", "context_id"); if (Array.isArray(obligation.triggers)) obligation.triggers.forEach((_trigger, index) => include("autonomous_replan_obligation", "triggers", index, "kind")); + replanReversal = scopedReplanYardstickReversal(context); } } include("agent_lane_next_action", "next_step"); @@ -952,6 +988,9 @@ function initialContextReadGroups(context: JsonObject, decisionFields: readonly // Deliver the opted-in decision cue before work, while the candidate or // test can still change. The template and evidence stay on their owner read. const cueStart = pointers.length; + if (replanReversal) { + for (const field of replanReversal.fields) include(...replanReversal.path, field); + } if (context.effective_action === EffectiveAction.NORMAL_RUN && context.normal_delivery_allowed === true && typeof selectedTodo?.todo_id === "string" && typeof context.goal_id === "string" && typeof agentId === "string" && (identityAgent === undefined || identityAgent === agentId)) { diff --git a/loopx/control_plane/quota/turn_envelope.ts b/loopx/control_plane/quota/turn_envelope.ts index 5e6ec826e3..3ee7c81864 100644 --- a/loopx/control_plane/quota/turn_envelope.ts +++ b/loopx/control_plane/quota/turn_envelope.ts @@ -1,7 +1,8 @@ import {EXECUTION_PREPARATION_SCHEMA_VERSION} from "../work_items/execution_preparation.ts"; import {capturedContextSelection} from "./context_selection.ts"; import {contextReadSectionDigests, initialContextReadPointers, prepareProgressiveContext, prepareProgressiveContextDelivery, - progressiveContextPlan, type PreparationScopeDetail, type ReusableExactBody} from "./progressive_context.ts"; + progressiveContextPlan, scopedReplanYardstickReversal, + type PreparationScopeDetail, type ReusableExactBody} from "./progressive_context.ts"; import type { LoopXTurnRouteValue } from "../turn_driver/turn_contract_generated.ts"; import { EFFECTIVE_ACTIONS, EffectiveAction, type QuotaEffectiveActionValue } from "./effective_action.generated.ts"; import { AGENT_SCOPE_FRONTIER_ACTIONS } from "../agents/agent_scope_frontier.generated.ts"; @@ -967,6 +968,8 @@ function projectReplanView(request: JsonObject, payload: JsonObject, sourceHash: retain(follow, originalFollow, [["recorded_progress_count", "observation_count"]]); } else retain(follow, originalFollow, followFields); const explore = jsonObject(context.explore_basis) ?? {}; + const yardstickCount = Array.isArray(explore.work_yardsticks) ? explore.work_yardsticks.length : 0; + const reversal = reads ? scopedReplanYardstickReversal(payload) : undefined; const decisionValueReview = jsonObject(jsonObject(packet.stride_control)?.decision_value_review); const review = retain({...common, core_goal: core, @@ -983,8 +986,8 @@ function projectReplanView(request: JsonObject, payload: JsonObject, sourceHash: availability: explore.availability, revision: explore.revision, ...(Array.isArray(explore.measurement_feedback) && explore.measurement_feedback.length ? {measurement_count: explore.measurement_feedback.length} : {}), - ...(Array.isArray(explore.work_yardsticks) && explore.work_yardsticks.length - ? {yardstick_count: explore.work_yardsticks.length} : {}), + ...(yardstickCount ? {yardstick_count: yardstickCount} : {}), + ...(reversal ? {work_yardstick_reversal: retain(compactFields(reversal.cue, reversal.fields), reversal.cue)} : {}), read_action: reads?.explore ?? null, }, explore, ["availability", "revision"])}} : {}), evidence: (Array.isArray(context.evidence) ? context.evidence : []).map(value => { diff --git a/loopx/control_plane/work_items/yardstick_decision.ts b/loopx/control_plane/work_items/yardstick_decision.ts index 1be5dad3d2..2ae386512c 100644 --- a/loopx/control_plane/work_items/yardstick_decision.ts +++ b/loopx/control_plane/work_items/yardstick_decision.ts @@ -103,10 +103,13 @@ export function normalizeWorkYardstickSourceSnapshot(value: unknown): NonNullabl } /** Share current-work eligibility between the read plan and its final projection. */ export function workYardstickTodos(params: JsonObject): JsonObject[] { - return params.yardstick_enabled === true && Array.isArray(params.work_todos) ? (params.work_todos as JsonObject[]) + const todos = params.yardstick_enabled === true && Array.isArray(params.work_todos) ? (params.work_todos as JsonObject[]) .filter(todo => todo.role === "agent" && !todo.done && todo.claimed_by === params.agent_id && ["open", "pending", "in_progress", "blocked"].includes(String(todo.status)) && (params.todo_id == null || todo.todo_id === params.todo_id)) : []; + const preferred = todos.findIndex(todo => todo.todo_id === params.preferred_yardstick_todo_id); + if (preferred > 0) todos.unshift(...todos.splice(preferred, 1)); + return todos; } function belongsToWork(row: JsonObject, binding: JsonObject): boolean { const capture = row.work_yardstick as WorkYardstickCapture | undefined; diff --git a/loopx/quota.py b/loopx/quota.py index 1ce7e8fa87..eded708cd4 100644 --- a/loopx/quota.py +++ b/loopx/quota.py @@ -1041,6 +1041,7 @@ def build_quota_should_run( operator_inbox_urgency_projector: Callable[..., dict[str, Any]] | None = None, receipt_bound_todo_id: str | None = None, requested_action_todo_id: str | None = None, + retained_action_selection_todo_id: str | None = None, receipt_bound_monitor_phase: ReceiptBoundMonitorPhase | None = None, receipt_bound_replay_phase: ReceiptBoundReplayPhase | None = None, receipt_bound_terminal_phase: ReceiptBoundTerminalPhase | None = None, @@ -1060,10 +1061,17 @@ def build_quota_should_run( build_quota_should_run as _build_quota_should_run, ) + # Inline reentry can retain an explicit choice before creating a receipt. + # This only orders the agent's eligible cards within the existing read cap. + preferred_yardstick_todo_id = receipt_bound_todo_id or requested_action_todo_id or retained_action_selection_todo_id + def read_explore_context(read_goal_id: str, read_agent_id: str | None) -> dict[str, Any] | None: from .capabilities.explore.turn_context import read_replan_explore_context - return read_replan_explore_context(source_status=status_payload, goal_id=read_goal_id, agent_id=read_agent_id) + return read_replan_explore_context( + source_status=status_payload, goal_id=read_goal_id, agent_id=read_agent_id, + preferred_yardstick_todo_id=preferred_yardstick_todo_id, + ) return _build_quota_should_run( status_payload, diff --git a/tests/control_plane/test_work_yardstick_cli.py b/tests/control_plane/test_work_yardstick_cli.py index 4518912179..1bfba7d982 100644 --- a/tests/control_plane/test_work_yardstick_cli.py +++ b/tests/control_plane/test_work_yardstick_cli.py @@ -107,6 +107,151 @@ def starting_page(project, runtime, path, turn, saved, monkeypatch): return page +@pytest.mark.parametrize("native_session", [False, True]) +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_bound_benchmark_replan_opening_shows_scoped_reversal(tmp_path, monkeypatch, native_session, provider): + from loopx.control_plane.turn_driver.codex_context import codex_context_hook + + scope = wide_write_scope() + project, runtime, path = setup(tmp_path, active=True, provider=provider, write_scope=scope) + run(project, runtime, path, "configure-goal", "--goal-id", GOAL_ID, + "--execution-replan-after-turns", "1", + "--execution-stride-strategy", "evidence_conditioned_v1", "--execute") + finding = attachment() + profiles = finding["work_yardstick_decision"] + profiles["proxy"]["comparison_scope"] = "validation:screen-slice" + measurements = [ + {**profiles[role], "candidate_ref": f"candidate:{name}", "evidence_ref": f"result:{role}-{name}", "value": value} + for role, scores in (("authoritative", (8, 4)), ("proxy", (12, 15))) + for name, value in zip(("a", "b"), scores) + ] + prepared_turn = binding("prepare-reversal") + run(project, runtime, path, "quota", "should-run", "--codex-app", *prepared_turn) + evidence = tmp_path / "finding.json" + + def record(payload): + evidence.write_text(json.dumps(payload)) + return run(project, runtime, path, "explore", "finding", *prepared_turn, "--result-json", str(evidence)) + + def record_measurement(measurement, **location): + return record({ + "schema_version": finding["schema_version"], "input_revision": finding["input_revision"], + "status": "confirmed", "observation": f"Observed {measurement['evidence_ref']}", + "interpretation": "Use the matched measurement for this candidate.", + "evidence_refs": [measurement["evidence_ref"]], "measurements": [measurement], **location, + }) + + proxy_a = record_measurement(measurements[2], question=finding["question"], applicability=finding["applicability"]) + node_id = proxy_a["node_id"] + proxy_b = record_measurement(measurements[3], node_id=node_id) + profiles["counterevidence_refs"] = [row["evidence_ref"] for row in measurements[2:]] + finding["evidence_refs"].extend(profiles["counterevidence_refs"]) + declaration = record({**finding, "node_id": node_id}) + assert "search_comparison" not in declaration + official = [record_measurement(row, node_id=node_id) for row in measurements[:2]] + finding_ids = [result["finding_id"] for result in [*official, proxy_a, proxy_b]] + assert len(set(finding_ids)) == 4 and declaration["finding_id"] not in finding_ids + assert official[-1]["search_comparison"]["proxy_order_reversed"] + first = run(project, runtime, path, "refresh-state", *prepared_turn, "--classification", "validated_change", + "--delivery-batch-scale", "implementation", "--delivery-outcome", "outcome_progress", + "--delivery-boundary", "in_flight_continuation", "--no-global-sync", "--suppress-external-sinks") + run(project, runtime, path, *_projected_cli_args(first["settlement_owed"]["command"], + turn_instance_id="prepare-reversal")) + + log = explore_result_log_path(runtime, GOAL_ID) + for index in range(3): + node = f"competing-question-{index}" + added = run(project, runtime, path, "todo", "add", "--goal-id", GOAL_ID, + "--role", "agent", "--claimed-by", AGENT_ID, "--priority", "P0", + "--text", f"Inspect independent evidence {index}", "--task-class", "advancement_task", + "--action-kind", "validate", "--required-capability", "network", "--target-key", node) + other = added["todo_id"] + run(project, runtime, path, "todo", "update", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, + "--todo-id", other, "--explore-result-node-ref", node) + append_explore_result_event(log, build_explore_node_event(goal_id=GOAL_ID, agent_id=AGENT_ID, + node_id=node, title=node, node_kind="question", status="exploring")) + other_work = dict(goal_id=GOAL_ID, agent_id=AGENT_ID, todo_id=other, turn_instance_id=f"other-{index}") + append_explore_result_event(log, build_explore_finding_event(goal_id=GOAL_ID, agent_id=AGENT_ID, + node_id=node, title=f"Adopt the scope for {node}", status="confirmed", + evidence_refs=finding["evidence_refs"], work_binding=other_work, + work_yardstick={"decision": {**profiles, "counterevidence_refs": []}, "binding": other_work})) + + native = tmp_path / "native" + native.mkdir(mode=0o700) + monkeypatch.setenv("LOOPX_EXECUTION_MODE", "heartbeat") + monkeypatch.setenv("LOOPX_CONTEXT_DELIVERY", "on") + if native_session: + monkeypatch.setenv("CODEX_THREAD_ID", "fixture-benchmark") + assert codex_context_hook(str(native), {"session_id": "fixture-benchmark", "cwd": str(project), + "hook_event_name": "SessionStart", "source": "startup"}) == {} + else: + monkeypatch.delenv("CODEX_THREAD_ID", raising=False) + capture = tmp_path / "captures" + capture.mkdir(mode=0o700) + opening = capture / "decision-original" + turn = "periodic-review" + prepared = run(project, runtime, path, "quota", "should-run", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, + "--turn-instance-id", turn, "--runtime-profile", "generic_cli", + "--available-capability", "shell", "--available-capability", "filesystem_write", + "--decision-output-dir", str(opening), "--prepared-capture-reference", "--complete-turn", + "--context-delivery", "on", "--codex-context-dir", str(native), "--defer-context-presentation") + original = opening / "decision.json" + original_bytes = original.read_bytes() + assert prepared["capture_sha256"] == hashlib.sha256(original_bytes).hexdigest() + unbound = json.loads(original_bytes) + unbound_cli = unbound["interaction_contract"]["cli_channel"] + assert unbound_cli["selection_required"] is True + obligation = "autonomous_replan_obligation" + unbound_explore = unbound[obligation]["replan_context"]["explore_basis"] + assert len(unbound_explore["work_yardsticks"]) == 3 + assert all(card["todo_id"] != TODO_ID for card in unbound_explore["work_yardsticks"]) + assert unbound_explore["omitted_work_yardsticks"] == 1 + selection = shlex.split(unbound_cli["selection_command"]["command_args_template"].replace("{todo_id}", TODO_ID)) + bound = run(project, runtime, path, *selection) + [selected] = [file for file in capture.glob("decision-*/decision.json") if file != original] + guard = json.loads(selected.read_bytes()) + assert guard["schema_version"] == "loopx_turn_envelope_v1" + identity = guard["heartbeat_receipt"]["settlement_identity"] + assert identity["todo_id"] == TODO_ID and identity["turn_instance_id"] == turn + assert guard["effective_action"] == "autonomous_replan_required" + assert guard[obligation]["obligation_id"] == unbound[obligation]["obligation_id"] + explore = guard[obligation]["replan_context"]["explore_basis"] + assert explore["revision"] == unbound_explore["revision"] + cue, *others = explore["work_yardsticks"] + assert cue["todo_id"] == TODO_ID and len(others) == 2 and explore["omitted_work_yardsticks"] == 1 + assert cue["proxy_order_reversed"] is True + assert original.read_bytes() == original_bytes + staged = run(project, runtime, path, "quota", "replan-view", "--decision-file", str(selected), + "--source-hash", guard["contract_binding"]["content_hash"], "--replan-stage", "review") + staged_cue = staged["capabilities"]["explore"]["work_yardstick_reversal"] + + command = shlex.split(bound["first_read_command"]) + page = run(project, runtime, path, *command[command.index("quota"):]) + assert page["offset"] == 0 and not page["eof"] + assert len(json.dumps(page, ensure_ascii=False, separators=(",", ":")).encode()) + 1 <= 12000 + basis = f"/{obligation}/replan_context/explore_basis/work_yardsticks/0/" + for field, expected in (("proxy_order_reversed", True), ("comparison_candidate_ref", "candidate:a"), + ("source_finding_id", declaration["finding_id"]), + ("proxy_comparison_scope", profiles["proxy"]["comparison_scope"]), + ("comparison_finding_ids", finding_ids)): + marker = json.dumps(basis + field) + ',"value":' + offset = page["content"].index(marker) + len(marker) + observed, _ = json.JSONDecoder().raw_decode(page["content"], offset) + assert observed == staged_cue[field] == expected + _, reference = read_context_read_reference(page["reference_file"]) + assert reference["sha256"] == hashlib.sha256(selected.read_bytes()).hexdigest() + chunks = [page["content"]] + while not page["eof"]: + command = shlex.split(page["next_command"]) + page = run(project, runtime, path, *command[command.index("quota"):]) + _, follow = read_context_read_reference(page["reference_file"]) + assert follow["path"] == reference["path"] and follow["sha256"] == reference["sha256"] + chunks.append(page["content"]) + values = {row["pointer"]: row["value"] for row in json.loads("".join(chunks))["selections"]} + assert values["/goal_boundary"]["write_scope"] == scope + assert _spend_run_count(runtime) == 1 + + def wide_write_scope(): return [ f"modules/component-{i:03d}/experiments/artifact-encodings/public-validation/" diff --git a/tests/control_plane_ts/turn_envelope.test.ts b/tests/control_plane_ts/turn_envelope.test.ts index 3cb9cc55e9..1871b1a7f7 100644 --- a/tests/control_plane_ts/turn_envelope.test.ts +++ b/tests/control_plane_ts/turn_envelope.test.ts @@ -18,12 +18,13 @@ import { turnEnvelopeScopedSettlementIdentity, } from "../../loopx/control_plane/quota/turn_envelope.ts"; import { EffectRuntimeRequestError } from "../../loopx/control_plane/effect_runtime_errors.ts"; -import {settlementIdentityPayload, type JsonObject} from "../../loopx/control_plane/effect_program.ts"; +import {settlementIdentityPayload, settlementPlanPayload, type JsonObject} from "../../loopx/control_plane/effect_program.ts"; import { TURN_ENVELOPE_SECTION_TARGETS } from "../../loopx/control_plane/quota/turn_envelope_budget.ts"; import { projectPeerOrchestration } from "../../loopx/control_plane/quota/peer_orchestration.ts"; import {projectQuotaDiagnostics, referenceQuotaSummaryLanes, resolveQuotaSummaryLanes} from "../../loopx/control_plane/quota/diagnostic_projection.ts"; import { controllerRequest } from "../../loopx/control_plane/turn_driver/loop_controller.ts"; +import {turnScopedCliSettlementPlan} from "../../loopx/control_plane/quota/settlement_plan.ts"; test("complete captures reuse progressive presentation only after source validation", () => { const source = payload(); @@ -332,6 +333,72 @@ function completeAcceptanceRead(source: JsonObject, outcomeRef = ""): JsonObject return outcomeRef ? read : {outcomes: (read.acceptance_index as JsonObject | undefined)?.outcomes}; } +test("both replan readers surface an opted-in reversal for the bound task only", () => { + const conflict = {todo_id: "todo-1", source_finding_id: "finding-1", + comparison_candidate_ref: "candidate:outcome-best", proxy_order_reversed: true, + proxy_comparison_scope: "validation:screen-slice", comparison_finding_ids: ["finding-2", "finding-3"], + fidelity_status: "order_reversed", paired_candidates: 4, counterevidence_refs: ["result:counter"]}; + const other = {...conflict, todo_id: "todo-foreign", comparison_candidate_ref: "candidate:foreign"}; + const cases = [ + {cues: [other, conflict], scoped: true}, + {cues: [other], scoped: false}, + {cues: [{...conflict, proxy_order_reversed: false}], scoped: false}, + {cues: [], scoped: false}, + {cues: [conflict], selectionRequired: true, scoped: false}, + {cues: [conflict], unbound: true, scoped: false}, + {cues: [conflict], unavailable: true, scoped: false}, + ]; + for (const {cues, scoped, selectionRequired, unbound, unavailable} of cases) { + const source = payload(), interaction = source.interaction_contract as JsonObject; + const cli = interaction.cli_channel as JsonObject; + if (!unbound) cli.settlement_plan = settlementPlanPayload(turnScopedCliSettlementPlan({ + identity: {goal_id: source.goal_id, agent_id: source.agent_id, todo_id: "todo-1", turn_instance_id: "turn"}, + delivery_batch_scale_choices: ["implementation"], command_templates: { + todo_claim: "claim", todo_completion: "completion", durable_writeback: "writeback", + quota_spend: "spend", terminal_closeout: "terminal", + }, + })); + cli.selection_required = selectionRequired ?? false; + Object.assign(source, { + effective_action: "autonomous_replan_required", normal_delivery_allowed: false, + replan_read_path: "/capture/original.json", + replan_action_packet: {schema_version: "replan_action_packet_v0", decision: "replan_required", + obligation_id: "original-obligation"}, + autonomous_replan_obligation: {schema_version: "autonomous_replan_obligation_v0", + obligation_id: "original-obligation", replan_context: {schema_version: "replan_context_v0", + obligation_id: "original-obligation", context_id: "original-context", + explore_basis: {availability: unavailable ? "unavailable" : "available", + revision: "captured", work_yardsticks: cues}, + }}, + }); + const captured = evaluateTurnEnvelope({operation: "compose_complete", payload: source, scheduler_execution_args: ""}); + const before = structuredClone(captured); + const source_hash = (captured.contract_binding as JsonObject).content_hash; + const read = evaluateTurnEnvelope({operation: "replan_view", payload: captured, source_hash, stage: "review"}); + const explore = (read.capabilities as JsonObject).explore as JsonObject; + const opening = evaluateTurnEnvelope({operation: "context_select", context: captured, initial_read: true}); + const cuesInOpening = (opening.selections as {pointer: string}[]) + .filter(row => row.pointer.includes("/explore_basis/work_yardsticks/")); + if (scoped) { + assert.deepEqual(explore.work_yardstick_reversal, { + todo_id: "todo-1", source_finding_id: "finding-1", comparison_candidate_ref: "candidate:outcome-best", + proxy_order_reversed: true, proxy_comparison_scope: "validation:screen-slice", + fidelity_status: "order_reversed", paired_candidates: 4, comparison_finding_ids: ["finding-2", "finding-3"], + }); + assert.match(String(explore.read_action), /--replan-stage explore$/); + const prefix = "/autonomous_replan_obligation/replan_context/explore_basis/work_yardsticks/1/"; + assert.deepEqual(cuesInOpening.map(row => row.pointer), + Object.keys(explore.work_yardstick_reversal as JsonObject).map(field => prefix + field)); + const detail = evaluateTurnEnvelope({operation: "replan_view", payload: captured, source_hash, stage: "explore"}); + assert.deepEqual((detail.explore_basis as JsonObject).work_yardsticks, cues); + } else { + assert.ok(!Object.hasOwn(explore, "work_yardstick_reversal")); + assert.equal(cuesInOpening.length, 0); + } + assert.deepEqual(captured, before); + } +}); + test("complete acceptance lookup shares exact content across registered carriers and retains revisions", () => { const one = {id: "shared", owner_agent_id: "peer", objective: "Original", revision: 1, requirements: [{id: "a", planning_satisfied: false, status: "unverified", description: "Full a"}, From 9df7fb593a54ae99e5f994530db111e1978e6eda Mon Sep 17 00:00:00 2001 From: DDL Date: Thu, 12 Nov 2026 16:16:07 +0800 Subject: [PATCH 24/31] fix(scheduler): reject illegal calendar timestamps instead of rolling them (#8632) * fix(scheduler): reject illegal calendar timestamps instead of rolling them schedulerTimestampMilliseconds parsed ISO text with Date.parse. Impossible calendar dates such as 2026-02-31 and 2026-11-31 became a later real instant, so monitor schedules and failure-cache TTL accepted them. Backoff also treated a null parse as an elapsed interval, which advanced cadence from a clock that is not a real ISO date. Use parseIsoTimestamp for this codec. Present illegal clocks fail closed. A missing or blank updated_at still counts as elapsed, and an omitted failure-cache reference time still uses the wall clock. Signed-off-by: Duang777 Co-authored-by: DDL * fix(scheduler): ignore an obsolete illegal clock on identity reset An illegal updated_at on a previous identity was validated before cadence decided whether that clock was an input. A reset-token change then failed closed instead of returning the new profile's initial interval. Read the old clock only on the same-identity elapsed branch. Current time and a same-identity interval comparison still reject an illegal calendar. Signed-off-by: Duang777 * fix(scheduler): accept a lowercase RFC 3339 time separator The calendar codec only matched an uppercase T or a space, so a monitor poll that already accepted 2026-12-04t01:15:00z was rejected. Normalize that separator before the existing calendar check. Impossible dates stay rejected, and the caller's original text is still what gets stored. Signed-off-by: Duang777 --------- Signed-off-by: Duang777 --- loopx/control_plane/runtime_timestamp.ts | 12 +- loopx/control_plane/scheduler/state_store.ts | 28 ++- .../scheduler/state_transition_rules.ts | 71 ++++++-- .../scheduler_state_store.test.ts | 71 ++++++++ .../scheduler_state_transition_rules.test.ts | 169 ++++++++++++++++++ 5 files changed, 324 insertions(+), 27 deletions(-) diff --git a/loopx/control_plane/runtime_timestamp.ts b/loopx/control_plane/runtime_timestamp.ts index 867be7b0fa..e80c89c531 100644 --- a/loopx/control_plane/runtime_timestamp.ts +++ b/loopx/control_plane/runtime_timestamp.ts @@ -1,7 +1,13 @@ -/** Calendar-checked ISO codec; Date.parse alone silently rolls invalid dates. */ +/** Calendar-checked ISO codec; Date.parse alone silently rolls invalid dates. + * RFC 3339 §5.6 allows a lowercase "t" separator. Normalize only that + * separator before the existing calendar grammar. */ export function parseIsoTimestamp(value: string): Date | null { + const source = value.trim().replace( + /^(\d{4}-\d{2}-\d{2})t(?=\d{2}:)/u, + "$1T", + ); const match = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?(Z|z|[+-]\d{2}(?::?\d{2})?)?)?$/u.exec( - value.trim(), + source, ); if (match === null) return null; const [, yearText, monthText, dayText, hourText, minuteText, secondText, fraction, timezone] = match; @@ -30,7 +36,7 @@ export function parseIsoTimestamp(value: string): Date | null { calendar.getUTCMilliseconds() !== millisecond ) return null; if (hourText === undefined) return calendar; - let text = value.trim().replace(" ", "T").replace(/z$/u, "Z"); + let text = source.replace(" ", "T").replace(/z$/u, "Z"); if (fraction !== undefined) text = text.replace(`.${fraction}`, `.${fraction.slice(0, 3)}`); if (timezone === undefined) text += "Z"; else text = text.replace(/([+-]\d{2})$/u, "$1:00"); diff --git a/loopx/control_plane/scheduler/state_store.ts b/loopx/control_plane/scheduler/state_store.ts index 53d498034b..81f8ecb6b9 100644 --- a/loopx/control_plane/scheduler/state_store.ts +++ b/loopx/control_plane/scheduler/state_store.ts @@ -17,6 +17,7 @@ import { requireNonEmptyString as requiredString, requireStringLiteral, } from "../runtime_decode.ts"; +import { parseIsoTimestamp } from "../runtime_timestamp.ts"; export const SCHEDULER_STATE_SCHEMA_VERSION = "loopx_scheduler_state_v0"; export const SCHEDULER_HOST_UPDATE_FAILURE_SCHEMA_VERSION = @@ -216,14 +217,26 @@ export function normalizeSchedulerHostUpdateFailures( return normalized.slice(-HOST_UPDATE_FAILURE_CACHE_LIMIT); } +/** Calendar-checked ISO instant. Blank is null; a missing timezone means UTC. */ export function schedulerTimestampMilliseconds(value: unknown): number | null { const text = trimmed(value); if (!text) return null; - const timezoneAware = /(?:[zZ]|[+-]\d{2}(?::?\d{2})?)$/.test(text) - ? text - : `${text}Z`; - const parsed = Date.parse(timezoneAware); - return Number.isNaN(parsed) ? null : parsed; + const parsed = parseIsoTimestamp(text); + return parsed === null ? null : parsed.getTime(); +} + +/** Blank stays null. Present text that is not a calendar-checked ISO timestamp is rejected. */ +export function requireSchedulerTimestampMilliseconds( + value: unknown, + label: string, +): number | null { + const text = trimmed(value); + if (!text) return null; + const parsed = schedulerTimestampMilliseconds(text); + if (parsed === null) { + throw new EffectRuntimeRequestError(`${label} must be an ISO timestamp`); + } + return parsed; } export function retainedSchedulerHostUpdateFailures( @@ -232,7 +245,10 @@ export function retainedSchedulerHostUpdateFailures( observedHostRrule: unknown = null, ): JsonObject[] { const failures = normalizeSchedulerHostUpdateFailures(value); - const now = schedulerTimestampMilliseconds(referenceTime) ?? Date.now(); + const now = requireSchedulerTimestampMilliseconds( + referenceTime, + "reference_time", + ) ?? Date.now(); const cutoff = now - HOST_UPDATE_FAILURE_TTL_MS; const expectedHostRrule = normalizeSchedulerRrule(observedHostRrule); return failures.filter((failure) => { diff --git a/loopx/control_plane/scheduler/state_transition_rules.ts b/loopx/control_plane/scheduler/state_transition_rules.ts index 79d9f8ceaf..cc5c634320 100644 --- a/loopx/control_plane/scheduler/state_transition_rules.ts +++ b/loopx/control_plane/scheduler/state_transition_rules.ts @@ -10,6 +10,7 @@ import { normalizeSchedulerHostUpdateFailures, normalizeSchedulerRrule, retainedSchedulerHostUpdateFailures, + requireSchedulerTimestampMilliseconds, rruleForMinutes, schedulerRruleIntervalMinutes, schedulerTimestampMilliseconds, @@ -359,17 +360,40 @@ function schedulerProgressionIntervalElapsed( schedulerState: JsonObject, currentTime: string, ): boolean { - const updatedAt = schedulerTimestampMilliseconds(schedulerState.updated_at); - const current = schedulerTimestampMilliseconds(currentTime); const appliedInterval = schedulerRruleIntervalMinutes( schedulerState.last_applied_rrule, ); - if (updatedAt === null || current === null || appliedInterval === null) { - return true; - } + // Missing interval or blank updated_at stays elapsed. A present illegal + // calendar is rejected instead of rolled or treated as elapsed. + if (appliedInterval === null) return true; + const updatedAt = requireSchedulerTimestampMilliseconds( + schedulerState.updated_at, + "updated_at", + ); + if (updatedAt === null) return true; + const current = requireSchedulerTimestampMilliseconds( + currentTime, + "current_time", + ); + if (current === null) return true; return current >= updatedAt + appliedInterval * 60_000; } +// evaluateCadence reads the old clock only after identity matches and the +// same-identity path is comparing the applied interval. An obsolete receipt +// must not block identity_reset, an unacknowledged retry, or a held initial. +function cadenceConsumesAppliedInterval( + statePresent: boolean, + identityMatches: boolean, + hasHostUpdateFailures: boolean, + currentCadenceAcknowledged: boolean, + advanceSameIdentity: boolean, +): boolean { + if (!statePresent || !identityMatches) return false; + if (hasHostUpdateFailures && !currentCadenceAcknowledged) return false; + return advanceSameIdentity; +} + function monitorRruleAppliedWithinStaleTolerance( cadenceClass: string, lastAppliedRrule: string, @@ -400,6 +424,7 @@ function evaluateBackoff(request: JsonObject): SchedulerBackoffTransitionResult "identity_signature", ); const currentTime = requiredString(request.current_time, "current_time"); + requireSchedulerTimestampMilliseconds(currentTime, "current_time"); const cadenceClass = requiredString(request.cadence_class, "cadence_class"); const staleToleranceMinutes = requiredInteger( request.stale_tolerance_minutes, @@ -428,23 +453,33 @@ function evaluateBackoff(request: JsonObject): SchedulerBackoffTransitionResult : ""; const currentCadenceAcknowledged = Boolean(appliedTargetRrule) && normalizeSchedulerRrule(lastAppliedRrule) === appliedTargetRrule; + const statePresent = Object.keys(schedulerState).length > 0; + const identityMatches = statePresent && + schedulerState.reset_token === resetToken && + schedulerState.identity_signature === identitySignature; + const advanceSameIdentity = requiredBoolean( + request.advance_same_identity, + "advance_same_identity", + ); + const hasHostUpdateFailures = allHostUpdateFailures.length > 0; + const appliedIntervalElapsed = cadenceConsumesAppliedInterval( + statePresent, + identityMatches, + hasHostUpdateFailures, + currentCadenceAcknowledged, + advanceSameIdentity, + ) + ? schedulerProgressionIntervalElapsed(schedulerState, currentTime) + : false; const cadence = evaluateCadence({ progression_size: progressionMinutes.length, - state_present: Object.keys(schedulerState).length > 0, - identity_matches: Object.keys(schedulerState).length > 0 && - schedulerState.reset_token === resetToken && - schedulerState.identity_signature === identitySignature, + state_present: statePresent, + identity_matches: identityMatches, applied_index: appliedIndex, current_cadence_acknowledged: currentCadenceAcknowledged, - advance_same_identity: requiredBoolean( - request.advance_same_identity, - "advance_same_identity", - ), - applied_interval_elapsed: schedulerProgressionIntervalElapsed( - schedulerState, - currentTime, - ), - has_host_update_failures: allHostUpdateFailures.length > 0, + advance_same_identity: advanceSameIdentity, + applied_interval_elapsed: appliedIntervalElapsed, + has_host_update_failures: hasHostUpdateFailures, }); const currentIndex = cadence.current_index; const currentInterval = progressionMinutes[currentIndex]; diff --git a/tests/control_plane_ts/scheduler_state_store.test.ts b/tests/control_plane_ts/scheduler_state_store.test.ts index 01cd69dca3..c269df5886 100644 --- a/tests/control_plane_ts/scheduler_state_store.test.ts +++ b/tests/control_plane_ts/scheduler_state_store.test.ts @@ -9,6 +9,7 @@ import { loadSchedulerState, normalizeSchedulerState, schedulerStatePath, + schedulerTimestampMilliseconds, SCHEDULER_STATE_OPERATION_REQUEST_SCHEMA, SCHEDULER_STATE_STORE_REQUEST_SCHEMA, writeSchedulerState, @@ -160,6 +161,76 @@ test("failure retention accepts the ISO offsets persisted by Python", () => { assert.equal((result as unknown[]).length, 1); }); +function hostUpdateFailure(failedAt: string) { + return { + schema_version: "scheduler_host_update_failure_v0", + target_rrule: "FREQ=MINUTELY;INTERVAL=3", + observed_host_rrule: "FREQ=MINUTELY;INTERVAL=30", + failure_kind: "timeout", + failure_count: 1, + failed_at: failedAt, + }; +} + +test("scheduler timestamp codec rejects illegal calendars and keeps offset instants", () => { + assert.equal(schedulerTimestampMilliseconds("2026-02-31T00:00:00Z"), null); + assert.equal(schedulerTimestampMilliseconds("2026-02-29T00:00:00Z"), null); + assert.equal(schedulerTimestampMilliseconds("2024-02-29T00:00:00Z"), Date.UTC(2024, 1, 29)); + assert.equal(schedulerTimestampMilliseconds("2026-11-31T10:04:00Z"), null); + assert.equal(schedulerTimestampMilliseconds("2026-01-32T12:10:00Z"), null); + assert.equal(schedulerTimestampMilliseconds(" "), null); + assert.equal( + schedulerTimestampMilliseconds("2026-08-25T10:00:00+08:00"), + Date.UTC(2026, 7, 25, 2, 0, 0), + ); + assert.equal( + schedulerTimestampMilliseconds("2026-01-01T12:00:00"), + Date.UTC(2026, 0, 1, 12, 0, 0), + ); + assert.equal( + schedulerTimestampMilliseconds("2026-01-01T12:00:00+0000"), + Date.UTC(2026, 0, 1, 12, 0, 0), + ); + assert.equal( + schedulerTimestampMilliseconds("2026-12-04t01:15:00z"), + Date.UTC(2026, 11, 4, 1, 15, 0), + ); + assert.equal( + schedulerTimestampMilliseconds("2026-12-04t01:15:00z"), + schedulerTimestampMilliseconds("2026-12-04T01:15:00Z"), + ); + assert.equal( + schedulerTimestampMilliseconds("2026-08-25t10:00:00+08:00"), + schedulerTimestampMilliseconds("2026-08-25T10:00:00+08:00"), + ); + assert.equal(schedulerTimestampMilliseconds("2026-11-31t10:04:00z"), null); + assert.equal(schedulerTimestampMilliseconds("2026-02-31t00:00:00z"), null); +}); + +test("failure retention does not roll illegal calendar timestamps into the TTL window", () => { + for (const [failedAt, referenceTime] of [ + ["2026-02-31T00:00:00Z", "2026-03-03T00:00:00Z"], + ["2026-11-31T10:04:00Z", "2026-12-01T10:04:00Z"], + ] as const) { + const retained = evaluateSchedulerStateOperation({ + schema_version: SCHEDULER_STATE_OPERATION_REQUEST_SCHEMA, + operation: "retain_failures", + value: [hostUpdateFailure(failedAt)], + reference_time: referenceTime, + }).value; + assert.deepEqual(retained, [], failedAt); + } + assert.throws( + () => evaluateSchedulerStateOperation({ + schema_version: SCHEDULER_STATE_OPERATION_REQUEST_SCHEMA, + operation: "retain_failures", + value: [hostUpdateFailure("2026-03-03T00:00:00Z")], + reference_time: "2026-02-31T00:00:00Z", + }), + /reference_time must be an ISO timestamp/, + ); +}); + test("pure scheduler state operations do not mutate caller input", () => { const params = fixture.cases[7].params as Record; const before = structuredClone(params); diff --git a/tests/control_plane_ts/scheduler_state_transition_rules.test.ts b/tests/control_plane_ts/scheduler_state_transition_rules.test.ts index 73cff165ed..5fbfc74349 100644 --- a/tests/control_plane_ts/scheduler_state_transition_rules.test.ts +++ b/tests/control_plane_ts/scheduler_state_transition_rules.test.ts @@ -141,6 +141,22 @@ test("monitor schedule materializes cadence and preserves explicit due time", () assert.equal(explicit.schedule_source, "explicit"); assert.equal(explicit.cadence_seconds, 3600); + const lowercaseDue = evaluateSchedulerStateTransition({ + schema_version: SCHEDULER_STATE_TRANSITION_REQUEST_SCHEMA, + operation: "monitor_schedule", + generated_at: "2026-08-25T10:00:00Z", + explicit_next_due_at: "2026-12-04t01:15:00z", + }); + assert.equal(lowercaseDue.next_due_at, "2026-12-04t01:15:00z"); + assert.equal(lowercaseDue.schedule_source, "explicit"); + const lowercaseGenerated = evaluateSchedulerStateTransition({ + schema_version: SCHEDULER_STATE_TRANSITION_REQUEST_SCHEMA, + operation: "monitor_schedule", + generated_at: "2026-08-25t10:00:00z", + cadence: "30 minutes", + }); + assert.equal(lowercaseGenerated.next_due_at, "2026-08-25T10:30:00Z"); + const missing = evaluateSchedulerStateTransition({ schema_version: SCHEDULER_STATE_TRANSITION_REQUEST_SCHEMA, operation: "monitor_schedule", @@ -172,6 +188,33 @@ test("monitor schedule rejects malformed timestamps without wall-clock fallback" ); }); +test("monitor schedule rejects illegal calendar timestamps without wall-clock fallback", () => { + for (const generatedAt of ["2026-02-29T00:00:00Z", "2026-02-31T00:00:00Z", "2026-11-31T10:04:00Z", "2026-02-31t00:00:00z"]) { + assert.throws( + () => evaluateSchedulerStateTransition({ + schema_version: SCHEDULER_STATE_TRANSITION_REQUEST_SCHEMA, + operation: "monitor_schedule", + generated_at: generatedAt, + cadence: "30 minutes", + }), + /generated_at must be an ISO timestamp/, + generatedAt, + ); + } + for (const explicitNextDueAt of ["2026-02-31T12:00:00Z", "2026-11-31T10:04:00Z", "2026-11-31t10:04:00z"]) { + assert.throws( + () => evaluateSchedulerStateTransition({ + schema_version: SCHEDULER_STATE_TRANSITION_REQUEST_SCHEMA, + operation: "monitor_schedule", + generated_at: "2026-08-25T10:00:00Z", + explicit_next_due_at: explicitNextDueAt, + }), + /explicit_next_due_at must be an ISO timestamp/, + explicitNextDueAt, + ); + } +}); + function backoffRequest(extra: Record = {}) { return { schema_version: SCHEDULER_STATE_TRANSITION_REQUEST_SCHEMA, @@ -234,6 +277,132 @@ test("coarse backoff transition owns cadence, retention, and host decision", () ); }); +test("backoff rejects illegal calendar clocks instead of rolling them", () => { + const schedulerState = { + progression_index: 1, + last_applied_rrule: "FREQ=MINUTELY;INTERVAL=30", + reset_token: "reset-token", + identity_signature: "identity-signature", + updated_at: "2026-01-01T12:00:00Z", + }; + const held = evaluateSchedulerStateTransition(backoffRequest({ + scheduler_state: schedulerState, + current_time: "2026-01-01T12:10:00Z", + })); + assert.equal(held.cadence_transition, "hold_until_interval"); + + // Jan 32 is not a Date.parse instant on this runtime; Feb 31 and Nov 31 are + // rolled forward. Neither clock may count as an elapsed backoff interval. + for (const currentTime of [ + "2026-01-32T12:10:00Z", + "2026-02-31T12:10:00Z", + "2026-11-31T12:10:00Z", + ]) { + assert.throws( + () => evaluateSchedulerStateTransition(backoffRequest({ + scheduler_state: schedulerState, + current_time: currentTime, + })), + /current_time must be an ISO timestamp/, + currentTime, + ); + } + for (const updatedAt of ["2026-01-32T12:00:00Z", "2026-02-31T12:00:00Z"]) { + assert.throws( + () => evaluateSchedulerStateTransition(backoffRequest({ + scheduler_state: { ...schedulerState, updated_at: updatedAt }, + current_time: "2026-01-01T12:10:00Z", + })), + /updated_at must be an ISO timestamp/, + updatedAt, + ); + } +}); + +test("backoff identity reset does not read an obsolete illegal updated_at", () => { + const obsolete = { + progression_index: 1, + last_applied_rrule: "FREQ=MINUTELY;INTERVAL=30", + reset_token: "old-reset-token", + identity_signature: "old-identity", + updated_at: "2026-02-31T12:00:00Z", + }; + const reset = evaluateSchedulerStateTransition(backoffRequest({ + scheduler_state: obsolete, + reset_token: "new-reset-token", + identity_signature: "new-identity", + current_time: "2026-01-01T12:10:00Z", + })); + assert.equal(reset.state_status, "reset_required"); + assert.equal(reset.cadence_transition, "identity_reset"); + assert.equal(reset.current_index, 0); + assert.equal(reset.current_interval_minutes, 15); + assert.equal(reset.current_rrule, "FREQ=MINUTELY;INTERVAL=15"); + assert.equal(reset.host_transition, "apply_required"); + + const legalClock = evaluateSchedulerStateTransition(backoffRequest({ + scheduler_state: { ...obsolete, updated_at: "2026-01-01T12:00:00Z" }, + reset_token: "new-reset-token", + identity_signature: "new-identity", + current_time: "2026-01-01T12:10:00Z", + })); + assert.equal(legalClock.cadence_transition, "identity_reset"); + assert.equal(legalClock.current_interval_minutes, 15); + assert.equal(legalClock.host_transition, "apply_required"); + + const retry = evaluateSchedulerStateTransition(backoffRequest({ + scheduler_state: { + progression_index: 0, + last_applied_rrule: "FREQ=MINUTELY;INTERVAL=60", + reset_token: "reset-token", + identity_signature: "identity-signature", + updated_at: "2026-11-31T12:00:00Z", + host_update_failures: [{ + schema_version: "scheduler_host_update_failure_v0", + target_rrule: "FREQ=MINUTELY;INTERVAL=15", + observed_host_rrule: "FREQ=MINUTELY;INTERVAL=60", + failure_kind: "timeout", + failure_count: 1, + failed_at: "2026-01-01T11:30:00Z", + }], + }, + observed_host_rrule: "RRULE:FREQ=MINUTELY;INTERVAL=60", + })); + assert.equal(retry.cadence_transition, "retry_unacknowledged_failure"); + + const heldInitial = evaluateSchedulerStateTransition(backoffRequest({ + scheduler_state: { + progression_index: 1, + last_applied_rrule: "FREQ=MINUTELY;INTERVAL=30", + reset_token: "reset-token", + identity_signature: "identity-signature", + updated_at: "2026-01-32T12:00:00Z", + }, + advance_same_identity: false, + })); + assert.equal(heldInitial.cadence_transition, "hold_active_initial"); + assert.equal(heldInitial.current_index, 0); +}); + +test("backoff still treats a missing or blank updated_at as an elapsed interval", () => { + const schedulerState = { + progression_index: 1, + last_applied_rrule: "FREQ=MINUTELY;INTERVAL=30", + reset_token: "reset-token", + identity_signature: "identity-signature", + }; + for (const updatedAt of [undefined, "", " "]) { + const state = updatedAt === undefined + ? schedulerState + : { ...schedulerState, updated_at: updatedAt }; + const advanced = evaluateSchedulerStateTransition(backoffRequest({ + scheduler_state: state, + current_time: "2026-01-01T12:10:00Z", + })); + assert.equal(advanced.cadence_transition, "advance_after_interval"); + } +}); + test("coarse backoff boundary rejects malformed state facts", () => { assert.throws( () => evaluateSchedulerStateTransition(backoffRequest({ From b14ff7f69a4475a8caa3a2f3db0c142be6c972ca Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Thu, 12 Nov 2026 16:17:32 +0800 Subject: [PATCH 25/31] docs(blog): bind context comparisons to their measured baselines (#8616) * docs(blog): foreground compaction-cycle structured delta cascade from 100% Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> * docs(blog): bind context comparisons to their actual baselines Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> * docs(blog): align entry summaries with independent comparisons Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --------- Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../public/blog/context-delivery/index.html | 23 +- .../context-delivery/context-cascade-en.svg | 430 ++++++++++++++ .../context-delivery/context-cascade-zh.svg | 430 ++++++++++++++ .../multitask-byte-ratios-en.svg | 530 +++++++++--------- .../multitask-byte-ratios-zh.svg | 530 +++++++++--------- apps/presentation/site/public/blog/index.html | 4 +- .../blog/zh/context-delivery/index.html | 23 +- .../site/public/blog/zh/index.html | 4 +- .../scripts/generate-long-horizon-audit.py | 264 ++++++++- examples/blog-bilingual-index-smoke.mjs | 25 + 10 files changed, 1693 insertions(+), 570 deletions(-) create mode 100644 apps/presentation/site/public/blog/images/context-delivery/context-cascade-en.svg create mode 100644 apps/presentation/site/public/blog/images/context-delivery/context-cascade-zh.svg diff --git a/apps/presentation/site/public/blog/context-delivery/index.html b/apps/presentation/site/public/blog/context-delivery/index.html index 79650dd58d..cfc155b805 100644 --- a/apps/presentation/site/public/blog/context-delivery/index.html +++ b/apps/presentation/site/public/blog/context-delivery/index.html @@ -6,18 +6,21 @@ -
← All articles

Context delivery

An Agent needs a way to look things up, too

One voice task, from choosing facts to fitting the first read, preserving exact values, and recovering without replaying work.

-

Long-running work accumulates material: the original goal, later constraints, attempted routes, failed experiments and current progress. Sending everything again on each continuation repeats more and more content; keeping only a summary can make the original basis of a decision difficult to recover.

-

LoopX's context delivery separates these jobs: preserve complete sources, read the facts needed for the current decision, expand supporting material around questions, and reuse unchanged structures exactly within a valid compaction cycle.

-

Results first: progressive reading, deltas and task ablation

-

Eight tasks: progressive reading and in-cycle deltas

-

This section recalculates the published eight-task Four-Arm Context ON 18h aggregates to distinguish Progressive and in-cycle Delta transport costs. The source reports 213 model-facing wakes: 101 new-session or post-compaction baseline reads and 112 in-cycle continuations. Per-task cells retain the published values; totals are recomputed from those cells. Raw per-wake logs are not included here.

+
← All articles

Context delivery

An Agent needs a way to look things up, too

Read necessary facts first, reuse verified structures within a compaction cycle, and recover the complete baseline when continuity is lost.

+

Long-running work accumulates material: the original goal, later constraints, attempted routes, failed experiments and current progress. Sending everything again on each continuation repeats more and more content; keeping only a summary can make the original basis of a decision difficult to recover.

+

Selective JSON Pointer lookup by itself is straightforward—it folds long-tail history not needed for the immediate decision. The core mechanism in LoopX's context delivery is compaction-cycle-bounded structured delta context: complete sources are saved first, unchanged typed subtrees are reused exactly against a verified baseline within the active context_epoch, and any host compaction or digest mismatch atomically resets to a fresh complete baseline rather than chaining dangling deltas.

+

Evidence: separate context comparisons, cycle deltas and task ablation

+

Four mechanisms, with separate measurement baselines

+

Complete capture, Progressive views, compaction-cycle structured Delta and a bounded --initial-read are composable mechanisms, but their bytes do not decrease monotonically for every input. Delta requires a verified same-cycle baseline; a new epoch, mismatch or smaller full representation uses complete delivery. The archived tutorial below measures three independent comparisons, not a same-source four-stage cascade.

+

Separate tutorial comparisons and 18h compaction-cycle Delta observations

Independent tutorial comparisons: Progressive view 5,107 / 57,014 B (9.0%); in-cycle Delta 2,164 / 5,543 B (39.0%); manual selection 674 / 51,547 B (1.3% body). ANN and eight-task archived cohorts retain separate baselines.
A: Three archived tutorial comparisons from measurements.json: the necessary view is 5,107 / 57,014 B (9.0%); the next capture's Delta file is 2,164 / 5,543 B of its full Progressive delivery file (39.0%); manually selecting three paths from the separate 51,547 B capture.json returns 674 B (1.3% body), or 1,362 B including CLI metadata (2.6%). The 674 B is not a measured --initial-read after Progressive + Delta. These saved observations belong to the revision recorded in measurements.json; output size may change with source revision and reader paths. B1: ANN's archived 18h pairs compare Delta against selected packets, with 38.54% reduction across 46 events and 74.25% across 24 in-cycle Delta events. B2: Eight-task aggregates retain the separate 213-wake and 112-continuation denominators; their percentages cannot be chained into this tutorial.
+

Eight tasks: single-layer payloads (Progressive / lossless Delta file) vs stacked first-read

+

When reading the published eight-task Four-Arm Context ON 18h table below, separate single-layer file payloads from the stacked first-read entrypoint. Across 213 model-facing wakes (101 new-session or post-compaction baseline reads and 112 in-cycle continuations), the published “In-cycle Delta payload” column (4.49 MB / ≈ 12.53 MB = 35.8%) measures the unprojected lossless delivery.json file on disk (All → Delta)—a parallel single-layer cut alongside “Progressive projection” (7.29 / 22.41 MB = 32.5%, All → Progressive), not stacked Progressive + Delta. Both mechanisms stack in the final column, Prog+Delta first-screen (3.0% body / 5.0% CLI). Per-task cells retain the published values; totals are recomputed from those cells without raw per-wake logs.

    -
  • Progressive reading: across all 213 wakes, full packets fall from 22.41 MB to 7.29 MB (−67.5%). The projection retains 32.5% of original bytes, with a per-task range of 26.5%–39.0%; unselected candidates and historical detail remain available on demand.
  • -
  • In-cycle deltas: across 112 continuations, corresponding full packets fall from approximately 12.53 MB to 4.49 MB (−64.2%). Deltas retain 35.8% of same-wake full bytes, with a per-task range of 20.7%–47.1%.
  • -
  • Progressive + delta first read: for those same 112 continuations, the --initial-read body carries 3.0% of original bytes, or 5.0% including the CLI JSON envelope (−97.0% / −95.0%). First-read size is not whole-episode model input.
  • +
  • Single-layer progressive view (All → Progressive): across all 213 wakes, full packets fall from 22.41 MB to 7.29 MB (−67.5%), retaining 32.5% of original bytes (26.5%–39.0% per task) while keeping unselected candidates and historical detail expandable on demand.
  • +
  • Single-layer lossless delta file (All → Delta): across 112 continuations, unprojected lossless delta files fall from approximately 12.53 MB to 4.49 MB (−64.2%), retaining 35.8% of same-wake full bytes (20.7%–47.1% per task).
  • +
  • Stacked entrypoint (Progressive + Delta First Read): for those same 112 continuations, stacking progressive selection with compaction-cycle deltas brings the --initial-read body to 3.0% of original bytes, or 5.0% including the CLI JSON envelope (−97.0% / −95.0%). First-read size is not whole-episode model input.
-

8-task Four-Arm Context ON byte ratios (N = 213 wakes, 112 in-cycle Deltas)

8-Task Four-Arm Context ON 18h Control-Plane Byte Share: Progressive 32.5% (-67.5%), In-Cycle Delta 35.8% (-64.2%), Prog+Delta First-Screen 3.0% / 5.0%
A: Per-task shares, with Progressive over all wakes and Delta/first-read over in-cycle wakes only. B: Separate cohort totals: Progressive 7.29 / 22.41 MB over 213 wakes; Delta 4.49 / approximately 12.53 MB over 112 wakes. First-read body / CLI totals are 0.374 / 0.631 MB for the same 112 wakes. The two denominators are not interchangeable. Open image for full size.
+

8-task Four-Arm Context ON byte audit (N = 213 wakes, 112 in-cycle continuations)

8-Task Four-Arm Context ON 18h Control-Plane Byte Audit: All 100% vs Lossless Delta File 35.8%, Progressive View 32.5%, and Stacked Prog+Delta First-Read 3.0% / 5.0%
A: Per-task shares comparing the two single-layer payloads (Progressive over all 213 wakes; lossless Delta file over 112 continuations) against the stacked first-read body. B: Cohort totals anchored at 100% Full: lossless Delta file ≈ 35.8% (4.49 / ≈ 12.53 MB), Progressive view 32.5% (7.29 / 22.41 MB), and stacked Prog+Delta first-read ≈ 5.0% (CLI) / ≈ 3.0% (body). The two denominators are not interchangeable.
Expand the eight-task data table
diff --git a/apps/presentation/site/public/blog/images/context-delivery/context-cascade-en.svg b/apps/presentation/site/public/blog/images/context-delivery/context-cascade-en.svg new file mode 100644 index 0000000000..c2594e7ec0 --- /dev/null +++ b/apps/presentation/site/public/blog/images/context-delivery/context-cascade-en.svg @@ -0,0 +1,430 @@ + + + + + + + + image/svg+xml + + + Matplotlib v3.11.2, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + + + + + + + 25 + + + + + + + + + 50 + + + + + + + + + 75 + + + + + + + + + 100 + + + + Share of each row's own baseline bytes (%) + + + + + + + Progressive view / original capture + + + + + + In-cycle Delta / full delivery + + + + + + Manual selection / tutorial capture + + + + + + + + + + + + + + 9.0% (5,107 / 57,014 B) + + + 39.0% (2,164 / 5,543 B) + + + 1.3% (674 / 51,547 B · 2.6% CLI) + + + A. Three independent archived tutorial comparisons + + + + + + + + + + + + + + 0 + + + + + + + + + 25 + + + + + + + + + 50 + + + + + + + + + 75 + + + + + + + + + 100 + + + + Share of Progressive selected bytes (%) + + + + + + + Progressive selected (46) + + + + + + + In-cycle Delta (all 46) + + + + + + + In-cycle Delta (24 delta) + + + + + + + + + + + + + + 100.0% (3.09 MB selected base) + + + 61.5% (1.90 MB · −38.54%) + + + 25.75% (in-cycle −74.25%) + + + B1. 18h core layer: stacking compaction-cycle Delta (ANN) + + + + + + + + + + + + + + 0 + + + + + + + + + 25 + + + + + + + + + 50 + + + + + + + + + 75 + + + + + + + + + 100 + + + + Share of cohort Full bytes (%) + + + + + + + All · Full packets + + + + + + Progressive · 213 wakes + + + + + + Prog+Delta First read · 112 + + + + + + + + + + + + + + 100.0% (22.41 MB / in-cycle ≈12.53 MB) + + + 32.5% (7.29 MB · −67.5%) + + + ≈ 3.0% body / ≈ 5.0% CLI (−97% / −95%) + + + B2. 8-task 18h cohorts: 100% Full to Prog+Delta entry + + + + Layered context: separate tutorial comparisons and 18h in-cycle Delta observations + + + Progressive views retain necessary facts; in-cycle Delta reuses verified subtrees. Compaction or mismatch restores the complete baseline. + + + A: independent archived comparisons in measurements.json; 674 B is a manual three-path selection, not a measured stacked --initial-read. + B2: 8-task shares over 213 wakes (Progressive) and 112 in-cycle continuations (Prog+Delta first-read); each cohort keeps its own denominator. + + + + + + + + + + + + + + diff --git a/apps/presentation/site/public/blog/images/context-delivery/context-cascade-zh.svg b/apps/presentation/site/public/blog/images/context-delivery/context-cascade-zh.svg new file mode 100644 index 0000000000..465b77db9e --- /dev/null +++ b/apps/presentation/site/public/blog/images/context-delivery/context-cascade-zh.svg @@ -0,0 +1,430 @@ + + + + + + + + image/svg+xml + + + Matplotlib v3.11.2, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + + + + + + + 25 + + + + + + + + + 50 + + + + + + + + + 75 + + + + + + + + + 100 + + + + 占各行自身对照基线的字节 (%) + + + + + + + Progressive 必要视图 / 原捕获 + + + + + + 周期内 Delta / 本轮完整交付 + + + + + + 手工批量选读 / 教程捕获 + + + + + + + + + + + + + + 9.0% (5,107 / 57,014 B) + + + 39.0% (2,164 / 5,543 B) + + + 1.3% (674 / 51,547 B · 2.6% CLI) + + + A. 归档教程的三组独立对照 + + + + + + + + + + + + + + 0 + + + + + + + + + 25 + + + + + + + + + 50 + + + + + + + + + 75 + + + + + + + + + 100 + + + + 占 Progressive 选中字节 (%) + + + + + + + Progressive 选中包 (46 轮) + + + + + + + 周期内 Delta (全 46 轮) + + + + + + + 周期内 Delta (24 增量轮) + + + + + + + + + + + + + + 100.0% (3.09 MB 选中基线) + + + 61.5% (1.90 MB · 再减 −38.54%) + + + 25.75% (同周期再减 −74.25%) + + + B1. 18h 实测核心增量:叠加压缩周期内结构化 Delta(ANN) + + + + + + + + + + + + + + 0 + + + + + + + + + 25 + + + + + + + + + 50 + + + + + + + + + 75 + + + + + + + + + 100 + + + + 占对应唤醒组 Full 字节 (%) + + + + + + + All · 完整决策包 + + + + + + Progressive · 213 次 + + + + + + Prog+Delta 首读 · 112 次 + + + + + + + + + + + + + + 100.0% (22.41 MB / 续跑 ≈12.53 MB) + + + 32.5% (7.29 MB · 净减 −67.5%) + + + ≈ 3.0% 正文 / ≈ 5.0% CLI (−97% / −95%) + + + B2. 八题 18h 队列汇总:从 100% 到叠加首读入口 + + + + 分层上下文:独立教程对照与 18h 周期内差分观测 + + + 渐进视图保留必要事实;周期内结构化 Delta 复用已验证子树。压缩或失配恢复完整基线,不保证任意输入逐级变小。 + + + A:measurements.json 的独立归档对照,各行分母不同;674 B 为三个 --select 的手工选读,未测同源四阶段的 --initial-read。 + B2:八题 213 次唤醒(Progressive)与 112 次同周期续跑(Prog+Delta 首读)各自相对原分母的比例;跨队列分母分开列出,不做混乘。 + + + + + + + + + + + + + + diff --git a/apps/presentation/site/public/blog/images/context-delivery/multitask-byte-ratios-en.svg b/apps/presentation/site/public/blog/images/context-delivery/multitask-byte-ratios-en.svg index cc9760a749..43dacc06c3 100644 --- a/apps/presentation/site/public/blog/images/context-delivery/multitask-byte-ratios-en.svg +++ b/apps/presentation/site/public/blog/images/context-delivery/multitask-byte-ratios-en.svg @@ -1,7 +1,7 @@ - + @@ -21,538 +21,544 @@ - - + - 0 + 0 - + - 10 + 10 - + - 20 + 20 - + - 30 + 30 - + - 40 + 40 - + - 50 + 50 - Share of Full bytes within each cohort (%) + Share of Full bytes within each cohort (%) - Grid Robot + Grid Robot - VLIW Scheduling + VLIW Scheduling - FFmpeg Encoding + FFmpeg Encoding - OpenTTD Transport + OpenTTD Transport - Ad Placement + Ad Placement - Borden Inversion + Borden Inversion - Rust Workspace + Rust Workspace - ANN Vector Search + ANN Vector Search - +" clip-path="url(#pbd0f859176)" style="fill: #2563eb"/> - +" clip-path="url(#pbd0f859176)" style="fill: #2563eb"/> - +" clip-path="url(#pbd0f859176)" style="fill: #2563eb"/> - +" clip-path="url(#pbd0f859176)" style="fill: #2563eb"/> - +" clip-path="url(#pbd0f859176)" style="fill: #2563eb"/> - +" clip-path="url(#pbd0f859176)" style="fill: #2563eb"/> - +" clip-path="url(#pbd0f859176)" style="fill: #2563eb"/> - +" clip-path="url(#pbd0f859176)" style="fill: #2563eb"/> - +" clip-path="url(#pbd0f859176)" style="fill: #0f766e"/> - +" clip-path="url(#pbd0f859176)" style="fill: #0f766e"/> - +" clip-path="url(#pbd0f859176)" style="fill: #0f766e"/> - +" clip-path="url(#pbd0f859176)" style="fill: #0f766e"/> - +" clip-path="url(#pbd0f859176)" style="fill: #0f766e"/> - +" clip-path="url(#pbd0f859176)" style="fill: #0f766e"/> - +" clip-path="url(#pbd0f859176)" style="fill: #0f766e"/> - +" clip-path="url(#pbd0f859176)" style="fill: #0f766e"/> - +" clip-path="url(#pbd0f859176)" style="fill: #7c3aed"/> - +" clip-path="url(#pbd0f859176)" style="fill: #7c3aed"/> - +" clip-path="url(#pbd0f859176)" style="fill: #7c3aed"/> - +" clip-path="url(#pbd0f859176)" style="fill: #7c3aed"/> - +" clip-path="url(#pbd0f859176)" style="fill: #7c3aed"/> - +" clip-path="url(#pbd0f859176)" style="fill: #7c3aed"/> - +" clip-path="url(#pbd0f859176)" style="fill: #7c3aed"/> - +" clip-path="url(#pbd0f859176)" style="fill: #7c3aed"/> - A. Task shares · cohorts stay explicit + A. Per-task shares · single-layer vs stacked - - + - 0 + 0 - + - 10 + 25 - + - 20 + 50 - + - 30 + 75 - + - 40 - - - - - - - - - 50 + 100 - - - Progressive · 213 + + + All (Full) · 213 / 112 - - - Delta · 112 + + + Delta file only · 112 - - - First body · 112 + + + Progressive view · 213 - + + + Prog+Delta First CLI · 112 + + + + - First CLI · 112 + Prog+Delta First body · 112 - +" clip-path="url(#p31b26ab10d)" style="fill: #334155"/> - +" clip-path="url(#p31b26ab10d)" style="fill: #0f766e"/> - +" clip-path="url(#p31b26ab10d)" style="fill: #2563eb"/> - +" clip-path="url(#p31b26ab10d)" style="fill: #64748b"/> + + + - 32.5% + 100.0% - ≈ 35.8% + ≈ 35.8% - ≈ 3.0% + 32.5% - ≈ 5.0% + ≈ 5.0% - B. Weighted totals · separate denominators + ≈ 3.0% + + + B. From 100%: single-layer vs stacked entry - - - Eight-task context byte audit: 213 wakes and 112 in-cycle wakes - Progressive: 7,285,951 / 22,408,690 B across 213 wakes. Delta/first-read: 112 wakes; reported Full denominator ≈ 12.53 MB. - Aggregate recalculation; exact per-wake denominators unavailable. The right panel is not a cumulative reduction across 213 wakes. + Eight-task context byte audit: single-layer payloads vs stacked Prog+Delta entry + + + All: 22,408,690 B (213 wakes) / ≈ 12.53 MB (112 continuations). Published 'Delta file only' (4,488,335 B ≈ 35.8%) is the unprojected lossless delta file, + parallel to Progressive (7,285,951 B = 32.5%); stacking both in Prog+Delta first-read drops the entry payload to ≈ 3.0% (body) / ≈ 5.0% (CLI). - - + - - Progressive · all wakes + + Progressive · all 213 wakes - - + - - Delta · in-cycle + + Lossless Delta file · 112 wakes - - + - - First-read body · in-cycle + + Prog+Delta First body · 112 wakes - - + + - - + + diff --git a/apps/presentation/site/public/blog/images/context-delivery/multitask-byte-ratios-zh.svg b/apps/presentation/site/public/blog/images/context-delivery/multitask-byte-ratios-zh.svg index ca40269534..18989bcba8 100644 --- a/apps/presentation/site/public/blog/images/context-delivery/multitask-byte-ratios-zh.svg +++ b/apps/presentation/site/public/blog/images/context-delivery/multitask-byte-ratios-zh.svg @@ -1,7 +1,7 @@ - + @@ -21,538 +21,544 @@ - - + - 0 + 0 - + - 10 + 10 - + - 20 + 20 - + - 30 + 30 - + - 40 + 40 - + - 50 + 50 - 占同组 Full 字节 (%) + 占同组 Full 字节 (%) - Grid Robot + Grid Robot - VLIW Scheduling + VLIW Scheduling - FFmpeg Encoding + FFmpeg Encoding - OpenTTD Transport + OpenTTD Transport - Ad Placement + Ad Placement - Borden Inversion + Borden Inversion - Rust Workspace + Rust Workspace - ANN Vector Search + ANN Vector Search - +" clip-path="url(#pbd0f859176)" style="fill: #2563eb"/> - +" clip-path="url(#pbd0f859176)" style="fill: #2563eb"/> - +" clip-path="url(#pbd0f859176)" style="fill: #2563eb"/> - +" clip-path="url(#pbd0f859176)" style="fill: #2563eb"/> - +" clip-path="url(#pbd0f859176)" style="fill: #2563eb"/> - +" clip-path="url(#pbd0f859176)" style="fill: #2563eb"/> - +" clip-path="url(#pbd0f859176)" style="fill: #2563eb"/> - +" clip-path="url(#pbd0f859176)" style="fill: #2563eb"/> - +" clip-path="url(#pbd0f859176)" style="fill: #0f766e"/> - +" clip-path="url(#pbd0f859176)" style="fill: #0f766e"/> - +" clip-path="url(#pbd0f859176)" style="fill: #0f766e"/> - +" clip-path="url(#pbd0f859176)" style="fill: #0f766e"/> - +" clip-path="url(#pbd0f859176)" style="fill: #0f766e"/> - +" clip-path="url(#pbd0f859176)" style="fill: #0f766e"/> - +" clip-path="url(#pbd0f859176)" style="fill: #0f766e"/> - +" clip-path="url(#pbd0f859176)" style="fill: #0f766e"/> - +" clip-path="url(#pbd0f859176)" style="fill: #7c3aed"/> - +" clip-path="url(#pbd0f859176)" style="fill: #7c3aed"/> - +" clip-path="url(#pbd0f859176)" style="fill: #7c3aed"/> - +" clip-path="url(#pbd0f859176)" style="fill: #7c3aed"/> - +" clip-path="url(#pbd0f859176)" style="fill: #7c3aed"/> - +" clip-path="url(#pbd0f859176)" style="fill: #7c3aed"/> - +" clip-path="url(#pbd0f859176)" style="fill: #7c3aed"/> - +" clip-path="url(#pbd0f859176)" style="fill: #7c3aed"/> - A. 逐题占比 · 分开标明唤醒组 + A. 逐题占比 · 单层文件载荷与叠加首读 - - + - 0 + 0 - + - 10 + 25 - + - 20 + 50 - + - 30 + 75 - + - 40 - - - - - - - - - 50 + 100 - - - Progressive · 213 + + + All (Full) · 213 / 112 - - - Delta · 112 + + + 单路 Delta 文件 · 112 - - - First body · 112 + + + Progressive 视图 · 213 - + + + Prog+Delta 首读 CLI · 112 + + + + - First CLI · 112 + Prog+Delta 首读正文 · 112 - +" clip-path="url(#p31b26ab10d)" style="fill: #334155"/> - +" clip-path="url(#p31b26ab10d)" style="fill: #0f766e"/> - +" clip-path="url(#p31b26ab10d)" style="fill: #2563eb"/> - +" clip-path="url(#p31b26ab10d)" style="fill: #64748b"/> + + + - 32.5% + 100.0% - ≈ 35.8% + ≈ 35.8% - ≈ 3.0% + 32.5% - ≈ 5.0% + ≈ 5.0% - B. 各自分母的加权总计 + ≈ 3.0% + + + B. 从 100% 对照单层载荷与叠加首读 - - - 八题上下文字节审计:先区分 213 次与 112 次唤醒 - Progressive:7,285,951 / 22,408,690 B,覆盖 213 次唤醒。Delta/首读:仅覆盖 112 次,Full 分母原报告约 12.53 MB。 - 聚合复算;原始逐 wake 分母未公开。不可将右图解释为覆盖 213 次的累积减量。 + 八题上下文字节审计:区分单层载荷(Progressive / 无损 Delta)与叠加首读 + + + All:22,408,690 B(213 次)/ 约 12.53 MB(112 次续跑)。归档表中「单路 Delta 文件」(4,488,335 B ≈ 35.8%)是未叠加渐进裁剪的完整无损差分文件, + 与 Progressive(7,285,951 B = 32.5%)同属单层裁剪;两者叠加后的 Prog+Delta 首读入口降至 ≈ 3.0%(正文)/ ≈ 5.0%(含 CLI 封装)。 - - + - - Progressive · all wakes + + Progressive · 全部 213 次 - - + - - Delta · in-cycle + + 单路无损 Delta 文件 · 112 次 - - + - - First-read body · in-cycle + + Prog+Delta 首读正文 · 112 次 - - + + - - + + diff --git a/apps/presentation/site/public/blog/index.html b/apps/presentation/site/public/blog/index.html index 4034183798..48f54b102c 100644 --- a/apps/presentation/site/public/blog/index.html +++ b/apps/presentation/site/public/blog/index.html @@ -35,8 +35,8 @@

LoopX · Blog

Engineering the long run.

Design, engineering, and field notes on long-horizon agents.

-

Context delivery · Mechanisms and examples

· Updated November 10

-

An Agent needs a way to look things up, too

Recalculate eight published task rows: Progressive is 32.5% over 213 wakes; in-cycle Delta is approximately 35.8% over a separate 112-wake cohort. Preserve complete reads, recovery and evidence boundaries.

Read article
+

Context delivery · Mechanisms and examples

· Updated November 12

+

An Agent needs a way to look things up, too

Read progressive views and compaction-cycle deltas with their own measurement baselines: 32.5% across 213 wakes, −74.25% on ANN in-cycle wakes, and ≈ 3.0% / 5.0% eight-task first-read entry. Compaction resets the complete baseline; these cohorts are separate comparisons.

Read article

Research notes · EdgeBench and LHTB

diff --git a/apps/presentation/site/public/blog/zh/context-delivery/index.html b/apps/presentation/site/public/blog/zh/context-delivery/index.html index 85af793ab2..5c7d831090 100644 --- a/apps/presentation/site/public/blog/zh/context-delivery/index.html +++ b/apps/presentation/site/public/blog/zh/context-delivery/index.html @@ -6,18 +6,21 @@ -
← 全部文章

Context delivery

Agent 读上下文,也需要一套“查书”的方法

沿一个语音任务,讲清选读、完整首读、按阶段补读、精确复用与恢复。

-

Agent 做长任务,资料会越来越多:原目标、后来追加的约束、试过的方案、失败的实验、当前做到哪里。每次继续都把整份材料再塞一遍,重复会不断累积;只留一段摘要,又可能找不回决定所依赖的原话。

-

LoopX 的 context delivery 把这件事拆开做:完整资料保存下来,当前决定需要的事实先读,支持材料沿问题展开;同一个有效 compaction 周期内,再精确复用没有变化的结构。

-

先看数据:渐进读取、差分交付与任务消融

-

八题对照:渐进读取与周期差分

-

本节复算已公开的八题四臂 Context ON 18h 聚合表,以区分 Progressive 与周期内 Delta 的传输开销。原表报告 213 次模型侧唤醒:101 次新会话或压缩后的基线读取,以及 112 次周期内续跑。逐题单元格保留来源原值,合计按表重算;这里不附原始逐 wake 日志。

+
← 全部文章

Context delivery

Agent 读上下文,也需要一套“查书”的方法

先读必要事实,在有效 compaction 周期内复用已验证的结构;连续性丢失时恢复完整基线。

+

Agent 做长任务,资料会越来越多:原目标、后来追加的约束、试过的方案、失败的实验、当前做到哪里。每次继续都把整份材料再塞一遍,重复会不断累积;只留一段摘要,又可能找不回决定所依赖的原话。

+

按 JSON Pointer 渐进选读本身并不复杂,主要用来折叠当前决定暂不需要的历史长尾;LoopX 上下文交付真正的机制重心,是受 compaction 周期约束的结构化差分上下文(compaction-cycle structured delta):完整来源先落盘存证,在同一个已核验的 context_epoch 内精确复用未变化的结构子树、只编码本轮结构增删改;一旦宿主发生上下文压缩或基线摘要失配,立即原子切换回新的完整基线,绝不悬空叠加旧差分。

+

先看数据:独立上下文对照、周期内结构化差分与任务消融

+

四种机制,分别核对测量基线

+

完整捕获、Progressive 必要视图、压缩周期内结构化 Delta 与受预算保护的 --initial-read 可以组合,但不保证任意输入的字节数逐级单调递减。Delta 依赖同周期内已验证的基线;新周期、摘要失配或完整表示更小时,恢复完整交付。下图教程数据是三组独立对照,不是同源四阶实测流水线。

+

独立教程对照与 18h 压缩周期内差分观测

独立教程对照:必要视图 5,107 / 57,014 B(9.0%);周期内 Delta 2,164 / 5,543 B(39.0%);手工选读 674 / 51,547 B(正文 1.3%)。ANN 与八题历史队列各自保留原分母。
A:measurements.json 的三组归档教程对照:必要视图为 5,107 / 57,014 B(9.0%);下一份捕获的 Delta 文件为 2,164 / 5,543 B,相对本轮完整 Progressive 交付文件保留 39.0%;另一份 51,547 B 的 capture.json 手工选择三个路径,返回 674 B 正文(1.3%),含 CLI 元数据为 1,362 B(2.6%)。674 B 不是 Progressive+Delta 后执行 --initial-read 的实测。这里保留的是 measurements.json 所注明源码版本的历史观测,输出大小可能随源码版本和读取路径变化。B1:ANN 归档 18h 配对以选中包为基线,全 46 轮再减 38.54%,24 个周期内增量轮再减 74.25%。B2:八题的 213 次唤醒与 112 次续跑各用原分母,不与教程数字串成一条削减链。
+

八题对照:区分单层载荷(Progressive / 无损 Delta 文件)与叠加首读

+

阅读下方已公开的八题四臂 Context ON 18h 聚合表时,需要区分单层文件载荷与双层叠加首读:原表共覆盖 213 次模型侧唤醒(101 次新会话或压缩后的基线读取 + 112 次同周期续跑),其中「同周期 Delta 载荷」列(4.49 MB / 约 12.53 MB = 35.8%)统计的是磁盘上尚未叠加渐进裁剪的单路无损 delivery.json 文件(All → Delta),与「Progressive 必要视图」(7.29 / 22.41 MB = 32.5%,All → Progressive)属于平行的单层裁剪,而非叠加后的 Progressive + Delta;把渐进选读与周期内差分真正叠在一起的,是最后一列 Prog+Delta 首读入口(3.0% 正文 / 5.0% 含 CLI 封装)。逐题单元格保留来源原值,合计按表重算;这里不附原始逐 wake 日志。

    -
  • 渐进读取:全部 213 次唤醒,完整包 22.41 MB → 7.29 MB(−67.5%)。投影保留原包的 32.5%,各题为 26.5%–39.0%;未选中候选与历史长尾详情按需展开。
  • -
  • 周期内差分:112 次同周期续跑,对应完整包 约 12.53 MB → 4.49 MB(−64.2%)。差分保留同组原包的 35.8%,各题为 20.7%–47.1%。
  • -
  • 渐进+差分首读:在同一组 112 次续跑中,--initial-read 正文占原包 3.0%,含 CLI JSON 封装为 5.0%(净减 −97.0% / −95.0%)。首读入口大小不代表整轮模型输入。
  • +
  • 单层渐进视图(All → Progressive):全部 213 次唤醒,完整包 22.41 MB → 7.29 MB(−67.5%),保留原包的 32.5%(各题 26.5%–39.0%);未选中候选与历史长尾按需展开。
  • +
  • 单层无损差分文件(All → Delta):112 次同周期续跑,未裁剪的完整无损差分文件对应同轮完整包 约 12.53 MB → 4.49 MB(−64.2%),占同组原包 35.8%(各题 20.7%–47.1%)。
  • +
  • 叠加首读入口(Progressive + Delta First Read):在同一组 112 次续跑中,叠加渐进选读与周期差分后的 --initial-read 正文占原包 3.0%,含 CLI JSON 封装为 5.0%(净减 −97.0% / −95.0%)。首读入口大小不代表整轮模型输入。
-

八题四臂 Context ON 控制面字节比例(N = 213 唤醒,112 周期内 Delta)

八题四臂 Context ON 18h 轨迹控制面字节比例:Progressive 占 32.5% (-67.5%),同周期 Delta 占 35.8% (-64.2%),Prog+Delta 首屏占 3.0% / 5.0%
A:逐题占比,Progressive 覆盖全部唤醒,Delta/首读仅覆盖周期内唤醒。B:分开汇总:213 次唤醒的 Progressive 为 7.29 / 22.41 MB;112 次周期内唤醒的 Delta 为 4.49 / 约 12.53 MB。同一 112 次的首读正文/CLI 为 0.374 / 0.631 MB。两个分母不可混用。可打开原图查看。
+

八题四臂 Context ON 控制面字节审计(N = 213 唤醒,112 周期内续跑)

八题四臂 Context ON 18h 轨迹控制面字节比例:从 All 100% 对照单路无损 Delta 文件 35.8%、Progressive 视图 32.5% 与叠加后的 Prog+Delta 首读 3.0% / 5.0%
A:逐题占比,对比两条单层载荷(Progressive 覆盖全部 213 次唤醒,单路无损 Delta 文件覆盖 112 次续跑)与叠加后的首读正文。B:以 100% Full 为基准分开汇总:单路无损 Delta 文件约 35.8%(4.49 / 约 12.53 MB),Progressive 视图 32.5%(7.29 / 22.41 MB),两者叠加后的 Prog+Delta 首读入口降至约 5.0%(CLI)/ 3.0%(正文)。两个队列分母不可混用。
展开八题逐项数据
diff --git a/apps/presentation/site/public/blog/zh/index.html b/apps/presentation/site/public/blog/zh/index.html index 89527d32d0..b2d02fc689 100644 --- a/apps/presentation/site/public/blog/zh/index.html +++ b/apps/presentation/site/public/blog/zh/index.html @@ -35,8 +35,8 @@

LoopX · 博客

长程工作的工程实践。

关于长程 Agent 的设计、工程与实践。

-

Context delivery · 机制与实例

· 11 月 8 日更新

-

Agent 读上下文,也需要一套“查书”的方法

八题公开表复算:213 次唤醒的 Progressive 为 32.5%;112 次周期内 Delta 约 35.8%,两者采用不同分母。保留完整读取、恢复及证据边界。

阅读全文
+

Context delivery · 机制与实例

· 11 月 12 日更新

+

Agent 读上下文,也需要一套“查书”的方法

分别核对渐进视图与周期内差分的测量基线:213 次唤醒保留 32.5%,ANN 周期内增量轮再减 74.25%,八题首读入口约 3.0% / 5.0%。这些队列独立比较,压缩或失配即恢复完整基线。

阅读全文

研究札记 · EdgeBench 与 LHTB

diff --git a/apps/presentation/site/scripts/generate-long-horizon-audit.py b/apps/presentation/site/scripts/generate-long-horizon-audit.py index 52de0edd71..4acda82a8c 100644 --- a/apps/presentation/site/scripts/generate-long-horizon-audit.py +++ b/apps/presentation/site/scripts/generate-long-horizon-audit.py @@ -13,11 +13,14 @@ BLOG = Path(__file__).resolve().parents[1] / "public/blog" DATA = json.loads((BLOG / "data/long-horizon-audit.json").read_text()) +LESSON = json.loads((BLOG / "context-delivery/measurements.json").read_text()) mpl.rcParams.update({"svg.fonttype": "none", "svg.hashsalt": "loopx-blog-audit", "font.family": "sans-serif", "font.sans-serif": ["PingFang SC", "Noto Sans CJK SC", "DejaVu Sans"], "axes.spines.top": False, "axes.spines.right": False, "axes.spines.left": False, "axes.spines.bottom": False, "axes.labelcolor": "#334155", "text.color": "#0f172a"}) -BLUE, TEAL, PURPLE, GRAY = "#2563eb", "#0f766e", "#7c3aed", "#94a3b8" +SLATE, BLUE, TEAL, EMERALD, INDIGO, PURPLE, GRAY = ( + "#334155", "#2563eb", "#0f766e", "#059669", "#4f46e5", "#7c3aed", "#64748b" +) def save(fig, relative): @@ -29,13 +32,192 @@ def save(fig, relative): for language in ("en", "zh"): zh = language == "zh" + # 1. Independent archived tutorial comparisons, not one measured cascade. + # Each numerator is paired with its own recorded baseline. In particular, + # a manual --select response is not a native --initial-read measurement. + orig_b = LESSON["native_projection"]["original_bytes"] + required_b = LESSON["native_projection"]["required_bytes"] + prog_b = LESSON["cycle_codec"]["current_full_bytes"] + pdelta_b = LESSON["cycle_codec"]["delta_bytes"] + manual_source_b = next(r for r in LESSON["reader_rows"] if r["case"] == "complete_capture")["body_bytes"] + batch_row = next(r for r in LESSON["reader_rows"] if r["case"] == "one_batched_select") + first_body_lesson = batch_row["body_bytes"] + first_cli_lesson = batch_row["output_bytes"] + + fig = plt.figure(figsize=(14.4, 6.9)) + gs = fig.add_gridspec( + 2, 2, width_ratios=[1.05, 1.15], height_ratios=[1.0, 1.0], + left=.18, right=.97, bottom=.20, top=.78, wspace=.48, hspace=.58, + ) + left = fig.add_subplot(gs[:, 0]) + right_top = fig.add_subplot(gs[0, 1]) + right_bot = fig.add_subplot(gs[1, 1]) + + cascade_vals = [100 * required_b / orig_b, 100 * pdelta_b / prog_b, + 100 * first_body_lesson / manual_source_b] + cascade_labels = ( + [ + "Progressive 必要视图 / 原捕获", + "周期内 Delta / 本轮完整交付", + "手工批量选读 / 教程捕获", + ] + if zh else + [ + "Progressive view / original capture", + "In-cycle Delta / full delivery", + "Manual selection / tutorial capture", + ] + ) + cascade_notes = [ + f"{cascade_vals[0]:.1f}% ({required_b:,} / {orig_b:,} B)", + f"{cascade_vals[1]:.1f}% ({pdelta_b:,} / {prog_b:,} B)", + f"{cascade_vals[2]:.1f}% ({first_body_lesson:,} / {manual_source_b:,} B · {100*first_cli_lesson/manual_source_b:.1f}% CLI)", + ] + left.barh(range(3), cascade_vals, color=[BLUE, TEAL, PURPLE], height=.50) + left.set_yticks(range(3), cascade_labels, fontsize=10.2) + left.invert_yaxis() + left.set_xlim(0, 168) + left.set_xlabel("占各行自身对照基线的字节 (%)" if zh else "Share of each row's own baseline bytes (%)", fontsize=9.8) + left.set_title( + "A. 归档教程的三组独立对照" if zh else "A. Three independent archived tutorial comparisons", + loc="left", pad=14, fontsize=11.5, fontweight="bold", + ) + for idx, (val, note) in enumerate(zip(cascade_vals, cascade_notes)): + weight = "bold" if idx == 1 else "normal" + color = "#0f766e" if idx == 1 else "#0f172a" + left.text(val + 2.2, idx, note, va="center", fontsize=9.6, fontweight=weight, color=color) + + # Panel B1: Real 18h ANN paired audit — stacking Compaction-Cycle Structured Delta on top of Progressive + ann_prog_b = 3089841 + ann_all46_b = 1898889 + ann_vals = [100.0, 100 * ann_all46_b / ann_prog_b, 100.0 - 74.25] + ann_labels = ( + [ + "Progressive 选中包 (46 轮)", + "+ 周期内 Delta (全 46 轮)", + "+ 周期内 Delta (24 增量轮)", + ] + if zh else + [ + "Progressive selected (46)", + "+ In-cycle Delta (all 46)", + "+ In-cycle Delta (24 delta)", + ] + ) + ann_notes = ( + [ + "100.0% (3.09 MB 选中基线)", + f"{ann_vals[1]:.1f}% (1.90 MB · 再减 −38.54%)", + f"{ann_vals[2]:.2f}% (同周期再减 −74.25%)", + ] + if zh else + [ + "100.0% (3.09 MB selected base)", + f"{ann_vals[1]:.1f}% (1.90 MB · −38.54%)", + f"{ann_vals[2]:.2f}% (in-cycle −74.25%)", + ] + ) + right_top.barh(range(3), ann_vals, color=[BLUE, TEAL, EMERALD], height=.52) + right_top.set_yticks(range(3), ann_labels, fontsize=9.8) + right_top.invert_yaxis() + right_top.set_xlim(0, 188) + right_top.set_xlabel("占 Progressive 选中字节 (%)" if zh else "Share of Progressive selected bytes (%)", fontsize=9.5) + right_top.set_title( + "B1. 18h 实测核心增量:叠加压缩周期内结构化 Delta(ANN)" + if zh else + "B1. 18h core layer: stacking compaction-cycle Delta (ANN)", + loc="left", pad=12, fontsize=11.0, fontweight="bold", + ) + for idx, (val, note) in enumerate(zip(ann_vals, ann_notes)): + weight = "bold" if idx >= 1 else "normal" + color = "#0f766e" if idx >= 1 else "#0f172a" + right_top.text(val + 2.4, idx, note, va="center", fontsize=9.4, fontweight=weight, color=color) + + # Panel B2: 8-task 18h aggregate step-down (explicit cohorts) + rows = DATA["context"]["tasks"] + prog_213 = 100 * sum(r["progressive_bytes"] for r in rows) / sum(r["full_bytes"] for r in rows) + denom_112 = DATA["context"]["delta_full_bytes_reported_approx"] + first_cli_112 = 100 * sum(r["first_cli_bytes"] for r in rows) / denom_112 + first_body_112 = 100 * sum(r["first_body_bytes"] for r in rows) / denom_112 + real_vals = [100.0, prog_213, first_body_112] + real_labels = ( + [ + "All · 完整决策包", + "Progressive · 213 次", + "Prog+Delta 首读 · 112 次", + ] + if zh else + [ + "All · Full packets", + "Progressive · 213 wakes", + "Prog+Delta First read · 112", + ] + ) + real_notes = ( + [ + "100.0% (22.41 MB / 续跑 ≈12.53 MB)", + f"{prog_213:.1f}% (7.29 MB · 净减 −67.5%)", + f"≈ {first_body_112:.1f}% 正文 / ≈ {first_cli_112:.1f}% CLI (−97% / −95%)", + ] + if zh else + [ + "100.0% (22.41 MB / in-cycle ≈12.53 MB)", + f"{prog_213:.1f}% (7.29 MB · −67.5%)", + f"≈ {first_body_112:.1f}% body / ≈ {first_cli_112:.1f}% CLI (−97% / −95%)", + ] + ) + right_bot.barh(range(3), real_vals, color=[SLATE, BLUE, PURPLE], height=.52) + right_bot.set_yticks(range(3), real_labels, fontsize=9.8) + right_bot.invert_yaxis() + right_bot.set_xlim(0, 188) + right_bot.set_xlabel("占对应唤醒组 Full 字节 (%)" if zh else "Share of cohort Full bytes (%)", fontsize=9.5) + right_bot.set_title( + "B2. 八题 18h 队列汇总:从 100% 到叠加首读入口" + if zh else + "B2. 8-task 18h cohorts: 100% Full to Prog+Delta entry", + loc="left", pad=12, fontsize=11.0, fontweight="bold", + ) + for idx, (val, note) in enumerate(zip(real_vals, real_notes)): + right_bot.text(val + 2.4, idx, note, va="center", fontsize=9.4) + + for axis in (left, right_top, right_bot): + axis.set_axisbelow(True) + axis.grid(axis="x", color="#e2e8f0", linewidth=.7) + axis.tick_params(axis="both", length=0) + axis.set_xticks([0, 25, 50, 75, 100]) + + fig.suptitle( + "分层上下文:独立教程对照与 18h 周期内差分观测" + if zh else + "Layered context: separate tutorial comparisons and 18h in-cycle Delta observations", + fontsize=15.0, fontweight="bold", y=.955, + ) + sub = ( + "渐进视图保留必要事实;周期内结构化 Delta 复用已验证子树。压缩或失配恢复完整基线,不保证任意输入逐级变小。" + if zh else + "Progressive views retain necessary facts; in-cycle Delta reuses verified subtrees. Compaction or mismatch restores the complete baseline." + ) + fig.text(.5, .87, sub, ha="center", fontsize=10.2, color="#475569") + footer_cascade = ( + "A:measurements.json 的独立归档对照,各行分母不同;674 B 为三个 --select 的手工选读,未测同源四阶段的 --initial-read。\n" + "B2:八题 213 次唤醒(Progressive)与 112 次同周期续跑(Prog+Delta 首读)各自相对原分母的比例;跨队列分母分开列出,不做混乘。" + if zh else + "A: independent archived comparisons in measurements.json; 674 B is a manual three-path selection, not a measured stacked --initial-read.\n" + "B2: 8-task shares over 213 wakes (Progressive) and 112 in-cycle continuations (Prog+Delta first-read); each cohort keeps its own denominator." + ) + fig.text(.03, .03, footer_cascade, fontsize=9.4, color="#475569", linespacing=1.5) + save(fig, f"images/context-delivery/context-cascade-{language}.svg") + + # 2. Eight-task detailed byte ratio audit (with 100% Full baseline bar & explicit single-layer vs stacked labels) rows = DATA["context"]["tasks"] - fig, axes = plt.subplots(1, 2, figsize=(13.8, 6.8), gridspec_kw={"width_ratios": [1.45, 1]}) - fig.subplots_adjust(left=.17, right=.97, bottom=.22, top=.80, wspace=.48) + fig, axes = plt.subplots(1, 2, figsize=(14.0, 6.8), gridspec_kw={"width_ratios": [1.38, 1.08]}) + fig.subplots_adjust(left=.15, right=.96, bottom=.23, top=.80, wspace=.50) left, right = axes - series = [("Progressive · all wakes", [100*r["progressive_bytes"]/r["full_bytes"] for r in rows], BLUE), - ("Delta · in-cycle", [r["delta_share_reported"] for r in rows], TEAL), - ("First-read body · in-cycle", [r["first_body_share_reported"] for r in rows], PURPLE)] + series = [ + ("Progressive · 全部 213 次" if zh else "Progressive · all 213 wakes", [100*r["progressive_bytes"]/r["full_bytes"] for r in rows], BLUE), + ("单路无损 Delta 文件 · 112 次" if zh else "Lossless Delta file · 112 wakes", [r["delta_share_reported"] for r in rows], TEAL), + ("Prog+Delta 首读正文 · 112 次" if zh else "Prog+Delta First body · 112 wakes", [r["first_body_share_reported"] for r in rows], PURPLE), + ] for index, (label, values, color) in enumerate(series): positions = [i + (index-1)*.23 for i in range(len(rows))] left.barh(positions, values, height=.20, label=label, color=color) @@ -43,28 +225,66 @@ def save(fig, relative): left.invert_yaxis() left.set_xlim(0, 55) left.set_xlabel("占同组 Full 字节 (%)" if zh else "Share of Full bytes within each cohort (%)") - left.set_title("A. 逐题占比 · 分开标明唤醒组" if zh else "A. Task shares · cohorts stay explicit", loc="left", pad=18) - totals = [100*sum(r["progressive_bytes"] for r in rows)/sum(r["full_bytes"] for r in rows)] + left.set_title( + "A. 逐题占比 · 单层文件载荷与叠加首读" if zh else "A. Per-task shares · single-layer vs stacked", + loc="left", pad=16, fontsize=11.5, fontweight="bold", + ) + + prog_share = 100 * sum(r["progressive_bytes"] for r in rows) / sum(r["full_bytes"] for r in rows) denominator = DATA["context"]["delta_full_bytes_reported_approx"] - totals += [100*sum(r[key] for r in rows)/denominator - for key in ("delta_bytes", "first_body_bytes", "first_cli_bytes")] - labels = ["Progressive · 213", "Delta · 112", "First body · 112", "First CLI · 112"] - right.barh(range(4), totals, color=[BLUE, TEAL, PURPLE, GRAY], height=.52) - right.set_yticks(range(4), labels, fontsize=10) + delta_share = 100 * sum(r["delta_bytes"] for r in rows) / denominator + first_cli_share = 100 * sum(r["first_cli_bytes"] for r in rows) / denominator + first_body_share = 100 * sum(r["first_body_bytes"] for r in rows) / denominator + + totals = [100.0, delta_share, prog_share, first_cli_share, first_body_share] + labels = ( + [ + "All (Full) · 213 / 112", + "单路 Delta 文件 · 112", + "Progressive 视图 · 213", + "Prog+Delta 首读 CLI · 112", + "Prog+Delta 首读正文 · 112", + ] + if zh else + [ + "All (Full) · 213 / 112", + "Delta file only · 112", + "Progressive view · 213", + "Prog+Delta First CLI · 112", + "Prog+Delta First body · 112", + ] + ) + right.barh(range(5), totals, color=[SLATE, TEAL, BLUE, GRAY, PURPLE], height=.54) + right.set_yticks(range(5), labels, fontsize=10) right.invert_yaxis() - right.set_xlim(0, 50) - for index, value in enumerate(totals): - right.text(value+1, index, f"{'≈ ' if index else ''}{value:.1f}%", va="center", fontsize=12) - right.set_title("B. 各自分母的加权总计" if zh else "B. Weighted totals · separate denominators", loc="left", pad=18) + right.set_xlim(0, 128) + right.set_xticks([0, 25, 50, 75, 100]) + approx_flags = [False, True, False, True, True] + for index, (value, approx) in enumerate(zip(totals, approx_flags)): + right.text(value + 2.0, index, f"{'≈ ' if approx else ''}{value:.1f}%", va="center", fontsize=10.5) + right.set_title( + "B. 从 100% 对照单层载荷与叠加首读" if zh else "B. From 100%: single-layer vs stacked entry", + loc="left", pad=16, fontsize=11.5, fontweight="bold", + ) for axis in axes: axis.set_axisbelow(True) axis.grid(axis="x", color="#e2e8f0", linewidth=.7) axis.tick_params(axis="both", length=0) - fig.suptitle("八题上下文字节审计:先区分 213 次与 112 次唤醒" if zh else "Eight-task context byte audit: 213 wakes and 112 in-cycle wakes", fontsize=17, y=.96) - fig.legend(*left.get_legend_handles_labels(), loc="lower center", ncol=3, frameon=False, bbox_to_anchor=(.53,.11), fontsize=10) - footer = ("Progressive:7,285,951 / 22,408,690 B,覆盖 213 次唤醒。Delta/首读:仅覆盖 112 次,Full 分母原报告约 12.53 MB。\n聚合复算;原始逐 wake 分母未公开。不可将右图解释为覆盖 213 次的累积减量。" if zh else - "Progressive: 7,285,951 / 22,408,690 B across 213 wakes. Delta/first-read: 112 wakes; reported Full denominator ≈ 12.53 MB.\nAggregate recalculation; exact per-wake denominators unavailable. The right panel is not a cumulative reduction across 213 wakes.") - fig.text(.03,.035,footer,fontsize=10,color="#475569",linespacing=1.6) + fig.suptitle( + "八题上下文字节审计:区分单层载荷(Progressive / 无损 Delta)与叠加首读" + if zh else + "Eight-task context byte audit: single-layer payloads vs stacked Prog+Delta entry", + fontsize=15.2, fontweight="bold", y=.955, + ) + fig.legend(*left.get_legend_handles_labels(), loc="lower center", ncol=3, frameon=False, bbox_to_anchor=(.50, .115), fontsize=9.8) + footer = ( + "All:22,408,690 B(213 次)/ 约 12.53 MB(112 次续跑)。归档表中「单路 Delta 文件」(4,488,335 B ≈ 35.8%)是未叠加渐进裁剪的完整无损差分文件,\n" + "与 Progressive(7,285,951 B = 32.5%)同属单层裁剪;两者叠加后的 Prog+Delta 首读入口降至 ≈ 3.0%(正文)/ ≈ 5.0%(含 CLI 封装)。" + if zh else + "All: 22,408,690 B (213 wakes) / ≈ 12.53 MB (112 continuations). Published 'Delta file only' (4,488,335 B ≈ 35.8%) is the unprojected lossless delta file,\n" + "parallel to Progressive (7,285,951 B = 32.5%); stacking both in Prog+Delta first-read drops the entry payload to ≈ 3.0% (body) / ≈ 5.0% (CLI)." + ) + fig.text(.03, .03, footer, fontsize=9.6, color="#475569", linespacing=1.55) save(fig, f"images/context-delivery/multitask-byte-ratios-{language}.svg") stride = DATA["stride"] diff --git a/examples/blog-bilingual-index-smoke.mjs b/examples/blog-bilingual-index-smoke.mjs index 123a5962b6..d99c731653 100644 --- a/examples/blog-bilingual-index-smoke.mjs +++ b/examples/blog-bilingual-index-smoke.mjs @@ -107,6 +107,31 @@ export async function validateBilingualBlog(blogDir) { assertIncludes(article, `data-audit-progressive-bytes="${sum("progressive_bytes")}"`, "Both published editions must use the recomputed total"); assertIncludes(article, "32.5%", "The weighted share matches the public rows"); + const langSuffix = locale === "zh/" ? "zh" : "en"; + assertIncludes(article, `context-cascade-${langSuffix}.svg`, + "Both published editions must embed the context measurement comparisons"); + const cascadeSvg = await readFile( + resolve(blogDir, `images/context-delivery/context-cascade-${langSuffix}.svg`), + "utf8", + ); + const lesson = JSON.parse(await readFile(resolve(blogDir, "context-delivery/measurements.json"), "utf8")); + const manual = lesson.reader_rows.find(row => row.case === "one_batched_select"); + const full = lesson.reader_rows.find(row => row.case === "complete_capture"); + for (const [method, numerator, denominator] of [ + ["progressive", lesson.native_projection.required_bytes, lesson.native_projection.original_bytes], + ["cycle-delta", lesson.cycle_codec.delta_bytes, lesson.cycle_codec.current_full_bytes], + ["manual-select", manual.body_bytes, full.body_bytes], + ]) { + assertIncludes(article, `data-comparison-${method}="${numerator}/${denominator}"`, + "Each comparison must retain its own measured numerator and denominator"); + const pair = [numerator, denominator].map(value => value.toLocaleString("en-US")).join(" / "); + assertIncludes(cascadeSvg, pair, `Chart and article must agree on the measured pair: ${method}`); + } + const multitaskSvg = await readFile( + resolve(blogDir, `images/context-delivery/multitask-byte-ratios-${langSuffix}.svg`), + "utf8", + ); + assertIncludes(multitaskSvg, "100.0%", `Eight-task byte audit chart must anchor at 100% Full: ${langSuffix}`); if (/7,479,469|33\.4%|69\.9 KB|57\.1ms/.test(article)) throw new Error("Unreconciled totals or unsupported C1 attribution reappeared"); } From ec419e80013f6d3907f69003832404d718c443d9 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Thu, 12 Nov 2026 16:17:44 +0800 Subject: [PATCH 26/31] fix(research): scope the timeline to loaded history during recovery (#8626) Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../goal-research-history.tsx | 33 +++++++++++-------- .../goal-research-timeline.tsx | 30 +++++++++-------- .../research-record-search.mjs | 26 +++++++++++++-- 3 files changed, 61 insertions(+), 28 deletions(-) diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx index 5876a80b50..2ecb9d2dbb 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx @@ -116,7 +116,9 @@ export function GoalResearchHistory({goalId, originalGoalId = goalId, preferHist const [recordsOpen, setRecordsOpen] = useState(false); const records = useRef(null); const [lens, setLens] = useState(() => viewFromUrl(goalId).lens); - const [phase, setPhase] = useState(() => viewFromUrl(goalId).phase); + const [requestedPhase, setPhase] = useState(() => viewFromUrl(goalId).phase); + const phase = read.complete ? requestedPhase : null; + const historyCoverage = useRef(null); const presentationId = useId().replace(/:/g, ""); const detail = useRef(null); const graph = useRef(null); @@ -127,16 +129,18 @@ export function GoalResearchHistory({goalId, originalGoalId = goalId, preferHist const copy = zh ? { title: "研究历程", loading: "正在读取完整记录", reading: "读取中", refresh: "刷新记录", all: "回到全图", scope: "Explore 已记录历史", coverage: "其他来源覆盖、尝试发生时序尚未核验", - partial: "历史尚未完整载入", failure: "无法读取此来源的研究记录", - time: "记录时间(本地)", unknown: "时间未记录", record: "条记录", records: "条记录", evidence: "已记录依据", + partialFailure: "只载入了部分研究记录", refreshFailure: "刷新失败,当前记录已保留", + failure: "无法读取此来源的研究记录", + time: "记录时间(本地)", partialTime: "已读记录(本地时间)", unknown: "时间未记录", record: "条记录", records: "条记录", evidence: "已记录依据", interpretation: "独立检查与实际采用尚未核验", versions: "来源与版本引用", updates: "历史记录", changes: "次记录", question: "个问题", questions: "个问题", links: "显式关联", missing: "部分关联的来源节点尚未记录", group: "来源分组", lost: "当前记录中找不到所选节点,其他历史仍可浏览。", } : { title: "Research history", loading: "Reading the full recorded history", reading: "Reading", refresh: "Refresh history", all: "Whole graph", scope: "Recorded Explore history", coverage: "Other-source coverage and attempt-time coverage are unverified", - partial: "History is not fully loaded", failure: "Could not read this source's research history", - time: "Recorded time (local)", unknown: "Time not recorded", record: "record", records: "records", evidence: "Recorded evidence", + partialFailure: "Only part of the history loaded", refreshFailure: "Refresh failed; current records are preserved", + failure: "Could not read this source's research history", + time: "Recorded time (local)", partialTime: "Loaded records (local time)", unknown: "Time not recorded", record: "record", records: "records", evidence: "Recorded evidence", interpretation: "Independent checking and actual adoption are unverified", versions: "Source and version references", updates: "History", changes: "records", question: "question", questions: "questions", links: "Explicit relationships", missing: "Some linked source nodes are not recorded", group: "Source grouping", lost: "The selected node is absent from this snapshot. Other history remains readable.", @@ -276,9 +280,9 @@ export function GoalResearchHistory({goalId, originalGoalId = goalId, preferHist url.searchParams.set("research_lens", next); if (nextPhase === null) url.searchParams.delete("research_phase"); else url.searchParams.set("research_phase", String(nextPhase)); - if (next !== lens || nextPhase !== phase) window.history.pushState(null, "", url); + if (next !== lens || nextPhase !== requestedPhase) window.history.pushState(null, "", url); }; - const chooseLens = (next: Lens) => chooseView(next, phase); + const chooseLens = (next: Lens) => chooseView(next, requestedPhase); const fullTime = (at: number, precise = false) => new Intl.DateTimeFormat(zh ? "zh-CN" : "en", { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false, second: precise ? "2-digit" : undefined, fractionalSecondDigits: precise ? 3 : undefined, @@ -444,7 +448,7 @@ export function GoalResearchHistory({goalId, originalGoalId = goalId, preferHist : null; const historyView = !value ? null : lens === "timeline" && timeline ?
- select({nodes: [], day}, true, button)} phase={phase} onPhase={next => chooseView(lens, next)} onSelect={(id, finding, event) => choose(id, true, finding, undefined, event)} onSelectMany={(nodes, button) => select({nodes, day: null}, true, button)} timeLabel={fullTime} dayLabel={dayTime}/>
:
@@ -553,19 +557,22 @@ export function GoalResearchHistory({goalId, originalGoalId = goalId, preferHist
{!emptySourceHistory ?
-
{emptyWithAnalysis ? <>{emptyHistoryMessage} · {read.events.length} / {read.total} +
{emptyWithAnalysis ? <>{emptyHistoryMessage} · {read.events.length} / {read.total}

{zh ? "已保存的分析、原生测量与 Explore 历程分别读取。这个 Goal 没有已记录的 Explore 历程;已保存的结果仍可在下方阅读。" : "Saved analysis, native measurements and Explore history are read separately. This Goal has no recorded Explore history; its saved results remain readable below."}

-
: <>{copy.time}{value?.startAt ? <> · — : null} · {busy ? `${copy.reading} ${loaded}${read.total ? ` / ${read.total}` : ""}` + : <>{!read.complete && read.events.length ? copy.partialTime : copy.time}{value?.startAt ? <> · — : null} · {busy ? `${copy.reading} ${loaded}${read.total ? ` / ${read.total}` : ""}` : read.total === null ? (zh ? "来源尚未读到" : "Source has not been read") - : `${read.events.length} / ${read.total} ${recordUnit}${read.complete ? "" : ` · ${copy.partial}`}`}}
+ : `${read.events.length} / ${read.total} ${recordUnit}`}}
{!readingSelection ? displayControl : null}
: null} {readFailure ?
-
{read.events.length ? (zh ? "刷新失败,当前记录已保留" : "Refresh failed; current records are preserved") : copy.failure} +
{read.events.length ? (read.complete ? copy.refreshFailure : copy.partialFailure) : copy.failure}

{readFailure}

- +
: null} {emptySourceHistory ? <>
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-timeline.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-timeline.tsx index 610115a35e..5dbf027af0 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-timeline.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-timeline.tsx @@ -11,7 +11,7 @@ import {ContextHelp} from "./context-help"; * Position means first record time only; it never encodes progress or cause. */ type Copy = Record; type Props = { - timeline: ExploreTimeline; zh: boolean; selectedIds: readonly string[]; selectedEventId?: string; phase: number | null; + timeline: ExploreTimeline; complete: boolean; zh: boolean; selectedIds: readonly string[]; selectedEventId?: string; phase: number | null; selectedDay: string | null; dayGroups: ReturnType["groups"]; onGroupSelect: (day: string, button: HTMLButtonElement) => void; onSelect: (id: string, finding?: string, event?: string) => void; onSelectMany: (ids: string[], button: HTMLButtonElement) => void; onPhase: (index: number | null) => void; timeLabel: (value: number, precise?: boolean) => string; dayLabel: (value: number) => string; @@ -37,7 +37,7 @@ function elapsedLabel(offset: number, zh: boolean) { return parts.join(" ") || (zh ? "0 秒" : "0s"); } -export function GoalResearchTimeline({timeline, zh, selectedIds, selectedEventId, selectedDay, dayGroups, onGroupSelect, phase, onSelect, onSelectMany, onPhase, timeLabel, dayLabel}: Props) { +export function GoalResearchTimeline({timeline, complete, zh, selectedIds, selectedEventId, selectedDay, dayGroups, onGroupSelect, phase, onSelect, onSelectMany, onPhase, timeLabel, dayLabel}: Props) { const plot = useRef(null); const [plotWidth, setPlotWidth] = useState(0); useLayoutEffect(() => { @@ -54,7 +54,8 @@ export function GoalResearchTimeline({timeline, zh, selectedIds, selectedEventId early: "前段", middle: "中段", late: "后段", item: "个有记录的研究项", items: "个有记录的研究项", question: "个问题", questions: "个问题", decisions: "显式决策", none: "未记录显式决策", changed: "变化", stopped: "停止", retained: "保留", unstructured: "决策未结构化", unstructuredHint: "结论未按 writeback 结构记录,无法拆出依据与决策。", - density: "记录密度", densityHint: "柱高为事件数;浅红为无记录空档", axis: "自首条记录起", + density: "记录密度", densityHint: "柱高为事件数;浅红为无记录空档", axis: "自首条记录起", gap: "无记录", + partialAxis: "自最早的已读记录起", partialDensityHint: "柱高为已读事件数", partialGap: "无已读记录", routes: "研究路线", routeHint: "仅由显式关联或父子分组相连", singles: "未关联研究项", trail: "决策轨迹", more: "显示更早记录", moreQuestions: "显示更多问题", record: "条决策记录", records: "条决策记录", unknownTime: "时间未知", all: "全部时段", span: "记录跨度", start: "起点", end: "末端", replan: "转向", continue: "继续", stop: "停止", wait: "等待", no_change: "无变化", ask_human: "请人决定", @@ -65,7 +66,8 @@ export function GoalResearchTimeline({timeline, zh, selectedIds, selectedEventId early: "Early", middle: "Middle", late: "Late", item: "research item with records", items: "research items with records", question: "question", questions: "questions", decisions: "Explicit decisions", none: "No explicit decision recorded", changed: "Changed", stopped: "Stopped", retained: "Retained", unstructured: "Unstructured decisions", unstructuredHint: "Findings were not recorded in writeback sections; evidence and decisions cannot be separated.", - density: "Recording density", densityHint: "Bar height is events; pale red is a recording gap", axis: "Since first record", + density: "Recording density", densityHint: "Bar height is events; pale red is a recording gap", axis: "Since first record", gap: "No records", + partialAxis: "Since earliest loaded record", partialDensityHint: "Bar height counts loaded events", partialGap: "No loaded records", routes: "Research routes", routeHint: "Connected only by recorded relationships or parent grouping", singles: "Unlinked research items", trail: "Decision history", more: "Show earlier records", moreQuestions: "Show more questions", record: "decision record", records: "decision records", unknownTime: "Unknown time", all: "Whole history", span: "Recorded span", start: "Start", end: "End", replan: "Replan", continue: "Continue", stop: "Stop", wait: "Wait", no_change: "No change", ask_human: "Ask human", @@ -75,6 +77,7 @@ export function GoalResearchTimeline({timeline, zh, selectedIds, selectedEventId }; const {start, end} = timeline; if (start === null || end === null) return

{copy.noTime}

; + const axisLabel = complete ? copy.axis : copy.partialAxis; const elapsed = end - start; const span = Math.max(1, elapsed); const pct = (at: number) => `${((at - start) / span) * 100}%`; @@ -131,24 +134,25 @@ export function GoalResearchTimeline({timeline, zh, selectedIds, selectedEventId }; return
-
{timeline.phases.map(phaseCard)}
+ {complete ?
{timeline.phases.map(phaseCard)}
: null}
{LABELS.map(key =>