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 @@ -2561,6 +2561,11 @@ source paths, authorize monitor writeback, or change provider/promotion holds.

**D1 — qualify permanent projection delivery; may overlap T1/T2.**

The T2 monitor successor route owner is now shared across preflight, the legacy
effect adapter and receipt checks. Its result proves only normalized intent,
not actor authority, provider commit or atomic monitor-plus-successor durability.
Keep the monitor writer fence and promotion hold until that transaction closes.

- Start from `loopx/control_plane/todos/provider_projection.py`, the existing
Todo-section renderer and canonical journal/outbox. #4097 already recovers
missing Todo sections with `recovery_scope=todo_sections_only`; reuse it.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2034,6 +2034,11 @@ summary,之前消费 legacy summary;真实 CLI 覆盖容量变化和 promote

**D1 — 资格化永久投影交付,可与 T1/T2 重叠推进。**

T2 monitor successor 的路由 owner 已由 preflight、legacy effect adapter 和回执校验
共享。其结果仅证明规范化 intent,不证明 actor authority、provider commit 或
monitor-plus-successor 原子持久化;事务闭合前继续保留 monitor writer fence 与
promotion hold。

- 从 `loopx/control_plane/todos/provider_projection.py`、既有 Todo-section renderer、
canonical journal/outbox 入手。复用 #4097 已有的缺失 Todo section 恢复及
`recovery_scope=todo_sections_only`;它不能恢复丢失的独立 Goal 正文。
Expand Down
14 changes: 14 additions & 0 deletions docs/architecture/rfcs/typescript-control-plane-migration-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,20 @@ the shared plan, not another per-agent checklist database.

**T2 — close monitor writeback and its atomic follow-up.**

Bounded prerequisite delivered: `scheduler/monitor_successor.ts` owns successor
route validation and normalization for quota preflight, legacy writeback and
receipt verification. The Python route guard/resolver and the separate TS
receipt-default/capability interpretation are removed. Invalid capability entries,
malformed successor claims and follow-ups without material change fail before
the observation write; valid action/claim/capability aliases and Git transports
are compared as the same route at readback. The original wire observation still
owns the v0 replay digest; normalization must not silently invalidate pending
receipts. The node-independent repository/bootstrap codec remains separately
characterized, not replaced by a runtime dependency.
This is **not** the T2 atomic transaction: monitor mutation and successor writes
still use existing fenced effects. Cross-effect crash recovery, native writer
closure and whole-Goal promotion remain held; do not infer them from a route plan.

- Inventory `monitor_poll_writeback.py` and its event/Todo/lease callers.
Reuse existing monitor generation, independent-successor and settlement
owners. Compose one transaction rather than adding a second monitor engine.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,16 @@ commit。#4121(SQLite 候选)和 #4101(投影 receipt 保留)是独立

**T2 — 闭合 monitor 写回及原子后续动作。**

已交付有边界前置项:`scheduler/monitor_successor.ts` 统一 quota preflight、legacy
writeback 与 receipt verification 的后续路由校验和规范化,删除 Python route
guard/resolver 及 TS 回执端独立的默认值/capability 解释。非法 capability 项、非法
后续 claim 和未声明 material change 的 follow-up 在 observation 写入前拒绝;合法
action/claim/capability 别名和 Git transport 在回执核对时指向同一路由。v0 replay
digest 仍绑定原始 wire observation,不能因规范化而悄悄使 pending receipt 失效。
无需 Node 的 repository/bootstrap codec 暂留并做跨运行时对照,不引入启动依赖。
这**不是** T2 原子事务:monitor mutation 和 successor 写入仍通过既有 fenced effect
执行;跨 effect crash 恢复、native writer 闭合及整 Goal promotion 仍未放行。

- 盘点 `monitor_poll_writeback.py` 及 event/Todo/lease caller,复用 monitor
generation、独立 successor 和 settlement owner,组成一笔事务,不建第二套引擎。
- 保持 unchanged poll/reschedule、generation fence、material-change successor
Expand Down
13 changes: 13 additions & 0 deletions docs/project-agent-todo-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,19 @@ Relevant command results expose the compact
`monitor_advancement_authoring_v0` contract so an Agent can recover this
sequence without parsing documentation prose.

