Skip to content
Open
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
53 changes: 41 additions & 12 deletions loopx/cli_commands/quota_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
cached_goal_run_index_is_current,
load_status_projection_cache,
resolve_status_projection_cache_runtime_root,
status_projection_cache_metadata,
write_status_projection_cache,
)
from ..control_plane.scheduler.execution_context import (
Expand Down Expand Up @@ -362,20 +363,48 @@ def prepare_quota_command_context(
available_capabilities=args.available_capabilities,
agent_lane_id=args.agent_id,
include_presentation_projections=False,
# A scheduler ACK/failure return writes cadence, not settlement
# facts. One damaged audit row must not fail the whole status read
# before the tolerant native fence can answer it.
tolerate_malformed_settlement_history=(
command in QUOTA_SCHEDULER_FOLLOWUP_COMMANDS
),
)
if bool(getattr(args, "write_projection_cache", False)):
cache_metadata = write_status_projection_cache(
registry_path=registry_path,
runtime_root=runtime_root,
limit=status_limit,
include_task_graph=False,
goal_id=status_goal_id,
payload=status_payload,
max_age_seconds=projection_cache_ttl_seconds,
available_capabilities=args.available_capabilities,
agent_lane_id=args.agent_id,
include_presentation_projections=False,
)
if command in QUOTA_SCHEDULER_FOLLOWUP_COMMANDS:
# The follow-up collector tolerates malformed history so a
# valid scheduler return can still be recorded. That partial
# view must never become the cache consumed by strict status
# readers under the same key.
cache_metadata = status_projection_cache_metadata(
registry_path=registry_path,
runtime_root=runtime_root,
limit=status_limit,
include_task_graph=False,
goal_id=status_goal_id,
max_age_seconds=projection_cache_ttl_seconds,
available_capabilities=args.available_capabilities,
agent_lane_id=args.agent_id,
include_presentation_projections=False,
)
cache_metadata.update({
"hit": False,
"written": False,
"skipped_reason": "tolerant_scheduler_followup_view",
})
else:
cache_metadata = write_status_projection_cache(
registry_path=registry_path,
runtime_root=runtime_root,
limit=status_limit,
include_task_graph=False,
goal_id=status_goal_id,
payload=status_payload,
max_age_seconds=projection_cache_ttl_seconds,
available_capabilities=args.available_capabilities,
agent_lane_id=args.agent_id,
include_presentation_projections=False,
)
elif isinstance(status_payload.get("projection_cache"), dict):
cache_metadata = dict(status_payload["projection_cache"])

Expand Down
38 changes: 9 additions & 29 deletions loopx/cli_commands/quota_scheduler_followup.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@ def _build_scheduler_followup_decision(
repository_delivery_interaction_hook(repo_path=Path.cwd()),
),
goal_ref=goal_ref,
# The compatibility ACK/failure route writes scheduler cadence, not
# settlement facts: one damaged audit row must not turn a valid return
# into a failure before the tolerant native fence runs. Exact
# settlement branches keep the strict readback.
tolerate_malformed_settlement_history=True,
)


Expand Down Expand Up @@ -105,35 +110,6 @@ def build_scheduler_followup_payload(
if turn_instance_id
else None
)
if turn_instance_id and heartbeat_receipt is None:
return {
"ok": False,
"schema_version": "quota_scheduler_followup_receipt_failure_v0",
"mode": args.quota_command,
"goal_id": args.goal_id,
"agent_id": args.agent_id,
"turn_instance_id": turn_instance_id,
"decision": "skip",
"should_run": False,
"status": "heartbeat_receipt_missing",
"state": "blocked_receipt",
"error_code": "SCHEDULER_FOLLOWUP_HEARTBEAT_RECEIPT_MISSING",
"reason": (
"scheduler follow-up requires the originating heartbeat receipt; "
"refusing to rebuild authority from the current live frontier"
),
"retry_guidance": (
"retry with the exact Turn id from a committed quota should-run "
"heartbeat receipt"
),
"write_performed": False,
"appended": False,
"registry_mutated": False,
"scheduler_state_mutated": False,
"quota_spend_performed": False,
"delivery_outcome": "surface_only",
}

