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
Original file line number Diff line number Diff line change
Expand Up @@ -2524,6 +2524,13 @@ commands fail closed; they do not fall back to the old writer.

#### Refactoring roadmap overview

The Monitor state owner now lives in TS and is composed by the legacy update
field plan. This removes Python poll/generation and metadata rules, but the
legacy writer still holds the lock and commits the result. The typed plan is
not an authority receipt; monitor/successor atomicity, native metadata update,
provider defaults and D1–D3 remain separate, unfinished gates. Permanent
Markdown projection remains part of the target architecture.

Todo authoring scope and terminal successors now share the TS resolved-binding
invariant. Only explicit `global_gate` can widen blocking to all registered
agents; `goal_bound` grants no global-gate semantics. This consolidates T1
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2002,6 +2002,11 @@ backend、实时双向同步或按命令拆开的权威;晋升后不支持的

#### 重构主线总览

Monitor 状态 owner 现位于 TS,并由 legacy update field plan 组合调用;删除 Python
poll/generation 与 metadata 规则,但持锁及结果提交仍由 legacy writer 负责。
Typed plan 不是 authority receipt;Monitor/successor 原子性、原生 metadata update、
provider 默认值及 D1–D3 仍是独立、未完成的门禁。永久 Markdown 投影仍属于终态架构。

Todo authoring scope 已与 terminal successor 共用 TS 最终绑定不变量;仅显式
`global_gate` 可以扩大阻塞到全部注册 agent,`goal_bound` 不授予全局 gate 语义。
这是 T1 准入规则收拢;不扩张 native update 字段权限、不改变 provider/profile 默认值,
Expand Down
18 changes: 18 additions & 0 deletions docs/architecture/rfcs/typescript-control-plane-migration-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,24 @@

## Current implementation checkpoint

Monitor metadata authoring and poll transitions now share `todos/monitor_metadata.ts`.
Public update composes that owner inside its existing field-plan request; cadence
calculation stays in-process instead of making two additional scheduler RPCs.
The Python observation/replay/counter/scope/boundedness rules are retired. Create
and the low-level Markdown add codec retain a metadata-plan adapter; this is not
the complete T1 transaction or T2 atomic monitor-plus-successor commit.

Intentional corrections: older observations cannot rewind state merely because
either effect ID is absent; issue-fix grouped membership updates use the locked
observation path and advance generation when a material result hash changes.
New counters reject negative or unsafe integers. ISO dates are calendar-checked;
the codec retains Python compact/week-date forms, offset seconds and microsecond
ordering without rewriting history. Lifecycle/ownership admission now precedes poll
diagnostics, so an unauthorized request cannot use malformed metadata to avoid
its authority rejection. Exact replay, same-second unkeyed polls, explicit
clears and legacy boundedness exemptions remain. The plan grants no permission,
receipt or promotion; native update still owns only text/note.

Public Todo add/update now resolve role, continuation binding, gate scope and
deferred-condition requirements through `todos/authoring_scope.ts`. Python's
`write_policy.py` and duplicated scope selection in `todos.py` are retired;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,20 @@

## 当前实现检查点

Monitor metadata authoring 与 poll transition 现共用 `todos/monitor_metadata.ts`。
公开 update 在已有 field-plan 请求内组合该 owner;cadence 在进程内计算,不再额外
调用两次 scheduler RPC。删除 Python 的 observation/replay/counter/scope/boundedness
规则。Create 与低层 Markdown add codec 仍保留 metadata-plan adapter;这不是完整
T1 事务,也不是 T2 的 Monitor 与 successor 原子提交。

有意修正:不再因任一 effect ID 缺失而允许旧 observation 倒退状态;issue-fix 分组
成员更新使用持锁 observation 路径,在 material result hash 改变时递增 generation。
新计数拒绝负数及不安全整数。ISO 日期进行日历校验,codec 保留 Python 的紧凑日期、
周日期、时区偏移秒数及微秒排序,不改写历史。
Lifecycle/ownership 准入现在先于 poll 诊断,未授权请求不能靠非法 metadata 回避
权限拒绝。精确 replay、同秒无 ID 轮询、显式清空及 legacy boundedness 豁免保持。
Plan 不授予权限、receipt 或 promotion;native update 仍只拥有 text/note。

