From 8e36f0f7be8496115f27bcc9052e8b8e4eb170ce Mon Sep 17 00:00:00 2001 From: Lihua <1017343802@qq.com> Date: Sun, 18 Oct 2026 01:36:31 -0700 Subject: [PATCH] fix(quota): fence primary Monitor writes with scoped gates Signed-off-by: Lihua <1017343802@qq.com> --- .../quota-monitor-observation-receipt-v0.md | 14 +- .../quota/monitor_poll_commit.ts | 39 +++-- .../references/repair-patterns.md | 1 + .../test_settled_monitor_user_gate.py | 162 +++++++++++++++++- .../quota_monitor_poll_commit.test.ts | 1 + 5 files changed, 194 insertions(+), 23 deletions(-) diff --git a/docs/reference/protocols/quota-monitor-observation-receipt-v0.md b/docs/reference/protocols/quota-monitor-observation-receipt-v0.md index 395f10e124..25e5c022a7 100644 --- a/docs/reference/protocols/quota-monitor-observation-receipt-v0.md +++ b/docs/reference/protocols/quota-monitor-observation-receipt-v0.md @@ -19,19 +19,20 @@ advancement work remains active. `continuous_monitor` visible to the same Agent. Admission checks the Todo authority as well as the bounded decision projection, so a due monitor is not rejected merely because it falls outside the compact list. -- Under a scoped User gate, an auxiliary observation is admitted only when the +- Under a scoped User gate, a primary or auxiliary observation is admitted only when the existing typed dependency owner proves every addressed live gate independent of that exact Monitor. Overlapping, global, conflicting or unknown scopes fail closed. The CLI projection and effect admission use the same rule; the effect rechecks the current complete Todo snapshot, so a cached command cannot - bypass a newly blocking gate. New auxiliary provider plans also require a + bypass a newly blocking gate. A due Monitor work-lane projection alone cannot + override that dependency decision. New Monitor provider plans also require a commit-head dependency guard: canonical providers evaluate the same typed rule on the head used by the existing projection revision CAS; legacy observations evaluate it inside the existing Todo mutation lock. A blocking gate committed first prevents observation writeback, including changes after preflight. This dependency result grants no approval or lease. Exact committed receipts remain historical replay. Frozen plans predating the guard retain their - intended-effect identity, while any still-uncommitted auxiliary provider + intended-effect identity, while any still-uncommitted Monitor provider dispatch is upgraded to the current guard without rewriting its WAL. A receipt proves a historical outcome, never permission for a new mutation. Guarded canonical requests use `loopx_coordination_monitor_poll_request_v3`: an older receiver @@ -322,14 +323,15 @@ using a complete read-only snapshot with disposable File/SQLite/PostgreSQL arms. 的观察回执。准入同时检查 Todo 权威源与有界决策投影,不能仅因到期 monitor 位于精简列表之外就拒绝它。 - 存在 scoped User gate 时,仅当既有 typed 依赖 owner 证明所有面向该 Agent - 的有效 gate 都与该精确 Monitor 独立,才允许辅助观察。重叠、全局、冲突或未知 + 的有效 gate 都与该精确 Monitor 独立,才允许主监控或辅助观察。重叠、全局、冲突或未知 scope 均失败关闭。CLI 投影与 effect 准入复用同一规则;effect 按当前完整 Todo - 快照重新核验,旧命令不能绕过新出现的阻塞 gate。新的辅助 provider plan 还 + 快照重新核验,旧命令不能绕过新出现的阻塞 gate。到期 Monitor 的 work-lane + 投影本身不能覆盖该依赖结论。新的 Monitor provider plan 还 强制提交时的依赖 guard:canonical provider 在既有 projection revision CAS 所使用的同一个 head 上执行 typed 规则;legacy 观察在既有 Todo 写锁内核验。 阻塞 gate 先提交时,包括 preflight 后的变更,不得写入观察。依赖结论不授予 批准或租约。已提交回执仍按历史结果精确重放;引入 guard 前的冻结 plan 保留 - 原 effect 身份,仍未提交的辅助 provider dispatch 则升级到当前 guard,不改写 + 原 effect 身份,仍未提交的 Monitor provider dispatch 则升级到当前 guard,不改写 其 WAL。回执证明历史结果,不授权新 mutation。带 guard 的 canonical 请求使用 `loopx_coordination_monitor_poll_request_v3`;旧 receiver 拒绝该 schema,不能静默 忽略必要的 gate 校验。不带 guard 的既有 caller 保持原请求与回执身份。 diff --git a/loopx/control_plane/quota/monitor_poll_commit.ts b/loopx/control_plane/quota/monitor_poll_commit.ts index 49068b9c88..e8c3e31ddd 100644 --- a/loopx/control_plane/quota/monitor_poll_commit.ts +++ b/loopx/control_plane/quota/monitor_poll_commit.ts @@ -567,6 +567,18 @@ interface Admission { external: boolean; } +function monitorGateAllowsObservation(decision: MonitorDecision): boolean { + if (!decision.requires_user_action) return true; + const gateScope = decision.auxiliary_gate_scope; + const monitor = decision.registry_due_monitor; + return decision.safe_bypass_allowed && + decision.safe_bypass_kind === "scoped_user_gate_fallback" && + gateScope?.schema_version === "todo_gate_scope_projection_v0" && + gateScope.agent_id === decision.agent_id && gateScope.todo_id === monitor.todo_id && + gateScope.state === "independent" && typeof gateScope.gate_count === "number" && + Number.isInteger(gateScope.gate_count) && gateScope.gate_count > 0; +} + async function readMonitorSettlement(request: MonitorRequest): Promise { const todoId = request.observation.settlement_todo_id ?? request.observation.todo_id; // The Turn/effect and run record belong to the decision Agent; provider actor @@ -628,13 +640,7 @@ async function auxiliaryMonitorAllowed( const monitor = decision.registry_due_monitor; // Retain ordinary quota/due-work admission and capability/gate projections; // lifecycle lookup must not become a second should-run bypass. - const gateScope = decision.auxiliary_gate_scope; - const independentGate = decision.safe_bypass_allowed && - decision.safe_bypass_kind === "scoped_user_gate_fallback" && - gateScope?.schema_version === "todo_gate_scope_projection_v0" && - gateScope.agent_id === decision.agent_id && gateScope.todo_id === monitor.todo_id && - gateScope.state === "independent" && typeof gateScope.gate_count === "number" && Number.isInteger(gateScope.gate_count) && gateScope.gate_count > 0; - return (!decision.requires_user_action || independentGate) && dueMonitorAllowed(decision, observation) && + return monitorGateAllowsObservation(decision) && dueMonitorAllowed(decision, observation) && monitor.due === true && candidateMatches(monitor, observation) && (monitor.claimed_by == null || monitor.claimed_by === decision.agent_id); } @@ -664,6 +670,10 @@ async function admission(request: MonitorRequest, historicalAdmission = false): throw new EffectRuntimeRequestError("auxiliary monitor-poll requires its own due Monitor target", "monitor_poll_admission_rejected"); } + if (due && !monitorGateAllowsObservation(request.decision)) { + throw new EffectRuntimeRequestError("Monitor observation is blocked by current User gate dependencies", + "monitor_poll_admission_rejected"); + } if ( request.decision.effective_action !== EffectiveAction.MONITOR_QUIET_SKIP && !external && !due && !blocked @@ -940,9 +950,8 @@ function requestDigest(request: MonitorRequest): string { })); } -function providerPlanFor(request: MonitorRequest, gateGuard = Boolean( - request.observation.settlement_todo_id && request.observation.settlement_todo_id !== request.observation.todo_id -), legacyBatchVersion: 1 | null = null): MonitorProviderPlan { +function providerPlanFor(request: MonitorRequest, gateGuard = true, + legacyBatchVersion: 1 | null = null): MonitorProviderPlan { const resultHash = request.observation.result_hash; if (!resultHash) { throw new EffectRuntimeRequestError("monitor todo writeback requires --result-hash"); @@ -977,12 +986,10 @@ function providerPlanFor(request: MonitorRequest, gateGuard = Boolean( }; } -/** Upgrade uncommitted historical auxiliary dispatch without rewriting its WAL. +/** Upgrade uncommitted historical Monitor dispatch without rewriting its WAL. * Existing business receipts replay the same intended-effect identity first. */ -function providerDispatchPlan(request: MonitorRequest, plan: MonitorProviderPlan): MonitorProviderPlan { - return request.observation.settlement_todo_id && - request.observation.settlement_todo_id !== request.observation.todo_id - ? {...plan, gate_scope_guard: true} : plan; +function providerDispatchPlan(plan: MonitorProviderPlan): MonitorProviderPlan { + return {...plan, gate_scope_guard: true}; } function providerPlanObject(value: unknown): MonitorProviderPlan { @@ -2397,7 +2404,7 @@ async function evaluateQuotaMonitorPollRequest( { ok: true, dry_run: false, provider_required: true }, "quota monitor-poll Todo provider is required", currentDigest, - providerDispatchPlan(request, plan), + providerDispatchPlan(plan), ); } diff --git a/skills/loopx-self-repair/references/repair-patterns.md b/skills/loopx-self-repair/references/repair-patterns.md index 9ae06b7f2a..2051a59732 100644 --- a/skills/loopx-self-repair/references/repair-patterns.md +++ b/skills/loopx-self-repair/references/repair-patterns.md @@ -130,6 +130,7 @@ teaches a reusable control-plane lesson. | `agent_scoped_user_gate_overreach` | `quota should-run --agent-id ` returns `user_gate`, `requires_user_action=true`, or `delivery_allowed=false` because an open user todo exists, but that todo is scoped to `` through `blocks_agent` or `claimed_by`; agent A has an independent runnable todo or only needs its own workspace/scope repair. | quota payload with the same `--agent-id`, `user_todo_summary.first_open_items`, `blocks_agent`, `claimed_by`, `agent_lane_next_action`, active state user/agent todos, related interaction pattern. | User-gate projection treated agent-scoped routing metadata as diagnostic text instead of a hard scope boundary, so a target-agent gate became a global owner gate. | Filter other-agent `blocks_agent` / `claimed_by` user todos out of the current agent's blocking summary while preserving diagnostic visibility; if the raw state was `operator_gate` only because of that other-agent gate and the current agent has runnable work, project an eligible current-agent lane. Cover with `examples/control_plane/quota-agent-scoped-user-gate-smoke.py` and workspace-guard smoke coverage. | | `user_action_binding_projection_gap` | A non-blocking user action intended for one peer appears in another peer's heartbeat, or its scoped summary has `open_count=0` while inherited goal state still produces `operator_gate` / `NOTIFY`. | Active user-todo metadata, registered agents, agent-scoped quota summary and diagnostics, inherited status state, interaction contract user channel, todo mutation actor. | User actions had no first-class goal/agent binding, `claimed_by` was overloaded as routing, and quota filtered item details without clearing the upstream operator-gate state. | Require every multi-agent user todo to declare `bound_agent` or `goal_bound`; keep gate scope separate, make the bound agent the response-continuation lifecycle actor, filter other-lane details and counts, and override stale operator-gate state before interaction projection. Preserve `claimed_by` only as a read compatibility fallback and cover the no-current-work quiet lane. | | `same_agent_scoped_gate_due_monitor_gap` | A same-agent user gate has an exact `unblocks_todo_id` or narrow `decision_scope`, an unrelated capability-runnable continuous monitor is due, but quota returns `must_attempt=false` and blocks the whole agent lane. | quota payload `scoped_user_gate_fallback`, `agent_todo_summary.monitor_due_items`, capability gate, gate relation, linked todo, and current-agent monitor metadata. | Scoped-gate fallback considered only advancement candidates, so the separately projected due-monitor lane disappeared before dependency filtering. | Add the already scoped, due, capability-runnable monitor projection to scoped-gate dependency filtering and preserve normal todo priority ordering; keep linked work blocked and reject monitors with missing capabilities. Cover both positive and negative cases in `examples/control_plane/quota-agent-scoped-user-gate-smoke.py`. | +| `primary_monitor_gate_admission_gap` | A receipt-bound due Monitor has `should_run=false` and `delivery_allowed=false` under a linked, unknown or global User gate, but direct `quota monitor-poll --execute` still writes its result and cadence. | Exact guard and poll identities, the current complete gate/Monitor snapshot, canonical revision and unchanged/no-spend index evidence; reproduce on disposable real providers. | Primary admission reused due work-lane facts without the existing scoped dependency check, while provider plans guarded only auxiliary observations. | Reuse the typed Monitor dependency rule for primary and auxiliary admission; guard every new provider write against its commit snapshot. Upgrade only uncommitted old dispatch without changing intended-effect identity or rewriting its WAL. Keep committed receipt replay and independent/no-gate/source/global parity; qualify preflight races on real providers. | | `mixed_monitor_capability_projection_split` | One due monitor is capability-runnable and another is capability-blocked, but quota selects the runnable monitor while `capability_gate.action=skip`, the work-lane due count includes both, and the interaction contract claims every candidate is unavailable. | quota `selected_todo`, `work_lane_contract.monitor_due_items`, `agent_todo_summary.monitor_due_count`, capability-gate runnable/blocked candidates, and the same `--available-capability` inputs. | Capability filtering split due monitors into runnable and blocked projections, then the capability gate consumed only the blocked projection while the work-lane count recomputed from unfiltered monitor items. | Feed both runnable and blocked due-monitor projections into the capability gate, keep blocked diagnostics, and make projected runnable due count authoritative for the work lane. Cover a mixed private-read/network pair in `examples/control_plane/monitor-scheduler-contract-smoke.py`. | | `monitor_debt_advancement_starvation` | Overdue monitor catch-up produces repeated unchanged no-spend turns while same- or higher-priority advancement remains runnable; a scoped user-gate fallback may keep reselecting the monitor even after the ordinary work lane chose advancement. | current-agent run history excluding scheduler/accounting/state-refresh bookkeeping, monitor material-change flags, scoped fallback selection, work-lane reason codes, and Lark inbox urgency. | Monitor fairness was applied only to the ordinary work-lane contract; scoped fallback rebuilt and re-sorted candidates without the recent unchanged-monitor streak. | After two consecutive unchanged monitor-only work turns, prefer same- or higher-priority runnable advancement in every candidate-selection seam and expose compact arbitration evidence. Any non-monitor work or material monitor transition resets the streak; `lark_event_inbox.reply_due` still preempts all ordinary lanes. Cover scoped fallback starvation, terminal transition reset, and reply-due precedence. | | `unscoped_multi_agent_user_gate` | A peer sees a `user_gate` todo that belongs to another lane because the todo has no `blocks_agent` and no explicit `global_gate=true`; every `quota should-run --agent-id ...` call may project it as a current owner gate. | active-state user todo metadata, registered agents, quota `user_todo_summary`, current `--agent-id`, CLI authoring command, interaction contract user channel. | The authoring layer allowed open multi-agent user gates without machine scope, relying on prose and agent memory to infer which lane was blocked. | Reject new multi-agent `user_gate` writes unless they specify `blocks_agent` / `--agent-id` or explicit `global_gate=true` / `--global-gate`; make `loopx check` flag existing unscoped gates so old/manual state must be repaired. Cover with `examples/control_plane/todo-user-gate-scope-smoke.py`. | diff --git a/tests/control_plane/test_settled_monitor_user_gate.py b/tests/control_plane/test_settled_monitor_user_gate.py index a84e6cc050..f301ffedf9 100644 --- a/tests/control_plane/test_settled_monitor_user_gate.py +++ b/tests/control_plane/test_settled_monitor_user_gate.py @@ -10,7 +10,8 @@ AGENT_ID, GOAL_ID, _add_monitor, _write_fixture, ) from loopx.control_plane.testing.canary_harness import run_json_cli -from loopx.todos import add_goal_todo +from loopx.control_plane.testing.canary_harness import run_json_cli_result +from loopx.todos import add_goal_todo, list_goal_todos, update_goal_todo from loopx.control_plane.effect_program import ReceiptBoundMonitorPhase from loopx.control_plane.quota.should_run import build_quota_should_run from loopx.control_plane.testing.quota_fixtures import ( @@ -100,3 +101,162 @@ def call(*args): rows = [json.loads(line) for line in index.read_text(encoding="utf-8").splitlines()] assert sum(row["classification"] == "quota_monitor_poll" for row in rows) == 1 assert not any(row["classification"] in {"state_refreshed", "quota_slot_spent"} for row in rows) + + +def _primary_monitor_fixture(tmp_path, monkeypatch, provider, route="source"): + isolate_sqlite_runtime(tmp_path, monkeypatch) + source, runtime, _state = _write_fixture(tmp_path, provider=provider) + gated = add_goal_todo(registry_path=source, goal_id=GOAL_ID, role="agent", + text="Await a scoped owner decision", task_class="advancement_task", + status="blocked", claimed_by=AGENT_ID, agent_id=AGENT_ID) + gate = add_goal_todo(registry_path=source, goal_id=GOAL_ID, role="user", + text="Decide the dependent work", task_class="user_gate", bound_agent=AGENT_ID, + blocks_agent=AGENT_ID, unblocks_todo_id=gated["todo_id"], agent_id=AGENT_ID) + monitor = _add_monitor(source, text="Observe a synthetic public target", + target_key="primary-target", next_due_at="2000-01-01T00:00:00Z") + registry = source + if route == "global": + registry = runtime / "registry.global.json" + payload = json.loads(source.read_text()) + payload["registry_role"] = "global-local" + payload["goals"][0]["source_registry"] = str(source) + registry.write_text(json.dumps(payload)) + return source, registry, runtime, gated, gate, monitor + + +def _primary_monitor_args(command, turn, monitor): + return ["quota", command, "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, + "--runtime-profile", "generic_cli", "--turn-instance-id", turn, + "--available-capability", "network", "--available-capability", "external_evidence_poll", + *(["--todo-id", monitor["todo_id"], "--target-key", "primary-target", + "--result-hash", "primary-observation", "--execute"] if command == "monitor-poll" else [])] + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("route", ["source", "global"]) +@pytest.mark.parametrize("gate_kind", ["linked", "unknown", "global"]) +def test_primary_monitor_gate_rejects_new_write_but_preserves_committed_replay( + tmp_path, monkeypatch, provider, route, gate_kind, +): + source, registry, runtime, gated, gate, monitor = _primary_monitor_fixture( + tmp_path, monkeypatch, provider, route) + turn = "primary-monitor-gate-turn" + def call(command): + return run_json_cli_result(*_primary_monitor_args(command, turn, monitor), + registry_path=registry, runtime_root=runtime) + rc, admitted = call("should-run") + assert rc == 0 and admitted["should_run"] is True + assert admitted["heartbeat_receipt"]["settlement_identity"]["todo_id"] == monitor["todo_id"] + if gate_kind == "unknown": + add_goal_todo(registry_path=source, goal_id=GOAL_ID, role="user", + text="Decide an unspecified dependency", task_class="user_gate", + bound_agent=AGENT_ID, blocks_agent=AGENT_ID, agent_id=AGENT_ID) + else: + patch = {"unblocks_todo_id": monitor["todo_id"]} if gate_kind == "linked" else { + "global_gate": True, "clear_blocks_agent": True} + update_goal_todo(registry_path=source, goal_id=GOAL_ID, todo_id=gate["todo_id"], + agent_id=AGENT_ID, **patch) + rc, blocked = call("should-run") + assert rc == 0 and blocked["should_run"] is False + assert blocked["interaction_contract"]["agent_channel"]["delivery_allowed"] is False + before = list_goal_todos(registry_path=source, goal_id=GOAL_ID) + rc, denied = call("monitor-poll") + assert rc == 1 and denied["error_code"] == "monitor_poll_admission_rejected", denied + assert list_goal_todos(registry_path=source, goal_id=GOAL_ID) == before + index = runtime / "goals" / GOAL_ID / "runs" / "index.jsonl" + assert not index.exists() + if gate_kind != "linked": + return + update_goal_todo(registry_path=source, goal_id=GOAL_ID, todo_id=gate["todo_id"], + agent_id=AGENT_ID, unblocks_todo_id=gated["todo_id"], clear_global_gate=True) + rc, observed = call("monitor-poll") + assert rc == 0 and observed["ok"] is True, observed + update_goal_todo(registry_path=source, goal_id=GOAL_ID, todo_id=gate["todo_id"], + agent_id=AGENT_ID, unblocks_todo_id=monitor["todo_id"]) + committed = list_goal_todos(registry_path=source, goal_id=GOAL_ID) + rc, replay = call("monitor-poll") + assert rc == 0 and replay["replayed"] is True and replay["appended"] is False + assert list_goal_todos(registry_path=source, goal_id=GOAL_ID) == committed + rows = [json.loads(line) for line in index.read_text().splitlines()] + assert [row["classification"] for row in rows] == ["quota_monitor_poll"] + + +def _primary_record_invocation(registry, runtime, monitor, turn): + from loopx.quota import record_quota_monitor_poll + from loopx.status import collect_status + run_json_cli(*_primary_monitor_args("should-run", turn, monitor), + registry_path=registry, runtime_root=runtime) + status = collect_status(registry_path=registry, runtime_root_override=str(runtime), + scan_roots=[registry.parent.parent], limit=20, + goal_id=GOAL_ID, include_public_boundary_scan=False) + return lambda: record_quota_monitor_poll(status, goal_id=GOAL_ID, + registry_path=registry, execute=True, agent_id=AGENT_ID, + todo_id=monitor["todo_id"], target_key="primary-target", + result_hash="primary-observation", cadence="1h", turn_instance_id=turn, + receipt_bound_todo_id=monitor["todo_id"], + available_capabilities=["network", "external_evidence_poll"]) + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_primary_monitor_gate_change_after_preflight_prevents_business_write( + tmp_path, monkeypatch, provider, +): + from loopx.control_plane.quota import monitor_poll + source, registry, runtime, _gated, gate, monitor = _primary_monitor_fixture( + tmp_path, monkeypatch, provider) + invoke = _primary_record_invocation(registry, runtime, monitor, "primary-gate-race") + original = monitor_poll._provider_writeback + def interleaved(plan, **kwargs): + update_goal_todo(registry_path=source, goal_id=GOAL_ID, + todo_id=gate["todo_id"], agent_id=AGENT_ID, + unblocks_todo_id=monitor["todo_id"]) + return original(plan, **kwargs) + monkeypatch.setattr(monitor_poll, "_provider_writeback", interleaved) + denied = invoke() + assert denied["ok"] is False and denied["error_code"] == "monitor_poll_rejected", denied + assert "current User gate dependencies" in denied["reason"] + current = list_goal_todos(registry_path=source, goal_id=GOAL_ID, + todo_id=monitor["todo_id"])["todo"] + assert current.get("result_hash") is None + assert current["next_due_at"] == "2000-01-01T00:00:00Z" + assert not (runtime / "goals" / GOAL_ID / "runs" / "index.jsonl").exists() + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("business_committed", [False, True]) +def test_old_primary_monitor_plan_keeps_identity_without_new_gate_authority( + tmp_path, monkeypatch, provider, business_committed, +): + from loopx.control_plane.quota import monitor_poll + source, registry, runtime, _gated, gate, monitor = _primary_monitor_fixture( + tmp_path, monkeypatch, provider) + invoke = _primary_record_invocation(registry, runtime, monitor, "primary-gate-recovery") + original = monitor_poll._provider_writeback + def interrupted(plan, **kwargs): + if business_committed: + original(plan, **kwargs) + pending = list((runtime / "goals" / GOAL_ID / "runs/.transactions/quota-monitor-poll").glob("*.json")) + assert len(pending) == 1 + # Compatibility fixture for a pre-guard primary Monitor reservation. + receipt = json.loads(pending[0].read_text()) + receipt["provider_plan"].pop("gate_scope_guard") + pending[0].write_text(json.dumps(receipt)) + raise ValueError("synthetic response interruption") + monkeypatch.setattr(monitor_poll, "_provider_writeback", interrupted) + assert invoke()["ok"] is False + monkeypatch.setattr(monitor_poll, "_provider_writeback", original) + update_goal_todo(registry_path=source, goal_id=GOAL_ID, + todo_id=gate["todo_id"], agent_id=AGENT_ID, unblocks_todo_id=monitor["todo_id"]) + recovered = invoke() + if business_committed: + assert recovered["ok"] is True, recovered + assert invoke()["replayed"] is True + else: + assert recovered["ok"] is False and recovered["error_code"] == "monitor_poll_rejected", recovered + assert "current User gate dependencies" in recovered["reason"] + current = list_goal_todos(registry_path=source, goal_id=GOAL_ID, + todo_id=monitor["todo_id"])["todo"] + assert current.get("result_hash") is None + index = runtime / "goals" / GOAL_ID / "runs" / "index.jsonl" + rows = [json.loads(line) for line in index.read_text().splitlines()] if index.exists() else [] + assert [row["classification"] for row in rows] == (["quota_monitor_poll"] if business_committed else []) diff --git a/tests/control_plane_ts/quota_monitor_poll_commit.test.ts b/tests/control_plane_ts/quota_monitor_poll_commit.test.ts index 8f252981bf..fec2380c7f 100644 --- a/tests/control_plane_ts/quota_monitor_poll_commit.test.ts +++ b/tests/control_plane_ts/quota_monitor_poll_commit.test.ts @@ -442,6 +442,7 @@ test("Todo commit journals provider intent before writeback and resumes exactly assert.equal(preflight.status, "provider_required"); assert.deepEqual(preflight.provider_plan, { schema_version: "monitor_poll_todo_provider_plan_v0", + gate_scope_guard: true, monitor_effect_id: "quota-monitor-poll:todo-turn", goal_id: goalId, generated_at: "2026-08-27T12:00:00+08:00",