Monitor successor routing uses one typed plan for preflight, writeback and
receipt verification. Common Git transport URLs resolve to the same canonical
repository identity, and action/claim/capability aliases are normalized before
comparison. Repository routes must be representable as canonical `git:<host>/<path>`
identities; control characters, backslashes and percent-encoded paths are rejected.
Every supplied capability must be valid: an invalid entry is not silently dropped
from a partly valid list. Follow-ups require `--material-change`; assignment or
other agent-route flags without `--next-agent-todo` are rejected before writeback.
User follow-ups still require explicit `user_action` or `user_gate`, never an
implicit global gate. A route plan is not a claim, approval or atomic commit.
Replay identity continues to bind the original observation, not a rewritten
canonical spelling; retry the same logical observation with the same arguments.

Open todos may also carry `resume_when` when they are visible but not yet
executable. Until the parsed `resume_condition.satisfied` value is true, status
and quota keep the todo out of `first_executable_items`,
Expand Down
10 changes: 10 additions & 0 deletions examples/shared-goal-authority-e2e/mutants.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,16 @@ def command(self) -> list[str]:
Case('todo_successor_scope_unbound', (('loopx/control_plane/todos/authoring_scope.ts', replacement(
'if (blocks && (goal || !bound || bound !== blocks)) return "agent_binding_conflict";', '')),),
'tests/control_plane_ts/todo_authoring_scope.test.ts', 'resolved successor scope'),
Case('monitor_route_drops_invalid_capability', (('loopx/control_plane/scheduler/monitor_successor.ts', replacement(
' throw new EffectRuntimeRequestError(`${label} must contain public-safe capability tokens; invalid entries cannot be dropped`);',
' continue;')),),
'tests/control_plane_ts/monitor_successor.test.ts', 'invalid successor intent is rejected'),
Case('monitor_route_material_guard_removed', (('loopx/control_plane/scheduler/monitor_successor.ts', replacement(
'if ((agentTodo || userTodo) && !material)', 'if (false)')),),
'tests/control_plane_ts/monitor_successor.test.ts', 'invalid successor intent is rejected'),
Case('monitor_route_rewrites_fingerprint', (('loopx/control_plane/quota/monitor_poll_commit.ts', replacement(
' monitorSuccessorIntent(result);', ' Object.assign(result, monitorSuccessorIntent(result));')),),
'tests/control_plane_ts/quota_monitor_poll_commit.test.ts', 'preserves the legacy pending observation fingerprint'),
Case('delivery_wait_target_unbound', (('loopx/control_plane/todos/resume_condition.ts', replacement(
'condition.target_todo_id !== spec.target || ', '')),),
'tests/control_plane_ts/delivery_response.test.ts', 'exact dependency identity'),
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 @@ -48,6 +48,7 @@ import { evaluateQuotaVoidCommit } from "./quota/void_commit.ts";
import { readQuotaSettlement } from "./quota/settlement_readback.ts";
import { evaluateTurnEnvelope } from "./quota/turn_envelope.ts";
import { evaluateQuotaMonitorPollCommit } from "./quota/monitor_poll_commit.ts";
import { planMonitorSuccessor } from "./scheduler/monitor_successor.ts";
import { evaluateDeliveryWorkspace } from "./agents/delivery_workspace.ts";
import {
interpretTurnJournal,
Expand Down Expand Up @@ -461,6 +462,7 @@ export function createEffectRuntimeHandlers(
],
["task_lease.write_scopes.overlap", evaluateTaskLeaseWriteScopesOverlap],
["quota.monitor_poll.commit", evaluateQuotaMonitorPollCommit],
["scheduler.monitor_successor.plan", planMonitorSuccessor],
["coordination.local_authority_shadow.record", recordLocalAuthorityShadow],
["coordination.runtime_shadow.commit_entry", commitLocalAuthorityShadowEntry],
["coordination.runtime_shadow.outbox_read", readLocalAuthorityShadow],
Expand Down
77 changes: 11 additions & 66 deletions loopx/control_plane/quota/monitor_poll_commit.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createHash } from "node:crypto";
import { access, readFile, rm } from "node:fs/promises";
import { basename, dirname, join, resolve } from "node:path";
import { monitorSuccessorIntent, monitorSuccessorRoute, monitorSuccessorCapabilities } from "../scheduler/monitor_successor.ts";