公开 Todo add/update 现通过 `todos/authoring_scope.ts` 统一解析角色、continuation
绑定、gate 作用域与 deferred 条件要求。删除 Python `write_policy.py` 及 `todos.py`
重复的 scope 选择;Markdown codec 只保留早期 class 检查的适配调用。已物化的 terminal
Expand Down
23 changes: 23 additions & 0 deletions docs/project-agent-todo-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,29 @@ eligible time, `--cadence` is the retry interval, `--monitor-target-key` is the
stable idempotency key, and optional `--expires-at` is the hard stop after
which the monitor must not catch up.

Monitor observations are reduced against the Todo under its existing writer lock.
Callers report a result hash and material-change fact; they must not independently
increment counters. A material observation with a different result hash increments
`material_change_generation`; repeating the same hash does not. An unchanged
same-hash poll increments `consecutive_no_change`; material change or a changed hash
resets that count. Issue-fix grouped membership updates use this same path.
New grouped monitors default to watch-only; subsequent observations preserve
the existing expiration/watch policy rather than silently re-enabling watch-only.

An exact `monitor_effect_id` replay keeps the committed counters. Reusing the ID
with different observation fields fails. Older timestamps fail even without an
effect ID; same-second unkeyed polls remain allowed, while distinct keyed effects
retain strict ordering. Ordering preserves microseconds. Newly written
`material_change_generation` and `consecutive_no_change` values must be
non-negative safe integers. Use ISO timestamps
(for example `2030-01-01T12:00:00.000001+00:00`); invalid calendar dates are
rejected. Existing compact/week-date and timezone-offset-second spellings remain readable.
Existing malformed historical timestamps do not prove an ordering fence.

These rules do not make a Monitor executable delivery work, grant claim/lease
authority, or make Monitor and successor writes atomic. A planning result is
not a durable receipt; provider promotion remains explicitly gated.