observed_rrule = str(
getattr(args, "app_automation_current_rrule", None)
or getattr(args, "codex_app_current_rrule", None)
Expand Down Expand Up @@ -228,6 +204,8 @@ def build_scheduler_followup_payload(
failed_rrule=args.failed_rrule,
observed_host_rrule=observed_rrule,
failure_kind=args.failure_kind,
turn_instance_id=turn_instance_id,
goal_ref=goal_ref,
)
return record_quota_scheduler_ack(
status_payload,
Expand All @@ -248,4 +226,6 @@ def build_scheduler_followup_payload(
scheduler_execution_context=scheduler_context,
operator_inbox_urgency_projector=operator_inbox_urgency_projector,
before_decision=before_decision,
turn_instance_id=turn_instance_id,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] 让有效的 Python 调度回报不被严格结算读提前拒绝。 用 should-run 写入有效回执,再追加一条 summary 含原始 0xff 的 JSONL,使用原回执执行 scheduler-ack-current 或 scheduler-fail-current。 strictUtf8 现在正确把坏行标为 firstErrorLine,但 Python 在 _build_scheduler_followup_decision 中先走 read_heartbeat_settlement,后者以 strictGoalRolloutEvents 拒绝该行,尚未到达容错的 TypeScript scheduler fence。 同一有效回执旁有一条坏日志时,Python 兼容入口仍无法回报。File 和 SQLite 的 ACK、失败回报都退出 1、零写入;省略 Turn ID 也从主干可写变成拒绝。 --verbose 返回 settlement readback line 2 is malformed;带 host-facts 的原生命令在同样输入下成功,合法的新回执仍能拒绝旧回报。保留严格结算校验。让非扣额的调度回报通过现有 TypeScript owner 使用容错回执与原身份,避免先做无关的全日志严格结算读;需要精确结算事实的特殊分支继续要求权威 readback,并保留锁内复验。 在 File 和 SQLite 夹具分别运行真实 Python should-run 与两条 scheduler follow-up,向同一 rollout JSONL 追加完整或无换行的坏行;再与 emitted launcher 及合法后继对照,读回调度文件和扣额。

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已在 fff4d61423c06cf212e08cb01eb5e141b3b8fc43 修复。

普通调度返回不再先走严格的全日志结算读:readQuotaSettlement 增加显式 tolerate_malformed_history 请求字段(默认 false),只有 build_scheduler_followup_payload 和 scheduler-ack-current / scheduler-fail-current 的状态收集传 true,标志沿 collect_status → attention queue → effective cadence → projectSettledReplanHistory 传递。需要权威结算事实的分支保持默认严格读,仍以 malformed_settlement_state 拒绝损坏行。容错视图只跳过不可读的行,可读的回执(包括取代原 Turn 的较新回执)仍按顺序参与原生围栏,入口检查与持锁提交时的复核不变。

真实 File 与 SQLite 的 scheduler-ack-current / scheduler-fail-current 四组 CLI 场景:在有效回执后追加一条 summary 含原始 0xff 的 JSONL 行,修复前退出 1、零写入,现在均退出 0、ok=true、scheduler_state_mutated=true;合法较新回执仍返回 heartbeat_receipt_stale。tests/control_plane/test_scheduler_heartbeat_receipt_fence.py 10 passed;TS 结算与 replan 定向 180 passed,其中新用例断言默认读回仍拒绝坏行、容错读回与干净事实完全一致。Ruff 与 git diff --check 通过。远端 CI 尚未观察到全绿。

goal_ref=goal_ref,
)
2 changes: 2 additions & 0 deletions loopx/control_plane/quota/live_decision.py
Original file line number Diff line number Diff line change
Expand Up @@ -685,6 +685,7 @@ def build_live_quota_should_run_decision(
workspace_path: Path | None = None,
goal_ref: Mapping[str, object] | None = None,
inline_goal_source: bool = False,
tolerate_malformed_settlement_history: bool = False,
) -> dict[str, Any]:
"""Build one live CLI decision while keeping host observation injectable."""
resolved_context = resolve_scheduler_execution_context(scheduler_execution_context)
Expand Down Expand Up @@ -742,6 +743,7 @@ def build_live_quota_should_run_decision(
),
registry_path=registry_path,
goal_ref=goal_ref,
tolerate_malformed_history=tolerate_malformed_settlement_history,
)
receipt_bound_monitor_phase = (
settlement_readback.monitor_phase if settlement_readback else None
Expand Down
16 changes: 14 additions & 2 deletions loopx/control_plane/quota/scheduler_ack.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

from collections.abc import Mapping
from typing import Any, cast

import json
Expand Down Expand Up @@ -184,14 +185,17 @@ def _run_native_followup(
host_facts: dict[str, Any],
use_current_hint: bool,
reason_summary: str | None,
turn_instance_id: str | None = None,
goal_ref: Mapping[str, object] | None = None,
) -> dict[str, Any]:
"""Decision-free compatibility transport for in-process Python callers."""

request = {
"schema_version": _FOLLOWUP_REQUEST_SCHEMA,
"runtime_root": str(runtime_root),
"turn_instance_id": None,
"require_heartbeat_receipt": False,
"goal_ref": dict(goal_ref) if goal_ref is not None else None,
"turn_instance_id": turn_instance_id,
"require_heartbeat_receipt": bool(turn_instance_id),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] 损坏的日志行会阻断仍有效的调度回报。 提交有效的 scoped heartbeat receipt 后,向同一日志追加一条结构近似新回执但 summary 含非法 UTF-8 的行,再执行原 Turn 的 ACK 或失败回报。只追加一条 summary 含非法 UTF-8 的后继记录时,Python 仍只读到原回执,head 的实际 CLI 却返回 heartbeat_receipt_stale,两个回报均无写入。Python 跳过坏字节,heartbeat_receipt_status 判 fresh。_run_native_followup 开启原生门后,receipt_log_snapshot.ts 用替换解码把坏行交给 JSON parser,heartbeatReceiptStatus 把其 run_id 当作真实较新回执。把完整日志行和未终止尾行的字节有效性留给现有 TypeScript reader 验证。让同一个 native fence 给出 freshness 决策并在持锁写入时复核,移除 Python 的重复分类。通过 File 和 SQLite 的真实 should-run 与 scheduler-ack-current / scheduler-fail-current 验证坏行可容忍、合法后继可拒绝、持锁后二次校验不发生写入。

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已在 6bf625a4ece9 处理。readReceiptLogSnapshot 对完整行与未终止尾行统一按严格 UTF-8 解码,坏行只记入 firstErrorLine 并跳过,不会再被替换字符伪装成较新的回执;调度回写路径使用该容错视图,精确结算仍严格拒绝。真实 File/SQLite 的 ACK 与失败回写在坏行下均退出 0 并写入调度状态,合法较新回执仍返回 heartbeat_receipt_stale。

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] 保留 Goal 实例范围 兼容 CLI 读取 Goal 的旧式 registry,日志里只有另一 Goal 实例的同名 Goal/Agent/Turn 回执。 在真实 Trae 兼容入口,主干返回 heartbeat_receipt_missing;候选返回成功且 scheduler_commit.written=true。Python 精确查询拒绝了异实例行,但 native 请求只带 Goal slug、Agent 和 Turn,因此异实例记录取得了 cadence 写入权。这里复现的是旧式 registry 中混入已标记实例的历史,不外推到启用 source-session 的入口。 保持 TypeScript 单一 freshness owner,把期望的 GoalRef(或显式旧式无实例范围)交给它;预检与持锁写入都用同一范围排除异实例行。补充异实例不能授权、也不能使有效回执过期的入口测试。 在同一隔离日志中仅保留标记成其他 Goal 实例的原 Turn 回执,执行 quota scheduler-ack-current;断言返回 blocked_receipt,调度文件与扣额无变化;再覆盖异实例后继不使正确实例过期。

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

