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
10 changes: 10 additions & 0 deletions docs/architecture/rfcs/typescript-control-plane-migration-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions docs/reference/protocols/goal-vision-replan-contract-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
52 changes: 48 additions & 4 deletions loopx/control_plane/goals/vision_wait_coverage.ts
Original file line number Diff line number Diff line change
@@ -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");
Expand Down Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion loopx/control_plane/work_items/interaction_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
42 changes: 9 additions & 33 deletions loopx/control_plane/work_items/interaction_contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
}
Expand Down
Loading
Loading