Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions docs/reference/protocols/quota-blocked-causal-closeout-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,41 @@ number alone is not a qualified blocked-closeout proof. Register a real
monitor or dependency Todo and use `monitor_changed` / `todo_done` for causal
closeout. Unsupported PR waits now name this recovery route explicitly.

### Unavailable Monitor observation

An admitted open Agent `continuous_monitor` on canonical authority may discover
that its observation cannot be obtained. Close the original Goal/Agent/Todo/Turn
with `refresh-state --delivery-outcome outcome_gap --progress-result-class
blocked`, a stable blocker ID, evidence IDs and a normal vision decision. Do not
invent a `monitor-poll` hash, record control metadata as an account observation,
or spend. The existing settlement plan exposes this path.

The same typed quota owner freezes `quota_monitor_unavailable_v0` in the existing
`blocked_retry` receipt slot, with `observation_available=false` and the Monitor
scope. Exact admission, identity, evidence and durable writeback still gate
`typed_blocked_writeback_no_spend`. This closes only the attempted Turn: the Todo
stays open; `last_checked_at`, `next_due_at`, result hash, material generation,
cadence and expiry remain unchanged. It creates neither a retry clock nor an
advancement wait, material successor, completion or delivery claim. Existing
advancement retry/causal-wait semantics and genuine committed polls retain their
behavior. A committed poll cannot be rewritten as an unavailable attempt.

Exact replay returns the frozen receipt, with no duplicate writeback or debit.
After closing the original effect, reenter the current recovery host Turn to
select independently eligible work; do not create a third identity or rebind the
old one. Missing capability admission or evidence and wrong bindings still fail.
File and SQLite CLI acceptance covers this recovery, preserved observation
fields, capability restoration, committed-poll rejection and zero debit.
PostgreSQL and live host adoption remain separate qualifications. Before rollback,
reconcile these receipts using a runtime that recognizes the new proof.

This is a bounded R1/S2/S10 recovery slice in the existing quota owner. The legacy
`isBoundedBlockedRetry` entry point remains for active callers, but now qualifies
Monitor unavailable proofs as well; only the advancement schema projects a
bounded retry. No Python decision owner, new switch, frontend control or Lark
authority is added. App/Chat consume the same CLI settlement guidance; their
packaged surfaces are not qualified by the CLI tests.

## 中文

已准入的 advancement Turn 可以发现真实依赖,以
Expand Down Expand Up @@ -130,3 +165,27 @@ excluded/bound 限制仍拒绝。恢复后,模式要求执行 fencing 时获
`pr_merged` 仍是合法的调度等待条件,但 PR 编号本身不能证明阻塞结算所需的
真实依赖。应登记实际 monitor/依赖 Todo,以 `monitor_changed`/`todo_done`
完成因果结算;错误信息明确给出此恢复路径。

### Monitor 观察不可取得

canonical authority 上已准入、开放的 Agent `continuous_monitor`,可能在尝试中
发现无法取得观察。用原 Goal/Agent/Todo/Turn 调用 `refresh-state`,提供
`outcome_gap`、typed `blocked`、稳定 blocker、证据 ID 与正常 vision 决策,沿既有
计划关闭本次尝试。不得编造 poll hash、把控制元数据记成账户观察或扣额。

同一 TS quota owner 在既有 `blocked_retry` 回执槽冻结
`quota_monitor_unavailable_v0`、`observation_available=false` 与 Monitor 范围;
精确准入、身份、证据和持久写回仍是无扣额结算门槛。只关闭原 Turn,Todo 仍开放,
检查/到期时钟、hash、material generation、cadence 与 expiry 不变。不生成重试时钟、
advancement 等待、material successor、完成或投递声明。旧 advancement 重试/因果等待
与真实 poll 保持原行为;已提交 poll 不能改写为不可观察尝试。

精确重放返回冻结回执,不重复写回或扣额;原 effect 关闭后,重新进入当前 recovery
host Turn 可选择独立合法任务,不新造第三身份或重绑旧 Turn。缺准入能力、缺证或错绑
仍拒绝。File/SQLite 的真实 CLI 验收覆盖恢复、观察字段保留、能力恢复、已 poll 拒绝与
零扣额;PostgreSQL、本机真实采用及 App/Lark 打包入口仍分阶段验收。降级前用支持新
证明的运行时核对回执。