import type { JsonObject } from "../effect_program.ts";
import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts";
Expand Down Expand Up @@ -363,42 +364,9 @@ function observationObject(value: unknown): MonitorObservation {
"`quota monitor-poll --material-change` requires --todo-id or --target-key",
);
}
if ((result.next_agent_todo || result.next_user_todo) && !materialChange) {
throw new EffectRuntimeRequestError(
"`--next-agent-todo` and `--next-user-todo` require --material-change",
);
}
if (result.next_agent_todo && !result.next_action_kind) {
throw new EffectRuntimeRequestError(
"`quota monitor-poll --next-agent-todo` requires explicit successor action semantics via --next-action-kind",
);
}
const agentRoute = result.next_action_kind || result.next_task_repository ||
result.next_required_capabilities.length || result.next_continuation_policy ||
result.next_target_key;
if (!result.next_agent_todo && agentRoute) {
throw new EffectRuntimeRequestError(
"monitor successor routing options require --next-agent-todo",
);
}
if (result.next_user_todo && !result.next_user_task_class) {
throw new EffectRuntimeRequestError(
"--next-user-todo requires explicit --next-user-task-class user_action|user_gate",
);
}
if (!result.next_user_todo && result.next_user_task_class) {
throw new EffectRuntimeRequestError(
"--next-user-task-class requires --next-user-todo",
);
}
if (
result.next_user_task_class &&
!["user_action", "user_gate"].includes(result.next_user_task_class)
) {
throw new EffectRuntimeRequestError(
"--next-user-task-class must be user_action or user_gate",
);
}
// Validate the route without rewriting the persisted observation fingerprint.
// Pending receipts from earlier versions must remain replayable.
monitorSuccessorIntent(result);
return result;
}

Expand Down Expand Up @@ -883,15 +851,8 @@ function requireProviderCapabilityMatch(
expected: readonly string[],
label: string,
): void {
const canonical = (items: readonly string[]): string[] => [
...new Set(
items
.map((item) => item.trim().toLowerCase().replace(/[-\s]+/g, "_"))
.filter(Boolean),
),
];
const actualCapabilities = canonical(requireStringArray(actual, label));
const expectedCapabilities = canonical(expected);
const actualCapabilities = monitorSuccessorCapabilities(actual, label);
const expectedCapabilities = monitorSuccessorCapabilities(expected, label);
if (pythonJson(actualCapabilities) !== pythonJson(expectedCapabilities)) {
throw new EffectRuntimeRequestError(`${label} must match provider plan`);
}
Expand All @@ -915,19 +876,9 @@ function requireProviderTodoText(
requireProviderMatch(actual, compactExpected, label);
}

function derivedMonitorSuccessorTargetKey(todoId: string, resultHash: string): string {
return `monitor-successor:${todoId}:${sha256Hex(resultHash).slice(0, 16)}`;
}

function requireCanonicalSuccessorRoute(
value: JsonObject,
expected: {
task_repository: string | null;
required_capabilities: readonly string[];
continuation_policy: string;
target_key: string;
claimed_by: string | null;
},
expected: JsonObject,
label: string,
): void {
requireProviderMatch(
Expand All @@ -937,7 +888,7 @@ function requireCanonicalSuccessorRoute(
);
requireProviderCapabilityMatch(
value.required_capabilities ?? [],
expected.required_capabilities,
requireStringArray(expected.required_capabilities, "expected capabilities"),
`${label} required_capabilities`,
);
requireProviderMatch(
Expand Down Expand Up @@ -978,6 +929,7 @@ function validateSuccessorReceipts(
}
let offset = 0;
if (plan.material_change && plan.next_agent_todo) {
const canonicalRoute = monitorSuccessorRoute(monitorSuccessorIntent(plan), todoId, plan.result_hash);
const receipt = receipts[offset];
const nextTodo = nextTodos[offset++];
requireProviderMatch(receipt.role, "agent", "agent successor role");
Expand All @@ -994,12 +946,12 @@ function validateSuccessorReceipts(
);
requireProviderMatch(
receipt.action_kind,
plan.next_action_kind,
canonicalRoute.action_kind,
"agent successor action_kind",
);
requireProviderMatch(
nextTodo.action_kind,
plan.next_action_kind,
canonicalRoute.action_kind,
"agent next_todo action_kind",
);
requireProviderMatch(
Expand All @@ -1022,13 +974,6 @@ function validateSuccessorReceipts(
requiredProviderTodoId(nextTodo.todo_id, "agent next_todo todo_id"),
"agent successor todo_id",
);
const canonicalRoute = {
task_repository: plan.next_task_repository,
required_capabilities: plan.next_required_capabilities,
continuation_policy: plan.next_continuation_policy ?? "independent_handoff",
target_key: plan.next_target_key ?? derivedMonitorSuccessorTargetKey(todoId, plan.result_hash),
claimed_by: plan.next_claimed_by,
};
requireCanonicalSuccessorRoute(receipt, canonicalRoute, "agent successor");
requireCanonicalSuccessorRoute(nextTodo, canonicalRoute, "agent next_todo");
}
Expand Down
Loading