diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md index 79eaa629a3..54d5834410 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md @@ -1641,7 +1641,7 @@ codec retire when CLI/Host callers use the typed kernel directly. The target is TypeScript ownership throughout, with no Python rule fallback. The adapter transports bounded facts and diagnostic strings, then applies the typed quota patch without round-tripping source numbers or metadata. Receipt-bound -overlays, later route/action builders and effect authority retain their owners. +overlays, remaining route/action builders and effect authority retain their owners. The existing effective-action vocabulary and generated TS binding are reused; its canonical Python enum remains until a separate contract-owner cutover. The TypeScript producer witness executes the real typed classifier against shared guard fixtures and rejects a @@ -1650,6 +1650,15 @@ semantic parity for one base-admission consumer. Full-CLI performance acceptance is held for owner review after a p95 regression; whole-Goal migration and provider/default qualification remain open. +The task-scoped `goal_route_hint` override now comes back in that same +`quota.run_decision` response. Python projects the lane count from the existing +task-orchestration source fact. TypeScript owns the ready-state check, route +decision and coordination wording. Python's duplicate `task_goal_route_hint` decision is +deleted, leaving only the bounded merge into the assembled hint. This adds no +request, provider read or authority. Other Goal-route and primary-action +builders remain separate migration work; this does not complete the quota read +transaction or claim a performance gain. + **Long-history transport boundary.** Replan history still has one TS decision owner. Small requests retain the inline codec; larger complete fact snapshots travel through a private, digest-bound local file reference. The same reducer diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md index 9f2a05f2fd..c35e44168b 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md @@ -1164,7 +1164,7 @@ automation、replan、终态、inbox 和协调者覆盖规则,生成基础运 迁移后退役。CLI/Host 调用方直接使用类型化内核后,Python 公共函数、dataclass 及诊断 codec 退役。最终实现全部由 TypeScript 持有,不设 Python 规则回退。 适配器传递有界事实及诊断字符串,再应用 -类型化配额补丁,不让来源数值或元数据经历 JSON 往返转换。回执绑定覆盖、后续 +类型化配额补丁,不让来源数值或元数据经历 JSON 往返转换。回执绑定覆盖、剩余 route/action builder 和 effect 权限保留各自 owner。沿用既有 effective-action 词表及生成的 TS binding;canonical Python enum 等独立契约 owner 切换后退役。 TS 生产者验证器通过共享 guard fixture 执行真实 TS 分类器,并拒绝规则变更或 @@ -1172,6 +1172,13 @@ TS 生产者验证器通过共享 guard fixture 执行真实 TS 分类器,并 出现 p95 回退,性能验收保留 owner 复核门禁。整 Goal 迁移及 provider/默认配置 资格仍待完成。 +Task-scoped `goal_route_hint` override 现在由同一个 +`quota.run_decision` 响应返回。Python 从既有 task-orchestration source fact 投影 +lane 数。TypeScript 持有 ready 状态检查、route 判定和协调说明。Python 重复实现的 +`task_goal_route_hint` 判定已删除,只保留将有界补丁合并进已组装 hint 的步骤。 +本改动不增加请求、provider 读取或 authority。其他 Goal route 与 primary-action +builder 仍是独立迁移工作;这不代表 quota read transaction 已完成,也不宣称性能提升。 + **长历史传输边界。** Replan 历史仍由一个 TS owner 决策。小请求保留 inline codec;较大的完整事实快照通过私有临时文件和摘要绑定的引用传递。同一 reducer 校验全部记录、agent 作用域内的 ACK 及重试身份;RPC 预算和展示窗口都不允许截断 diff --git a/loopx/control_plane/goals/acceptance_authority.ts b/loopx/control_plane/goals/acceptance_authority.ts index 995137180d..9372dfc139 100644 --- a/loopx/control_plane/goals/acceptance_authority.ts +++ b/loopx/control_plane/goals/acceptance_authority.ts @@ -578,8 +578,9 @@ export async function inspectGoalAcceptance( const state = authority.kind === "legacy" ? authority.state : authority.owner_matches && authority.lifecycle.state === "active" ? authority.state : null; const tasks = [...todos.entries()].filter(([key]) => todoId === undefined || key === todoId).map(([key, todo]) => ({ - todo_id: key, todo_semantic_digest: goalAcceptanceTodoDigest(todo), - ...(state ? acceptanceTask(key, todo, state) : {state: "unbound", criterion_ids: [], applicable: false}), + todo_semantic_digest: goalAcceptanceTodoDigest(todo), + ...(state ? acceptanceTask(key, todo, state) + : {todo_id: key, state: "unbound", criterion_ids: [], applicable: false}), })); return source(store, {status: "loaded", provider_revision: head.provider_revision, revision: state?.revision ?? null, contract_digest: state?.digest ?? null, diff --git a/loopx/control_plane/quota/decision_summary.py b/loopx/control_plane/quota/decision_summary.py index f080693567..8a279260ea 100644 --- a/loopx/control_plane/quota/decision_summary.py +++ b/loopx/control_plane/quota/decision_summary.py @@ -149,6 +149,7 @@ class QuotaRunDecision: state: str quota: dict[str, Any] replan_decision_allowed: bool + goal_route_hint_override: dict[str, Any] | None = None def refine_quota_recommended_action( @@ -272,6 +273,12 @@ def resolve_quota_run_decision( ) if replan_required and not (inbox_reply_due or inbox_material_review_due) else {} repair_action = (stall_self_repair.get("effective_action") if isinstance(stall_self_repair, dict) else None) + task_orchestration_lane_count = 0 + if task_orchestration_contract is not None: + lanes = task_orchestration_contract.get("eligible_child_lanes") + if not isinstance(lanes, list): + lanes = task_orchestration_contract.get("eligible_peer_lanes") + task_orchestration_lane_count = len(lanes) if isinstance(lanes, list) else 0 result = effect_runtime_result("quota.run_decision", { "normal_delivery_allowed": normal_delivery_allowed, "recovery_delivery_allowed": recovery_delivery_allowed, @@ -291,9 +298,15 @@ def resolve_quota_run_decision( "inbox_material_review_due": inbox_material_review_due, "terminal_no_followup": goal_frontier_is_terminal_no_followup( projection=goal_frontier_projection), + "task_orchestration_lane_count": task_orchestration_lane_count, "task_orchestration_contract": _guard_strings( task_orchestration_contract, ("execution_state", "mode")), }) patch = result.pop("quota_patch") + route_hint_override = result.pop("goal_route_hint_override", None) # Only typed edits cross the wire; preserve source numbers and unmodified metadata. - return QuotaRunDecision(quota={**quota, **patch} if patch else quota, **result) + return QuotaRunDecision( + quota={**quota, **patch} if patch else quota, + goal_route_hint_override=route_hint_override, + **result, + ) diff --git a/loopx/control_plane/quota/run_decision.ts b/loopx/control_plane/quota/run_decision.ts index a4527f027b..751c96b286 100644 --- a/loopx/control_plane/quota/run_decision.ts +++ b/loopx/control_plane/quota/run_decision.ts @@ -23,6 +23,7 @@ interface RunFacts { inbox_material_review_due: boolean; terminal_no_followup: boolean; task_orchestration_contract: JsonObject | null; + task_orchestration_lane_count: number; } interface RunDecision extends JsonObject { @@ -37,6 +38,7 @@ interface RunDecision extends JsonObject { state: string; quota_patch: JsonObject; replan_decision_allowed: boolean; + goal_route_hint_override: JsonObject | null; } function text(value: unknown, label: string): string { @@ -51,6 +53,8 @@ function optionalRecord(value: unknown, label: string): JsonObject | null { } function decodeRunFacts(params: JsonObject): RunFacts { + const taskOrchestrationContract = optionalRecord(params.task_orchestration_contract, + "task_orchestration_contract"); return { normal_delivery_allowed: requireBoolean(params.normal_delivery_allowed, "normal_delivery_allowed"), recovery_delivery_allowed: requireBoolean(params.recovery_delivery_allowed, "recovery_delivery_allowed"), @@ -69,10 +73,18 @@ function decodeRunFacts(params: JsonObject): RunFacts { inbox_reply_due: requireBoolean(params.inbox_reply_due, "inbox_reply_due"), inbox_material_review_due: requireBoolean(params.inbox_material_review_due, "inbox_material_review_due"), terminal_no_followup: requireBoolean(params.terminal_no_followup, "terminal_no_followup"), - task_orchestration_contract: optionalRecord(params.task_orchestration_contract, "task_orchestration_contract"), + task_orchestration_lane_count: nonNegativeCount(params.task_orchestration_lane_count, + "task_orchestration_lane_count"), + task_orchestration_contract: taskOrchestrationContract, }; } +function nonNegativeCount(value: unknown, label: string): number { + if (!Number.isSafeInteger(value) || Number(value) < 0) + throw new EffectRuntimeRequestError(`${label} must be a non-negative safe integer`); + return Number(value); +} + function guardReason(guard: JsonObject | null, fallback: string): string { return (guard ? text(guard.reason ?? "", "guard.reason") : "") || fallback; } @@ -178,6 +190,19 @@ function taskOrchestrationEffectiveAction(facts: RunFacts, decision: RunDecision return [decision.effective_action, decision.reason]; } +function taskOrchestrationRouteHint(contract: JsonObject | null, laneCount: number): JsonObject | null { + if (!contract || (text(contract.execution_state ?? "", "execution_state") || "ready") !== "ready") return null; + return { + omit_current_agent_next_action: true, + kind: "task_orchestration", + route_decision: "coordinate_task_bundle", + reason: contract.mode === "adaptive" + ? "task-scoped coordinator may spawn admitted child lanes" + : "task-scoped coordinator must activate/resume eligible peer lanes", + peer_lane_count: laneCount, + }; +} + /** Compose base quota admission after source reads; receipt-bound overlays keep their owners. */ export function resolveQuotaRunDecision(params: JsonObject): JsonObject { const facts = decodeRunFacts(params); @@ -188,11 +213,14 @@ export function resolveQuotaRunDecision(params: JsonObject): JsonObject { capability_repair_allowed: false, workspace_repair_allowed: false, should_run: false, effective_action: EffectiveAction.QUOTA_SKIP, reason: facts.reason, state: facts.state, quota_patch: {}, replan_decision_allowed: false, + goal_route_hint_override: null, }; applyDeliveryGuards(facts, decision); decision.effective_action = quotaEffectiveAction(facts, decision); applyGoalOverrides(facts, decision); applyInboxOverrides(facts, decision); [decision.effective_action, decision.reason] = taskOrchestrationEffectiveAction(facts, decision); + decision.goal_route_hint_override = taskOrchestrationRouteHint( + facts.task_orchestration_contract, facts.task_orchestration_lane_count); return decision; } diff --git a/loopx/control_plane/quota/should_run_packet.py b/loopx/control_plane/quota/should_run_packet.py index 373e26ee47..2c4448c59e 100644 --- a/loopx/control_plane/quota/should_run_packet.py +++ b/loopx/control_plane/quota/should_run_packet.py @@ -73,8 +73,8 @@ ) from ..quota.task_orchestration import ( PEER_COORDINATION_BLOCKED_ACTION, + apply_task_route_hint_override, attach_task_orchestration_payload, - task_goal_route_hint, task_orchestration_contract_is_actionable, task_orchestration_requires_material_change_stop, ) @@ -1278,9 +1278,9 @@ def _resolve_quota_should_run_route( latest_run_recommended_action=latest_run_recommended_action_text, selected_recommended_action=selected_recommended_action, ) - goal_route_hint = task_goal_route_hint( + goal_route_hint = apply_task_route_hint_override( goal_route_hint, - prepared.task_orchestration_contract, + run_decision.goal_route_hint_override, ) payload_work_lane_contract = _payload_work_lane_contract( prepared.work_lane_contract, diff --git a/loopx/control_plane/quota/task_orchestration.py b/loopx/control_plane/quota/task_orchestration.py index 84a4b8cd97..6629208c2e 100644 --- a/loopx/control_plane/quota/task_orchestration.py +++ b/loopx/control_plane/quota/task_orchestration.py @@ -34,6 +34,23 @@ def task_orchestration_contract_is_actionable( return str(contract.get("execution_state") or "ready") == "ready" +def apply_task_route_hint_override( + hint: dict[str, Any] | None, + override: dict[str, Any] | None, +) -> dict[str, Any] | None: + """Apply the route patch returned by the TypeScript decision owner.""" + if hint is None or override is None: + return hint + projected_hint = { + key: value for key, value in hint.items() + if not (override.get("omit_current_agent_next_action") is True + and key == "current_agent_next_action") + } + projected_hint.update({key: value for key, value in override.items() + if key != "omit_current_agent_next_action"}) + return projected_hint + + def task_orchestration_requires_material_change_stop( contract: dict[str, Any] | None, *, @@ -145,34 +162,6 @@ def apply_task_orchestration_contract( return contract, _task_orchestration_work_lane_contract(contract) -def task_goal_route_hint( - goal_route_hint: dict[str, Any] | None, - contract: dict[str, Any] | None, -) -> dict[str, Any] | None: - if not contract or not isinstance(goal_route_hint, dict): - return goal_route_hint - if not task_orchestration_contract_is_actionable(contract): - return goal_route_hint - lanes = contract.get("eligible_child_lanes") - if not isinstance(lanes, list): - lanes = contract.get("eligible_peer_lanes") - return { - **{ - key: value - for key, value in goal_route_hint.items() - if key != "current_agent_next_action" - }, - "kind": "task_orchestration", - "route_decision": "coordinate_task_bundle", - "reason": ( - "task-scoped coordinator may spawn admitted child lanes" - if contract.get("mode") == "adaptive" - else "task-scoped coordinator must activate/resume eligible peer lanes" - ), - "peer_lane_count": len(lanes) if isinstance(lanes, list) else 0, - } - - def attach_task_orchestration_payload( payload: dict[str, Any], contract: dict[str, Any] | None, diff --git a/tests/control_plane/test_quota_run_decision.py b/tests/control_plane/test_quota_run_decision.py index 49e83f930b..33481d7043 100644 --- a/tests/control_plane/test_quota_run_decision.py +++ b/tests/control_plane/test_quota_run_decision.py @@ -15,6 +15,7 @@ QuotaRunDecision, resolve_quota_run_decision, ) +from loopx.control_plane.quota.task_orchestration import apply_task_route_hint_override def _resolve(**overrides: Any) -> QuotaRunDecision: @@ -176,6 +177,36 @@ def test_task_orchestration_refines_normal_run_action() -> None: assert decision.should_run is True assert decision.normal_delivery_allowed is True assert decision.effective_action == "coordinate_task_bundle" + assert decision.goal_route_hint_override == { + "omit_current_agent_next_action": True, + "kind": "task_orchestration", + "route_decision": "coordinate_task_bundle", + "reason": "task-scoped coordinator must activate/resume eligible peer lanes", + "peer_lane_count": 0, + } + decision = _resolve(task_orchestration_contract={"execution_state": "ready", "mode": "adaptive", + "eligible_child_lanes": [{}, {}]}) + assert decision.goal_route_hint_override["peer_lane_count"] == 2 + + +def test_goal_route_hint_applies_only_the_typed_owner_patch() -> None: + hint = {"kind": "agent_lane_synthesis", "current_agent_next_action": {"todo_id": "todo-a"}, + "has_durable_next_action": True} + patched = apply_task_route_hint_override(hint, { + "omit_current_agent_next_action": True, + "kind": "task_orchestration", + "route_decision": "coordinate_task_bundle", + "reason": "task-scoped coordinator may spawn admitted child lanes", + "peer_lane_count": 2, + }) + assert patched == { + "kind": "task_orchestration", + "route_decision": "coordinate_task_bundle", + "reason": "task-scoped coordinator may spawn admitted child lanes", + "peer_lane_count": 2, + "has_durable_next_action": True, + } + assert hint["current_agent_next_action"] == {"todo_id": "todo-a"} @pytest.mark.parametrize( diff --git a/tests/control_plane_ts/fixtures/quota_run_decision.json b/tests/control_plane_ts/fixtures/quota_run_decision.json index aa240f9039..9e81c71c50 100644 --- a/tests/control_plane_ts/fixtures/quota_run_decision.json +++ b/tests/control_plane_ts/fixtures/quota_run_decision.json @@ -18,6 +18,7 @@ "inbox_reply_due": false, "inbox_material_review_due": false, "terminal_no_followup": false, + "task_orchestration_lane_count": 0, "task_orchestration_contract": null }, "base_result": { @@ -31,7 +32,8 @@ "reason": "eligible", "state": "eligible", "quota_patch": {}, - "replan_decision_allowed": false + "replan_decision_allowed": false, + "goal_route_hint_override": null }, "cases": [ { @@ -324,7 +326,14 @@ }, "expected": { "effective_action": "coordinate_task_bundle", - "reason": "the task coordinator may use admitted child lanes before its own worker-lane delivery" + "reason": "the task coordinator may use admitted child lanes before its own worker-lane delivery", + "goal_route_hint_override": { + "omit_current_agent_next_action": true, + "kind": "task_orchestration", + "route_decision": "coordinate_task_bundle", + "reason": "task-scoped coordinator may spawn admitted child lanes", + "peer_lane_count": 0 + } } }, { @@ -337,7 +346,14 @@ }, "expected": { "effective_action": "coordinate_task_bundle", - "reason": "the explicitly selected task coordinator must activate or resume eligible peer lanes before doing its own worker-lane delivery" + "reason": "the explicitly selected task coordinator must activate or resume eligible peer lanes before doing its own worker-lane delivery", + "goal_route_hint_override": { + "omit_current_agent_next_action": true, + "kind": "task_orchestration", + "route_decision": "coordinate_task_bundle", + "reason": "task-scoped coordinator must activate/resume eligible peer lanes", + "peer_lane_count": 0 + } } }, { @@ -361,7 +377,14 @@ }, "expected": { "effective_action": "operator_inbox_material_review_due", - "reason": "captured unaddressed operator-inbox material is pending bounded review" + "reason": "captured unaddressed operator-inbox material is pending bounded review", + "goal_route_hint_override": { + "omit_current_agent_next_action": true, + "kind": "task_orchestration", + "route_decision": "coordinate_task_bundle", + "reason": "task-scoped coordinator may spawn admitted child lanes", + "peer_lane_count": 0 + } } }, { diff --git a/tests/control_plane_ts/goal_acceptance_authority.test.ts b/tests/control_plane_ts/goal_acceptance_authority.test.ts index 834f9ded16..9016695958 100644 --- a/tests/control_plane_ts/goal_acceptance_authority.test.ts +++ b/tests/control_plane_ts/goal_acceptance_authority.test.ts @@ -584,6 +584,7 @@ for (const provider of providers) { assert.deepEqual(await head(store), after, "task-scoped planning has no provider write"); assert.equal(((inspection.contract as JsonObject).criteria as JsonObject[])[0].validation_timeout_seconds, 5); assert.ok((inspection.tasks as JsonObject[]).every(task => typeof task.todo_semantic_digest === "string")); + assert.ok((inspection.tasks as JsonObject[]).every(task => typeof task.todo_id === "string")); const projection = projectGoalAcceptance(after.head, goal); assert.equal(projection.status, "held"); assert.deepEqual(projection.held_todo_ids, ["todo_second"]); diff --git a/tests/control_plane_ts/quota_run_decision.test.ts b/tests/control_plane_ts/quota_run_decision.test.ts index eea306ffec..dc6ec5c00c 100644 --- a/tests/control_plane_ts/quota_run_decision.test.ts +++ b/tests/control_plane_ts/quota_run_decision.test.ts @@ -14,7 +14,40 @@ for (const scenario of fixture.cases) { test("malformed admission facts cannot manufacture a runnable decision", () => { for (const patch of [{normal_delivery_allowed: "true"}, {replan_applies: null}, - {workspace_guard: "not a guard"}, {quota_focus_wait: 1}, {reason: null}]) { + {workspace_guard: "not a guard"}, {quota_focus_wait: 1}, {reason: null}, + {task_orchestration_lane_count: -1}, {task_orchestration_lane_count: 1.5}]) { assert.throws(() => resolveQuotaRunDecision({...fixture.base_facts, ...patch} as JsonObject)); } }); + +for (const scenario of [ + { + name: "adaptive counts child lanes", + laneCount: 2, + contract: {execution_state: "ready", mode: "adaptive"}, + expected: {omit_current_agent_next_action: true, kind: "task_orchestration", + route_decision: "coordinate_task_bundle", reason: "task-scoped coordinator may spawn admitted child lanes", + peer_lane_count: 2}, + }, + { + name: "peer coordinator counts peer lanes", + laneCount: 1, + contract: {execution_state: "", mode: "task_scoped_peer"}, + expected: {omit_current_agent_next_action: true, kind: "task_orchestration", + route_decision: "coordinate_task_bundle", + reason: "task-scoped coordinator must activate/resume eligible peer lanes", peer_lane_count: 1}, + }, + { + name: "waiting coordinator has no override", + laneCount: 0, + contract: {execution_state: "waiting", mode: "adaptive"}, + expected: null, + }, +]) { + test(`task route hint: ${scenario.name}`, () => { + const decision = resolveQuotaRunDecision({...fixture.base_facts, + task_orchestration_lane_count: scenario.laneCount, + task_orchestration_contract: scenario.contract}); + assert.deepEqual(decision.goal_route_hint_override, scenario.expected); + }); +}