这是既有 quota owner 的 R1/S2/S10 有界修复,兼容保留活动调用者使用的
`isBoundedBlockedRetry` 名称;只有 advancement schema 产生有界重试投影。
不新增 Python 判断源、开关、前端控件或 Lark 权威;CLI 计划提供同源说明。
35 changes: 33 additions & 2 deletions loopx/control_plane/quota/blocked_wait.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {

export const BLOCKED_WAIT_REQUEST_SCHEMA = "loopx_quota_blocked_wait_request_v0";
const CAUSAL_WAIT_SCHEMA = "quota_blocked_causal_wait_v0";
export const MONITOR_UNAVAILABLE_SCHEMA = "quota_monitor_unavailable_v0";

function reject(message: string): never {
throw new EffectRuntimeRequestError(`typed blocked no-spend closeout ${message}`);
Expand Down Expand Up @@ -79,6 +80,21 @@ function retainedTodo(todo: JsonObject): JsonObject {
.map((field) => [field, todo[field]]));
}

/** An unavailable attempt is not an observation or a retry clock. The
* surrounding settlement owner verifies the exact guard, typed blocker and
* evidence before committing this frozen, canonical Monitor scope. */
export function isMonitorUnavailableWait(value: unknown, todoId: string | null): boolean {
const wait = jsonObject(value);
const monitor = jsonObject(wait?.monitor_todo);
return !!wait && wait.schema_version === MONITOR_UNAVAILABLE_SCHEMA &&
wait.source === "turn_settlement" && wait.observation_available === false &&
!!todoId && wait.todo_id === todoId && monitor?.todo_id === todoId &&
monitor.role === "agent" && monitor.task_class === "continuous_monitor" &&
monitor.status === "open" &&
(monitor.archive_state == null || monitor.archive_state === "active") &&
timestamp(wait.observed_at) !== null;
}

/** Preflight belongs to the same TS settlement owner as durable readback.
* Python only transports the complete current Todo facts and observation clock. */
export function prepareBlockedWait(value: unknown): JsonObject {
Expand All @@ -89,10 +105,25 @@ export function prepareBlockedWait(value: unknown): JsonObject {
const todos = request.todos.map((value) => requireJsonObject(value, "blocked wait Todo"));
const matches = todos.filter((todo) => todo.todo_id === request.todo_id);
const todo = matches[0];
if (matches.length !== 1 || !todo || !["open", "deferred"].includes(String(todo.status)) ||
todo.task_class !== "advancement_task") reject("requires the same unfinished advancement Todo");
if (matches.length !== 1 || !todo || !["open", "deferred"].includes(String(todo.status))) {
reject("requires the same unfinished Todo");
}
const observed = timestamp(request.observed_at);
if (observed === null) reject("has an invalid timestamp");
if (todo.task_class === "continuous_monitor") {
if (todo.role !== "agent" || todo.status !== "open" ||
(todo.archive_state != null && todo.archive_state !== "active") ||
request.allow_turn_settlement_retry !== true) {
reject("requires an admitted open Monitor attempt on canonical authority");
}
const fields = ["claimed_by", "last_checked_at", "next_due_at", "result_hash",
"material_change", "material_change_generation", "cadence", "expires_at", "watch_only"];
return {schema_version: MONITOR_UNAVAILABLE_SCHEMA, source: "turn_settlement",
todo_id: request.todo_id, observed_at: request.observed_at, observation_available: false,
monitor_todo: {...retainedTodo(todo), ...Object.fromEntries(
fields.filter(field => todo[field] !== undefined).map(field => [field, todo[field]]))}};
}
if (todo.task_class !== "advancement_task") reject("requires the same unfinished advancement Todo");
const resume = todo.resume_when;
const condition = jsonObject(todo.resume_condition);
if (typeof resume === "string" && /^(?:monitor_changed|todo_done):/.test(resume)) {
Expand Down
11 changes: 8 additions & 3 deletions loopx/control_plane/quota/settlement_phase.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import type { SettlementIdentity } from "../effect_program.ts";
import { jsonObject } from "../runtime_decode.ts";
import { isCausalBlockedWait } from "./blocked_wait.ts";
import { isCausalBlockedWait, isMonitorUnavailableWait } from "./blocked_wait.ts";

/** A blocked Turn needs a bounded retry or a verified canonical causal wait. */
/** Historical entry point for blocked no-spend qualification: an advancement
* retry/causal wait or a canonical Monitor unavailable attempt. The latter
* creates no retry clock and cannot be projected as an advancement wait. */
export function isBoundedBlockedRetry(value: unknown, todoId: string | null): boolean {
if (isCausalBlockedWait(value, todoId)) return true;
if (isMonitorUnavailableWait(value, todoId)) return true;
const retry = jsonObject(value);
if (!retry || retry.schema_version !== "quota_blocked_retry_v0" ||
(retry.source !== "todo" && retry.source !== "turn_settlement") ||
Expand Down Expand Up @@ -96,12 +99,14 @@ export interface ReceiptBoundMonitorSettlementState {
material_change: boolean;
durable_writeback_present: boolean;
quota_spend_present: boolean;
/** Exact typed unavailable writeback, verified by the settlement owner. */
unavailable_attempt_present?: boolean;
}

export function receiptBoundMonitorPhase(
state: ReceiptBoundMonitorSettlementState,
): ReceiptBoundMonitorPhase {
if (!state.poll_present) return "poll_due";
if (!state.poll_present && !state.unavailable_attempt_present) return "poll_due";
// The committed monitor-poll is the durable no-spend closeout for this
// monitor Turn. A material observation may atomically release an independent
// successor, but it never upgrades the observe-only monitor into an
Expand Down
9 changes: 7 additions & 2 deletions loopx/control_plane/quota/settlement_plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,11 @@ export function turnScopedCliSettlementPlan(params: JsonObject): SettlementPlan
: "validation succeeded; " + VISION_MATERIAL_CLOSEOUT_HINT +
" Pass a new packet with --agent-vision-json.") +
" Route elimination needs evidence; failure alone is not progress. " +
"outcome_gap: blocked + blocker/evidence IDs; continuation checks.",
"outcome_gap: blocked + blocker/evidence IDs; continuation checks. " +
"For an admitted open continuous Monitor whose observation is unavailable, " +
"use this original identity for typed blocked outcome_gap; it settles without " +
"poll or spend and preserves observation clocks/hash/generation. " +
"An already committed Monitor poll needs neither refresh nor spend.",
idempotency_key_ref: "$.identity.effect_id", expected_receipt: "durable_writeback_receipt",
command_template: writeback,
// Autonomous replan already carries this owner in its writeback contract.
Expand All @@ -62,7 +66,8 @@ export function turnScopedCliSettlementPlan(params: JsonObject): SettlementPlan
{
kind: "quota_spend", owner: "agent",
precondition: "matching durable writeback exists and any declared completion validation " +
"has completed the Todo, or the same Turn has qualified in-flight/replan progress",
"has completed the Todo, or the same Turn has qualified in-flight/replan progress; " +
"skip this step after typed_blocked_writeback_no_spend",
idempotency_key_ref: "$.identity.effect_id", expected_receipt: "quota_spend_receipt",
command_template: spend,
},
Expand Down
14 changes: 12 additions & 2 deletions loopx/control_plane/quota/settlement_readback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ import {
} from "./heartbeat_receipt_identity.ts";

import { refreshExternalDelivery } from "./refresh_external_delivery.ts";
import { BLOCKED_WAIT_REQUEST_SCHEMA, prepareBlockedWait, RECEIPT_BOUND_WAIT_REQUEST_SCHEMA, projectReceiptBoundWait } from "./blocked_wait.ts";
import { BLOCKED_WAIT_REQUEST_SCHEMA, prepareBlockedWait, RECEIPT_BOUND_WAIT_REQUEST_SCHEMA, projectReceiptBoundWait, isMonitorUnavailableWait } from "./blocked_wait.ts";
import {nativeChildReportAdmission} from "../capabilities/native_child_admission.ts";
import {
parseQuotaAccountingOwner,
Expand Down Expand Up @@ -1142,6 +1142,8 @@ function readQuotaSettlementFromRequest(
material_change: isMaterialMonitorPoll(monitorPoll),
durable_writeback_present: writeback.failure === null,
quota_spend_present: spend.failure === null,
unavailable_attempt_present: blockedNoSpend &&
isMonitorUnavailableWait(writebackRun?.blocked_retry, identity.todo_id),
});
// A committed observation closes its exact Turn independently of whether
// the monitor still appears in the current Todo frontier. Reuse the monitor
Expand All @@ -1157,7 +1159,7 @@ function readQuotaSettlementFromRequest(
no_spend_closeout_present: blockedNoSpend,
});

const recovery = request.refresh_retry === null ? null : refreshRecovery(
const refreshDecision = request.refresh_retry === null ? null : refreshRecovery(
request.refresh_retry, writebackRun, writeback.failure === null,
workspaceCausality?.requirement,
writebackRun !== null && snapshot.runs.slice(
Expand All @@ -1169,6 +1171,14 @@ function readQuotaSettlementFromRequest(
(jsonObject(run.agent_vision) !== null || jsonObject(run.vision_checkpoint)?.required === true)
),
);
// A non-material poll need not appear as findWriteback's advancement run.
// Its exact committed observation still fences an incompatible unavailable
// claim; retain the normal workspace/vision supplement paths for real polls.
const recovery = monitorPoll !== null && request.refresh_retry?.delivery_outcome === "outcome_gap" &&
isTurnScopedSettlementOutcome(request.refresh_retry.delivery_outcome,
request.refresh_retry.progress_observation, identity.todo_id)
? {...refreshDecision, decision: "reject", reason: "committed_writeback_payload_conflict"}
: refreshDecision;

return {
schema_version: QUOTA_SETTLEMENT_READBACK_RESULT_SCHEMA,
Expand Down
5 changes: 5 additions & 0 deletions loopx/control_plane/quota/unsettled_host_turn_recovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,11 @@ function recoveryObligation(
? `Recover prior unsettled host Turn for ${bindingId}; re-enter its original ` +
"guard, inspect existing effects, then resume and settle verified work under " +
"that identity before rerunning the current Turn; do not invent an external wait"
: repair === "monitor_poll"
? `Recover prior unsettled host Turn for ${bindingId}; record a verified ` +
"original observation with monitor-poll, or use the original identity for " +
"typed blocked writeback if observation is unavailable; never invent a " +
"result hash. Then rerun the current Turn and continue eligible work"
: `Recover prior unsettled host Turn for ${bindingId}; use a typed ` +
"lifecycle observation, then rerun quota and continue eligible work",
repair,
Expand Down
22 changes: 20 additions & 2 deletions loopx/control_plane/work_items/unsettled_host_turn_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,9 @@ def recovery_cli_actions(
cadence_arg = f" --cadence {shlex.quote(cadence)}" if cadence else ""
return [
(
"inspect the monitor target and record its exact prior-turn "
"observation; never infer external state from Todo prose, and add "
"If a verified observation was obtained under the original Turn, "
"record it with monitor-poll below. Never infer external state "
"from Todo prose, and add "
"--material-change plus a runnable successor only for a real change"
),
(
Expand All @@ -93,6 +94,23 @@ def recovery_cli_actions(
f"{cadence_arg} --turn-instance-id {shlex.quote(prior_turn_id)} "
"--execute"
),
(
"If observation is unavailable, instead close only the original "
"attempt with the typed blocked writeback below: supply stable "
"blocker/evidence IDs and a normal vision decision. It preserves "
"Monitor clocks/hash/generation, creates no poll and spends "
"nothing. An already committed poll needs neither branch."
),
(
f"{command_prefix} refresh-state --goal-id {goal_id}{lifecycle_actor_args}"
f" --todo-id {shlex.quote(prior_todo_id)}"
f" --turn-instance-id {shlex.quote(prior_turn_id)}"
" --classification monitor_observation_unavailable"
" --delivery-batch-scale single_surface --delivery-outcome outcome_gap"
" --progress-result-class blocked --progress-blocker-id '<verified-blocker-id>'"
" --progress-evidence-id '<verified-evidence-ref>'"
" --vision-unchanged-reason '<verified-unchanged-vision-reason>'"
),
f"{typed_quota_guard}{current_turn_arg}",
]
return [
Expand Down
Loading
Loading