三条已在 6bf625a4ece9(本轮对应本地提交 3aececf2a)处理。

Goal 实例范围。 heartbeatReceiptStatus 增加可选精确 goalRef:首发回执查找与后续“更新回执”扫描都要求 goal_id + goal_instance_id 同时相等;没有 goal_ref 的回执归入旧式无实例范围。heartbeat_followup.ts 用 parseExactGoalRef 校验并拒绝与 host facts 的 goal_id 不一致的请求,同一 ref 同时传入入口预检与 SchedulerHeartbeatCommitOptions.heartbeatReceiptGoalRef,持锁写前复核使用同一范围。新增 TS 用例:异实例回执既不授权也不使精确实例回执过期;持锁期间追加异实例新回执,精确实例仍以 written 提交。

容错投影污染严格缓存。 tolerate_malformed_settlement_history 白名单收敛到 QUOTA_SCHEDULER_FOLLOWUP_COMMANDS(scheduler-ack、scheduler-ack-current、scheduler-fail-current)。这三个命令下 --write-projection-cache 不再写共享缓存,返回 written=false、skipped_reason: "tolerant_scheduler_followup_view"。真实 File/SQLite 用例:坏行存在时严格 quota status 失败;scheduler-ack-current --execute --write-projection-cache 不落缓存文件;随后 quota status --use-projection-cache 退出码不变且无缓存命中。

