From fa6d16d323620437e6dcd0bd200c0d89c32d69c0 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Fri, 30 Oct 2026 00:43:10 +0800 Subject: [PATCH] fix(quota): qualify complete automatic wait frontiers Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../typescript-control-plane-migration-v0.md | 10 +++ ...script-control-plane-migration-v0.zh-CN.md | 7 ++ .../goal-vision-replan-contract-v0.md | 14 ++++ .../goals/vision_wait_coverage.ts | 52 ++++++++++++- .../work_items/interaction_contract.py | 10 ++- .../work_items/interaction_contract.ts | 42 +++-------- .../references/repair-patterns.md | 1 + .../test_dated_vision_wait_cli.py | 75 +++++++++++++++++++ .../interaction_contract.test.ts | 1 + .../vision_wait_coverage.test.ts | 58 +++++++++++++- 10 files changed, 231 insertions(+), 39 deletions(-) diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md index 7e4643039f..77d425f6c5 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md @@ -974,6 +974,16 @@ this slice reduces semantic owners, not crossing count. Native transactions stay in-process. Fold the remaining crossings into that complete transaction rather than extending these adapters field by field. +The automatic-wait read model now qualifies all canonical witnesses before +display compaction. `pending_resume_count` extends the existing quiet path only +when it covers every waiting successor; clock-only frontiers use the earliest +deadline. The interaction adapter reuses the scoped notice-only classifier so +an optional user action does not replace that proven wait. Actual gates, +unknown sources, Turn bindings and strict monitor-poll identity checks remain. +File/SQLite CLI initial/replay, preserved state and no-debit checks qualify +this source slice; installed adoption and long-running Host behavior remain +separate acceptance. + The waiting/resume planning slice now uses `todos/resume_planning.ts` for the complete deferred, resume-blocked, monitor-repair and blocked-successor selection. Quota composes capacity evaluation with these lanes in one request per source summary, reusing the diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md index fe74828ebd..daf0138f69 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md @@ -732,6 +732,13 @@ ownership-holder 路由及 native complete/supersede 统一到 语义 owner,不宣称减少 crossings,native transaction 仍进程内调用。下一步将这些 crossing 一起折叠进完整事务,不能沿着 adapter 逐字段继续加桥。 +自动等待读模型现在在展示压缩前校验全部 canonical witness;只有 +`pending_resume_count` 覆盖每个等待后继时,才扩展既有安静等待路径。纯时钟 frontier +使用最早期限。交互适配层复用按 Agent 过滤的 notice-only 分类,避免可选用户事项 +取代已验证的等待。真实门槛、未知来源、Turn 绑定和 monitor-poll 的严格身份检查 +保持。File/SQLite CLI 的首次与重放、状态保留和不扣费检查只限定本源码切片; +安装采用及长期 Host 行为仍须分别验收。 + 等待/恢复规划现由 `todos/resume_planning.ts` 一次完成 deferred、resume-blocked、 monitor-repair 和 blocked-successor 选择。Quota 为每个 source summary 将容量条件与这些 lane 合为一个请求, 在 TS 进程内复用既有 resume evaluator;vision-wait、agent-scope、frontier、replan diff --git a/docs/reference/protocols/goal-vision-replan-contract-v0.md b/docs/reference/protocols/goal-vision-replan-contract-v0.md index 9daef0adce..baf92ac129 100644 --- a/docs/reference/protocols/goal-vision-replan-contract-v0.md +++ b/docs/reference/protocols/goal-vision-replan-contract-v0.md @@ -1358,6 +1358,20 @@ advances the original generation fence so the successor can resume. Goal stop, user gates and independently runnable advancement retain their existing priority. +For multiple automatic successors, the existing TypeScript wait owner checks +every canonical witness before display compaction and exposes +`pending_resume_count`. Only a complete match to `waiting_todo_count` can extend +the positively qualified quiet path beyond one successor. The displayed ids +remain bounded; they are not used to count or certify unseen sources. A +clock-only frontier selects its earliest pending deadline for rechecks; a +pending dependency retains periodic source rechecks. Missing or unsupported +source proof, a foreign actor, pending action selection and an existing +settlement binding retain supervision. An optional `user_action` notice does +not turn this proven automatic wait into a user gate; actual gates retain +their existing behavior. An unbound heartbeat is not told to execute +`monitor-poll`, whose Todo-binding checks remain unchanged. Replaying that +heartbeat preserves its original receipt without creating work or a debit. + This is a read model, not a stored vision or todo lifecycle state. When the condition becomes ready, normal open-todo or deferred-successor routing resumes automatically and the active vision remains available for acceptance auditing. diff --git a/loopx/control_plane/goals/vision_wait_coverage.ts b/loopx/control_plane/goals/vision_wait_coverage.ts index 94c3b29e49..c4c2190fed 100644 --- a/loopx/control_plane/goals/vision_wait_coverage.ts +++ b/loopx/control_plane/goals/vision_wait_coverage.ts @@ -1,13 +1,43 @@ import type { JsonObject } from "../effect_program.ts"; import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts"; -import { optionalNonEmptyString, requireInteger, requireJsonObject, requireStringArray } from "../runtime_decode.ts"; +import { jsonObject, optionalNonEmptyString, requireInteger, requireJsonObject, requireStringArray } from "../runtime_decode.ts"; import { projectTodoResumePlanning } from "../todos/resume_planning.ts"; +import { resumeConditionHasKnownPendingTarget } from "../todos/resume_condition.ts"; +import { parseTodoTimestampMicros } from "../runtime_timestamp.ts"; +import { monitorIsActive } from "../todos/summary_lanes.ts"; const WAIT_STATE_SCHEMA = "goal_vision_wait_state_v0"; const VISION_GAP = "vision_acceptance_gap"; const CAUSAL_ID_KEYS = ["todo_id", "current_todo_id", "blocked_todo_id", "completed_todo_id", "successor_todo_id", "vision_todo_ids", "lineage_todo_ids", "successor_todo_ids", "completed_todo_ids"] as const; +function hasScheduledMonitorSource(condition: JsonObject): boolean { + const time = (value: unknown): number | null => { + const micros = typeof value === "string" ? parseTodoTimestampMicros(value) : null; + return micros === null ? null : Number(micros) / 1_000_000; + }; + const observed = time(condition.evaluated_at), due = time(condition.target_next_due_at); + const expiry = time(condition.target_expires_at); + if (condition.clock_provider !== "runtime_clock" || observed === null || due === null || + !(condition.target_archive_state === null || condition.target_archive_state === "active") || + condition.target_expires_at !== null && expiry === null) return false; + return monitorIsActive({expiresAt: expiry}, observed) && monitorIsActive({expiresAt: expiry}, due); +} + +/** Qualify each current source before its wait is compacted for a consumer. */ +export function pendingAutomaticResumeCondition(todo: JsonObject, agentId: string): JsonObject | null { + const condition = jsonObject(todo.resume_condition); + if (!normalizedTodoId(todo.todo_id) || !["open", "deferred"].includes(String(todo.status)) || + !(todo.claimed_by == null || todo.claimed_by === "" || todo.claimed_by === agentId) || + !condition || !["resume_at", "todo_done", "monitor_changed"].includes(String(condition.kind))) return null; + try { + return resumeConditionHasKnownPendingTarget(condition, todo) && + (condition.kind !== "monitor_changed" || hasScheduledMonitorSource(condition)) ? condition : null; + } catch { + return null; + } +} + /** Every causal obligation needs its own witness; shared prerequisites do not cover sibling work. */ export function projectVisionWaitCoverage(requestPayload: unknown): JsonObject { const request = requireJsonObject(requestPayload, "vision wait coverage"); @@ -161,14 +191,28 @@ function blockerReasonField(selectedTodo: JsonObject): JsonObject { function waitStatePayload(input: {causalIds: string[]; gapCount: number; selectedTodo: JsonObject; waitItems: JsonObject[]; agentId: string | null; automatic: boolean}): JsonObject { const {causalIds, gapCount, selectedTodo, waitItems, agentId, automatic} = input; + const pending = automatic && agentId ? waitItems.map(todo => ({todo, + condition: pendingAutomaticResumeCondition(todo, agentId)})).filter( + (source): source is {todo: JsonObject; condition: JsonObject} => source.condition !== null) : []; + const pendingCount = pending.length; + let selected = selectedTodo; + if (pendingCount === waitItems.length && pendingCount > 0) { + // A dependency needs periodic source rechecks. For a clock-only frontier, + // use its earliest deadline rather than the first priority-sorted row. + selected = (pending.find(source => source.condition.kind !== "resume_at") ?? pending.reduce((first, source) => { + const due = parseTodoTimestampMicros(String(source.condition.scheduled_for)); + const prior = parseTodoTimestampMicros(String(first.condition.scheduled_for)); + return due !== null && prior !== null && due < prior ? source : first; + })).todo; + } const payload: JsonObject = { schema_version: WAIT_STATE_SCHEMA, state: "waiting", reason_code: automatic ? "exact_blocked_successor" : "current_agent_blocker", agent_id: agentId, waiting_todo_count: waitItems.length, waiting_todo_ids: waitItems.slice(0, 5).map(candidate => normalizedTodoId(candidate.todo_id)).filter((id): id is string => id !== null), - selected_todo_id: normalizedTodoId(selectedTodo.todo_id), selected_todo_status: selectedTodo.status ?? null, - selected_todo_priority: selectedTodo.priority ?? null, selected_todo_claimed_by: selectedTodo.claimed_by ?? null, - ...(automatic ? automaticResumeFields(selectedTodo) : blockerReasonField(selectedTodo)), + selected_todo_id: normalizedTodoId(selected.todo_id), selected_todo_status: selected.status ?? null, + selected_todo_priority: selected.priority ?? null, selected_todo_claimed_by: selected.claimed_by ?? null, + ...(automatic ? {...automaticResumeFields(selected), pending_resume_count: pendingCount} : blockerReasonField(selected)), causal_todo_ids: causalIds, deferred_acceptance_gap_count: gapCount, deferred_acceptance_gap_kinds: [VISION_GAP], automatic_resume: automatic, resume_behavior: automatic diff --git a/loopx/control_plane/work_items/interaction_contract.py b/loopx/control_plane/work_items/interaction_contract.py index a0d04232fe..5be7511830 100644 --- a/loopx/control_plane/work_items/interaction_contract.py +++ b/loopx/control_plane/work_items/interaction_contract.py @@ -39,7 +39,7 @@ normalize_todo_replan_obligation_id, ) from ..todos.todo_semantics import todo_item_task_class -from ..todos.user_gate import open_todo_count +from ..todos.user_gate import has_only_open_user_actions, open_todo_count from ..todos.write_hint import build_capability_resolution_writeback_actions from .autonomous_replan_obligation import ( build_autonomous_replan_cli_actions, @@ -168,6 +168,14 @@ def user_channel_action_required(payload: dict[str, Any]) -> bool: return False if _user_gate_notification_suppressed(payload): return False + if ( + has_only_open_user_actions(payload.get("user_todo_summary")) + and exact_blocked_successor_wait_state(payload) + and not _blocked_successor_wait_observation_required(payload) + ): + # A proven automatic wait is existing Agent work, not an empty lane + # owned by an unrelated optional notice. Keep actual gates unchanged. + return False obligation = payload.get("autonomous_replan_obligation") if isinstance(obligation, dict) and obligation.get("required") is True: # An active replan obligation owns the frontier; a non-blocking user diff --git a/loopx/control_plane/work_items/interaction_contract.ts b/loopx/control_plane/work_items/interaction_contract.ts index 75122b1de3..4bf0ff4c6b 100644 --- a/loopx/control_plane/work_items/interaction_contract.ts +++ b/loopx/control_plane/work_items/interaction_contract.ts @@ -18,9 +18,7 @@ import { ENVELOPED_SHA256_PATTERN } from "../content_digest.ts"; import type { JsonObject } from "../effect_program.ts"; import { AGENT_SCOPE_FRONTIER_ACTIONS, AgentScopeFrontierAction } from "../agents/agent_scope_frontier.generated.ts"; -import { resumeConditionHasKnownPendingTarget } from "../todos/resume_condition.ts"; -import { parseTodoTimestampMicros } from "../runtime_timestamp.ts"; -import { monitorIsActive } from "../todos/summary_lanes.ts"; +import { pendingAutomaticResumeCondition } from "../goals/vision_wait_coverage.ts"; import {compactTodoContextSource} from "../quota/context_delivery.ts"; function exactBlockedSuccessorWait(request: JsonObject, monitorWait = false): JsonObject | null { @@ -34,43 +32,21 @@ function exactBlockedSuccessorWait(request: JsonObject, monitorWait = false): Js return exact && wait ? wait : null; } -function hasScheduledMonitorSource(condition: JsonObject): boolean { - const time = (value: unknown): number | null => { - const micros = typeof value === "string" ? parseTodoTimestampMicros(value) : null; - return micros === null ? null : Number(micros) / 1_000_000; - }; - const observed = time(condition.evaluated_at), due = time(condition.target_next_due_at); - const expiry = time(condition.target_expires_at); - if (condition.clock_provider !== "runtime_clock" || observed === null || due === null || - !(condition.target_archive_state === null || condition.target_archive_state === "active") || - condition.target_expires_at !== null && expiry === null) return false; - // A Monitor must still be able to reach a poll. The ordinary quota owner - // retains due-work priority; an expired or unscheduled source cannot keep - // an unchanged external worker alive indefinitely. - return monitorIsActive({expiresAt: expiry}, observed) && monitorIsActive({expiresAt: expiry}, due); -} - /** Share the resume owner's source proof; a Turn binding retains supervision. */ function pendingAutomaticSuccessor(request: JsonObject, wait: JsonObject): JsonObject | null { const receipt = jsonObject(request.heartbeat_receipt); const unbound = request.heartbeat_receipt == null || (!!receipt && ["committed", "replayed"].includes(String(receipt.status)) && !("settlement_identity" in receipt) && !("pending_action_selection" in receipt)); - const condition = jsonObject(wait.resume_condition); + const count = wait.waiting_todo_count; + const complete = typeof count === "number" && Number.isSafeInteger(count) && count > 0 && + (wait.pending_resume_count === undefined ? count === 1 : wait.pending_resume_count === count); if (unbound && typeof request.agent_id === "string" && !!request.agent_id && - wait.agent_id === request.agent_id && wait.waiting_todo_count === 1 && - ["open", "deferred"].includes(String(wait.selected_todo_status)) && - (wait.selected_todo_claimed_by == null || wait.selected_todo_claimed_by === "" || - wait.selected_todo_claimed_by === request.agent_id) && - condition && ["resume_at", "todo_done", "monitor_changed"].includes(String(condition.kind))) { - try { - if (resumeConditionHasKnownPendingTarget(condition, { - todo_id: wait.selected_todo_id, resume_when: wait.resume_when, - resume_monitor_generation: wait.resume_monitor_generation, - }) && (condition.kind !== "monitor_changed" || hasScheduledMonitorSource(condition))) return condition; - } catch { - // An incomplete or malformed source proof cannot relax the contract. - } + wait.agent_id === request.agent_id && complete) { + return pendingAutomaticResumeCondition({todo_id: wait.selected_todo_id, + status: wait.selected_todo_status, claimed_by: wait.selected_todo_claimed_by, + resume_when: wait.resume_when, resume_monitor_generation: wait.resume_monitor_generation, + resume_condition: wait.resume_condition}, request.agent_id); } return null; } diff --git a/skills/loopx-self-repair/references/repair-patterns.md b/skills/loopx-self-repair/references/repair-patterns.md index 0f61442347..88cc509b8f 100644 --- a/skills/loopx-self-repair/references/repair-patterns.md +++ b/skills/loopx-self-repair/references/repair-patterns.md @@ -82,6 +82,7 @@ teaches a reusable control-plane lesson. | `deep_doctor_install_contract_mismatch` | `doctor --deep` passes runtime semantics for an installed wheel but still fails a source-wrapper path, source-root identity check, or ownership check against another LoopX found on PATH. | Doctor install kind, actual invocation, PATH-default command, imported distribution ownership, wheel `RECORD` console-script row, representative installed module paths, source snapshot checks, and a conflicting-PATH clean-wheel deep-doctor run. | Deep validation reused the release-snapshot promotion contract for every installation shape, then initial wheel repair also treated the PATH-default command as the active distribution invocation. | Select the deep-validation contract by proven install kind. Keep source snapshot promotion checks and PATH diagnostics unchanged; for a Python distribution, match the actual invocation to the distribution-recorded console script and validate only packaged paths. Recognize native console-script names, retain the cross-install negative check, and cover conflicting PATH in a clean-wheel `doctor --deep` release smoke. | | `settlement_identity_failure_phase_leak` | Settlement identity validation fails, but later unguarded poll/writeback/spend facts still produce a terminal monitor or replay phase and suppress required work. | Exact guard binding and effect id, requested identity, identity failure, later same-turn facts, projected monitor/replay phase, and work-lane obligation. | An aggregate read transaction treated the requested identity as phase authority after the guard-owned identity had already failed validation. | Gate every decision-driving phase and durable fact projection on the successfully validated identity. On missing or mismatched guard, return null phases and no unverified settlement facts; cover wrong/missing guard plus an otherwise complete later fact chain with a negative regression. | | `receipt_bound_monitor_settlement_replay_gap` | A same-turn monitor replay remains `settlement_pending` and `must_attempt=true` after the exact poll; a material poll is incorrectly told to append accountable refresh and quota-spend receipts even though monitor observation is a no-spend closeout. | Heartbeat receipt identity, exact monitor-poll row and commit effect, `material_change`, replayed work-lane phase, run classifications, and next-turn successor selection. | Receipt binding preserved the original monitor Todo, but phase reduction treated a material observation as delivery and waited for a second refresh/spend chain. That contradicts the monitor-poll authority, which atomically writes the observation and optional independent successor. | Resolve the phase from the exact poll commit: no poll keeps ordinary due selection; any committed unchanged or material poll is settled by itself. Project a typed settled quiet turn with no repoll, accountable refresh or spend, keep the automation active, and select the independent successor only under a fresh turn identity. | +| `multiple_automatic_wait_observation_gap` | An unbound heartbeat with several pending automatic successors requires a wait observation, but monitor-poll rejects the missing Todo binding; an optional user notice can also mask the automatic wait. | Canonical wait witnesses before compaction, source conditions and counts, exact unbound receipt, interaction channels, scheduler disposition and unchanged Todo/accounting readback. | The quiet-wait proof qualified only one successor, while the empty-lane notice fallback treated retained waiting work as absent. | Qualify every canonical witness through the existing resume owner before counting or compacting; extend quiet waiting only when the complete pending count matches. Use the earliest clock deadline, keep dependency rechecks, actual gates and all receipt fences, and leave monitor-poll binding strict. Cover File/SQLite initial and same-Turn replay, more witnesses than display rows, optional notices, unknown sources and independent/due work. | | `same_turn_due_monitor_selection_binding_gap` | A heartbeat commits an unbound pre-due receipt, the monitor becomes due in the same Turn, and explicitly selecting that exact due Todo returns `heartbeat_receipt.status=write_failed`; monitor-poll then cannot settle because the receipt is still unbound. | Original Turn receipt, requested and freshly selected Todo ids, due-monitor/capability projection, execution and interaction obligations, receipt events, and unchanged monitor-poll replay. | Explicit selection qualification recognized only `pending_action_selection`. A newly due monitor was already the authoritative automatic selection, so it lacked that marker and its exact id was misclassified as a stale competing choice. | Allow the exact currently selected capability-ready required obligation to bind the identity-less receipt after full live-decision arbitration, while a different stale request still fails closed. Project the committed selection as `heartbeat_receipt`, settle an unchanged poll with no spend, and prove replay idempotency. | | `opaque_revision_observation_gap` | An event-gated read model returns a business snapshot and opaque source revision that each look valid, yet a source change during the read is never refreshed; runtime-only events may also consume the cursor past an unchecked business candidate. | Pre/post source revisions, exact Session cursor, candidate and runtime event order, returned snapshot, expected binding, and business-reader call counts. | The snapshot and revision were captured in different source generations, or runtime delivery advanced the cursor before source reconciliation. | Capture the binding-scoped revision before and after the business read and publish only a stable pair; retry or fail closed on drift. Reconcile source candidates before runtime-only delivery, bind the exact expected identity into the revision, reject cursors beyond the exact Session log, and cover mid-read replacement plus simultaneous candidate/runtime races. | | `task_lease_generation_aba` | A lease is released or expires, the same todo is acquired again, and an older execution key/version can renew, release, or complete the new owner generation; a remote provider may show healthy monotonic storage generations while the LoopX fence repeats. | First and second acquire payloads, terminal lease record, full `(idempotency_key, version, lease_epoch)` fences, stale writeback result, provider generation, and release/re-acquire crash boundary. | Terminal release deleted the only per-todo generation record, so acquire rebuilt version 1; key-only mutation also made the CAS version optional and let execution-key reuse bypass no-ABA. | Keep provider generation opaque. Under the existing per-goal lock, atomically retain one inactive terminal lease record; advance authority-owned `lease_epoch` only for a new ownership generation and keep CAS `version` monotonic across every mutation. Require the current key/version pair for renew, transfer, release, and terminal writeback, reject immediate reuse of a retired key, and cover local CLI plus a live provider chain. | diff --git a/tests/control_plane/test_dated_vision_wait_cli.py b/tests/control_plane/test_dated_vision_wait_cli.py index b839bb150c..5e0bad4753 100644 --- a/tests/control_plane/test_dated_vision_wait_cli.py +++ b/tests/control_plane/test_dated_vision_wait_cli.py @@ -136,6 +136,58 @@ def test_future_vision_wait_is_quiet_without_binding_or_poll(tmp_path, isolated_ assert not any(run.get("classification") in ("quota_monitor_poll", "quota_slot_spent") for run in runs) +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("optional_notice", [False, True]) +def test_multiple_future_waits_keep_the_original_unbound_turn_quiet( + tmp_path, isolated_effect, monkeypatch, provider, optional_notice, +): + registry, runtime, state = fixture(tmp_path) + waiting_ids = [TODO, *(f"todo_scheduled_{index}" for index in range(6))] + with state.open("a", encoding="utf-8") as output: + for todo_id in waiting_ids[1:]: + output.write( + f"\n- [ ] [P1] Retain scheduled work {todo_id}.\n" + f" \n" + ) + if optional_notice: + output.write( + "\n## User Todo / Owner Review Reading Queue\n\n" + "- [ ] [P2] Review the optional public draft.\n" + f" \n" + ) + vision = runtime / f"goals/{GOAL}/runs/vision.json" + run = json.loads(vision.read_text()) + run["agent_vision"]["todo_delta"] = [f"retain:{todo_id}" for todo_id in waiting_ids] + vision.write_text(json.dumps(run), encoding="utf-8") + (vision.parent / "index.jsonl").write_text( + json.dumps({**run, "json_path": str(vision)}) + "\n", encoding="utf-8", + ) + if provider == "sqlite": + monkeypatch.setenv("NODE_OPTIONS", os.environ.get("NODE_OPTIONS", "") + " --experimental-sqlite") + initialize_canonical_source_fixture(runtime, GOAL, state_path=state, provider=provider) + original = state.read_bytes() + args = ("quota", "should-run", "--codex-app", "--goal-id", GOAL, + "--agent-id", AGENT, "--turn-instance-id", "multiple-scheduled-waits") + for _ in range(2): + rc, guard = cli(registry, runtime, *args) + assert rc == 0, guard + assert guard["effective_action"] == "agent_scope_wait" + assert guard["vision_wait_state"]["waiting_todo_count"] == len(waiting_ids) + assert "settlement_identity" not in guard["heartbeat_receipt"] + contract = guard["interaction_contract"] + assert contract["agent_channel"]["must_attempt"] is False, contract + assert contract["agent_channel"]["quiet_noop_allowed"] is True, contract + assert contract["agent_channel"]["delivery_allowed"] is False + assert contract["user_channel"]["action_required"] is False + assert not any("monitor-poll" in action for action in contract["cli_channel"]["next_cli_actions"]) + assert guard["scheduler_hint"]["action"] == "backoff_until_state_change" + assert state.read_bytes() == original + runs = [json.loads(line) for line in (vision.parent / "index.jsonl").read_text().splitlines()] + assert not any(run.get("classification") in ("quota_monitor_poll", "quota_slot_spent") for run in runs) + + def test_due_vision_wait_restores_explicit_resume(tmp_path, isolated_effect): registry, runtime, state = fixture(tmp_path, "resume_at:2026-01-01T00:00:00Z") initialize_canonical_source_fixture(runtime, GOAL, state_path=state, provider="sqlite") @@ -155,6 +207,29 @@ def test_due_vision_wait_restores_explicit_resume(tmp_path, isolated_effect): assert state.read_bytes() == original +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_automatic_wait_does_not_remove_a_real_user_gate(tmp_path, isolated_effect, provider): + registry, runtime, state = fixture(tmp_path) + with state.open("a", encoding="utf-8") as output: + output.write( + "\n## User Todo / Owner Review Reading Queue\n\n" + "- [ ] [P0] Approve the protected publication.\n" + f" \n" + ) + initialize_canonical_source_fixture(runtime, GOAL, state_path=state, provider=provider) + original = state.read_bytes() + rc, guard = cli(registry, runtime, "quota", "should-run", "--codex-app", + "--goal-id", GOAL, "--agent-id", AGENT, "--turn-instance-id", "gated-scheduled-work") + assert rc == 0, guard + contract = guard["interaction_contract"] + assert contract["user_channel"]["action_required"] is True + assert contract["agent_channel"]["delivery_allowed"] is False + assert contract["agent_channel"]["quiet_noop_allowed"] is False + assert contract["cli_channel"]["spend_allowed_now"] is False + assert state.read_bytes() == original + + @pytest.mark.parametrize("provider", ["file", "sqlite"]) def test_real_cli_worker_keeps_automatic_wait_alive_and_honors_explicit_stop( tmp_path, isolated_effect, monkeypatch, provider, diff --git a/tests/control_plane_ts/interaction_contract.test.ts b/tests/control_plane_ts/interaction_contract.test.ts index 5f7212d2f3..798bc146e7 100644 --- a/tests/control_plane_ts/interaction_contract.test.ts +++ b/tests/control_plane_ts/interaction_contract.test.ts @@ -42,6 +42,7 @@ test("only a positively qualified unbound scheduled wait can omit observation", {heartbeat_receipt: {status: "unknown"}}, {heartbeat_receipt: "invalid"}, {agent_id: "agent-foreign"}, + ...[0, 2, "1"].map(pending_resume_count => ({vision_wait_state: {...wait, pending_resume_count}})), {vision_wait_state: {...wait, waiting_todo_count: 2}}, {vision_wait_state: {...wait, selected_todo_status: "done"}}, {vision_wait_state: {...wait, resume_condition: {...condition, satisfied: true}}}, diff --git a/tests/control_plane_ts/vision_wait_coverage.test.ts b/tests/control_plane_ts/vision_wait_coverage.test.ts index 81c74eb197..2200e22329 100644 --- a/tests/control_plane_ts/vision_wait_coverage.test.ts +++ b/tests/control_plane_ts/vision_wait_coverage.test.ts @@ -1,6 +1,9 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { projectVisionWaitCoverage } from "../../loopx/control_plane/goals/vision_wait_coverage.ts"; +import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; +import { projectVisionWaitCoverage, projectGoalVisionWaitState } from "../../loopx/control_plane/goals/vision_wait_coverage.ts"; +import {evaluateTodoResumeConditions} from "../../loopx/control_plane/todos/resume_condition.ts"; +import {projectBlockedSuccessorWaitObservation} from "../../loopx/control_plane/work_items/interaction_contract.ts"; const facts = (extra: Record = {}) => ({ causal_todo_ids: ["todo_a", "todo_b"], waiting_todo_ids: ["todo_a"], @@ -47,3 +50,56 @@ test("unbound acceptance and malformed facts cannot prove a wait", () => { assert.throws(() => projectVisionWaitCoverage(facts(patch))); } }); + +function scheduledFrontier(resumeOverrides: Record = {}): JsonObject { + const todos = Array.from({length: 7}, (_, index) => ({todo_id: `todo_scheduled_${index}`, + text: "Deliver scheduled public work.", role: "agent", status: "deferred", + task_class: "advancement_task", claimed_by: "agent-a", + resume_when: resumeOverrides[index] ?? `resume_at:2099-01-0${7 - index}T00:00:00Z`})); + const conditions = evaluateTodoResumeConditions({schema_version: "todo_resume_evaluation_request_v0", + items: todos, source_items: [], available_capabilities: [], evaluated_at: "2026-01-01T00:00:00Z"}).conditions as JsonObject[]; + const records = todos.map((todo, index) => ({payload: {...todo, resume_ready: false, + resume_condition: conditions[index].condition}, id: todo.todo_id, status: todo.status, + claim: todo.claimed_by, excluded: [], done: false, resume: todo.resume_when, + ready: false, ready_truthy: false, priority: 1, index, target_id: null, + target_status: null, target_class: todo.task_class})); + const refs = records.map((_, index) => index); + return {agent_id: "agent-a", selectable_advancement_count: 0, agent_todo_summary: {}, + acceptance_gaps: [{kind: "vision_acceptance_gap", vision_todo_ids: todos.map(todo => todo.todo_id)}], + lineage_source_items: todos, resume_planning_request: { + schema_version: "todo_resume_planning_request_v1", agent_id: "agent-a", item_limit: 2, + has_deferred_count: false, has_visible_deferred_count: false, deferred_count: null, + available_capabilities: null, records, sources: {items: refs, deferred_items: refs, + backlog_items: [], first_open_items: [], deferred_resume_candidates: [], resume_blocked_items: [], + monitor_open_items: [], current_agent_claimed_monitor_items: [], claimed_monitor_open_items: []}}}; +} + +test("all canonical waits qualify before display limits and the earliest clock drives rechecks", () => { + const input = scheduledFrontier(), before = structuredClone(input); + const wait = projectGoalVisionWaitState(input)!; + assert.equal(wait.waiting_todo_count, 7); + assert.equal((wait.waiting_todo_ids as string[]).length, 5); + assert.equal(wait.pending_resume_count, 7); + assert.equal(wait.selected_todo_id, "todo_scheduled_6"); + assert.equal(wait.resume_when, "resume_at:2099-01-01T00:00:00Z"); + const request = {effective_action: "agent_scope_wait", agent_id: "agent-a", vision_wait_state: wait}; + assert.deepEqual(projectBlockedSuccessorWaitObservation(request), {required: false}); + assert.deepEqual(input, before); + for (const patch of [ + {heartbeat_receipt: {status: "committed", settlement_identity: {todo_id: "todo_scheduled_0"}}}, + {heartbeat_receipt: {status: "replayed", pending_action_selection: {}}}, + {agent_id: "agent-b"}, + ...[undefined, 0, 6, "7", 7.5].map(pending_resume_count => ({vision_wait_state: {...wait, pending_resume_count}})), + ]) assert.deepEqual(projectBlockedSuccessorWaitObservation({...request, ...patch}), {required: true}); +}); + +test("unsupported or missing sources cannot relax automatic-wait supervision", () => { + for (const resume of ["capacity_available:compiler", "todo_done:todo_missing_source"]) { + const wait = projectGoalVisionWaitState(scheduledFrontier({6: resume}))!; + assert.equal(wait.waiting_todo_count, 7); + assert.equal(wait.pending_resume_count, 6); + assert.deepEqual(projectBlockedSuccessorWaitObservation({effective_action: "agent_scope_wait", + agent_id: "agent-a", vision_wait_state: wait}), {required: true}); + } + assert.equal(projectGoalVisionWaitState({...scheduledFrontier(), selectable_advancement_count: 1}), null); +});