Terminology: a `goal_id` is the LoopX control-plane boundary: registry
entry, active-state file, quota lane, status projection, and run-history stream.
A `todo_id` is a structured work item inside that goal. LoopX does not
Expand Down
3 changes: 0 additions & 3 deletions examples/control_plane/monitor-todo-policy-seam-smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
monitor_todo_is_expired,
monitor_todo_missing_schedule,
monitor_todo_next_due_at,
parse_monitor_counter,
)
from loopx.status import ( # noqa: E402
todo_item_is_due_monitor,
Expand Down Expand Up @@ -92,8 +91,6 @@ def main() -> int:
assert_policy_matches_wrappers(cadence_only, due=False, expired=False)
assert monitor_todo_missing_schedule(cadence_only, now=NOW) is True, cadence_only
assert monitor_todo_next_due_at({"next_due_at": "2026-01-01T00:00:00"}) == NOW
assert parse_monitor_counter("3") == 3
assert parse_monitor_counter("not-a-number") == 0
assert monitor_cadence_delta("2h").total_seconds() == 7200
cadence_due_at = monitor_next_due_at(
generated_at="2026-01-01T00:00:00+00:00",
Expand Down
6 changes: 3 additions & 3 deletions examples/control_plane/todo-readmodel-boundary-smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,6 @@ def assert_status_compatibility_boundary() -> None:
assert status_module.compact_operator_gate_resume_contract is run_compaction_read_model.compact_operator_gate_resume_contract
assert status_module.compact_controller_readiness is run_compaction_read_model.compact_controller_readiness
assert status_module.parse_timestamp is runtime_time_read_model.parse_timestamp
assert monitor_metadata_read_model.parse_timestamp is runtime_time_read_model.parse_timestamp
assert scheduler_time_read_model.parse_timestamp is runtime_time_read_model.parse_timestamp
assert status_cache_read_model.parse_timestamp is runtime_time_read_model.parse_timestamp
assert evidence_log_read_model.parse_timestamp is runtime_time_read_model.parse_timestamp
Expand Down Expand Up @@ -158,8 +157,9 @@ def assert_wrapper_parity() -> None:
assert stripped is not None
assert stripped.isoformat() == "2026-01-01T00:00:00+00:00", stripped
assert status_module.parse_timestamp("not-a-time") is None
assert monitor_metadata_read_model.normalize_monitor_metadata(
{"next_due_at": "2026-01-01T00:00:00Z"}
assert monitor_metadata_read_model.require_monitor_metadata_scope(
monitor_metadata={"next_due_at": "2026-01-01T00:00:00Z"},
role="agent", task_class="continuous_monitor",
) == {"next_due_at": "2026-01-01T00:00:00Z"}


Expand Down
18 changes: 10 additions & 8 deletions loopx/capabilities/issue_fix/pr_monitor_materialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@

from ...control_plane.scheduler.monitor_todo import (
monitor_next_due_at,
parse_monitor_counter,
)
from ...control_plane.todos.monitor_metadata import MonitorPollObservation
from ...todos import (
add_goal_todo,
complete_goal_todo,
Expand Down Expand Up @@ -145,18 +145,13 @@ def materialize_issue_fix_grouped_monitors(
previous_hash = str((previous or {}).get("result_hash") or "")
reopening = bool((previous or {}).get("done"))
material_change = reopening or previous_hash != result_hash
previous_no_change = parse_monitor_counter(
(previous or {}).get("consecutive_no_change")
)
monitor_metadata = {
"target_key": target_key,
"cadence": cadence,
"next_due_at": next_due_at,
"last_checked_at": generated_at,
"result_hash": result_hash,
"consecutive_no_change": (
"0" if material_change else str(previous_no_change + 1)
),
"consecutive_no_change": "0",
"material_change": "true" if material_change else "false",
"watch_only": "true",
}
Expand Down Expand Up @@ -185,7 +180,14 @@ def materialize_issue_fix_grouped_monitors(
role="agent",
status="open" if reopening else None,
reason=reason,
monitor_metadata=monitor_metadata,
# Membership is an observation, not precomputed Todo state.
# The writer derives counters/generation against its locked
# snapshot, just like quota monitor-poll.
monitor_metadata=MonitorPollObservation(
generated_at=generated_at, result_hash=result_hash,
material_change=material_change, target_key=target_key,
cadence=cadence, next_due_at=next_due_at,
),
no_followup=False if reopening else None,
agent_id=claimed_by,
project=project,
Expand Down
2 changes: 2 additions & 0 deletions loopx/control_plane/effect_runtime_handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import {
import { reduceTodoCompletionTransaction } from "./todos/completion_transaction.ts";
import { transitionTodoNextAction } from "./todos/next_action.ts";
import { planTodoFieldUpdate } from "./todos/field_update.ts";
import { planMonitorMetadata } from "./todos/monitor_metadata.ts";
import { planTodoAuthoringScope } from "./todos/authoring_scope.ts";
import {
evaluateTodoResumeConditions,
Expand Down Expand Up @@ -370,6 +371,7 @@ export function createEffectRuntimeHandlers(
["todo.completion_state.require_metadata", requireTodoCompletionMetadataValue],
["todo.completion_state.continuation_for_write", selectTodoCompletionContinuation],
["todo.field_update.plan", planTodoFieldUpdate],
["todo.monitor_metadata.plan", planMonitorMetadata],
["todo.authoring_scope.plan", planTodoAuthoringScope],
[
"todo.claim.decide",
Expand Down
88 changes: 88 additions & 0 deletions loopx/control_plane/runtime_timestamp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/** Calendar-checked ISO codec; Date.parse alone silently rolls invalid dates. */
export function parseIsoTimestamp(value: string): Date | null {
const match = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?(Z|z|[+-]\d{2}(?::?\d{2})?)?)?$/u.exec(
value.trim(),
);
if (match === null) return null;
const [, yearText, monthText, dayText, hourText, minuteText, secondText, fraction, timezone] = match;
const [year, month, day, hour, minute, second, millisecond] = [
yearText,
monthText,
dayText,
hourText ?? "0",
minuteText ?? "0",
secondText ?? "0",
(fraction ?? "").slice(0, 3).padEnd(3, "0") || "0",
].map(Number);
const endOfDay = hour === 24;
if (
endOfDay &&
(minute !== 0 || second !== 0 || (fraction !== undefined && /[1-9]/u.test(fraction)))
) return null;
const calendarHour = endOfDay ? 0 : hour;
const calendar = new Date(0);
calendar.setUTCHours(calendarHour, minute, second, millisecond);
calendar.setUTCFullYear(year, month - 1, day);
if (
calendar.getUTCFullYear() !== year || calendar.getUTCMonth() !== month - 1 ||
calendar.getUTCDate() !== day || calendar.getUTCHours() !== calendarHour ||
calendar.getUTCMinutes() !== minute || calendar.getUTCSeconds() !== second ||
calendar.getUTCMilliseconds() !== millisecond
) return null;
if (hourText === undefined) return calendar;
let text = value.trim().replace(" ", "T").replace(/z$/u, "Z");
if (fraction !== undefined) text = text.replace(`.${fraction}`, `.${fraction.slice(0, 3)}`);
if (timezone === undefined) text += "Z";
else text = text.replace(/([+-]\d{2})$/u, "$1:00");
const parsed = new Date(text);
return Number.isNaN(parsed.valueOf()) ? null : parsed;
}

/** Compatibility codec for datetime.fromisoformat inputs used by Todo metadata.
* It keeps microseconds and offset seconds, which a JS Date cannot represent.
* Missing timezone means UTC, matching the existing Python runtime codec. */
export function parseTodoTimestampMicros(value: string): bigint | null {
// The legacy wrapper replaces Z/z with +00:00 before fromisoformat, so
// these letters are timezone suffixes, never date/time separators.
const match = /^(\d{4}-\d{2}-\d{2}|\d{8}|\d{4}-W\d{2}(?:-[1-7])?|\d{4}W\d{2}[1-7]?)(?:[^Zz](.+))?$/u.exec(value);
if (!match) return null;
const [, date, time] = match;
let calendar: Date | null;
if (date.includes("W")) {
const week = /^(\d{4})-?W(\d{2})(?:-?([1-7]))?$/.exec(date)!;
const year = Number(week[1]), number = Number(week[2]), day = Number(week[3] ?? 1);
if (year < 1 || number < 1 || number > 53) return null;
calendar = parseIsoTimestamp(`${week[1]}-01-04`);
if (!calendar) return null;
calendar.setUTCDate(calendar.getUTCDate() - (calendar.getUTCDay() + 6) % 7 + (number - 1) * 7 + day - 1);
const thursday = new Date(calendar);
thursday.setUTCDate(thursday.getUTCDate() + 3 - (thursday.getUTCDay() + 6) % 7);
if (thursday.getUTCFullYear() !== year || calendar.getUTCFullYear() > 9999) return null;
} else {
const expanded = date.includes("-") ? date : `${date.slice(0, 4)}-${date.slice(4, 6)}-${date.slice(6)}`;
calendar = parseIsoTimestamp(expanded);
if (!calendar || calendar.getUTCFullYear() < 1) return null;
}
if (time === undefined) return BigInt(calendar.valueOf()) * 1000n;
const parts = /^(.*?)(Z|z|[+-].*)?$/.exec(time)!;
function clock(raw: string, offset: boolean): bigint | null {
const parsed = /^(\d{2})(?:(:?)(\d{2})(?:\2(\d{2}))?)?(?:[.,](\d+))?$/.exec(raw);
if (!parsed) return null;
const hour = Number(parsed[1]), minute = Number(parsed[3] ?? 0), second = Number(parsed[4] ?? 0);
if (!offset && (hour > 23 || minute > 59 || second > 59)) return null;
const seconds = hour * 3600 + minute * 60 + second;
const micros = BigInt(seconds) * 1000000n + BigInt((parsed[5] ?? "").padEnd(6, "0").slice(0, 6));
if (offset && micros >= 86400000000n) return null;
// Python treats an all-zero offset as UTC even with fractional seconds.
return offset && seconds === 0 ? 0n : micros;
}
const local = clock(parts[1], false);
if (local === null) return null;
let offset = 0n;
if (parts[2] && !["Z", "z"].includes(parts[2])) {
const parsed = clock(parts[2].slice(1), true);
if (parsed === null) return null;
offset = parts[2][0] === "-" ? -parsed : parsed;
}
return BigInt(calendar.valueOf()) * 1000n + local - offset;
}
7 changes: 0 additions & 7 deletions loopx/control_plane/scheduler/monitor_todo.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,6 @@
parse_monitor_timestamp = parse_scheduler_timestamp


def parse_monitor_counter(value: Any) -> int:
try:
return max(0, int(str(value or "0").strip()))
except ValueError:
return 0


def monitor_cadence_delta(value: Any) -> timedelta | None:
projected = project_monitor_todo_schedule(
generated_at="1970-01-01T00:00:00Z",
Expand Down
20 changes: 13 additions & 7 deletions loopx/control_plane/todos/field_update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
TODO_COMPLETION_STATE_REQUEST_SCHEMA,
} from "./completion_state.ts";
import { normalizeTodoResumeWhen, TODO_RESUME_NORMALIZE_REQUEST_SCHEMA_VERSION } from "./resume_condition.ts";
import { MONITOR_METADATA_FIELDS, planMonitorMetadata, TODO_MONITOR_METADATA_REQUEST_SCHEMA } from "./monitor_metadata.ts";

export const TODO_FIELD_UPDATE_REQUEST_SCHEMA = "loopx_todo_field_update_request_v0";
export const TODO_FIELD_UPDATE_RESULT_SCHEMA = "loopx_todo_field_update_result_v0";
Expand All @@ -27,9 +28,6 @@ const STRING_FIELDS = ["note", "evidence", "completion_turn_key", "reason", "tas
const PRESENT_FIELDS = ["required_write_scopes", "required_capabilities", "target_capabilities",
"explore_result_node_refs", "decision_scope", "required_decision_scopes", "decision_outcome",
"decision_scope_outcomes"] as const;
const MONITOR_FIELDS = ["target_key", "monitor_effect_id", "cadence", "next_due_at", "expires_at",
"last_checked_at", "result_hash", "consecutive_no_change", "material_change",
"material_change_generation", "max_no_change_before_replan", "watch_only"] as const;
const FLAGS = ["clear_claim", "claim_only", "clear_user_binding", "clear_blocks_agent",
"clear_global_gate", "clear_resume_when"] as const;
const INTENT_FIELDS = new Set<string>([...STRING_FIELDS, ...PRESENT_FIELDS, ...FLAGS,
Expand Down Expand Up @@ -190,10 +188,18 @@ export function planTodoFieldUpdate(value: unknown): TodoFieldUpdatePlan {
}
if (present(intent.no_followup)) updates.no_followup = intent.no_followup;
Object.assign(updates, completionUpdates(block, intent, targetStatus, normalizedStatus));
if (present(intent.monitor_metadata)) {
const monitor = requireJsonObject(intent.monitor_metadata, "monitor metadata");
for (const field of MONITOR_FIELDS) if (Object.hasOwn(monitor, field)) updates[field] = monitor[field];
// Public update carries the effective scope and raw observation once. The
// field plan composes validation and generation without another RPC.
const monitorPlan = request.monitor_context == null ? null : planMonitorMetadata({
...requireJsonObject(request.monitor_context, "monitor context"),
schema_version: TODO_MONITOR_METADATA_REQUEST_SCHEMA, existing: block, generated_at: updatedAt,
});
const monitor = monitorPlan?.metadata ?? intent.monitor_metadata;
if (present(monitor)) {
const metadata = requireJsonObject(monitor, "monitor metadata");
for (const field of MONITOR_METADATA_FIELDS) if (Object.hasOwn(metadata, field)) updates[field] = metadata[field];
}
return {schema_version: TODO_FIELD_UPDATE_RESULT_SCHEMA, normalized_status: normalizedStatus,
target_status: targetStatus, metadata_updates: updates};
target_status: targetStatus, metadata_updates: updates,
...(monitorPlan?.transition ? {monitor_poll_transition: monitorPlan.transition} : {})};
}
Loading
Loading