手工 ACK 兼容性。 quota scheduler-ack --use-current-hint 现在与 * -current 共用同一容错调度读,坏行下带或不带 --turn-instance-id 都能写回;普通状态、精确结算与消费读取保持严格。

验证:test_scheduler_heartbeat_receipt_fence.py 加三个相关 CLI 套件共 51 passed;TS 三个 follow-up 套件 36 passed;Ruff 与 git diff --check 通过。未覆盖:Windows、托管 CI、真实 Trae/Codex App 即时 UI。普通旧式夹具不写 goal_ref,所以精确实例行为由 TS 入口与持锁用例证明,真实 CLI 覆盖旧式无实例范围。

"before": before,
"host_facts": host_facts,
"use_current_hint": use_current_hint,
Expand Down Expand Up @@ -298,6 +302,8 @@ def record_quota_scheduler_ack_for_decision(
generated_at: str | None = None,
use_current_hint: bool = False,
host_match_observed: bool = False,
turn_instance_id: str | None = None,
goal_ref: Mapping[str, object] | None = None,
) -> dict[str, Any]:
state_key = _followup_state_key(before, surface=surface, state_key=state_key)
safe_agent_id = normalize_todo_claimed_by(agent_id)
Expand Down Expand Up @@ -367,6 +373,8 @@ def record_quota_scheduler_ack_for_decision(
host_facts=facts,
use_current_hint=use_current_hint,
reason_summary=reason_summary,
turn_instance_id=turn_instance_id,
goal_ref=goal_ref,
)
except ValueError as exc:
return _failure_payload(
Expand Down Expand Up @@ -396,6 +404,8 @@ def record_quota_scheduler_failure_for_decision(
observed_host_rrule: str | None = None,
failure_kind: str = "host_tool_failure",
generated_at: str | None = None,
turn_instance_id: str | None = None,
goal_ref: Mapping[str, object] | None = None,
) -> dict[str, Any]:
state_key = _followup_state_key(before, surface=surface, state_key=state_key)
safe_agent_id = normalize_todo_claimed_by(agent_id)
Expand Down Expand Up @@ -443,6 +453,8 @@ def record_quota_scheduler_failure_for_decision(
host_facts=facts,
use_current_hint=False,
reason_summary=None,
turn_instance_id=turn_instance_id,
goal_ref=goal_ref,
)
except ValueError as exc:
return _failure_payload(
Expand Down
6 changes: 6 additions & 0 deletions loopx/control_plane/quota/settlement.py
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,7 @@ def read_heartbeat_settlement(
borrow_source_admission: bool = False,
heartbeat_reentry_guard: dict[str, Any] | None = None,
include_record_bodies: bool = True,
tolerate_malformed_history: bool = False,
) -> QuotaSettlementReadback | None:
"""Read one complete heartbeat settlement through the TS domain owner."""

Expand All @@ -485,6 +486,11 @@ def read_heartbeat_settlement(
if include_record_bodies is not True
else {}
),
**(
{"tolerate_malformed_history": True}
if tolerate_malformed_history
else {}
),
**({"resolve_original_binding": True} if resolve_original_binding else {}),
**({"refresh_retry": refresh_retry} if refresh_retry is not None else {}),
**({"borrow_source_admission": True} if borrow_source_admission else {}),
Expand Down
34 changes: 29 additions & 5 deletions loopx/control_plane/quota/settlement_readback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ interface ReadbackRequest {
resolve_original_binding: boolean;
borrow_source_admission: boolean;
include_record_bodies: boolean;
tolerate_malformed_history: boolean;
refresh_retry: RefreshRetryRequest | null;
heartbeat_reentry_guard: JsonObject | null;
owner: QuotaAccountingOwner;
Expand Down Expand Up @@ -224,6 +225,14 @@ function decodeRequest(
if (request.include_record_bodies !== undefined && typeof request.include_record_bodies !== "boolean") {
throw new EffectRuntimeRequestError("include_record_bodies must be a boolean");
}
if (
request.tolerate_malformed_history !== undefined &&
typeof request.tolerate_malformed_history !== "boolean"
) {
throw new EffectRuntimeRequestError(
"tolerate_malformed_history must be a boolean",
);
}
if (request.resolve_original_binding === true && request.infer_turn_instance_id) {
throw new EffectRuntimeRequestError("original binding requires an explicit Turn identity");
}
Expand Down Expand Up @@ -283,16 +292,20 @@ function decodeRequest(
resolve_original_binding: request.resolve_original_binding === true,
borrow_source_admission: request.borrow_source_admission === true,
include_record_bodies: request.include_record_bodies !== false,
tolerate_malformed_history: request.tolerate_malformed_history === true,
refresh_retry: decodeRefreshRetry(request.refresh_retry),
heartbeat_reentry_guard: request.heartbeat_reentry_guard == null ? null
: requireJsonObject(request.heartbeat_reentry_guard, "heartbeat_reentry_guard"),
owner,
};
}

async function readRunReceipts(path: string): Promise<readonly JsonObject[]> {
async function readRunReceipts(
path: string,
tolerateMalformedHistory: boolean = false,
): Promise<readonly JsonObject[]> {
const snapshot = await readReceiptLogSnapshot(path);
if (snapshot?.firstErrorLine != null) {
if (!tolerateMalformedHistory && snapshot?.firstErrorLine != null) {
throw new EffectRuntimeRequestError(
`settlement readback line ${snapshot.firstErrorLine} is malformed`,
"malformed_settlement_state",
Expand Down Expand Up @@ -373,6 +386,7 @@ export async function readQuotaSettlementSnapshot(
runtimeRoot: string,
goalId: string,
rolloutSnapshot?: GoalRolloutEventSnapshot | null,
tolerateMalformedHistory: boolean = false,
): Promise<QuotaSettlementReadbackSnapshot> {
settlementScope(runtimeRoot, goalId);
if (
Expand All @@ -393,12 +407,17 @@ export async function readQuotaSettlementSnapshot(
rolloutSnapshot === undefined
? readGoalRolloutEventSnapshot(runtimeRoot, goalId)
: Promise.resolve(rolloutSnapshot),
readRunReceipts(join(goalRoot, "runs", "index.jsonl")),
readRunReceipts(join(goalRoot, "runs", "index.jsonl"), tolerateMalformedHistory),
]);
// Settlement-authority callers keep failing closed on the first damaged row.
// The scheduler follow-up route only needs the receipts it can actually read,
// so it opts into the same tolerant view the heartbeat reader already uses.
return indexSettlementSnapshot(
runtimeRoot,
goalId,
strictGoalRolloutEvents(snapshot),
tolerateMalformedHistory
? snapshot?.events ?? []
: strictGoalRolloutEvents(snapshot),
runs,
);
}
Expand Down Expand Up @@ -1446,7 +1465,12 @@ export async function readQuotaSettlement(value: unknown): Promise<JsonObject> {
request.owner,
async () => {
const result = readQuotaSettlementFromRequest(request,
await readQuotaSettlementSnapshot(request.runtime_root, request.goal_id));
await readQuotaSettlementSnapshot(
request.runtime_root,
request.goal_id,
undefined,
request.tolerate_malformed_history,
));
const identity = jsonObject(jsonObject(jsonObject(result.identity)?.result)?.value);
if (identity && request.refresh_retry &&
jsonObject(result.refresh_recovery)?.decision !== "reject" &&
Expand Down
19 changes: 16 additions & 3 deletions loopx/control_plane/rollout_receipt_log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { relative, resolve, sep } from "node:path";

import type { JsonObject } from "./effect_program.ts";
import { EffectRuntimeRequestError } from "./effect_runtime_errors.ts";
import { requireNonEmptyString } from "./runtime_decode.ts";
import { jsonObject, requireNonEmptyString } from "./runtime_decode.ts";
import { readReceiptLogSnapshot } from "./runtime/receipt_log_snapshot.ts";

export const ROLLOUT_EVENT_SCHEMA_VERSION = "loopx_rollout_event_v0";
Expand Down Expand Up @@ -154,12 +154,23 @@ export async function readGoalHeartbeatReceipts(
);
}

/** A receipt without an exact Goal instance belongs to the legacy goal scope. */
function receiptMatchesGoalRef(event: JsonObject, goalRef: JsonObject | null): boolean {
if (goalRef === null) return !Object.hasOwn(event, "goal_ref");
const eventGoalRef = jsonObject(event.goal_ref);
return eventGoalRef !== null &&
eventGoalRef.goal_id === goalRef.goal_id &&
eventGoalRef.goal_instance_id === goalRef.goal_instance_id;
}

export async function heartbeatReceiptStatus(params: {
runtimeRoot: string;
goalId: string;
agentId: string;
turnInstanceId: string;
goalRef?: JsonObject | null;
}): Promise<HeartbeatReceiptStatus> {
const goalRef = params.goalRef ?? null;
const snapshot = await readGoalRolloutEventSnapshot(
params.runtimeRoot,
params.goalId,
Expand All @@ -171,7 +182,8 @@ export async function heartbeatReceiptStatus(params: {
);
if (snapshot === null || receipts === null) return "missing";
const firstReceipt = receipts.find(
(event) => event.run_id === params.turnInstanceId,
(event) => event.run_id === params.turnInstanceId &&
receiptMatchesGoalRef(event, goalRef),
);
const firstMatch = firstReceipt === undefined
? -1
Expand All @@ -181,7 +193,8 @@ export async function heartbeatReceiptStatus(params: {
if (
event.event_kind !== HEARTBEAT_RECEIPT_EVENT_KIND ||
event.goal_id !== params.goalId ||
event.agent_id !== params.agentId
event.agent_id !== params.agentId ||
!receiptMatchesGoalRef(event, goalRef)
) return false;
if (typeof event.run_id === "string" && event.run_id.trim().length > 0) {
return event.run_id !== params.turnInstanceId;
Expand Down
Loading
Loading