From 02ba8999fd5d04903d2ed60f4a021745ff4be0b8 Mon Sep 17 00:00:00 2001 From: KashiwaByte <471314513@qq.com> Date: Thu, 17 Sep 2026 00:37:27 +0800 Subject: [PATCH 01/16] feat(control-plane): add company work routing contract Signed-off-by: KashiwaByte <471314513@qq.com> --- .../work_items/company_control_loop.ts | 323 ++++++++++++++++++ .../company_control_loop.test.ts | 124 +++++++ tsconfig.control-plane.json | 2 + 3 files changed, 449 insertions(+) create mode 100644 loopx/control_plane/work_items/company_control_loop.ts create mode 100644 tests/control_plane_ts/company_control_loop.test.ts diff --git a/loopx/control_plane/work_items/company_control_loop.ts b/loopx/control_plane/work_items/company_control_loop.ts new file mode 100644 index 0000000000..330ae4baed --- /dev/null +++ b/loopx/control_plane/work_items/company_control_loop.ts @@ -0,0 +1,323 @@ +import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts"; +import { + optionalNonEmptyString, + requireBoolean, + requireInteger, + requireJsonObject, + requireNonEmptyString, + requireStringArray, + requireStringLiteral, +} from "../runtime_decode.ts"; + +import type { JsonObject } from "../effect_program.ts"; + +export const COMPANY_CONTROL_LOOP_REQUEST_SCHEMA_VERSION = + "company_control_loop_request_v0"; +export const COMPANY_CONTROL_LOOP_SCHEMA_VERSION = "company_control_loop_v0"; + +const MAX_OUTCOMES = 128; +const MAX_WORK_ITEMS = 256; +const MAX_FEEDBACK_ITEMS = 256; +const PUBLIC_ID = /^[a-z][a-z0-9_-]{2,127}$/; + +export const COMPANY_WORK_ROUTES = [ + "ai_execute", + "human_decide", + "human_execute", + "observe", + "reject", +] as const; + +export type CompanyWorkRoute = (typeof COMPANY_WORK_ROUTES)[number]; +export type CompanyAuthorityTier = "A" | "B" | "C" | "D"; + +interface CompanyWorkItem extends JsonObject { + work_item_id: string; + outcome_id: string; + title: string; + acceptance: string; + authority_tier: CompanyAuthorityTier; + ai_capable: boolean; + prohibited: boolean; + material_decision: boolean; + human_identity_required: boolean; + wait_for?: string; + target_key: string; +} + +interface RoutedCompanyWorkItem extends CompanyWorkItem { + route: CompanyWorkRoute; + route_reason: string; + status: + | "ready" + | "waiting_human_decision" + | "waiting_human_execution" + | "waiting_external_evidence" + | "cancelled"; + todo_projection: JsonObject; +} + +function boundedArray( + value: unknown, + label: string, + maximum: number, +): unknown[] { + if (!Array.isArray(value)) { + throw new EffectRuntimeRequestError(`${label} must be an array`); + } + if (value.length > maximum) { + throw new EffectRuntimeRequestError( + `${label} must contain at most ${maximum} items`, + ); + } + return value; +} + +function publicId(value: unknown, label: string): string { + const normalized = requireNonEmptyString(value, label); + if (!PUBLIC_ID.test(normalized)) { + throw new EffectRuntimeRequestError(`${label} must be a public-safe id`); + } + return normalized; +} + +function companyWorkItem(value: unknown, label: string): CompanyWorkItem { + const raw = requireJsonObject(value, label); + const waitFor = optionalNonEmptyString(raw.wait_for, `${label}.wait_for`); + return { + work_item_id: publicId(raw.work_item_id, `${label}.work_item_id`), + outcome_id: publicId(raw.outcome_id, `${label}.outcome_id`), + title: requireNonEmptyString(raw.title, `${label}.title`), + acceptance: requireNonEmptyString(raw.acceptance, `${label}.acceptance`), + authority_tier: requireStringLiteral( + raw.authority_tier, + ["A", "B", "C", "D"] as const, + `${label}.authority_tier`, + ), + ai_capable: requireBoolean(raw.ai_capable, `${label}.ai_capable`), + prohibited: raw.prohibited === undefined + ? false + : requireBoolean(raw.prohibited, `${label}.prohibited`), + material_decision: raw.material_decision === undefined + ? false + : requireBoolean(raw.material_decision, `${label}.material_decision`), + human_identity_required: raw.human_identity_required === undefined + ? false + : requireBoolean( + raw.human_identity_required, + `${label}.human_identity_required`, + ), + ...(waitFor === null ? {} : { wait_for: waitFor }), + target_key: publicId(raw.target_key, `${label}.target_key`), + }; +} + +export function routeCompanyWorkItem( + item: CompanyWorkItem, +): { route: CompanyWorkRoute; reason: string } { + if (item.prohibited || item.authority_tier === "D") { + return { + route: "reject", + reason: "policy or current authority prohibits execution", + }; + } + if (item.wait_for) { + return { + route: "observe", + reason: "work depends on a future external state", + }; + } + if (item.material_decision || item.authority_tier === "B") { + return { + route: "human_decide", + reason: "a material choice or authority grant is required", + }; + } + if (item.human_identity_required || item.authority_tier === "C") { + return { + route: "human_execute", + reason: "a human identity or physical action is required", + }; + } + if (item.ai_capable && item.authority_tier === "A") { + return { + route: "ai_execute", + reason: "AI capability, authority, and acceptance criteria are present", + }; + } + return { + route: "human_decide", + reason: "AI execution preconditions are incomplete", + }; +} + +function routeStatus(route: CompanyWorkRoute): RoutedCompanyWorkItem["status"] { + switch (route) { + case "ai_execute": return "ready"; + case "human_decide": return "waiting_human_decision"; + case "human_execute": return "waiting_human_execution"; + case "observe": return "waiting_external_evidence"; + case "reject": return "cancelled"; + } +} + +function todoProjection(item: CompanyWorkItem, route: CompanyWorkRoute): JsonObject { + const mapping: Record = { + ai_execute: ["agent", "advancement_task"], + human_decide: ["user", "user_gate"], + human_execute: ["user", "user_action"], + observe: ["agent", "continuous_monitor"], + reject: ["agent", "blocker"], + }; + const [role, taskClass] = mapping[route]; + return { + role, + task_class: taskClass, + action_kind: route, + target_key: item.target_key, + text: item.title, + acceptance: item.acceptance, + }; +} + +function projectWorkItem(value: unknown, label: string): RoutedCompanyWorkItem { + const item = companyWorkItem(value, label); + const decision = routeCompanyWorkItem(item); + return { + ...item, + route: decision.route, + route_reason: decision.reason, + status: routeStatus(decision.route), + todo_projection: todoProjection(item, decision.route), + }; +} + +function projectFeedback(value: unknown, label: string): JsonObject { + const raw = requireJsonObject(value, label); + const kind = requireStringLiteral( + raw.kind, + [ + "fact", + "decision", + "execution_result", + "risk", + "metric_change", + "comment", + ] as const, + `${label}.kind`, + ); + return { + feedback_id: publicId(raw.feedback_id, `${label}.feedback_id`), + source: requireNonEmptyString(raw.source, `${label}.source`), + subject: requireNonEmptyString(raw.subject, `${label}.subject`), + kind, + observed_at: requireNonEmptyString(raw.observed_at, `${label}.observed_at`), + evidence_ref: requireNonEmptyString(raw.evidence_ref, `${label}.evidence_ref`), + affected_outcome_ids: requireStringArray( + raw.affected_outcome_ids, + `${label}.affected_outcome_ids`, + ).map((item, index) => publicId( + item, + `${label}.affected_outcome_ids[${index}]`, + )), + disposition: kind === "comment" ? "recorded" : "replan", + }; +} + +/** + * Validate one company-level planning snapshot and project each open unit of + * work into LoopX's existing Todo lanes. This is a pure control-plane + * contract: providers own collection and execution, while LoopX owns routing + * precedence and the provider-neutral projection. + */ +export function projectCompanyControlLoop(value: unknown): JsonObject { + const request = requireJsonObject(value, "company_control_loop_request"); + if (request.schema_version !== COMPANY_CONTROL_LOOP_REQUEST_SCHEMA_VERSION) { + throw new EffectRuntimeRequestError( + `company_control_loop_request.schema_version must be ${COMPANY_CONTROL_LOOP_REQUEST_SCHEMA_VERSION}`, + ); + } + const cycle = requireInteger(request.cycle, "company_control_loop_request.cycle"); + if (cycle < 0) { + throw new EffectRuntimeRequestError( + "company_control_loop_request.cycle must be non-negative", + ); + } + const outcomes = boundedArray( + request.outcomes, + "company_control_loop_request.outcomes", + MAX_OUTCOMES, + ).map((value, index) => { + const raw = requireJsonObject( + value, + `company_control_loop_request.outcomes[${index}]`, + ); + return { + outcome_id: publicId( + raw.outcome_id, + `company_control_loop_request.outcomes[${index}].outcome_id`, + ), + title: requireNonEmptyString( + raw.title, + `company_control_loop_request.outcomes[${index}].title`, + ), + metric: requireNonEmptyString( + raw.metric, + `company_control_loop_request.outcomes[${index}].metric`, + ), + target: requireNonEmptyString( + raw.target, + `company_control_loop_request.outcomes[${index}].target`, + ), + evidence_source: requireNonEmptyString( + raw.evidence_source, + `company_control_loop_request.outcomes[${index}].evidence_source`, + ), + }; + }); + const outcomeIds = new Set(outcomes.map((outcome) => outcome.outcome_id)); + const workItems = boundedArray( + request.work_items, + "company_control_loop_request.work_items", + MAX_WORK_ITEMS, + ).map((item, index) => projectWorkItem( + item, + `company_control_loop_request.work_items[${index}]`, + )); + for (const [index, item] of workItems.entries()) { + if (!outcomeIds.has(item.outcome_id)) { + throw new EffectRuntimeRequestError( + `company_control_loop_request.work_items[${index}].outcome_id must reference an outcome`, + ); + } + } + const feedback = boundedArray( + request.feedback, + "company_control_loop_request.feedback", + MAX_FEEDBACK_ITEMS, + ).map((item, index) => projectFeedback( + item, + `company_control_loop_request.feedback[${index}]`, + )); + for (const [index, item] of feedback.entries()) { + for (const outcomeId of item.affected_outcome_ids as string[]) { + if (!outcomeIds.has(outcomeId)) { + throw new EffectRuntimeRequestError( + `company_control_loop_request.feedback[${index}].affected_outcome_ids must reference outcomes`, + ); + } + } + } + return { + schema_version: COMPANY_CONTROL_LOOP_SCHEMA_VERSION, + direction: requireNonEmptyString( + request.direction, + "company_control_loop_request.direction", + ), + cycle, + outcomes, + work_items: workItems, + feedback, + replan_required: feedback.some((item) => item.disposition === "replan"), + }; +} diff --git a/tests/control_plane_ts/company_control_loop.test.ts b/tests/control_plane_ts/company_control_loop.test.ts new file mode 100644 index 0000000000..1734db5d4b --- /dev/null +++ b/tests/control_plane_ts/company_control_loop.test.ts @@ -0,0 +1,124 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + COMPANY_CONTROL_LOOP_REQUEST_SCHEMA_VERSION, + projectCompanyControlLoop, +} from "../../loopx/control_plane/work_items/company_control_loop.ts"; + +function request(overrides: Record = {}) { + return { + schema_version: COMPANY_CONTROL_LOOP_REQUEST_SCHEMA_VERSION, + direction: "Improve durable customer value.", + cycle: 3, + outcomes: [{ + outcome_id: "outcome_activation", + title: "Improve activation", + metric: "seven day activation rate", + target: ">= 40%", + evidence_source: "activation analytics", + }], + work_items: [], + feedback: [], + ...overrides, + }; +} + +function work(overrides: Record = {}) { + return { + work_item_id: "work_activation_analysis", + outcome_id: "outcome_activation", + title: "Analyze the activation funnel.", + acceptance: "Baseline every stage and propose three measurable experiments.", + authority_tier: "A", + ai_capable: true, + target_key: "activation_funnel_analysis", + ...overrides, + }; +} + +test("company control loop routes AI work into an advancement Todo", () => { + const result = projectCompanyControlLoop(request({ work_items: [work()] })); + const item = (result.work_items as Record[])[0]; + + assert.equal(result.schema_version, "company_control_loop_v0"); + assert.equal(item.route, "ai_execute"); + assert.equal(item.status, "ready"); + assert.deepEqual(item.todo_projection, { + role: "agent", + task_class: "advancement_task", + action_kind: "ai_execute", + target_key: "activation_funnel_analysis", + text: "Analyze the activation funnel.", + acceptance: "Baseline every stage and propose three measurable experiments.", + }); +}); + +test("routing precedence preserves authority, waiting, and human boundaries", () => { + const result = projectCompanyControlLoop(request({ + work_items: [ + work({ work_item_id: "work_rejected", prohibited: true }), + work({ work_item_id: "work_observe", wait_for: "provider result" }), + work({ work_item_id: "work_decide", material_decision: true }), + work({ work_item_id: "work_execute", human_identity_required: true }), + work({ work_item_id: "work_incomplete", ai_capable: false }), + ], + })); + + assert.deepEqual( + (result.work_items as Record[]).map((item) => item.route), + ["reject", "observe", "human_decide", "human_execute", "human_decide"], + ); + assert.deepEqual( + (result.work_items as Record[]).map((item) => + (item.todo_projection as Record).task_class + ), + ["blocker", "continuous_monitor", "user_gate", "user_action", "user_gate"], + ); +}); + +test("material feedback creates an explicit replan signal", () => { + const result = projectCompanyControlLoop(request({ + feedback: [ + { + feedback_id: "feedback_metric_change", + source: "analytics", + subject: "activation", + kind: "metric_change", + observed_at: "2026-09-17T00:00:00Z", + evidence_ref: "report:activation-2026-09-17", + affected_outcome_ids: ["outcome_activation"], + }, + { + feedback_id: "feedback_comment", + source: "support", + subject: "onboarding copy", + kind: "comment", + observed_at: "2026-09-17T00:01:00Z", + evidence_ref: "ticket:123", + affected_outcome_ids: ["outcome_activation"], + }, + ], + })); + + assert.equal(result.replan_required, true); + assert.deepEqual( + (result.feedback as Record[]).map((item) => item.disposition), + ["replan", "recorded"], + ); +}); + +test("company control loop rejects dangling outcome references and unsafe ids", () => { + assert.throws( + () => projectCompanyControlLoop(request({ + work_items: [work({ outcome_id: "outcome_missing" })], + })), + /outcome_id must reference an outcome/, + ); + assert.throws( + () => projectCompanyControlLoop(request({ + work_items: [work({ target_key: "../../private" })], + })), + /target_key must be a public-safe id/, + ); +}); diff --git a/tsconfig.control-plane.json b/tsconfig.control-plane.json index bcf19da66a..af219d44c2 100644 --- a/tsconfig.control-plane.json +++ b/tsconfig.control-plane.json @@ -70,6 +70,7 @@ "loopx/control_plane/turn_driver/turn_journal_effects.ts", "loopx/control_plane/turn_driver/delivery_continuity.ts", "loopx/control_plane/work_items/delivery_outcome.ts", + "loopx/control_plane/work_items/company_control_loop.ts", "loopx/control_plane/work_items/interaction_contract.ts", "loopx/control_plane/work_items/task_lease_acquire.ts", "loopx/control_plane/work_items/task_lease_lifecycle.ts", @@ -113,6 +114,7 @@ "tests/control_plane_ts/postgresql_authority_service_fixture.ts", "tests/control_plane_ts/delivery_continuity.test.ts", "tests/control_plane_ts/delivery_history.test.ts", + "tests/control_plane_ts/company_control_loop.test.ts", "tests/control_plane_ts/delivery_workspace.test.ts", "tests/control_plane_ts/settlement_workspace_causality.test.ts", "tests/control_plane_ts/quota_settlement_readback.test.ts", From 10b411b75ce47adf546cdfa740640c957c40f85d Mon Sep 17 00:00:00 2001 From: KashiwaByte <471314513@qq.com> Date: Thu, 17 Sep 2026 00:47:22 +0800 Subject: [PATCH 02/16] feat(cli): expose company control loop projection Signed-off-by: KashiwaByte <471314513@qq.com> --- loopx/cli.py | 13 +++ loopx/cli_commands/company_control_loop.py | 99 +++++++++++++++++++ .../control_plane/effect_runtime_handlers.ts | 2 + .../test_company_control_loop_cli.py | 76 ++++++++++++++ 4 files changed, 190 insertions(+) create mode 100644 loopx/cli_commands/company_control_loop.py create mode 100644 tests/control_plane/test_company_control_loop_cli.py diff --git a/loopx/cli.py b/loopx/cli.py index e50388ab13..d8f11d95c0 100644 --- a/loopx/cli.py +++ b/loopx/cli.py @@ -6,6 +6,10 @@ from .cli_commands.agent_capabilities import register_agent_capabilities, handle_agent_capabilities from .cli_commands.agent_directory import register_agent_directory, handle_agent_directory from .cli_commands.agent_context import register_agent_context, handle_agent_context +from .cli_commands.company_control_loop import ( + handle_company_control_loop_command, + register_company_control_loop_command, +) from .cli_commands.todo_continuation import register_todo_continuation, handle_todo_continuation from .cli_commands.manager_inbox import register_manager_inbox, handle_manager_inbox from .capabilities.content_ops.cli import ( @@ -330,6 +334,7 @@ def build_parser() -> LoopXArgumentParser: register_manager_inbox(sub, add_subcommand_format) register_agent_capabilities(sub, add_subcommand_format) register_agent_context(sub, add_subcommand_format) + register_company_control_loop_command(sub, add_subcommand_format) register_agent_directory(sub, add_subcommand_format) register_lark_inbox_commands(sub, add_subcommand_format) register_lark_kanban_commands(sub, add_subcommand_format) @@ -770,6 +775,14 @@ def main(argv: list[str] | None = None) -> int: if args.command == "agent-context": return handle_agent_context(args, registry_path, print_payload, output_format) + company_control_loop_result = handle_company_control_loop_command( + args, + output_format=output_format, + print_payload=print_payload, + ) + if company_control_loop_result is not None: + return company_control_loop_result + if args.command == "agent-directory": return handle_agent_directory( args, registry_path, effective_runtime_root(registry_path, args.runtime_root), diff --git a/loopx/cli_commands/company_control_loop.py b/loopx/cli_commands/company_control_loop.py new file mode 100644 index 0000000000..2fc5e75f77 --- /dev/null +++ b/loopx/cli_commands/company_control_loop.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +import argparse +import json +from collections.abc import Callable +from pathlib import Path +from typing import Any + +from ..control_plane.effect_runtime import effect_runtime_result + + +def register_company_control_loop_command( + subparsers: argparse._SubParsersAction, + add_subcommand_format: Callable[[argparse.ArgumentParser], None], +) -> None: + parser = subparsers.add_parser( + "company-control-loop", + help="Validate and project a company planning snapshot into LoopX Todo lanes.", + ) + add_subcommand_format(parser) + actions = parser.add_subparsers( + dest="company_control_loop_command", + required=True, + ) + project = actions.add_parser( + "project", + help="Project a company_control_loop_request_v0 JSON object without writing state.", + ) + add_subcommand_format(project) + project.add_argument( + "--state-json", + required=True, + help="Path to a company_control_loop_request_v0 JSON object.", + ) + + +def render_company_control_loop_markdown(payload: dict[str, Any]) -> str: + lines = [ + "# LoopX Company Control Loop", + "", + f"- ok: `{payload.get('ok')}`", + ] + if payload.get("error"): + lines.append(f"- error: {payload['error']}") + return "\n".join(lines) + lines.extend([ + f"- schema_version: `{payload.get('schema_version')}`", + f"- direction: {payload.get('direction')}", + f"- cycle: {payload.get('cycle')}", + f"- replan_required: `{payload.get('replan_required')}`", + "", + "## Work routing", + "", + ]) + work_items = payload.get("work_items") + if not isinstance(work_items, list) or not work_items: + lines.append("- No work items.") + else: + for item in work_items: + if isinstance(item, dict): + lines.append( + f"- `{item.get('work_item_id')}` -> `{item.get('route')}` " + f"({item.get('status')}): {item.get('title')}" + ) + return "\n".join(lines) + + +def _read_json_object(path_text: str) -> dict[str, Any]: + payload = json.loads(Path(path_text).expanduser().read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise TypeError("company control state JSON must contain an object") + return payload + + +def handle_company_control_loop_command( + args: argparse.Namespace, + *, + output_format: Callable[..., str], + print_payload: Callable[[dict[str, Any], str, Callable[[dict[str, Any]], str]], None], +) -> int | None: + if args.command != "company-control-loop": + return None + try: + request = _read_json_object(args.state_json) + projection = effect_runtime_result( + "work_item.company_control_loop.project", + request, + ) + payload = {"ok": True, **projection} + exit_code = 0 + except Exception as exc: + payload = {"ok": False, "error": str(exc)} + exit_code = 1 + print_payload( + payload, + output_format(args), + render_company_control_loop_markdown, + ) + return exit_code diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index e926acbc42..4764c8456e 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -172,6 +172,7 @@ import { projectTodoPlanningInventoryDetail, } from "./work_items/planning_inventory.ts"; import { resolveRefreshRecommendation } from "./work_items/refresh_recommendation.ts"; +import { projectCompanyControlLoop } from "./work_items/company_control_loop.ts"; import { validateInteractionProjectionHookInvocation, validateInteractionProjectionHookRegistration, @@ -451,6 +452,7 @@ export function createEffectRuntimeHandlers( ["work_item.planning_inventory.project", projectTodoPlanningInventory], ["work_item.planning_inventory.detail", projectTodoPlanningInventoryDetail], ["work_item.refresh_recommendation.resolve", resolveRefreshRecommendation], + ["work_item.company_control_loop.project", projectCompanyControlLoop], ["work_item.delivery_history.project", projectDeliveryHistory], ["work_item.delivery_response.project", projectDeliveryResponse], ["work_item.delivery_claim.validate", validateDeliveryClaim], diff --git a/tests/control_plane/test_company_control_loop_cli.py b/tests/control_plane/test_company_control_loop_cli.py new file mode 100644 index 0000000000..1384dba1c1 --- /dev/null +++ b/tests/control_plane/test_company_control_loop_cli.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import json + +from loopx.cli import main +from loopx.cli_commands import company_control_loop + + +def _request() -> dict[str, object]: + return { + "schema_version": "company_control_loop_request_v0", + "direction": "Improve durable customer value.", + "cycle": 1, + "outcomes": [ + { + "outcome_id": "outcome_activation", + "title": "Improve activation", + "metric": "seven day activation rate", + "target": ">= 40%", + "evidence_source": "activation analytics", + } + ], + "work_items": [], + "feedback": [], + } + + +def test_company_control_loop_cli_calls_typed_projection( + tmp_path, monkeypatch, capsys +) -> None: + state_path = tmp_path / "company.json" + state_path.write_text(json.dumps(_request()), encoding="utf-8") + calls: list[tuple[str, dict[str, object]]] = [] + + def project(method: str, params: dict[str, object]) -> dict[str, object]: + calls.append((method, params)) + return { + "schema_version": "company_control_loop_v0", + "direction": params["direction"], + "cycle": params["cycle"], + "outcomes": params["outcomes"], + "work_items": [], + "feedback": [], + "replan_required": False, + } + + monkeypatch.setattr(company_control_loop, "effect_runtime_result", project) + + assert main([ + "--format", + "json", + "company-control-loop", + "project", + "--state-json", + str(state_path), + ]) == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["schema_version"] == "company_control_loop_v0" + assert calls == [("work_item.company_control_loop.project", _request())] + + +def test_company_control_loop_cli_rejects_non_object_json(tmp_path, capsys) -> None: + state_path = tmp_path / "company.json" + state_path.write_text("[]", encoding="utf-8") + + assert main([ + "--format", + "json", + "company-control-loop", + "project", + "--state-json", + str(state_path), + ]) == 1 + payload = json.loads(capsys.readouterr().out) + assert payload["ok"] is False + assert "must contain an object" in payload["error"] From c775b119e12819b082c1f314fd8190d774c27bfb Mon Sep 17 00:00:00 2001 From: KashiwaByte <471314513@qq.com> Date: Thu, 17 Sep 2026 00:53:01 +0800 Subject: [PATCH 03/16] feat(control-plane): add company state upgrade preview Signed-off-by: KashiwaByte <471314513@qq.com> --- loopx/cli_commands/company_control_loop.py | 18 ++- .../control_plane/effect_runtime_handlers.ts | 6 +- .../work_items/company_control_loop.ts | 109 ++++++++++++++++++ .../test_company_control_loop_cli.py | 30 +++++ .../company_control_loop.test.ts | 47 ++++++++ 5 files changed, 206 insertions(+), 4 deletions(-) diff --git a/loopx/cli_commands/company_control_loop.py b/loopx/cli_commands/company_control_loop.py index 2fc5e75f77..9fbd016a39 100644 --- a/loopx/cli_commands/company_control_loop.py +++ b/loopx/cli_commands/company_control_loop.py @@ -32,6 +32,16 @@ def register_company_control_loop_command( required=True, help="Path to a company_control_loop_request_v0 JSON object.", ) + upgrade = actions.add_parser( + "upgrade", + help="Preview conversion of a legacy loopx_company_control_state_v0 file.", + ) + add_subcommand_format(upgrade) + upgrade.add_argument( + "--state-json", + required=True, + help="Path to a legacy or current company control state JSON object.", + ) def render_company_control_loop_markdown(payload: dict[str, Any]) -> str: @@ -82,10 +92,12 @@ def handle_company_control_loop_command( return None try: request = _read_json_object(args.state_json) - projection = effect_runtime_result( - "work_item.company_control_loop.project", - request, + method = ( + "work_item.company_control_loop.upgrade" + if args.company_control_loop_command == "upgrade" + else "work_item.company_control_loop.project" ) + projection = effect_runtime_result(method, request) payload = {"ok": True, **projection} exit_code = 0 except Exception as exc: diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index 4764c8456e..b6b1457ec1 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -172,7 +172,10 @@ import { projectTodoPlanningInventoryDetail, } from "./work_items/planning_inventory.ts"; import { resolveRefreshRecommendation } from "./work_items/refresh_recommendation.ts"; -import { projectCompanyControlLoop } from "./work_items/company_control_loop.ts"; +import { + projectCompanyControlLoop, + upgradeCompanyControlLoopState, +} from "./work_items/company_control_loop.ts"; import { validateInteractionProjectionHookInvocation, validateInteractionProjectionHookRegistration, @@ -453,6 +456,7 @@ export function createEffectRuntimeHandlers( ["work_item.planning_inventory.detail", projectTodoPlanningInventoryDetail], ["work_item.refresh_recommendation.resolve", resolveRefreshRecommendation], ["work_item.company_control_loop.project", projectCompanyControlLoop], + ["work_item.company_control_loop.upgrade", upgradeCompanyControlLoopState], ["work_item.delivery_history.project", projectDeliveryHistory], ["work_item.delivery_response.project", projectDeliveryResponse], ["work_item.delivery_claim.validate", validateDeliveryClaim], diff --git a/loopx/control_plane/work_items/company_control_loop.ts b/loopx/control_plane/work_items/company_control_loop.ts index 330ae4baed..90e22ba2fa 100644 --- a/loopx/control_plane/work_items/company_control_loop.ts +++ b/loopx/control_plane/work_items/company_control_loop.ts @@ -14,6 +14,10 @@ import type { JsonObject } from "../effect_program.ts"; export const COMPANY_CONTROL_LOOP_REQUEST_SCHEMA_VERSION = "company_control_loop_request_v0"; export const COMPANY_CONTROL_LOOP_SCHEMA_VERSION = "company_control_loop_v0"; +export const COMPANY_CONTROL_LOOP_UPGRADE_SCHEMA_VERSION = + "company_control_loop_upgrade_v0"; +export const LEGACY_COMPANY_CONTROL_STATE_SCHEMA_VERSION = + "loopx_company_control_state_v0"; const MAX_OUTCOMES = 128; const MAX_WORK_ITEMS = 256; @@ -321,3 +325,108 @@ export function projectCompanyControlLoop(value: unknown): JsonObject { replan_required: feedback.some((item) => item.disposition === "replan"), }; } + +/** + * Convert the executable reference implementation's persisted v0 state into + * the native request contract. The upgrade is preview-only and fails closed + * when legacy feedback points at Goal ids that cannot be proven to be Outcome + * ids. + */ +export function upgradeCompanyControlLoopState(value: unknown): JsonObject { + const source = requireJsonObject(value, "company_control_loop_state"); + if (source.schema_version === COMPANY_CONTROL_LOOP_REQUEST_SCHEMA_VERSION) { + projectCompanyControlLoop(source); + return { + schema_version: COMPANY_CONTROL_LOOP_UPGRADE_SCHEMA_VERSION, + source_schema_version: COMPANY_CONTROL_LOOP_REQUEST_SCHEMA_VERSION, + target_schema_version: COMPANY_CONTROL_LOOP_REQUEST_SCHEMA_VERSION, + changed: false, + state: structuredClone(source), + }; + } + if (source.schema_version !== LEGACY_COMPANY_CONTROL_STATE_SCHEMA_VERSION) { + throw new EffectRuntimeRequestError( + "company_control_loop_state.schema_version is unsupported", + ); + } + const outcomes = boundedArray( + source.outcomes, + "company_control_loop_state.outcomes", + MAX_OUTCOMES, + ).map((item, index) => { + const raw = requireJsonObject(item, `company_control_loop_state.outcomes[${index}]`); + return { + outcome_id: raw.outcome_id, + title: raw.title, + metric: raw.metric, + target: raw.target, + evidence_source: raw.evidence_source, + }; + }); + const outcomeIds = new Set(outcomes.map((item) => String(item.outcome_id))); + const feedback = boundedArray( + source.feedback ?? [], + "company_control_loop_state.feedback", + MAX_FEEDBACK_ITEMS, + ).map((item, index) => { + const raw = requireJsonObject(item, `company_control_loop_state.feedback[${index}]`); + const affected = raw.affected_outcome_ids ?? raw.affected_goal_ids; + const affectedIds = requireStringArray( + affected, + `company_control_loop_state.feedback[${index}].affected_outcome_ids`, + ); + if (affectedIds.some((id) => !outcomeIds.has(id))) { + throw new EffectRuntimeRequestError( + `company_control_loop_state.feedback[${index}] needs an explicit Outcome mapping`, + ); + } + return { + feedback_id: raw.feedback_id, + source: raw.source, + subject: raw.subject, + kind: raw.kind, + observed_at: raw.observed_at, + evidence_ref: raw.evidence_ref, + affected_outcome_ids: affectedIds, + }; + }); + const request: JsonObject = { + schema_version: COMPANY_CONTROL_LOOP_REQUEST_SCHEMA_VERSION, + direction: source.company_direction, + cycle: source.cycle ?? 0, + outcomes, + work_items: boundedArray( + source.work_items, + "company_control_loop_state.work_items", + MAX_WORK_ITEMS, + ).map((item, index) => { + const raw = requireJsonObject(item, `company_control_loop_state.work_items[${index}]`); + return { + work_item_id: raw.work_item_id, + outcome_id: raw.outcome_id, + title: raw.title, + acceptance: raw.acceptance, + authority_tier: raw.authority_tier, + ai_capable: raw.ai_capable, + ...(raw.prohibited === undefined ? {} : { prohibited: raw.prohibited }), + ...(raw.material_decision === undefined + ? {} + : { material_decision: raw.material_decision }), + ...(raw.human_identity_required === undefined + ? {} + : { human_identity_required: raw.human_identity_required }), + ...(raw.wait_for === undefined ? {} : { wait_for: raw.wait_for }), + target_key: raw.target_key, + }; + }), + feedback, + }; + projectCompanyControlLoop(request); + return { + schema_version: COMPANY_CONTROL_LOOP_UPGRADE_SCHEMA_VERSION, + source_schema_version: LEGACY_COMPANY_CONTROL_STATE_SCHEMA_VERSION, + target_schema_version: COMPANY_CONTROL_LOOP_REQUEST_SCHEMA_VERSION, + changed: true, + state: request, + }; +} diff --git a/tests/control_plane/test_company_control_loop_cli.py b/tests/control_plane/test_company_control_loop_cli.py index 1384dba1c1..f1cb70a231 100644 --- a/tests/control_plane/test_company_control_loop_cli.py +++ b/tests/control_plane/test_company_control_loop_cli.py @@ -74,3 +74,33 @@ def test_company_control_loop_cli_rejects_non_object_json(tmp_path, capsys) -> N payload = json.loads(capsys.readouterr().out) assert payload["ok"] is False assert "must contain an object" in payload["error"] + + +def test_company_control_loop_cli_selects_upgrade_contract( + tmp_path, monkeypatch, capsys +) -> None: + state_path = tmp_path / "company.json" + state_path.write_text(json.dumps(_request()), encoding="utf-8") + calls: list[str] = [] + + def upgrade(method: str, params: dict[str, object]) -> dict[str, object]: + calls.append(method) + return { + "schema_version": "company_control_loop_upgrade_v0", + "source_schema_version": params["schema_version"], + "target_schema_version": params["schema_version"], + "changed": False, + "state": params, + } + + monkeypatch.setattr(company_control_loop, "effect_runtime_result", upgrade) + assert main([ + "--format", + "json", + "company-control-loop", + "upgrade", + "--state-json", + str(state_path), + ]) == 0 + json.loads(capsys.readouterr().out) + assert calls == ["work_item.company_control_loop.upgrade"] diff --git a/tests/control_plane_ts/company_control_loop.test.ts b/tests/control_plane_ts/company_control_loop.test.ts index 1734db5d4b..3ef081b686 100644 --- a/tests/control_plane_ts/company_control_loop.test.ts +++ b/tests/control_plane_ts/company_control_loop.test.ts @@ -4,6 +4,7 @@ import test from "node:test"; import { COMPANY_CONTROL_LOOP_REQUEST_SCHEMA_VERSION, projectCompanyControlLoop, + upgradeCompanyControlLoopState, } from "../../loopx/control_plane/work_items/company_control_loop.ts"; function request(overrides: Record = {}) { @@ -122,3 +123,49 @@ test("company control loop rejects dangling outcome references and unsafe ids", /target_key must be a public-safe id/, ); }); + +test("legacy reference state upgrades into the native request contract", () => { + const legacy = { + schema_version: "loopx_company_control_state_v0", + company_direction: "Improve durable customer value.", + cycle: 2, + outcomes: request().outcomes, + work_items: [ + { + ...work(), + route: "ai_execute", + route_reason: "legacy derived value", + status: "ready", + evidence_refs: [], + }, + ], + feedback: [], + events: [], + }; + const result = upgradeCompanyControlLoopState(legacy); + assert.equal(result.schema_version, "company_control_loop_upgrade_v0"); + assert.equal(result.changed, true); + assert.deepEqual(result.state, request({ cycle: 2, work_items: [work()] })); +}); + +test("legacy feedback with ambiguous Goal references fails closed", () => { + assert.throws( + () => upgradeCompanyControlLoopState({ + schema_version: "loopx_company_control_state_v0", + company_direction: "Improve durable customer value.", + cycle: 1, + outcomes: request().outcomes, + work_items: [], + feedback: [{ + feedback_id: "feedback_legacy", + source: "operator", + subject: "activation", + kind: "decision", + observed_at: "2026-09-17T00:00:00Z", + evidence_ref: "decision:42", + affected_goal_ids: ["goal_company"], + }], + }), + /needs an explicit Outcome mapping/, + ); +}); From e1f0584ca947a5d95d767505302f588f253322ca Mon Sep 17 00:00:00 2001 From: KashiwaByte <471314513@qq.com> Date: Thu, 17 Sep 2026 00:59:16 +0800 Subject: [PATCH 04/16] feat(control-plane): persist company control state Signed-off-by: KashiwaByte <471314513@qq.com> --- .../control_plane/effect_runtime_handlers.ts | 6 + .../work_items/company_control_state.ts | 172 ++++++++++++++++++ .../company_control_state.test.ts | 109 +++++++++++ tsconfig.control-plane.json | 2 + 4 files changed, 289 insertions(+) create mode 100644 loopx/control_plane/work_items/company_control_state.ts create mode 100644 tests/control_plane_ts/company_control_state.test.ts diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index b6b1457ec1..6833dd7c78 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -176,6 +176,10 @@ import { projectCompanyControlLoop, upgradeCompanyControlLoopState, } from "./work_items/company_control_loop.ts"; +import { + loadCompanyControlState, + writeCompanyControlState, +} from "./work_items/company_control_state.ts"; import { validateInteractionProjectionHookInvocation, validateInteractionProjectionHookRegistration, @@ -457,6 +461,8 @@ export function createEffectRuntimeHandlers( ["work_item.refresh_recommendation.resolve", resolveRefreshRecommendation], ["work_item.company_control_loop.project", projectCompanyControlLoop], ["work_item.company_control_loop.upgrade", upgradeCompanyControlLoopState], + ["work_item.company_control_state.load", loadCompanyControlState], + ["work_item.company_control_state.write", writeCompanyControlState], ["work_item.delivery_history.project", projectDeliveryHistory], ["work_item.delivery_response.project", projectDeliveryResponse], ["work_item.delivery_claim.validate", validateDeliveryClaim], diff --git a/loopx/control_plane/work_items/company_control_state.ts b/loopx/control_plane/work_items/company_control_state.ts new file mode 100644 index 0000000000..7edc132362 --- /dev/null +++ b/loopx/control_plane/work_items/company_control_state.ts @@ -0,0 +1,172 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { isAbsolute, join } from "node:path"; + +import type { JsonObject } from "../effect_program.ts"; +import { + EffectRuntimeConflictError, + EffectRuntimeRequestError, +} from "../effect_runtime_errors.ts"; +import { atomicWriteJson, withFileMutationLock } from "../effect_runtime_io.ts"; +import { + optionalNonEmptyString, + requireJsonObject, + requireNonEmptyString, +} from "../runtime_decode.ts"; +import { + COMPANY_CONTROL_LOOP_SCHEMA_VERSION, + projectCompanyControlLoop, +} from "./company_control_loop.ts"; + +export const COMPANY_CONTROL_STATE_STORE_REQUEST_SCHEMA = + "company_control_state_store_request_v0"; +export const COMPANY_CONTROL_STATE_STORE_SCHEMA = + "company_control_state_store_v0"; +export const COMPANY_CONTROL_STATE_STORE_RESULT_SCHEMA = + "company_control_state_store_result_v0"; + +function stableValue(value: unknown): unknown { + if (Array.isArray(value)) return value.map(stableValue); + if (typeof value !== "object" || value === null) return value; + return Object.fromEntries( + Object.entries(value as JsonObject) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, child]) => [key, stableValue(child)]), + ); +} + +function revision(projection: JsonObject): string { + return createHash("sha256") + .update(JSON.stringify(stableValue(projection)), "utf8") + .digest("hex"); +} + +function safeGoalSegment(goalId: string): string { + const label = goalId + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 47) || "goal"; + const digest = createHash("sha256").update(goalId, "utf8").digest("hex").slice(0, 16); + return `${label}-${digest}`; +} + +export function companyControlStatePath(runtimeRoot: string, goalId: string): string { + if (!isAbsolute(runtimeRoot)) { + throw new EffectRuntimeRequestError("runtime_root must be absolute"); + } + return join( + runtimeRoot, + "goals", + safeGoalSegment(goalId), + "company-control-loop", + "state.json", + ); +} + +function storeRequest(value: unknown): { + request: JsonObject; + goalId: string; + path: string; +} { + const request = requireJsonObject(value, "company_control_state_store params"); + if (request.schema_version !== COMPANY_CONTROL_STATE_STORE_REQUEST_SCHEMA) { + throw new EffectRuntimeRequestError("company control state store request schema mismatch"); + } + const runtimeRoot = requireNonEmptyString(request.runtime_root, "runtime_root"); + const goalId = requireNonEmptyString(request.goal_id, "goal_id"); + return { request, goalId, path: companyControlStatePath(runtimeRoot, goalId) }; +} + +function decodeStoredState(value: unknown, goalId: string): JsonObject { + const stored = requireJsonObject(value, "stored company control state"); + if ( + stored.schema_version !== COMPANY_CONTROL_STATE_STORE_SCHEMA || + stored.goal_id !== goalId || + typeof stored.revision !== "string" || + !/^[a-f0-9]{64}$/.test(stored.revision) + ) { + throw new EffectRuntimeRequestError("stored company control state is invalid"); + } + const projection = requireJsonObject(stored.projection, "stored projection"); + if (projection.schema_version !== COMPANY_CONTROL_LOOP_SCHEMA_VERSION) { + throw new EffectRuntimeRequestError("stored company control projection schema is invalid"); + } + if (revision(projection) !== stored.revision) { + throw new EffectRuntimeRequestError("stored company control state revision does not match content"); + } + return stored; +} + +async function readStoredState(path: string, goalId: string): Promise { + try { + return decodeStoredState(JSON.parse(await readFile(path, "utf8")), goalId); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } +} + +export async function loadCompanyControlState(value: unknown): Promise { + const { goalId, path } = storeRequest(value); + return { + schema_version: COMPANY_CONTROL_STATE_STORE_RESULT_SCHEMA, + operation: "load", + goal_id: goalId, + path, + state: await readStoredState(path, goalId), + }; +} + +export async function writeCompanyControlState(value: unknown): Promise { + const { request, goalId, path } = storeRequest(value); + const expectedRevision = optionalNonEmptyString( + request.expected_revision, + "expected_revision", + ); + const projection = projectCompanyControlLoop(request.state); + const nextRevision = revision(projection); + return await withFileMutationLock(path, async () => { + const existing = await readStoredState(path, goalId); + if (existing?.revision === nextRevision) { + return { + schema_version: COMPANY_CONTROL_STATE_STORE_RESULT_SCHEMA, + operation: "write", + goal_id: goalId, + path, + state: existing, + written: false, + replayed: true, + }; + } + if (existing && expectedRevision === null) { + throw new EffectRuntimeConflictError( + "expected_revision is required when company control state already exists", + ); + } + if (expectedRevision !== (existing?.revision ?? null)) { + throw new EffectRuntimeConflictError("company control state revision changed"); + } + const stored: JsonObject = { + schema_version: COMPANY_CONTROL_STATE_STORE_SCHEMA, + goal_id: goalId, + revision: nextRevision, + updated_at: requireNonEmptyString(request.updated_at, "updated_at"), + projection, + }; + await atomicWriteJson(path, stored); + const readback = await readStoredState(path, goalId); + if (!readback || readback.revision !== nextRevision) { + throw new Error("company control state readback failed"); + } + return { + schema_version: COMPANY_CONTROL_STATE_STORE_RESULT_SCHEMA, + operation: "write", + goal_id: goalId, + path, + state: readback, + written: true, + replayed: false, + }; + }); +} diff --git a/tests/control_plane_ts/company_control_state.test.ts b/tests/control_plane_ts/company_control_state.test.ts new file mode 100644 index 0000000000..6961feaaec --- /dev/null +++ b/tests/control_plane_ts/company_control_state.test.ts @@ -0,0 +1,109 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { + COMPANY_CONTROL_STATE_STORE_REQUEST_SCHEMA, + companyControlStatePath, + loadCompanyControlState, + writeCompanyControlState, +} from "../../loopx/control_plane/work_items/company_control_state.ts"; + +function state(direction = "Improve durable customer value.") { + return { + schema_version: "company_control_loop_request_v0", + direction, + cycle: 1, + outcomes: [{ + outcome_id: "outcome_activation", + title: "Improve activation", + metric: "seven day activation rate", + target: ">= 40%", + evidence_source: "activation analytics", + }], + work_items: [], + feedback: [], + }; +} + +function request(runtimeRoot: string, extra: Record = {}) { + return { + schema_version: COMPANY_CONTROL_STATE_STORE_REQUEST_SCHEMA, + runtime_root: runtimeRoot, + goal_id: "company-goal", + ...extra, + }; +} + +test("company control state writes atomically and reads back exact revision", async (t) => { + const runtimeRoot = await mkdtemp(join(tmpdir(), "loopx-company-state-")); + t.after(() => rm(runtimeRoot, { recursive: true, force: true })); + + const first = await writeCompanyControlState(request(runtimeRoot, { + state: state(), + updated_at: "2026-09-17T00:00:00Z", + })); + assert.equal(first.written, true); + const stored = first.state as Record; + assert.match(String(stored.revision), /^[a-f0-9]{64}$/); + + const loaded = await loadCompanyControlState(request(runtimeRoot)); + assert.deepEqual(loaded.state, first.state); + assert.equal(loaded.path, companyControlStatePath(runtimeRoot, "company-goal")); + + const replay = await writeCompanyControlState(request(runtimeRoot, { + state: state(), + updated_at: "2026-09-17T00:01:00Z", + })); + assert.equal(replay.written, false); + assert.equal(replay.replayed, true); +}); + +test("company control state requires revision matching for updates", async (t) => { + const runtimeRoot = await mkdtemp(join(tmpdir(), "loopx-company-state-")); + t.after(() => rm(runtimeRoot, { recursive: true, force: true })); + const first = await writeCompanyControlState(request(runtimeRoot, { + state: state(), + updated_at: "2026-09-17T00:00:00Z", + })); + const stored = first.state as Record; + + await assert.rejects( + writeCompanyControlState(request(runtimeRoot, { + state: state("Changed direction."), + updated_at: "2026-09-17T00:01:00Z", + })), + /expected_revision is required/, + ); + await assert.rejects( + writeCompanyControlState(request(runtimeRoot, { + state: state("Changed direction."), + expected_revision: "0".repeat(64), + updated_at: "2026-09-17T00:01:00Z", + })), + /revision changed/, + ); + const updated = await writeCompanyControlState(request(runtimeRoot, { + state: state("Changed direction."), + expected_revision: stored.revision, + updated_at: "2026-09-17T00:01:00Z", + })); + assert.equal(updated.written, true); + assert.notEqual( + (updated.state as Record).revision, + stored.revision, + ); +}); + +test("company control state path is bounded and rejects relative runtime roots", () => { + const left = companyControlStatePath("/runtime", "company goal"); + const right = companyControlStatePath("/runtime", "company-goal"); + assert.notEqual(left, right); + assert.match(left, /company-control-loop\/state\.json$/); + assert.throws( + () => companyControlStatePath("relative", "company-goal"), + /runtime_root must be absolute/, + ); +}); diff --git a/tsconfig.control-plane.json b/tsconfig.control-plane.json index af219d44c2..7e56caabab 100644 --- a/tsconfig.control-plane.json +++ b/tsconfig.control-plane.json @@ -71,6 +71,7 @@ "loopx/control_plane/turn_driver/delivery_continuity.ts", "loopx/control_plane/work_items/delivery_outcome.ts", "loopx/control_plane/work_items/company_control_loop.ts", + "loopx/control_plane/work_items/company_control_state.ts", "loopx/control_plane/work_items/interaction_contract.ts", "loopx/control_plane/work_items/task_lease_acquire.ts", "loopx/control_plane/work_items/task_lease_lifecycle.ts", @@ -115,6 +116,7 @@ "tests/control_plane_ts/delivery_continuity.test.ts", "tests/control_plane_ts/delivery_history.test.ts", "tests/control_plane_ts/company_control_loop.test.ts", + "tests/control_plane_ts/company_control_state.test.ts", "tests/control_plane_ts/delivery_workspace.test.ts", "tests/control_plane_ts/settlement_workspace_causality.test.ts", "tests/control_plane_ts/quota_settlement_readback.test.ts", From 4b3740112d34b6de72c92c3a826fe04d3cac4b52 Mon Sep 17 00:00:00 2001 From: KashiwaByte <471314513@qq.com> Date: Thu, 17 Sep 2026 01:05:29 +0800 Subject: [PATCH 05/16] feat(cli): manage company control state Signed-off-by: KashiwaByte <471314513@qq.com> --- loopx/cli.py | 11 +++ loopx/cli_commands/company_control_loop.py | 87 +++++++++++++++++-- .../test_company_control_loop_cli.py | 68 +++++++++++++++ 3 files changed, 158 insertions(+), 8 deletions(-) diff --git a/loopx/cli.py b/loopx/cli.py index d8f11d95c0..bc845ffae4 100644 --- a/loopx/cli.py +++ b/loopx/cli.py @@ -2,6 +2,7 @@ import argparse import sys +from pathlib import Path from .cli_commands.agent_capabilities import register_agent_capabilities, handle_agent_capabilities from .cli_commands.agent_directory import register_agent_directory, handle_agent_directory @@ -779,6 +780,16 @@ def main(argv: list[str] | None = None) -> int: args, output_format=output_format, print_payload=print_payload, + runtime_root=( + ( + Path(args.runtime_root).expanduser().resolve() + if args.runtime_root + else effective_runtime_root(registry_path, None) + ) + if args.command == "company-control-loop" + and args.company_control_loop_command in {"save", "show"} + else None + ), ) if company_control_loop_result is not None: return company_control_loop_result diff --git a/loopx/cli_commands/company_control_loop.py b/loopx/cli_commands/company_control_loop.py index 9fbd016a39..4061c085c6 100644 --- a/loopx/cli_commands/company_control_loop.py +++ b/loopx/cli_commands/company_control_loop.py @@ -3,6 +3,7 @@ import argparse import json from collections.abc import Callable +from datetime import UTC, datetime from pathlib import Path from typing import Any @@ -42,6 +43,28 @@ def register_company_control_loop_command( required=True, help="Path to a legacy or current company control state JSON object.", ) + save = actions.add_parser( + "save", + help="Validate and persist company control state under one Goal runtime.", + ) + add_subcommand_format(save) + save.add_argument("--goal-id", required=True, help="Goal that owns the company state.") + save.add_argument("--state-json", required=True, help="Legacy or current company state JSON.") + save.add_argument( + "--expected-revision", + help="Exact revision returned by show/save. Required to replace existing state.", + ) + save.add_argument( + "--execute", + action="store_true", + help="Persist the validated projection. Without this flag, return a preview.", + ) + show = actions.add_parser( + "show", + help="Read the persisted company control state for one Goal.", + ) + add_subcommand_format(show) + show.add_argument("--goal-id", required=True, help="Goal that owns the company state.") def render_company_control_loop_markdown(payload: dict[str, Any]) -> str: @@ -87,18 +110,66 @@ def handle_company_control_loop_command( *, output_format: Callable[..., str], print_payload: Callable[[dict[str, Any], str, Callable[[dict[str, Any]], str]], None], + runtime_root: Path | None = None, ) -> int | None: if args.command != "company-control-loop": return None try: - request = _read_json_object(args.state_json) - method = ( - "work_item.company_control_loop.upgrade" - if args.company_control_loop_command == "upgrade" - else "work_item.company_control_loop.project" - ) - projection = effect_runtime_result(method, request) - payload = {"ok": True, **projection} + command = args.company_control_loop_command + if command == "show": + if runtime_root is None: + raise ValueError("company control state requires a runtime root") + projection = effect_runtime_result( + "work_item.company_control_state.load", + { + "schema_version": "company_control_state_store_request_v0", + "runtime_root": str(runtime_root), + "goal_id": args.goal_id, + }, + ) + payload = {"ok": True, **projection} + else: + request = _read_json_object(args.state_json) + method = ( + "work_item.company_control_loop.upgrade" + if command in {"upgrade", "save"} + else "work_item.company_control_loop.project" + ) + projection = effect_runtime_result(method, request) + if command != "save": + payload = {"ok": True, **projection} + else: + state = projection.get("state") + if not isinstance(state, dict): + raise TypeError("company control upgrade did not return state") + preview = effect_runtime_result( + "work_item.company_control_loop.project", + state, + ) + if not args.execute: + payload = { + "ok": True, + "dry_run": True, + "goal_id": args.goal_id, + "projection": preview, + } + else: + if runtime_root is None: + raise ValueError("company control state requires a runtime root") + write_request: dict[str, Any] = { + "schema_version": "company_control_state_store_request_v0", + "runtime_root": str(runtime_root), + "goal_id": args.goal_id, + "state": state, + "updated_at": datetime.now(UTC).isoformat(), + } + if args.expected_revision: + write_request["expected_revision"] = args.expected_revision + saved = effect_runtime_result( + "work_item.company_control_state.write", + write_request, + ) + payload = {"ok": True, "dry_run": False, **saved} exit_code = 0 except Exception as exc: payload = {"ok": False, "error": str(exc)} diff --git a/tests/control_plane/test_company_control_loop_cli.py b/tests/control_plane/test_company_control_loop_cli.py index f1cb70a231..8629a69f7e 100644 --- a/tests/control_plane/test_company_control_loop_cli.py +++ b/tests/control_plane/test_company_control_loop_cli.py @@ -104,3 +104,71 @@ def upgrade(method: str, params: dict[str, object]) -> dict[str, object]: ]) == 0 json.loads(capsys.readouterr().out) assert calls == ["work_item.company_control_loop.upgrade"] + + +def test_company_control_loop_save_previews_then_writes_with_revision( + tmp_path, monkeypatch, capsys +) -> None: + state_path = tmp_path / "company.json" + state_path.write_text(json.dumps(_request()), encoding="utf-8") + calls: list[str] = [] + + def runtime(method: str, params: dict[str, object]) -> dict[str, object]: + calls.append(method) + if method.endswith("upgrade"): + return {"state": params} + if method.endswith("project"): + return {"schema_version": "company_control_loop_v0"} + assert params["expected_revision"] == "a" * 64 + return { + "schema_version": "company_control_state_store_result_v0", + "operation": "write", + "written": True, + "replayed": False, + "state": {"revision": "b" * 64}, + } + + monkeypatch.setattr(company_control_loop, "effect_runtime_result", runtime) + common = [ + "--format", "json", "--runtime-root", str(tmp_path / "runtime"), + "company-control-loop", "save", "--goal-id", "company-goal", + "--state-json", str(state_path), + ] + assert main(common) == 0 + assert json.loads(capsys.readouterr().out)["dry_run"] is True + assert calls == [ + "work_item.company_control_loop.upgrade", + "work_item.company_control_loop.project", + ] + + calls.clear() + assert main([*common, "--expected-revision", "a" * 64, "--execute"]) == 0 + assert json.loads(capsys.readouterr().out)["written"] is True + assert calls == [ + "work_item.company_control_loop.upgrade", + "work_item.company_control_loop.project", + "work_item.company_control_state.write", + ] + + +def test_company_control_loop_show_reads_goal_state(tmp_path, monkeypatch, capsys) -> None: + calls: list[tuple[str, dict[str, object]]] = [] + + def runtime(method: str, params: dict[str, object]) -> dict[str, object]: + calls.append((method, params)) + return { + "schema_version": "company_control_state_store_result_v0", + "operation": "load", + "goal_id": params["goal_id"], + "state": None, + } + + monkeypatch.setattr(company_control_loop, "effect_runtime_result", runtime) + assert main([ + "--format", "json", "--runtime-root", str(tmp_path / "runtime"), + "company-control-loop", "show", "--goal-id", "company-goal", + ]) == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["operation"] == "load" + assert calls[0][0] == "work_item.company_control_state.load" + assert calls[0][1]["goal_id"] == "company-goal" From d2c5704e2af22182d6f5a6ea56ac88a64302745d Mon Sep 17 00:00:00 2001 From: KashiwaByte <471314513@qq.com> Date: Thu, 17 Sep 2026 01:14:08 +0800 Subject: [PATCH 06/16] feat(cli): sync company work into Todos Signed-off-by: KashiwaByte <471314513@qq.com> --- loopx/cli.py | 3 +- loopx/cli_commands/company_control_loop.py | 163 ++++++++++++++++- .../test_company_control_loop_cli.py | 170 ++++++++++++++++++ 3 files changed, 333 insertions(+), 3 deletions(-) diff --git a/loopx/cli.py b/loopx/cli.py index bc845ffae4..13929cd3d9 100644 --- a/loopx/cli.py +++ b/loopx/cli.py @@ -787,9 +787,10 @@ def main(argv: list[str] | None = None) -> int: else effective_runtime_root(registry_path, None) ) if args.command == "company-control-loop" - and args.company_control_loop_command in {"save", "show"} + and args.company_control_loop_command in {"save", "show", "sync-todos"} else None ), + registry_path=registry_path, ) if company_control_loop_result is not None: return company_control_loop_result diff --git a/loopx/cli_commands/company_control_loop.py b/loopx/cli_commands/company_control_loop.py index 4061c085c6..3e3ba23e32 100644 --- a/loopx/cli_commands/company_control_loop.py +++ b/loopx/cli_commands/company_control_loop.py @@ -8,6 +8,7 @@ from typing import Any from ..control_plane.effect_runtime import effect_runtime_result +from ..todos import add_goal_todo, list_goal_todos def register_company_control_loop_command( @@ -65,6 +66,25 @@ def register_company_control_loop_command( ) add_subcommand_format(show) show.add_argument("--goal-id", required=True, help="Goal that owns the company state.") + sync = actions.add_parser( + "sync-todos", + help="Create missing LoopX Todos from persisted company work and verify readback.", + ) + add_subcommand_format(sync) + sync.add_argument( + "--goal-id", required=True, help="Goal that owns the company state and Todos." + ) + sync.add_argument( + "--agent-id", + required=True, + help="Registered agent that owns routed agent work.", + ) + sync.add_argument("--project", help="Project containing the Goal active state.") + sync.add_argument( + "--execute", + action="store_true", + help="Create missing Todos. Without this flag, return the idempotent plan.", + ) def render_company_control_loop_markdown(payload: dict[str, Any]) -> str: @@ -111,12 +131,13 @@ def handle_company_control_loop_command( output_format: Callable[..., str], print_payload: Callable[[dict[str, Any], str, Callable[[dict[str, Any]], str]], None], runtime_root: Path | None = None, + registry_path: Path | None = None, ) -> int | None: if args.command != "company-control-loop": return None try: command = args.company_control_loop_command - if command == "show": + if command in {"show", "sync-todos"}: if runtime_root is None: raise ValueError("company control state requires a runtime root") projection = effect_runtime_result( @@ -127,7 +148,20 @@ def handle_company_control_loop_command( "goal_id": args.goal_id, }, ) - payload = {"ok": True, **projection} + if command == "show": + payload = {"ok": True, **projection} + else: + if registry_path is None: + raise ValueError("company Todo sync requires a registry") + payload = _sync_todos( + stored=projection, + goal_id=args.goal_id, + agent_id=args.agent_id, + project=Path(args.project).expanduser() if args.project else None, + registry_path=registry_path, + runtime_root=runtime_root, + execute=bool(args.execute), + ) else: request = _read_json_object(args.state_json) method = ( @@ -180,3 +214,128 @@ def handle_company_control_loop_command( render_company_control_loop_markdown, ) return exit_code + + +def _sync_todos( + *, + stored: dict[str, Any], + goal_id: str, + agent_id: str, + project: Path | None, + registry_path: Path, + runtime_root: Path, + execute: bool, +) -> dict[str, Any]: + state = stored.get("state") + if not isinstance(state, dict): + raise ValueError("persisted company control state does not exist") + company = state.get("projection") + if not isinstance(company, dict): + raise TypeError("persisted company control projection is invalid") + work_items = company.get("work_items") + if not isinstance(work_items, list): + raise TypeError("persisted company work_items must be an array") + listing = list_goal_todos( + registry_path=registry_path, + runtime_root_arg=str(runtime_root), + goal_id=goal_id, + agent_id=agent_id, + project=project, + limit=500, + ) + todos = [item for item in listing.get("todos", []) if isinstance(item, dict)] + by_target: dict[str, dict[str, Any]] = {} + for todo in todos: + target = str(todo.get("target_key") or "").strip() + if not target: + continue + if target in by_target: + raise ValueError(f"multiple LoopX Todos use target_key {target!r}") + by_target[target] = todo + actions: list[dict[str, Any]] = [] + seen_targets: set[str] = set() + for raw in work_items: + if not isinstance(raw, dict): + raise TypeError("persisted company work item is invalid") + todo_projection = raw.get("todo_projection") + if not isinstance(todo_projection, dict): + raise TypeError("persisted company Todo projection is invalid") + target = str(todo_projection.get("target_key") or "").strip() + if not target or target in seen_targets: + raise ValueError("company work target_key must be present and unique") + seen_targets.add(target) + matched = by_target.get(target) + if matched: + actions.append({ + "work_item_id": raw.get("work_item_id"), + "target_key": target, + "action": "linked_existing", + "todo_id": matched.get("todo_id"), + }) + continue + action: dict[str, Any] = { + "work_item_id": raw.get("work_item_id"), + "target_key": target, + "action": "would_create", + "role": todo_projection.get("role"), + "task_class": todo_projection.get("task_class"), + } + if execute: + role = str(todo_projection.get("role") or "") + task_class = str(todo_projection.get("task_class") or "") + monitor_metadata: dict[str, Any] = {"target_key": target} + if task_class == "continuous_monitor": + monitor_metadata["watch_only"] = "true" + created = add_goal_todo( + registry_path=registry_path, + runtime_root_arg=str(runtime_root), + goal_id=goal_id, + project=project, + role=role, + text=f"[P1] {todo_projection.get('text')}", + status="open", + note=f"Acceptance: {todo_projection.get('acceptance')}", + task_class=task_class, + action_kind=str(todo_projection.get("action_kind") or ""), + claimed_by=agent_id if role == "agent" else None, + agent_id=agent_id, + blocks_agent=agent_id if task_class == "user_gate" else None, + bound_agent=agent_id if task_class == "user_action" else None, + decision_scope=( + f"direction:action:{target}" + if task_class == "user_gate" + else None + ), + monitor_metadata=monitor_metadata, + ) + action["action"] = "created" + action["todo_id"] = created.get("todo_id") + actions.append(action) + if execute: + readback = list_goal_todos( + registry_path=registry_path, + runtime_root_arg=str(runtime_root), + goal_id=goal_id, + agent_id=agent_id, + project=project, + limit=500, + ) + readback_by_target = { + str(item.get("target_key")): item + for item in readback.get("todos", []) + if isinstance(item, dict) and item.get("target_key") + } + missing = sorted(seen_targets - readback_by_target.keys()) + if missing: + raise RuntimeError(f"LoopX Todo readback missing target keys: {missing}") + for action in actions: + item = readback_by_target[str(action["target_key"])] + action["todo_id"] = item.get("todo_id") + return { + "ok": True, + "dry_run": not execute, + "goal_id": goal_id, + "state_revision": state.get("revision"), + "actions": actions, + "readback_verified": execute, + } diff --git a/tests/control_plane/test_company_control_loop_cli.py b/tests/control_plane/test_company_control_loop_cli.py index 8629a69f7e..589caf3f5c 100644 --- a/tests/control_plane/test_company_control_loop_cli.py +++ b/tests/control_plane/test_company_control_loop_cli.py @@ -25,6 +25,42 @@ def _request() -> dict[str, object]: } +def _stored_projection(*work_items: dict[str, object]) -> dict[str, object]: + return { + "schema_version": "company_control_state_store_result_v0", + "operation": "load", + "goal_id": "company-goal", + "state": { + "revision": "a" * 64, + "projection": { + "schema_version": "company_control_loop_v0", + "work_items": list(work_items), + }, + }, + } + + +def _routed_work( + work_item_id: str, + target_key: str, + *, + role: str = "agent", + task_class: str = "advancement_task", + action_kind: str = "ai_execute", +) -> dict[str, object]: + return { + "work_item_id": work_item_id, + "todo_projection": { + "role": role, + "task_class": task_class, + "action_kind": action_kind, + "target_key": target_key, + "text": f"Advance {work_item_id}", + "acceptance": f"Evidence for {work_item_id}", + }, + } + + def test_company_control_loop_cli_calls_typed_projection( tmp_path, monkeypatch, capsys ) -> None: @@ -172,3 +208,137 @@ def runtime(method: str, params: dict[str, object]) -> dict[str, object]: assert payload["operation"] == "load" assert calls[0][0] == "work_item.company_control_state.load" assert calls[0][1]["goal_id"] == "company-goal" + + +def test_company_control_loop_sync_todos_previews_existing_and_missing( + tmp_path, monkeypatch, capsys +) -> None: + monkeypatch.setattr( + company_control_loop, + "effect_runtime_result", + lambda method, params: _stored_projection( + _routed_work("work_existing", "target_existing"), + _routed_work("work_missing", "target_missing"), + ), + ) + monkeypatch.setattr( + company_control_loop, + "list_goal_todos", + lambda **kwargs: { + "todos": [{ + "todo_id": "todo_existing", + "target_key": "target_existing", + "status": "open", + }] + }, + ) + writes: list[dict[str, object]] = [] + monkeypatch.setattr( + company_control_loop, + "add_goal_todo", + lambda **kwargs: writes.append(kwargs), + ) + + assert main([ + "--format", "json", "--registry", str(tmp_path / "registry.json"), + "--runtime-root", str(tmp_path / "runtime"), + "company-control-loop", "sync-todos", "--goal-id", "company-goal", + "--agent-id", "agent-ceo", "--project", str(tmp_path), + ]) == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["dry_run"] is True + assert payload["readback_verified"] is False + assert [item["action"] for item in payload["actions"]] == [ + "linked_existing", "would_create", + ] + assert writes == [] + + +def test_company_control_loop_sync_todos_creates_and_verifies_readback( + tmp_path, monkeypatch, capsys +) -> None: + monkeypatch.setattr( + company_control_loop, + "effect_runtime_result", + lambda method, params: _stored_projection( + _routed_work( + "work_decision", + "target_decision", + role="user", + task_class="user_gate", + action_kind="human_decide", + ), + _routed_work( + "work_watch", + "target_watch", + task_class="continuous_monitor", + action_kind="observe", + ), + ), + ) + listings = iter([ + {"todos": []}, + {"todos": [ + {"todo_id": "todo_decision", "target_key": "target_decision"}, + {"todo_id": "todo_watch", "target_key": "target_watch"}, + ]}, + ]) + monkeypatch.setattr( + company_control_loop, "list_goal_todos", lambda **kwargs: next(listings) + ) + writes: list[dict[str, object]] = [] + + def add(**kwargs: object) -> dict[str, object]: + writes.append(kwargs) + return {"todo_id": f"created_{len(writes)}"} + + monkeypatch.setattr(company_control_loop, "add_goal_todo", add) + + assert main([ + "--format", "json", "--registry", str(tmp_path / "registry.json"), + "--runtime-root", str(tmp_path / "runtime"), + "company-control-loop", "sync-todos", "--goal-id", "company-goal", + "--agent-id", "agent-ceo", "--project", str(tmp_path), "--execute", + ]) == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["readback_verified"] is True + assert [item["todo_id"] for item in payload["actions"]] == [ + "todo_decision", "todo_watch", + ] + assert writes[0]["blocks_agent"] == "agent-ceo" + assert writes[0]["decision_scope"] == "direction:action:target_decision" + assert writes[1]["monitor_metadata"] == { + "target_key": "target_watch", + "watch_only": "true", + } + + +def test_company_control_loop_sync_todos_fails_on_missing_readback( + tmp_path, monkeypatch, capsys +) -> None: + monkeypatch.setattr( + company_control_loop, + "effect_runtime_result", + lambda method, params: _stored_projection( + _routed_work("work_missing", "target_missing") + ), + ) + listings = iter([{"todos": []}, {"todos": []}]) + monkeypatch.setattr( + company_control_loop, "list_goal_todos", lambda **kwargs: next(listings) + ) + monkeypatch.setattr( + company_control_loop, + "add_goal_todo", + lambda **kwargs: {"todo_id": "todo_unreadable"}, + ) + + assert main([ + "--format", "json", "--registry", str(tmp_path / "registry.json"), + "--runtime-root", str(tmp_path / "runtime"), + "company-control-loop", "sync-todos", "--goal-id", "company-goal", + "--agent-id", "agent-ceo", "--project", str(tmp_path), "--execute", + ]) == 1 + payload = json.loads(capsys.readouterr().out) + assert payload["ok"] is False + assert "readback missing target keys" in payload["error"] From cbde5cc13984520d180969ec6bf62c07e4a849a8 Mon Sep 17 00:00:00 2001 From: KashiwaByte <471314513@qq.com> Date: Thu, 17 Sep 2026 01:23:05 +0800 Subject: [PATCH 07/16] feat(control-plane): reconcile company Todos with evidence Signed-off-by: KashiwaByte <471314513@qq.com> --- loopx/cli.py | 3 +- loopx/cli_commands/company_control_loop.py | 104 +++++++++++- .../control_plane/effect_runtime_handlers.ts | 2 + loopx/control_plane/todos/monitor_metadata.ts | 4 +- .../work_items/company_control_state.ts | 160 +++++++++++++++++- .../test_company_control_loop_cli.py | 86 ++++++++++ .../test_todo_mutation_authority.py | 22 +++ .../company_control_state.test.ts | 138 +++++++++++++++ .../control_plane_ts/monitor_metadata.test.ts | 5 +- 9 files changed, 518 insertions(+), 6 deletions(-) diff --git a/loopx/cli.py b/loopx/cli.py index 13929cd3d9..b75711e3d3 100644 --- a/loopx/cli.py +++ b/loopx/cli.py @@ -787,7 +787,8 @@ def main(argv: list[str] | None = None) -> int: else effective_runtime_root(registry_path, None) ) if args.command == "company-control-loop" - and args.company_control_loop_command in {"save", "show", "sync-todos"} + and args.company_control_loop_command + in {"save", "show", "sync-todos", "reconcile-todos"} else None ), registry_path=registry_path, diff --git a/loopx/cli_commands/company_control_loop.py b/loopx/cli_commands/company_control_loop.py index 3e3ba23e32..92d3f0f314 100644 --- a/loopx/cli_commands/company_control_loop.py +++ b/loopx/cli_commands/company_control_loop.py @@ -85,6 +85,25 @@ def register_company_control_loop_command( action="store_true", help="Create missing Todos. Without this flag, return the idempotent plan.", ) + reconcile = actions.add_parser( + "reconcile-todos", + help="Reconcile Todo status and evidence into persisted company state.", + ) + add_subcommand_format(reconcile) + reconcile.add_argument( + "--goal-id", required=True, help="Goal that owns the company state and Todos." + ) + reconcile.add_argument( + "--agent-id", + required=True, + help="Registered agent whose routed Todo lane is reconciled.", + ) + reconcile.add_argument("--project", help="Project containing the Goal active state.") + reconcile.add_argument( + "--execute", + action="store_true", + help="Persist reconciliation. Without this flag, return a preview.", + ) def render_company_control_loop_markdown(payload: dict[str, Any]) -> str: @@ -137,7 +156,7 @@ def handle_company_control_loop_command( return None try: command = args.company_control_loop_command - if command in {"show", "sync-todos"}: + if command in {"show", "sync-todos", "reconcile-todos"}: if runtime_root is None: raise ValueError("company control state requires a runtime root") projection = effect_runtime_result( @@ -150,7 +169,7 @@ def handle_company_control_loop_command( ) if command == "show": payload = {"ok": True, **projection} - else: + elif command == "sync-todos": if registry_path is None: raise ValueError("company Todo sync requires a registry") payload = _sync_todos( @@ -162,6 +181,18 @@ def handle_company_control_loop_command( runtime_root=runtime_root, execute=bool(args.execute), ) + else: + if registry_path is None: + raise ValueError("company Todo reconciliation requires a registry") + payload = _reconcile_todos( + stored=projection, + goal_id=args.goal_id, + agent_id=args.agent_id, + project=Path(args.project).expanduser() if args.project else None, + registry_path=registry_path, + runtime_root=runtime_root, + execute=bool(args.execute), + ) else: request = _read_json_object(args.state_json) method = ( @@ -339,3 +370,72 @@ def _sync_todos( "actions": actions, "readback_verified": execute, } + + +def _reconcile_todos( + *, + stored: dict[str, Any], + goal_id: str, + agent_id: str, + project: Path | None, + registry_path: Path, + runtime_root: Path, + execute: bool, +) -> dict[str, Any]: + state = stored.get("state") + if not isinstance(state, dict): + raise ValueError("persisted company control state does not exist") + company = state.get("projection") + if not isinstance(company, dict): + raise TypeError("persisted company control projection is invalid") + work_items = company.get("work_items") + if not isinstance(work_items, list): + raise TypeError("persisted company work_items must be an array") + targets = { + str(item.get("target_key")) + for item in work_items + if isinstance(item, dict) and item.get("target_key") + } + listing = list_goal_todos( + registry_path=registry_path, + runtime_root_arg=str(runtime_root), + goal_id=goal_id, + agent_id=agent_id, + project=project, + limit=500, + ) + observations: list[dict[str, Any]] = [] + seen_targets: set[str] = set() + for raw in listing.get("todos", []): + if not isinstance(raw, dict): + continue + target = str(raw.get("target_key") or "").strip() + if target not in targets: + continue + if target in seen_targets: + raise ValueError(f"multiple LoopX Todos use target_key {target!r}") + seen_targets.add(target) + observation: dict[str, Any] = { + "target_key": target, + "todo_id": raw.get("todo_id"), + "status": raw.get("status"), + } + evidence = raw.get("evidence") + if isinstance(evidence, str) and evidence.strip(): + observation["evidence_ref"] = evidence.strip() + observations.append(observation) + if not observations: + raise ValueError("no LoopX Todos match persisted company work targets") + result = effect_runtime_result( + "work_item.company_control_state.reconcile", + { + "schema_version": "company_control_state_reconcile_request_v0", + "runtime_root": str(runtime_root), + "goal_id": goal_id, + "expected_revision": state.get("revision"), + "updated_at": datetime.now(UTC).isoformat(), + "execute": execute, + "observations": observations, + }, + ) + return {"ok": True, **result} diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index 6833dd7c78..58039a97f0 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -178,6 +178,7 @@ import { } from "./work_items/company_control_loop.ts"; import { loadCompanyControlState, + reconcileCompanyControlState, writeCompanyControlState, } from "./work_items/company_control_state.ts"; import { @@ -462,6 +463,7 @@ export function createEffectRuntimeHandlers( ["work_item.company_control_loop.project", projectCompanyControlLoop], ["work_item.company_control_loop.upgrade", upgradeCompanyControlLoopState], ["work_item.company_control_state.load", loadCompanyControlState], + ["work_item.company_control_state.reconcile", reconcileCompanyControlState], ["work_item.company_control_state.write", writeCompanyControlState], ["work_item.delivery_history.project", projectDeliveryHistory], ["work_item.delivery_response.project", projectDeliveryResponse], diff --git a/loopx/control_plane/todos/monitor_metadata.ts b/loopx/control_plane/todos/monitor_metadata.ts index 50c38e0c02..25a995d40e 100644 --- a/loopx/control_plane/todos/monitor_metadata.ts +++ b/loopx/control_plane/todos/monitor_metadata.ts @@ -188,7 +188,9 @@ export function planMonitorMetadata(value: unknown): MonitorMetadataPlan { if (nonTarget && (request.role !== "agent" || request.task_class !== "continuous_monitor")) { throw new EffectRuntimeRequestError("monitor schedule metadata requires --role agent --task-class continuous_monitor"); } - if (metadata.target_key != null && request.role !== "agent") throw new EffectRuntimeRequestError("target_key requires --role agent"); + if (metadata.target_key != null && !["agent", "user"].includes(String(request.role))) { + throw new EffectRuntimeRequestError("target_key requires --role agent or user"); + } if (request.generated_at != null && request.task_class === "continuous_monitor" && metadata.next_due_at == null && metadata.cadence != null) { const due = schedule(text(request.generated_at), metadata.cadence).next_due_at; if (due) metadata = {...metadata, next_due_at: due}; diff --git a/loopx/control_plane/work_items/company_control_state.ts b/loopx/control_plane/work_items/company_control_state.ts index 7edc132362..030b62099f 100644 --- a/loopx/control_plane/work_items/company_control_state.ts +++ b/loopx/control_plane/work_items/company_control_state.ts @@ -10,8 +10,10 @@ import { import { atomicWriteJson, withFileMutationLock } from "../effect_runtime_io.ts"; import { optionalNonEmptyString, + requireBoolean, requireJsonObject, requireNonEmptyString, + requireStringLiteral, } from "../runtime_decode.ts"; import { COMPANY_CONTROL_LOOP_SCHEMA_VERSION, @@ -24,6 +26,10 @@ export const COMPANY_CONTROL_STATE_STORE_SCHEMA = "company_control_state_store_v0"; export const COMPANY_CONTROL_STATE_STORE_RESULT_SCHEMA = "company_control_state_store_result_v0"; +export const COMPANY_CONTROL_STATE_RECONCILE_REQUEST_SCHEMA = + "company_control_state_reconcile_request_v0"; +export const COMPANY_CONTROL_STATE_RECONCILIATION_SCHEMA = + "company_control_state_reconciliation_v0"; function stableValue(value: unknown): unknown { if (Array.isArray(value)) return value.map(stableValue); @@ -92,7 +98,19 @@ function decodeStoredState(value: unknown, goalId: string): JsonObject { if (projection.schema_version !== COMPANY_CONTROL_LOOP_SCHEMA_VERSION) { throw new EffectRuntimeRequestError("stored company control projection schema is invalid"); } - if (revision(projection) !== stored.revision) { + const reconciliation = stored.reconciliation === undefined + ? null + : requireJsonObject(stored.reconciliation, "stored reconciliation"); + if ( + reconciliation !== null && + reconciliation.schema_version !== COMPANY_CONTROL_STATE_RECONCILIATION_SCHEMA + ) { + throw new EffectRuntimeRequestError("stored company control reconciliation is invalid"); + } + const revisionContent = reconciliation === null + ? projection + : { projection, reconciliation }; + if (revision(revisionContent) !== stored.revision) { throw new EffectRuntimeRequestError("stored company control state revision does not match content"); } return stored; @@ -170,3 +188,143 @@ export async function writeCompanyControlState(value: unknown): Promise(); + for (const item of workItems) { + const work = requireJsonObject(item, "stored company work item"); + const target = requireNonEmptyString(work.target_key, "stored work target_key"); + if (byTarget.has(target)) { + throw new EffectRuntimeRequestError("stored company work target_key must be unique"); + } + byTarget.set(target, work); + } + const seenTargets = new Set(); + const observations = request.observations.map((value, index) => { + const raw = requireJsonObject(value, `observations[${index}]`); + const targetKey = requireNonEmptyString(raw.target_key, `observations[${index}].target_key`); + if (seenTargets.has(targetKey)) { + throw new EffectRuntimeRequestError("company Todo observations must have unique target_key values"); + } + seenTargets.add(targetKey); + const work = byTarget.get(targetKey); + if (!work) { + throw new EffectRuntimeRequestError( + `company Todo observation target_key ${JSON.stringify(targetKey)} is unknown`, + ); + } + const todoStatus = requireStringLiteral( + raw.status, + ["open", "done", "blocked", "deferred"] as const, + `observations[${index}].status`, + ); + const evidenceRef = optionalNonEmptyString( + raw.evidence_ref, + `observations[${index}].evidence_ref`, + ); + const priorStatus = requireNonEmptyString(work.status, "stored work status"); + const nextStatus = todoStatus === "done" + ? (evidenceRef === null ? "awaiting_evidence" : "done") + : todoStatus === "blocked" + ? "replanning" + : priorStatus; + return { + work_item_id: work.work_item_id, + target_key: targetKey, + todo_id: requireNonEmptyString(raw.todo_id, `observations[${index}].todo_id`), + todo_status: todoStatus, + prior_status: priorStatus, + next_status: nextStatus, + ...(evidenceRef === null ? {} : { evidence_ref: evidenceRef }), + changed: priorStatus !== nextStatus, + }; + }); + return { + schema_version: COMPANY_CONTROL_STATE_RECONCILIATION_SCHEMA, + observations, + replan_required: observations.some((item) => + item.next_status === "replanning" || item.next_status === "awaiting_evidence" + ), + }; +} + +export async function reconcileCompanyControlState(value: unknown): Promise { + const request = requireJsonObject(value, "company_control_state_reconcile params"); + if (request.schema_version !== COMPANY_CONTROL_STATE_RECONCILE_REQUEST_SCHEMA) { + throw new EffectRuntimeRequestError("company control reconciliation request schema mismatch"); + } + const runtimeRoot = requireNonEmptyString(request.runtime_root, "runtime_root"); + const goalId = requireNonEmptyString(request.goal_id, "goal_id"); + const path = companyControlStatePath(runtimeRoot, goalId); + const expectedRevision = requireNonEmptyString( + request.expected_revision, + "expected_revision", + ); + const execute = requireBoolean(request.execute, "execute"); + const updatedAt = requireNonEmptyString(request.updated_at, "updated_at"); + return await withFileMutationLock(path, async () => { + const existing = await readStoredState(path, goalId); + if (!existing) { + throw new EffectRuntimeRequestError("persisted company control state does not exist"); + } + if (existing.revision !== expectedRevision) { + throw new EffectRuntimeConflictError("company control state revision changed"); + } + const projection = requireJsonObject(existing.projection, "stored projection"); + const reconciliation = todoReconciliation(request, projection); + const nextRevision = revision({ projection, reconciliation }); + const nextState: JsonObject = { + ...existing, + revision: nextRevision, + updated_at: updatedAt, + reconciliation, + }; + if (!execute) { + return { + schema_version: COMPANY_CONTROL_STATE_STORE_RESULT_SCHEMA, + operation: "reconcile", + goal_id: goalId, + path, + dry_run: true, + state: nextState, + written: false, + replayed: false, + }; + } + if (existing.revision === nextRevision) { + return { + schema_version: COMPANY_CONTROL_STATE_STORE_RESULT_SCHEMA, + operation: "reconcile", + goal_id: goalId, + path, + dry_run: false, + state: existing, + written: false, + replayed: true, + }; + } + await atomicWriteJson(path, nextState); + const readback = await readStoredState(path, goalId); + if (!readback || readback.revision !== nextRevision) { + throw new Error("company control reconciliation readback failed"); + } + return { + schema_version: COMPANY_CONTROL_STATE_STORE_RESULT_SCHEMA, + operation: "reconcile", + goal_id: goalId, + path, + dry_run: false, + state: readback, + written: true, + replayed: false, + }; + }); +} diff --git a/tests/control_plane/test_company_control_loop_cli.py b/tests/control_plane/test_company_control_loop_cli.py index 589caf3f5c..5c3eb7ef64 100644 --- a/tests/control_plane/test_company_control_loop_cli.py +++ b/tests/control_plane/test_company_control_loop_cli.py @@ -50,6 +50,7 @@ def _routed_work( ) -> dict[str, object]: return { "work_item_id": work_item_id, + "target_key": target_key, "todo_projection": { "role": role, "task_class": task_class, @@ -342,3 +343,88 @@ def test_company_control_loop_sync_todos_fails_on_missing_readback( payload = json.loads(capsys.readouterr().out) assert payload["ok"] is False assert "readback missing target keys" in payload["error"] + + +def test_company_control_loop_reconcile_todos_sends_evidence_to_typed_owner( + tmp_path, monkeypatch, capsys +) -> None: + calls: list[tuple[str, dict[str, object]]] = [] + + def runtime(method: str, params: dict[str, object]) -> dict[str, object]: + calls.append((method, params)) + if method.endswith(".load"): + return _stored_projection( + _routed_work("work_activation", "target_activation") + ) + return { + "schema_version": "company_control_state_store_result_v0", + "operation": "reconcile", + "dry_run": True, + "written": False, + } + + monkeypatch.setattr(company_control_loop, "effect_runtime_result", runtime) + monkeypatch.setattr( + company_control_loop, + "list_goal_todos", + lambda **kwargs: {"todos": [ + { + "todo_id": "todo_activation", + "target_key": "target_activation", + "status": "done", + "evidence": "artifact:activation-report", + }, + { + "todo_id": "todo_unrelated", + "target_key": "other_target", + "status": "done", + }, + ]}, + ) + + assert main([ + "--format", "json", "--registry", str(tmp_path / "registry.json"), + "--runtime-root", str(tmp_path / "runtime"), + "company-control-loop", "reconcile-todos", "--goal-id", "company-goal", + "--agent-id", "agent-ceo", "--project", str(tmp_path), + ]) == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["dry_run"] is True + assert calls[1][0] == "work_item.company_control_state.reconcile" + assert calls[1][1]["expected_revision"] == "a" * 64 + assert calls[1][1]["execute"] is False + assert calls[1][1]["observations"] == [{ + "target_key": "target_activation", + "todo_id": "todo_activation", + "status": "done", + "evidence_ref": "artifact:activation-report", + }] + + +def test_company_control_loop_reconcile_todos_rejects_duplicate_targets( + tmp_path, monkeypatch, capsys +) -> None: + monkeypatch.setattr( + company_control_loop, + "effect_runtime_result", + lambda method, params: _stored_projection( + _routed_work("work_activation", "target_activation") + ), + ) + monkeypatch.setattr( + company_control_loop, + "list_goal_todos", + lambda **kwargs: {"todos": [ + {"todo_id": "todo_first", "target_key": "target_activation", "status": "open"}, + {"todo_id": "todo_second", "target_key": "target_activation", "status": "done"}, + ]}, + ) + assert main([ + "--format", "json", "--registry", str(tmp_path / "registry.json"), + "--runtime-root", str(tmp_path / "runtime"), + "company-control-loop", "reconcile-todos", "--goal-id", "company-goal", + "--agent-id", "agent-ceo", "--execute", + ]) == 1 + payload = json.loads(capsys.readouterr().out) + assert payload["ok"] is False + assert "multiple LoopX Todos" in payload["error"] diff --git a/tests/control_plane/test_todo_mutation_authority.py b/tests/control_plane/test_todo_mutation_authority.py index c683835e94..2953dc3874 100644 --- a/tests/control_plane/test_todo_mutation_authority.py +++ b/tests/control_plane/test_todo_mutation_authority.py @@ -708,6 +708,28 @@ def test_advancement_todo_preserves_public_target_key(tmp_path: Path) -> None: assert projected["target_key"] == "issue-fix:owner/repo:issue_42" +def test_user_gate_preserves_company_target_key(tmp_path: Path) -> None: + registry, state = _write_fixture(tmp_path) + todo = add_goal_todo( + registry_path=registry, + goal_id=GOAL_ID, + role="user", + text="Decide the activation direction.", + task_class="user_gate", + action_kind="human_decide", + agent_id=AUTHOR_AGENT, + blocks_agent=AUTHOR_AGENT, + decision_scope="direction:action:activation_decision", + monitor_metadata={"target_key": "activation_decision"}, + ) + projected = parse_active_state_todos(state.read_text(encoding="utf-8")) + user = next( + item for item in projected["user_todos"]["items"] + if item["todo_id"] == todo["todo_id"] + ) + assert user["target_key"] == "activation_decision" + + def test_capability_binding_follows_generated_agent_successor(tmp_path: Path) -> None: registry, state = _write_fixture(tmp_path) todo = add_goal_todo( diff --git a/tests/control_plane_ts/company_control_state.test.ts b/tests/control_plane_ts/company_control_state.test.ts index 6961feaaec..b066dc889e 100644 --- a/tests/control_plane_ts/company_control_state.test.ts +++ b/tests/control_plane_ts/company_control_state.test.ts @@ -5,9 +5,11 @@ import { join } from "node:path"; import test from "node:test"; import { + COMPANY_CONTROL_STATE_RECONCILE_REQUEST_SCHEMA, COMPANY_CONTROL_STATE_STORE_REQUEST_SCHEMA, companyControlStatePath, loadCompanyControlState, + reconcileCompanyControlState, writeCompanyControlState, } from "../../loopx/control_plane/work_items/company_control_state.ts"; @@ -28,6 +30,22 @@ function state(direction = "Improve durable customer value.") { }; } +function stateWithWork() { + const value = state(); + return { + ...value, + work_items: [{ + work_item_id: "work_activation", + outcome_id: "outcome_activation", + title: "Ship activation improvement", + acceptance: "validated activation evidence", + authority_tier: "A", + ai_capable: true, + target_key: "activation_delivery", + }], + }; +} + function request(runtimeRoot: string, extra: Record = {}) { return { schema_version: COMPANY_CONTROL_STATE_STORE_REQUEST_SCHEMA, @@ -107,3 +125,123 @@ test("company control state path is bounded and rejects relative runtime roots", /runtime_root must be absolute/, ); }); + +test("company control reconciliation previews and persists evidence-gated Todo status", async (t) => { + const runtimeRoot = await mkdtemp(join(tmpdir(), "loopx-company-state-")); + t.after(() => rm(runtimeRoot, { recursive: true, force: true })); + const first = await writeCompanyControlState(request(runtimeRoot, { + state: stateWithWork(), + updated_at: "2026-09-17T00:00:00Z", + })); + const original = first.state as Record; + const reconcileRequest = { + schema_version: COMPANY_CONTROL_STATE_RECONCILE_REQUEST_SCHEMA, + runtime_root: runtimeRoot, + goal_id: "company-goal", + expected_revision: original.revision, + updated_at: "2026-09-17T00:01:00Z", + execute: false, + observations: [{ + target_key: "activation_delivery", + todo_id: "todo_activation", + status: "done", + evidence_ref: "artifact:activation-report", + }], + }; + const preview = await reconcileCompanyControlState(reconcileRequest); + assert.equal(preview.dry_run, true); + assert.equal(preview.written, false); + assert.equal( + ((preview.state as Record).reconciliation.observations[0]).next_status, + "done", + ); + assert.deepEqual((await loadCompanyControlState(request(runtimeRoot))).state, first.state); + + const written = await reconcileCompanyControlState({ + ...reconcileRequest, + execute: true, + }); + assert.equal(written.written, true); + const reconciled = written.state as Record; + assert.notEqual(reconciled.revision, original.revision); + assert.equal(reconciled.reconciliation.replan_required, false); + assert.equal(reconciled.reconciliation.observations[0].evidence_ref, "artifact:activation-report"); + + const replay = await reconcileCompanyControlState({ + ...reconcileRequest, + expected_revision: reconciled.revision, + updated_at: "2026-09-17T00:02:00Z", + execute: true, + }); + assert.equal(replay.written, false); + assert.equal(replay.replayed, true); +}); + +test("company control reconciliation requests replanning for blocked or unproven completion", async (t) => { + const runtimeRoot = await mkdtemp(join(tmpdir(), "loopx-company-state-")); + t.after(() => rm(runtimeRoot, { recursive: true, force: true })); + const first = await writeCompanyControlState(request(runtimeRoot, { + state: stateWithWork(), + updated_at: "2026-09-17T00:00:00Z", + })); + const revision = (first.state as Record).revision; + + for (const [status, expected] of [ + ["blocked", "replanning"], + ["done", "awaiting_evidence"], + ] as const) { + const result = await reconcileCompanyControlState({ + schema_version: COMPANY_CONTROL_STATE_RECONCILE_REQUEST_SCHEMA, + runtime_root: runtimeRoot, + goal_id: "company-goal", + expected_revision: revision, + updated_at: "2026-09-17T00:01:00Z", + execute: false, + observations: [{ + target_key: "activation_delivery", + todo_id: "todo_activation", + status, + }], + }); + const reconciliation = (result.state as Record).reconciliation; + assert.equal(reconciliation.replan_required, true); + assert.equal(reconciliation.observations[0].next_status, expected); + } +}); + +test("company control reconciliation rejects stale revisions and unknown targets", async (t) => { + const runtimeRoot = await mkdtemp(join(tmpdir(), "loopx-company-state-")); + t.after(() => rm(runtimeRoot, { recursive: true, force: true })); + const first = await writeCompanyControlState(request(runtimeRoot, { + state: stateWithWork(), + updated_at: "2026-09-17T00:00:00Z", + })); + const revision = (first.state as Record).revision; + const base = { + schema_version: COMPANY_CONTROL_STATE_RECONCILE_REQUEST_SCHEMA, + runtime_root: runtimeRoot, + goal_id: "company-goal", + expected_revision: revision, + updated_at: "2026-09-17T00:01:00Z", + execute: false, + }; + await assert.rejects( + reconcileCompanyControlState({ + ...base, + expected_revision: "0".repeat(64), + observations: [], + }), + /revision changed/, + ); + await assert.rejects( + reconcileCompanyControlState({ + ...base, + observations: [{ + target_key: "unknown_target", + todo_id: "todo_unknown", + status: "open", + }], + }), + /is unknown/, + ); +}); diff --git a/tests/control_plane_ts/monitor_metadata.test.ts b/tests/control_plane_ts/monitor_metadata.test.ts index cdd42903a3..99d11b6f83 100644 --- a/tests/control_plane_ts/monitor_metadata.test.ts +++ b/tests/control_plane_ts/monitor_metadata.test.ts @@ -67,7 +67,10 @@ test("create/edit scope, boundedness and explicit clearing share one owner", () assert.doesNotThrow(() => planMonitorMetadata(request({metadata: {watch_only: null}, resume_when: "todo_done:todo_dependency"}))); assert.doesNotThrow(() => planMonitorMetadata(request({metadata: {watch_only: null}, enforce_boundedness: false}))); assert.throws(() => planMonitorMetadata(request({role: "user", metadata: {cadence: "1h"}})), /schedule metadata/); - assert.throws(() => planMonitorMetadata(request({role: "user", metadata: {target_key: "fixture"}})), /target_key/); + assert.deepEqual( + planMonitorMetadata(request({role: "user", metadata: {target_key: "fixture"}})).metadata, + {target_key: "fixture"}, + ); assert.throws(() => planMonitorMetadata(request({metadata: {cadence: "never"}})), /cadence/); for (const field of ["consecutive_no_change", "material_change_generation"]) { for (const value of ["-1", "9007199254740993", "1.5"]) { From 78c8ddcbc58b9f09bdffad44484d182775d5d4ee Mon Sep 17 00:00:00 2001 From: KashiwaByte <471314513@qq.com> Date: Thu, 17 Sep 2026 01:29:37 +0800 Subject: [PATCH 08/16] feat(control-plane): plan next company cycle Signed-off-by: KashiwaByte <471314513@qq.com> --- loopx/cli.py | 2 +- loopx/cli_commands/company_control_loop.py | 22 +++- .../control_plane/effect_runtime_handlers.ts | 2 + .../work_items/company_control_state.ts | 106 ++++++++++++++++++ .../test_company_control_loop_cli.py | 46 ++++++++ .../company_control_state.test.ts | 91 +++++++++++++++ 6 files changed, 267 insertions(+), 2 deletions(-) diff --git a/loopx/cli.py b/loopx/cli.py index b75711e3d3..0329476701 100644 --- a/loopx/cli.py +++ b/loopx/cli.py @@ -788,7 +788,7 @@ def main(argv: list[str] | None = None) -> int: ) if args.command == "company-control-loop" and args.company_control_loop_command - in {"save", "show", "sync-todos", "reconcile-todos"} + in {"save", "show", "sync-todos", "reconcile-todos", "next-cycle"} else None ), registry_path=registry_path, diff --git a/loopx/cli_commands/company_control_loop.py b/loopx/cli_commands/company_control_loop.py index 92d3f0f314..356417d953 100644 --- a/loopx/cli_commands/company_control_loop.py +++ b/loopx/cli_commands/company_control_loop.py @@ -104,6 +104,14 @@ def register_company_control_loop_command( action="store_true", help="Persist reconciliation. Without this flag, return a preview.", ) + next_cycle = actions.add_parser( + "next-cycle", + help="Plan the next company cycle from reconciled Todo outcomes.", + ) + add_subcommand_format(next_cycle) + next_cycle.add_argument( + "--goal-id", required=True, help="Goal that owns the reconciled company state." + ) def render_company_control_loop_markdown(payload: dict[str, Any]) -> str: @@ -156,7 +164,7 @@ def handle_company_control_loop_command( return None try: command = args.company_control_loop_command - if command in {"show", "sync-todos", "reconcile-todos"}: + if command in {"show", "sync-todos", "reconcile-todos", "next-cycle"}: if runtime_root is None: raise ValueError("company control state requires a runtime root") projection = effect_runtime_result( @@ -169,6 +177,18 @@ def handle_company_control_loop_command( ) if command == "show": payload = {"ok": True, **projection} + elif command == "next-cycle": + payload = { + "ok": True, + **effect_runtime_result( + "work_item.company_control_state.next_cycle", + { + "schema_version": "company_control_next_cycle_request_v0", + "goal_id": args.goal_id, + "state": projection.get("state"), + }, + ), + } elif command == "sync-todos": if registry_path is None: raise ValueError("company Todo sync requires a registry") diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index 58039a97f0..172c085dc2 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -178,6 +178,7 @@ import { } from "./work_items/company_control_loop.ts"; import { loadCompanyControlState, + planCompanyControlNextCycle, reconcileCompanyControlState, writeCompanyControlState, } from "./work_items/company_control_state.ts"; @@ -463,6 +464,7 @@ export function createEffectRuntimeHandlers( ["work_item.company_control_loop.project", projectCompanyControlLoop], ["work_item.company_control_loop.upgrade", upgradeCompanyControlLoopState], ["work_item.company_control_state.load", loadCompanyControlState], + ["work_item.company_control_state.next_cycle", planCompanyControlNextCycle], ["work_item.company_control_state.reconcile", reconcileCompanyControlState], ["work_item.company_control_state.write", writeCompanyControlState], ["work_item.delivery_history.project", projectDeliveryHistory], diff --git a/loopx/control_plane/work_items/company_control_state.ts b/loopx/control_plane/work_items/company_control_state.ts index 030b62099f..6a0792d352 100644 --- a/loopx/control_plane/work_items/company_control_state.ts +++ b/loopx/control_plane/work_items/company_control_state.ts @@ -30,6 +30,10 @@ export const COMPANY_CONTROL_STATE_RECONCILE_REQUEST_SCHEMA = "company_control_state_reconcile_request_v0"; export const COMPANY_CONTROL_STATE_RECONCILIATION_SCHEMA = "company_control_state_reconciliation_v0"; +export const COMPANY_CONTROL_NEXT_CYCLE_REQUEST_SCHEMA = + "company_control_next_cycle_request_v0"; +export const COMPANY_CONTROL_NEXT_CYCLE_SCHEMA = + "company_control_next_cycle_v0"; function stableValue(value: unknown): unknown { if (Array.isArray(value)) return value.map(stableValue); @@ -328,3 +332,105 @@ export async function reconcileCompanyControlState(value: unknown): Promise(); + for (const value of observations) { + const observation = requireJsonObject(value, "reconciliation observation"); + byTarget.set( + requireNonEmptyString(observation.target_key, "observation target_key"), + observation, + ); + } + const workItems = projection.work_items; + if (!Array.isArray(workItems)) { + throw new EffectRuntimeRequestError("stored company work_items must be an array"); + } + const nextWorkItems: JsonObject[] = []; + const feedback: JsonObject[] = Array.isArray(projection.feedback) + ? projection.feedback.map((item) => { + const prior = requireJsonObject(item, "stored company feedback"); + const { disposition: _disposition, ...requestFeedback } = prior; + return requestFeedback; + }) + : []; + let convergedCount = 0; + for (const value of workItems) { + const work = requireJsonObject(value, "stored company work item"); + const targetKey = requireNonEmptyString(work.target_key, "stored work target_key"); + const observation = byTarget.get(targetKey); + const nextStatus = observation?.next_status; + if (nextStatus === "done") { + convergedCount += 1; + feedback.push({ + feedback_id: `todo_${work.work_item_id}_done`, + source: "loopx_todo", + subject: `Todo completed: ${work.title}`, + kind: "execution_result", + observed_at: requireNonEmptyString(stored.updated_at, "stored updated_at"), + evidence_ref: requireNonEmptyString( + observation?.evidence_ref, + "completion evidence_ref", + ), + affected_outcome_ids: [work.outcome_id], + }); + continue; + } + const { + route: _route, + route_reason: _routeReason, + status: _status, + todo_projection: _todoProjection, + ...nextWork + } = work; + nextWorkItems.push(nextWork); + if (nextStatus === "replanning" || nextStatus === "awaiting_evidence") { + const todoId = requireNonEmptyString(observation?.todo_id, "observation todo_id"); + feedback.push({ + feedback_id: `todo_${work.work_item_id}_${nextStatus}`, + source: "loopx_todo", + subject: nextStatus === "replanning" + ? `Todo blocked: ${work.title}` + : `Todo completion needs evidence: ${work.title}`, + kind: "risk", + observed_at: requireNonEmptyString(stored.updated_at, "stored updated_at"), + evidence_ref: `loopx-todo:${todoId}`, + affected_outcome_ids: [work.outcome_id], + }); + } + } + const nextState: JsonObject = { + schema_version: "company_control_loop_request_v0", + direction: projection.direction, + cycle: Number(projection.cycle) + 1, + outcomes: projection.outcomes, + work_items: nextWorkItems, + feedback, + }; + const nextProjection = projectCompanyControlLoop(nextState); + return { + schema_version: COMPANY_CONTROL_NEXT_CYCLE_SCHEMA, + goal_id: goalId, + source_revision: stored.revision, + converged_work_item_count: convergedCount, + remaining_work_item_count: nextWorkItems.length, + goal_converged: nextWorkItems.length === 0, + replan_required: nextProjection.replan_required, + state: nextState, + projection: nextProjection, + }; +} diff --git a/tests/control_plane/test_company_control_loop_cli.py b/tests/control_plane/test_company_control_loop_cli.py index 5c3eb7ef64..9f8d8722ef 100644 --- a/tests/control_plane/test_company_control_loop_cli.py +++ b/tests/control_plane/test_company_control_loop_cli.py @@ -428,3 +428,49 @@ def test_company_control_loop_reconcile_todos_rejects_duplicate_targets( payload = json.loads(capsys.readouterr().out) assert payload["ok"] is False assert "multiple LoopX Todos" in payload["error"] + + +def test_company_control_loop_next_cycle_uses_persisted_reconciliation( + tmp_path, monkeypatch, capsys +) -> None: + stored = _stored_projection( + _routed_work("work_activation", "target_activation") + ) + state = stored["state"] + assert isinstance(state, dict) + state["reconciliation"] = { + "schema_version": "company_control_state_reconciliation_v0", + "observations": [{ + "work_item_id": "work_activation", + "target_key": "target_activation", + "todo_id": "todo_activation", + "todo_status": "done", + "prior_status": "ready", + "next_status": "done", + "evidence_ref": "artifact:activation-report", + "changed": True, + }], + "replan_required": False, + } + calls: list[tuple[str, dict[str, object]]] = [] + + def runtime(method: str, params: dict[str, object]) -> dict[str, object]: + calls.append((method, params)) + if method.endswith(".load"): + return stored + return { + "schema_version": "company_control_next_cycle_v0", + "goal_id": "company-goal", + "goal_converged": True, + "remaining_work_item_count": 0, + } + + monkeypatch.setattr(company_control_loop, "effect_runtime_result", runtime) + assert main([ + "--format", "json", "--runtime-root", str(tmp_path / "runtime"), + "company-control-loop", "next-cycle", "--goal-id", "company-goal", + ]) == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["goal_converged"] is True + assert calls[1][0] == "work_item.company_control_state.next_cycle" + assert calls[1][1]["state"] == state diff --git a/tests/control_plane_ts/company_control_state.test.ts b/tests/control_plane_ts/company_control_state.test.ts index b066dc889e..b8a231b2ef 100644 --- a/tests/control_plane_ts/company_control_state.test.ts +++ b/tests/control_plane_ts/company_control_state.test.ts @@ -9,6 +9,7 @@ import { COMPANY_CONTROL_STATE_STORE_REQUEST_SCHEMA, companyControlStatePath, loadCompanyControlState, + planCompanyControlNextCycle, reconcileCompanyControlState, writeCompanyControlState, } from "../../loopx/control_plane/work_items/company_control_state.ts"; @@ -245,3 +246,93 @@ test("company control reconciliation rejects stale revisions and unknown targets /is unknown/, ); }); + +test("next company cycle converts evidence and blockers into feedback and replanning", async (t) => { + const runtimeRoot = await mkdtemp(join(tmpdir(), "loopx-company-state-")); + t.after(() => rm(runtimeRoot, { recursive: true, force: true })); + const input = stateWithWork(); + input.work_items.push({ + work_item_id: "work_retention", + outcome_id: "outcome_activation", + title: "Resolve retention risk", + acceptance: "risk is cleared", + authority_tier: "A", + ai_capable: true, + target_key: "retention_risk", + }); + const first = await writeCompanyControlState(request(runtimeRoot, { + state: input, + updated_at: "2026-09-17T00:00:00Z", + })); + const reconciled = await reconcileCompanyControlState({ + schema_version: COMPANY_CONTROL_STATE_RECONCILE_REQUEST_SCHEMA, + runtime_root: runtimeRoot, + goal_id: "company-goal", + expected_revision: (first.state as Record).revision, + updated_at: "2026-09-17T00:01:00Z", + execute: true, + observations: [ + { + target_key: "activation_delivery", + todo_id: "todo_activation", + status: "done", + evidence_ref: "artifact:activation-report", + }, + { + target_key: "retention_risk", + todo_id: "todo_retention", + status: "blocked", + }, + ], + }); + const next = planCompanyControlNextCycle({ + schema_version: "company_control_next_cycle_request_v0", + goal_id: "company-goal", + state: reconciled.state, + }); + assert.equal(next.goal_converged, false); + assert.equal(next.converged_work_item_count, 1); + assert.equal(next.remaining_work_item_count, 1); + assert.equal(next.replan_required, true); + const nextState = next.state as Record; + assert.equal(nextState.cycle, 2); + assert.deepEqual( + nextState.work_items.map((item: Record) => item.work_item_id), + ["work_retention"], + ); + assert.deepEqual( + nextState.feedback.map((item: Record) => item.kind), + ["execution_result", "risk"], + ); +}); + +test("next company cycle reports goal convergence after all work has evidence", async (t) => { + const runtimeRoot = await mkdtemp(join(tmpdir(), "loopx-company-state-")); + t.after(() => rm(runtimeRoot, { recursive: true, force: true })); + const first = await writeCompanyControlState(request(runtimeRoot, { + state: stateWithWork(), + updated_at: "2026-09-17T00:00:00Z", + })); + const reconciled = await reconcileCompanyControlState({ + schema_version: COMPANY_CONTROL_STATE_RECONCILE_REQUEST_SCHEMA, + runtime_root: runtimeRoot, + goal_id: "company-goal", + expected_revision: (first.state as Record).revision, + updated_at: "2026-09-17T00:01:00Z", + execute: true, + observations: [{ + target_key: "activation_delivery", + todo_id: "todo_activation", + status: "done", + evidence_ref: "artifact:activation-report", + }], + }); + const next = planCompanyControlNextCycle({ + schema_version: "company_control_next_cycle_request_v0", + goal_id: "company-goal", + state: reconciled.state, + }); + assert.equal(next.goal_converged, true); + assert.equal(next.remaining_work_item_count, 0); + assert.equal(next.replan_required, true); +}); From 694d85600512ad7cb6c9ed9e3a29ba8ce8a23946 Mon Sep 17 00:00:00 2001 From: KashiwaByte <471314513@qq.com> Date: Thu, 17 Sep 2026 01:35:16 +0800 Subject: [PATCH 09/16] test(control-plane): cover company CEO lifecycle Signed-off-by: KashiwaByte <471314513@qq.com> --- .../company_control_loop_e2e.test.ts | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 tests/control_plane_ts/company_control_loop_e2e.test.ts diff --git a/tests/control_plane_ts/company_control_loop_e2e.test.ts b/tests/control_plane_ts/company_control_loop_e2e.test.ts new file mode 100644 index 0000000000..c541b1a884 --- /dev/null +++ b/tests/control_plane_ts/company_control_loop_e2e.test.ts @@ -0,0 +1,154 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { + COMPANY_CONTROL_STATE_RECONCILE_REQUEST_SCHEMA, + COMPANY_CONTROL_STATE_STORE_REQUEST_SCHEMA, + loadCompanyControlState, + planCompanyControlNextCycle, + reconcileCompanyControlState, + writeCompanyControlState, +} from "../../loopx/control_plane/work_items/company_control_state.ts"; + +function state() { + return { + schema_version: "company_control_loop_request_v0", + direction: "Improve durable customer value.", + cycle: 1, + outcomes: [{ + outcome_id: "outcome_activation", + title: "Improve activation", + metric: "seven day activation rate", + target: ">= 40%", + evidence_source: "activation analytics", + }], + work_items: [ + { + work_item_id: "work_ai_delivery", + outcome_id: "outcome_activation", + title: "Implement activation instrumentation", + acceptance: "instrumentation report", + authority_tier: "A", + ai_capable: true, + target_key: "ai_delivery", + }, + { + work_item_id: "work_human_decision", + outcome_id: "outcome_activation", + title: "Choose the activation threshold", + acceptance: "recorded threshold decision", + authority_tier: "B", + ai_capable: false, + material_decision: true, + target_key: "human_decision", + }, + { + work_item_id: "work_human_execution", + outcome_id: "outcome_activation", + title: "Interview the launch customer", + acceptance: "customer interview record", + authority_tier: "C", + ai_capable: false, + human_identity_required: true, + target_key: "human_execution", + }, + ], + feedback: [], + }; +} + +function storeRequest(runtimeRoot: string, extra: Record) { + return { + schema_version: COMPANY_CONTROL_STATE_STORE_REQUEST_SCHEMA, + runtime_root: runtimeRoot, + goal_id: "company-goal", + ...extra, + }; +} + +test("company CEO v0 closes AI, human, restart, escalation, and convergence scenarios", async (t) => { + const runtimeRoot = await mkdtemp(join(tmpdir(), "loopx-company-e2e-")); + t.after(() => rm(runtimeRoot, { recursive: true, force: true })); + + const first = await writeCompanyControlState(storeRequest(runtimeRoot, { + state: state(), + updated_at: "2026-09-17T00:00:00Z", + })); + const firstState = first.state as Record; + assert.deepEqual( + firstState.projection.work_items.map((item: Record) => item.route), + ["ai_execute", "human_decide", "human_execute"], + ); + + const restarted = await loadCompanyControlState(storeRequest(runtimeRoot, {})); + assert.deepEqual(restarted.state, first.state); + + const failedCycle = await reconcileCompanyControlState({ + schema_version: COMPANY_CONTROL_STATE_RECONCILE_REQUEST_SCHEMA, + runtime_root: runtimeRoot, + goal_id: "company-goal", + expected_revision: firstState.revision, + updated_at: "2026-09-17T00:01:00Z", + execute: true, + observations: [ + { + target_key: "ai_delivery", + todo_id: "todo_ai_delivery", + status: "done", + evidence_ref: "artifact:instrumentation-report", + }, + { + target_key: "human_decision", + todo_id: "todo_human_decision", + status: "done", + evidence_ref: "decision:activation-threshold", + }, + { + target_key: "human_execution", + todo_id: "todo_human_execution", + status: "blocked", + }, + ], + }); + const replan = planCompanyControlNextCycle({ + schema_version: "company_control_next_cycle_request_v0", + goal_id: "company-goal", + state: failedCycle.state, + }); + assert.equal(replan.replan_required, true); + assert.equal(replan.converged_work_item_count, 2); + assert.equal(replan.remaining_work_item_count, 1); + const replannedState = replan.state as Record; + assert.equal(replannedState.feedback.at(-1).kind, "risk"); + assert.equal(replannedState.work_items[0].work_item_id, "work_human_execution"); + + const second = await writeCompanyControlState(storeRequest(runtimeRoot, { + state: replannedState, + expected_revision: (failedCycle.state as Record).revision, + updated_at: "2026-09-17T00:02:00Z", + })); + const completedCycle = await reconcileCompanyControlState({ + schema_version: COMPANY_CONTROL_STATE_RECONCILE_REQUEST_SCHEMA, + runtime_root: runtimeRoot, + goal_id: "company-goal", + expected_revision: (second.state as Record).revision, + updated_at: "2026-09-17T00:03:00Z", + execute: true, + observations: [{ + target_key: "human_execution", + todo_id: "todo_human_execution_retry", + status: "done", + evidence_ref: "record:customer-interview", + }], + }); + const converged = planCompanyControlNextCycle({ + schema_version: "company_control_next_cycle_request_v0", + goal_id: "company-goal", + state: completedCycle.state, + }); + assert.equal(converged.goal_converged, true); + assert.equal(converged.remaining_work_item_count, 0); +}); From b6b0a753de9e20d8bc76305c2733da2de5eb0aa8 Mon Sep 17 00:00:00 2001 From: KashiwaByte <471314513@qq.com> Date: Thu, 17 Sep 2026 01:44:56 +0800 Subject: [PATCH 10/16] docs: add company control loop operator guide Signed-off-by: KashiwaByte <471314513@qq.com> --- README.md | 1 + README.zh-CN.md | 1 + docs/reference/company-control-loop.md | 105 +++++++++++++++++++++++++ 3 files changed, 107 insertions(+) create mode 100644 docs/reference/company-control-loop.md diff --git a/README.md b/README.md index 8c21799ea7..1e6bcf41b9 100644 --- a/README.md +++ b/README.md @@ -414,6 +414,7 @@ evidence → recovery; continuation → governance. | --- | --- | --- | | Goal state and status | Tracks active state, todos, claims, gates, evidence, run history, and first-screen attention. | `loopx status`, `loopx diagnose`, `loopx review-packet` | | Quota and interaction contract | Decides whether a turn should deliver, ask, wait, self-repair, or stay quiet. | `loopx quota should-run`, [quota allocation](docs/quota-allocation.md) | +| Company control loop | Routes company work to AI, human decisions, human execution, monitors, or blockers; reconciles evidence into the next planning cycle. | `loopx company-control-loop`, [company control loop](docs/reference/company-control-loop.md) | | Agent runtime bridges | Keeps Codex App, Codex CLI, Claude Code, and generic workers aligned with the same guard. | `loopx heartbeat-prompt`, `loopx codex-cli-bootstrap-message`, `loopx worker-bridge` | | Operator surfaces | Renders compact status without making the browser the state authority. | `loopx serve-status`, [dashboard](apps/presentation/dashboard/README.md) | | Session dash | Starts a live single-page panel that tracks fleet progress: sessions, their goals, and each goal's status/todo progress, with result statistics; auto-refreshes in place. | `loopx dash`, [session dash design](docs/product/surfaces/session-dash-panel-design.md) | diff --git a/README.zh-CN.md b/README.zh-CN.md index 254e5a0670..1445b3d076 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -370,6 +370,7 @@ Kernel 把控制面归结为五个用户可以直接行动的问题。每个问 | --- | --- | --- | | Goal state 与 status | 跟踪 active state、todo、claim、gate、evidence、run history 和首屏关注点。 | `loopx status`、`loopx diagnose`、`loopx review-packet` | | Quota 与 interaction contract | 决定一轮应该执行、提问、等待、自修复还是静默。 | `loopx quota should-run`、[Quota Allocation](docs/quota-allocation.md) | +| Company Control Loop | 将公司工作路由给 AI、人类决策、人类执行、监控或 blocker,并把证据汇入下一轮规划。 | `loopx company-control-loop`、[Company Control Loop](docs/reference/company-control-loop.md) | | Agent runtime bridge | 让 Codex App、Codex CLI、Claude Code 和 generic worker 服从同一 guard。 | `loopx heartbeat-prompt`、`loopx codex-cli-bootstrap-message`、`loopx worker-bridge` | | Operator surface | 呈现紧凑状态,但不让浏览器成为状态事实源。 | `loopx serve-status`、[Dashboard](apps/presentation/dashboard/README.md) | | External projection | 把 todo / gate 投影到协作表面,同时保持 LoopX 权威。 | `loopx lark-kanban`、[Lark Kanban adapter](docs/integrations/lark-kanban-control-plane-adapter.md) | diff --git a/docs/reference/company-control-loop.md b/docs/reference/company-control-loop.md new file mode 100644 index 0000000000..6864382663 --- /dev/null +++ b/docs/reference/company-control-loop.md @@ -0,0 +1,105 @@ +# Company Control Loop + +The Company Control Loop is LoopX's provider-neutral planning layer for a +long-running company direction. It routes bounded work to AI or people, keeps +the result under one Goal, reconciles Todo evidence, and produces the next +planning cycle. + +## Authority boundary + +The command does not grant execution authority. Existing LoopX Todo rules own +claims, user gates, leases, validation, and completion. The company layer owns +only these decisions: + +- `ai_execute` becomes an agent advancement Todo. +- `human_decide` becomes a blocking user gate. +- `human_execute` becomes a user action. +- `observe` becomes a bounded continuous monitor. +- prohibited work becomes a blocker. + +A Todo marked done is accepted only when reconciliation also receives an +evidence reference. Completion without evidence becomes `awaiting_evidence`; +a blocked Todo becomes `replanning`. + +## State lifecycle + +Start with a `company_control_loop_request_v0` JSON object. It contains one +direction, a cycle number, outcomes, work items, and feedback. + +```sh +loopx company-control-loop project --state-json company.json +loopx company-control-loop save \ + --goal-id company-goal \ + --state-json company.json +``` + +Both commands are read-only at this point. Add `--execute` to `save` after +review. Replacing existing state also requires the exact revision returned by +`show` or the prior write: + +```sh +loopx company-control-loop save \ + --goal-id company-goal \ + --state-json company.json \ + --expected-revision REVISION \ + --execute + +loopx company-control-loop show --goal-id company-goal +``` + +Legacy `loopx_company_control_state_v0` files can be previewed with `upgrade`. +Ambiguous legacy Goal-to-Outcome feedback mappings fail closed. + +## Materialize work as Todos + +Preview the idempotent plan first, then execute it: + +```sh +loopx company-control-loop sync-todos \ + --goal-id company-goal \ + --agent-id company-ceo \ + --project /path/to/project + +loopx company-control-loop sync-todos \ + --goal-id company-goal \ + --agent-id company-ceo \ + --project /path/to/project \ + --execute +``` + +`target_key` links each work item to exactly one Todo. Existing links are +reused. Duplicate links and failed write readback stop the command. + +## Reconcile and plan the next cycle + +Reconciliation is also dry-run by default: + +```sh +loopx company-control-loop reconcile-todos \ + --goal-id company-goal \ + --agent-id company-ceo \ + --project /path/to/project + +loopx company-control-loop reconcile-todos \ + --goal-id company-goal \ + --agent-id company-ceo \ + --project /path/to/project \ + --execute + +loopx company-control-loop next-cycle --goal-id company-goal +``` + +`next-cycle` returns a new request object. Evidence-backed completed work leaves +the active frontier. Failures and missing evidence become typed feedback and +set `replan_required`. When no work remains, `goal_converged` is true. + +Review the returned state before saving it as the next cycle. Revision checks +prevent an older planner or restarted worker from overwriting newer state. + +## Always-on operation + +An always-on host should run the ordinary LoopX heartbeat contract. Each wake +must enter through `quota should-run`, advance only the selected Todo, validate +the result, write state, and spend the matching slot. Scheduler cadence and +human notification remain host responsibilities; this command does not create +an independent hidden scheduler. From 8ca5259f51b9ec233057c529724d3c89182170f0 Mon Sep 17 00:00:00 2001 From: KashiwaByte <471314513@qq.com> Date: Thu, 17 Sep 2026 01:55:17 +0800 Subject: [PATCH 11/16] fix(control-plane): bound derived company feedback ids Signed-off-by: KashiwaByte <471314513@qq.com> --- .../work_items/company_control_state.ts | 28 ++++++++++++- .../company_control_state.test.ts | 42 +++++++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/loopx/control_plane/work_items/company_control_state.ts b/loopx/control_plane/work_items/company_control_state.ts index 6a0792d352..a0aaab5999 100644 --- a/loopx/control_plane/work_items/company_control_state.ts +++ b/loopx/control_plane/work_items/company_control_state.ts @@ -51,6 +51,19 @@ function revision(projection: JsonObject): string { .digest("hex"); } +function todoFeedbackId( + workItemId: string, + nextStatus: string, + todoId: string, + cycle: number, +): string { + const digest = createHash("sha256") + .update(`${workItemId}\u001f${nextStatus}\u001f${todoId}\u001f${cycle}`, "utf8") + .digest("hex") + .slice(0, 24); + return `todo_feedback_${digest}`; +} + function safeGoalSegment(goalId: string): string { const label = goalId .toLowerCase() @@ -375,9 +388,15 @@ export function planCompanyControlNextCycle(value: unknown): JsonObject { const observation = byTarget.get(targetKey); const nextStatus = observation?.next_status; if (nextStatus === "done") { + const todoId = requireNonEmptyString(observation?.todo_id, "observation todo_id"); convergedCount += 1; feedback.push({ - feedback_id: `todo_${work.work_item_id}_done`, + feedback_id: todoFeedbackId( + requireNonEmptyString(work.work_item_id, "stored work work_item_id"), + nextStatus, + todoId, + Number(projection.cycle), + ), source: "loopx_todo", subject: `Todo completed: ${work.title}`, kind: "execution_result", @@ -401,7 +420,12 @@ export function planCompanyControlNextCycle(value: unknown): JsonObject { if (nextStatus === "replanning" || nextStatus === "awaiting_evidence") { const todoId = requireNonEmptyString(observation?.todo_id, "observation todo_id"); feedback.push({ - feedback_id: `todo_${work.work_item_id}_${nextStatus}`, + feedback_id: todoFeedbackId( + requireNonEmptyString(work.work_item_id, "stored work work_item_id"), + nextStatus, + todoId, + Number(projection.cycle), + ), source: "loopx_todo", subject: nextStatus === "replanning" ? `Todo blocked: ${work.title}` diff --git a/tests/control_plane_ts/company_control_state.test.ts b/tests/control_plane_ts/company_control_state.test.ts index b8a231b2ef..eb65671f67 100644 --- a/tests/control_plane_ts/company_control_state.test.ts +++ b/tests/control_plane_ts/company_control_state.test.ts @@ -336,3 +336,45 @@ test("next company cycle reports goal convergence after all work has evidence", assert.equal(next.remaining_work_item_count, 0); assert.equal(next.replan_required, true); }); + +test("next company cycle keeps derived feedback ids valid for maximum-length work ids", async (t) => { + const runtimeRoot = await mkdtemp(join(tmpdir(), "loopx-company-state-")); + t.after(() => rm(runtimeRoot, { recursive: true, force: true })); + const input = stateWithWork(); + input.work_items[0].work_item_id = `w${"a".repeat(127)}`; + const first = await writeCompanyControlState(request(runtimeRoot, { + state: input, + updated_at: "2026-09-17T00:00:00Z", + })); + const reconciled = await reconcileCompanyControlState({ + schema_version: COMPANY_CONTROL_STATE_RECONCILE_REQUEST_SCHEMA, + runtime_root: runtimeRoot, + goal_id: "company-goal", + expected_revision: (first.state as Record).revision, + updated_at: "2026-09-17T00:01:00Z", + execute: true, + observations: [{ + target_key: "activation_delivery", + todo_id: "todo_activation", + status: "done", + evidence_ref: "artifact:activation-report", + }], + }); + + const next = planCompanyControlNextCycle({ + schema_version: "company_control_next_cycle_request_v0", + goal_id: "company-goal", + state: reconciled.state, + }); + const feedback = (next.state as Record).feedback[0]; + assert.match(feedback.feedback_id, /^todo_feedback_[a-f0-9]{24}$/); + assert.ok(feedback.feedback_id.length <= 128); + assert.deepEqual( + planCompanyControlNextCycle({ + schema_version: "company_control_next_cycle_request_v0", + goal_id: "company-goal", + state: reconciled.state, + }).state, + next.state, + ); +}); From 00269e168dc215b5a38c088db1d73d4350951660 Mon Sep 17 00:00:00 2001 From: KashiwaByte <471314513@qq.com> Date: Thu, 17 Sep 2026 02:02:42 +0800 Subject: [PATCH 12/16] fix(control-plane): reject ambiguous company state ids Signed-off-by: KashiwaByte <471314513@qq.com> --- .../work_items/company_control_loop.ts | 23 +++++++- .../company_control_loop.test.ts | 54 +++++++++++++++++-- 2 files changed, 70 insertions(+), 7 deletions(-) diff --git a/loopx/control_plane/work_items/company_control_loop.ts b/loopx/control_plane/work_items/company_control_loop.ts index 90e22ba2fa..3f129f8c1b 100644 --- a/loopx/control_plane/work_items/company_control_loop.ts +++ b/loopx/control_plane/work_items/company_control_loop.ts @@ -85,6 +85,21 @@ function publicId(value: unknown, label: string): string { return normalized; } +function requireUniqueIds( + values: readonly JsonObject[], + field: string, + label: string, +): void { + const seen = new Set(); + for (const value of values) { + const identifier = String(value[field]); + if (seen.has(identifier)) { + throw new EffectRuntimeRequestError(`${label} must be unique`); + } + seen.add(identifier); + } +} + function companyWorkItem(value: unknown, label: string): CompanyWorkItem { const raw = requireJsonObject(value, label); const waitFor = optionalNonEmptyString(raw.wait_for, `${label}.wait_for`); @@ -242,9 +257,9 @@ export function projectCompanyControlLoop(value: unknown): JsonObject { ); } const cycle = requireInteger(request.cycle, "company_control_loop_request.cycle"); - if (cycle < 0) { + if (cycle < 0 || !Number.isSafeInteger(cycle)) { throw new EffectRuntimeRequestError( - "company_control_loop_request.cycle must be non-negative", + "company_control_loop_request.cycle must be a non-negative safe integer", ); } const outcomes = boundedArray( @@ -279,6 +294,7 @@ export function projectCompanyControlLoop(value: unknown): JsonObject { ), }; }); + requireUniqueIds(outcomes, "outcome_id", "company outcome_id values"); const outcomeIds = new Set(outcomes.map((outcome) => outcome.outcome_id)); const workItems = boundedArray( request.work_items, @@ -288,6 +304,8 @@ export function projectCompanyControlLoop(value: unknown): JsonObject { item, `company_control_loop_request.work_items[${index}]`, )); + requireUniqueIds(workItems, "work_item_id", "company work_item_id values"); + requireUniqueIds(workItems, "target_key", "company work target_key values"); for (const [index, item] of workItems.entries()) { if (!outcomeIds.has(item.outcome_id)) { throw new EffectRuntimeRequestError( @@ -303,6 +321,7 @@ export function projectCompanyControlLoop(value: unknown): JsonObject { item, `company_control_loop_request.feedback[${index}]`, )); + requireUniqueIds(feedback, "feedback_id", "company feedback_id values"); for (const [index, item] of feedback.entries()) { for (const outcomeId of item.affected_outcome_ids as string[]) { if (!outcomeIds.has(outcomeId)) { diff --git a/tests/control_plane_ts/company_control_loop.test.ts b/tests/control_plane_ts/company_control_loop.test.ts index 3ef081b686..634873c32c 100644 --- a/tests/control_plane_ts/company_control_loop.test.ts +++ b/tests/control_plane_ts/company_control_loop.test.ts @@ -58,11 +58,11 @@ test("company control loop routes AI work into an advancement Todo", () => { test("routing precedence preserves authority, waiting, and human boundaries", () => { const result = projectCompanyControlLoop(request({ work_items: [ - work({ work_item_id: "work_rejected", prohibited: true }), - work({ work_item_id: "work_observe", wait_for: "provider result" }), - work({ work_item_id: "work_decide", material_decision: true }), - work({ work_item_id: "work_execute", human_identity_required: true }), - work({ work_item_id: "work_incomplete", ai_capable: false }), + work({ work_item_id: "work_rejected", target_key: "target_rejected", prohibited: true }), + work({ work_item_id: "work_observe", target_key: "target_observe", wait_for: "provider result" }), + work({ work_item_id: "work_decide", target_key: "target_decide", material_decision: true }), + work({ work_item_id: "work_execute", target_key: "target_execute", human_identity_required: true }), + work({ work_item_id: "work_incomplete", target_key: "target_incomplete", ai_capable: false }), ], })); @@ -124,6 +124,50 @@ test("company control loop rejects dangling outcome references and unsafe ids", ); }); +test("company control loop rejects ambiguous identifiers and unsafe cycle integers", () => { + assert.throws( + () => projectCompanyControlLoop(request({ + outcomes: [request().outcomes[0], request().outcomes[0]], + })), + /outcome_id values must be unique/, + ); + assert.throws( + () => projectCompanyControlLoop(request({ + work_items: [ + work({ work_item_id: "work_first" }), + work({ work_item_id: "work_second" }), + ], + })), + /target_key values must be unique/, + ); + assert.throws( + () => projectCompanyControlLoop(request({ + work_items: [ + work({ target_key: "target_first" }), + work({ target_key: "target_second" }), + ], + })), + /work_item_id values must be unique/, + ); + const feedback = { + feedback_id: "feedback_duplicate", + source: "analytics", + subject: "activation", + kind: "fact", + observed_at: "2026-09-17T00:00:00Z", + evidence_ref: "report:activation", + affected_outcome_ids: ["outcome_activation"], + }; + assert.throws( + () => projectCompanyControlLoop(request({ feedback: [feedback, feedback] })), + /feedback_id values must be unique/, + ); + assert.throws( + () => projectCompanyControlLoop(request({ cycle: Number.MAX_SAFE_INTEGER + 1 })), + /non-negative safe integer/, + ); +}); + test("legacy reference state upgrades into the native request contract", () => { const legacy = { schema_version: "loopx_company_control_state_v0", From a6b388b88dab633f56ca4af01898d9ce7a92b7f1 Mon Sep 17 00:00:00 2001 From: KashiwaByte <471314513@qq.com> Date: Thu, 17 Sep 2026 02:06:33 +0800 Subject: [PATCH 13/16] fix(todos): scope user target identities to typed lanes Signed-off-by: KashiwaByte <471314513@qq.com> --- loopx/control_plane/todos/monitor_metadata.ts | 10 ++++++++-- tests/control_plane_ts/monitor_metadata.test.ts | 16 +++++++++++++--- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/loopx/control_plane/todos/monitor_metadata.ts b/loopx/control_plane/todos/monitor_metadata.ts index 25a995d40e..fd63f31c5d 100644 --- a/loopx/control_plane/todos/monitor_metadata.ts +++ b/loopx/control_plane/todos/monitor_metadata.ts @@ -188,8 +188,14 @@ export function planMonitorMetadata(value: unknown): MonitorMetadataPlan { if (nonTarget && (request.role !== "agent" || request.task_class !== "continuous_monitor")) { throw new EffectRuntimeRequestError("monitor schedule metadata requires --role agent --task-class continuous_monitor"); } - if (metadata.target_key != null && !["agent", "user"].includes(String(request.role))) { - throw new EffectRuntimeRequestError("target_key requires --role agent or user"); + const targetAllowed = request.role === "agent" || ( + request.role === "user" && + ["user_gate", "user_action"].includes(String(request.task_class)) + ); + if (metadata.target_key != null && !targetAllowed) { + throw new EffectRuntimeRequestError( + "target_key requires an agent Todo or a user gate/action Todo", + ); } if (request.generated_at != null && request.task_class === "continuous_monitor" && metadata.next_due_at == null && metadata.cadence != null) { const due = schedule(text(request.generated_at), metadata.cadence).next_due_at; diff --git a/tests/control_plane_ts/monitor_metadata.test.ts b/tests/control_plane_ts/monitor_metadata.test.ts index 99d11b6f83..5c2e12cd16 100644 --- a/tests/control_plane_ts/monitor_metadata.test.ts +++ b/tests/control_plane_ts/monitor_metadata.test.ts @@ -67,10 +67,20 @@ test("create/edit scope, boundedness and explicit clearing share one owner", () assert.doesNotThrow(() => planMonitorMetadata(request({metadata: {watch_only: null}, resume_when: "todo_done:todo_dependency"}))); assert.doesNotThrow(() => planMonitorMetadata(request({metadata: {watch_only: null}, enforce_boundedness: false}))); assert.throws(() => planMonitorMetadata(request({role: "user", metadata: {cadence: "1h"}})), /schedule metadata/); - assert.deepEqual( - planMonitorMetadata(request({role: "user", metadata: {target_key: "fixture"}})).metadata, - {target_key: "fixture"}, + assert.throws( + () => planMonitorMetadata(request({role: "user", metadata: {target_key: "fixture"}})), + /agent Todo or a user gate\/action Todo/, ); + for (const task_class of ["user_gate", "user_action"]) { + assert.deepEqual( + planMonitorMetadata(request({ + role: "user", + task_class, + metadata: {target_key: "fixture"}, + })).metadata, + {target_key: "fixture"}, + ); + } assert.throws(() => planMonitorMetadata(request({metadata: {cadence: "never"}})), /cadence/); for (const field of ["consecutive_no_change", "material_change_generation"]) { for (const value of ["-1", "9007199254740993", "1.5"]) { From 36deb327a0cb0c1e297e600849a09f16170d6e6e Mon Sep 17 00:00:00 2001 From: KashiwaByte <471314513@qq.com> Date: Thu, 17 Sep 2026 02:06:33 +0800 Subject: [PATCH 14/16] docs: align company control loop with roadmap Signed-off-by: KashiwaByte <471314513@qq.com> --- .../architecture/rfcs/loopx-overall-roadmap-v0.md | 1 + .../rfcs/loopx-overall-roadmap-v0.zh-CN.md | 1 + docs/reference/company-control-loop.md | 15 +++++++++++++++ docs/reference/monitor-configuration.md | 15 +++++++++++++++ 4 files changed, 32 insertions(+) diff --git a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md index 64d1086771..5461eead13 100644 --- a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md +++ b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md @@ -56,6 +56,7 @@ These directly determine whether a long-running team is usable. A directory or R | Area and existing entry | Streams | Next requirement in this roadmap | | --- | --- | --- | | [Goal Vision/replan](../../reference/protocols/goal-vision-replan-contract-v0.md), [work graph](../../reference/protocols/task-graph-projection-v0.md), [peer runtime](../../reference/protocols/peer-agent-runtime-v1.md), [supervisor](../../reference/protocols/peer-supervisor-v0.md) | S2/S3 | Exercise dependencies, replanning, acceptance and handoff in one real case; aggregate closeout consumes acceptance facts | +| [Company control-loop profile](../../reference/company-control-loop.md) | S1/S3 | Keep it a bounded caller of Goal/Todo/evidence owners; qualify the broader steward claim only through R2/R3 multi-Agent adoption, dependent artifacts, independent acceptance, recovery and result return | | [Quota](../../quota-allocation.md), [cadence](../../operations/long-task-cadence-policy.md), [attention](../../operations/attention-queue.md) | S5/S7 | Budget exhaustion, deferral and blocking expose next triggers/readback; scale without frequent full-state polling | | [Material lifecycle](../../reference/protocols/material-lifecycle-architecture-v0.md), [material frontier](../../reference/protocols/agent-material-frontier-v0.md), [authority registration](../../operations/authority-source-registration.md) | S6 | Agents discover roadmap/RFC revisions and record reads; reading grants neither agreement nor authority; archival preserves raw-source ownership | | [Decision Context](../../../loopx/capabilities/decision_context/README.md), [Reward Memory](../../../loopx/capabilities/reward_memory/README.md), [Semantic Preference](../../../loopx/capabilities/semantic_preference/README.md), [Turn Recall](../../../loopx/capabilities/agent_turn_recall/README.md) | S6/S11 | Distinguish facts/preferences/advice/attribution/authority; scoped recall, expiry and outcome-feedback counterexamples first | diff --git a/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md b/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md index a9ea996afe..ab033f02b1 100644 --- a/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md +++ b/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md @@ -56,6 +56,7 @@ LoopX 的目标是让人用本地前端或 Lark 提出、修订和验收复杂 | 领域与已有入口 | 所属工作流 | 当前总纲要求的下一步 | | --- | --- | --- | | [Goal Vision/replan](../../reference/protocols/goal-vision-replan-contract-v0.md)、[work graph](../../reference/protocols/task-graph-projection-v0.md)、[peer runtime](../../reference/protocols/peer-agent-runtime-v1.md)、[监督](../../reference/protocols/peer-supervisor-v0.md) | S2/S3 | 将跨工作依赖、重规划、验收与 handoff 放进同一个真实案例;aggregate closeout 必须消费验收事实 | +| [公司控制闭环 profile](../../reference/company-control-loop.md) | S1/S3 | 保持为 Goal/Todo/证据 owner 的有界 caller;更广的 steward 主张必须通过 R2/R3 的多 Agent adoption、依赖产物、独立验收、恢复和结果返回来验收 | | [quota](../../quota-allocation.md)、[cadence](../../operations/long-task-cadence-policy.md)、[attention](../../operations/attention-queue.md) | S5/S7 | 预算耗尽/延期/被阻塞时有明确下一次触发及用户回读;百 Agent 不靠高频全文轮询 | | [材料生命周期](../../reference/protocols/material-lifecycle-architecture-v0.zh-CN.md)、[材料 frontier](../../reference/protocols/agent-material-frontier-v0.md)、[authority 注册](../../operations/authority-source-registration.md) | S6 | 路线/RFC 更新能由 Agent 按 revision 发现并登记阅读;read receipt 不表示同意或获得权限;归档不丢原始来源 | | [Decision Context](../../../loopx/capabilities/decision_context/README.md)、[Reward Memory](../../../loopx/capabilities/reward_memory/README.md)、[Semantic Preference](../../../loopx/capabilities/semantic_preference/README.md)、[Turn Recall](../../../loopx/capabilities/agent_turn_recall/README.md) | S6/S11 | 区分事实、偏好、建议、归因和权威;同一 scope 的回忆/失效/结果反馈负例先行 | diff --git a/docs/reference/company-control-loop.md b/docs/reference/company-control-loop.md index 6864382663..1b1c9a368b 100644 --- a/docs/reference/company-control-loop.md +++ b/docs/reference/company-control-loop.md @@ -5,6 +5,17 @@ long-running company direction. It routes bounded work to AI or people, keeps the result under one Goal, reconciles Todo evidence, and produces the next planning cycle. +## Roadmap placement + +This command is a bounded planning profile for the persistent steward path in +the [LoopX overall roadmap](../architecture/rfcs/loopx-overall-roadmap-v0.md), +primarily S1 and S3. It exercises existing Goal, Todo, evidence, quota, and +replan owners; it does not create a second steward, work ledger, scheduler, or +authority model. Its current acceptance boundary is the documented CLI and +packaged runtime lifecycle. The broader R2/R3 journey still requires real +multi-Agent adoption, dependent artifacts, independent acceptance, restart +recovery, and automatic result return through their existing owners. + ## Authority boundary The command does not grant execution authority. Existing LoopX Todo rules own @@ -69,6 +80,10 @@ loopx company-control-loop sync-todos \ `target_key` links each work item to exactly one Todo. Existing links are reused. Duplicate links and failed write readback stop the command. +Agent Todos retain the existing target identity behavior. Human work uses the +same identity only on the typed `user_gate` and `user_action` lanes. This is an +identity correlation rule, not execution authority, and monitor scheduling +fields remain restricted to agent continuous monitors. ## Reconcile and plan the next cycle diff --git a/docs/reference/monitor-configuration.md b/docs/reference/monitor-configuration.md index f05ccec898..fd97e3efde 100644 --- a/docs/reference/monitor-configuration.md +++ b/docs/reference/monitor-configuration.md @@ -41,6 +41,15 @@ successor may carry `target_key` as its route identity without becoming a Monitor, while cadence, due time, expiry and watch-only require `task_class=continuous_monitor`. +The default target-identity rule is intentionally narrow. Agent Todos may +carry `target_key` as before. User Todos may carry it only when their typed +lane is `user_gate` or `user_action`; this lets an external planner correlate +human decisions and actions with its work item without turning user Todos into +Monitors. Other user task classes still reject `target_key`, and every cadence, +due-time, expiry, watch-only, and observation field remains agent-monitor only. +Consumers must continue to enforce their own authority checks: a target key is +an identity link and grants no claim, lease, execution, or capability access. + Once a Monitor has observation evidence, its target identity cannot be changed or cleared by configuration. Create a new independent Monitor for a different target; do not reuse the former target's generations as new evidence. An @@ -78,6 +87,12 @@ Monitor 配置修改复用 `todo update`。晋升后由 TS 在同一个 canonica 而非调度字段:Monitor 后继 Todo 可以只带 target 而不成为 Monitor,频率、到期、 检查时间和 watch-only 仍要求 task_class=continuous_monitor。 +target 身份的默认规则只做窄幅扩展:Agent Todo 继续可以携带 `target_key`;User +Todo 仅在 typed lane 为 `user_gate` 或 `user_action` 时可以携带它,以便外部规划器 +把人类决策/执行结果关联回工作项。其他 User task class 仍拒绝 `target_key`,所有 +频率、下次检查、到期、watch-only 与观察字段仍只属于 Agent Monitor。`target_key` +只提供身份关联,不授予 claim、lease、执行或 capability 权限。 + 已有 claim/exclusion/lease 检查继续生效,lease proof 不会因配置而续期。Chat 委托 owner 动作和带 lease 的 polling 尚未闭合,文字理由不能替代可信授权。 提交成功但展示 pending 时,用原操作重试回执/投影;不能改旧 Markdown 当作回滚。 From aec2228d5c38da5125d006681bfe5cbdaeb12d68 Mon Sep 17 00:00:00 2001 From: KashiwaByte <471314513@qq.com> Date: Thu, 17 Sep 2026 02:30:38 +0800 Subject: [PATCH 15/16] refactor: remove producerless company state upgrade Signed-off-by: KashiwaByte <471314513@qq.com> --- docs/reference/company-control-loop.md | 3 - loopx/cli_commands/company_control_loop.py | 35 ++---- .../control_plane/effect_runtime_handlers.ts | 2 - .../work_items/company_control_loop.ts | 110 ------------------ .../test_company_control_loop_cli.py | 38 +----- .../company_control_loop.test.ts | 47 -------- 6 files changed, 11 insertions(+), 224 deletions(-) diff --git a/docs/reference/company-control-loop.md b/docs/reference/company-control-loop.md index 1b1c9a368b..25109853b3 100644 --- a/docs/reference/company-control-loop.md +++ b/docs/reference/company-control-loop.md @@ -58,9 +58,6 @@ loopx company-control-loop save \ loopx company-control-loop show --goal-id company-goal ``` -Legacy `loopx_company_control_state_v0` files can be previewed with `upgrade`. -Ambiguous legacy Goal-to-Outcome feedback mappings fail closed. - ## Materialize work as Todos Preview the idempotent plan first, then execute it: diff --git a/loopx/cli_commands/company_control_loop.py b/loopx/cli_commands/company_control_loop.py index 356417d953..de8a88c5a5 100644 --- a/loopx/cli_commands/company_control_loop.py +++ b/loopx/cli_commands/company_control_loop.py @@ -34,23 +34,17 @@ def register_company_control_loop_command( required=True, help="Path to a company_control_loop_request_v0 JSON object.", ) - upgrade = actions.add_parser( - "upgrade", - help="Preview conversion of a legacy loopx_company_control_state_v0 file.", - ) - add_subcommand_format(upgrade) - upgrade.add_argument( - "--state-json", - required=True, - help="Path to a legacy or current company control state JSON object.", - ) save = actions.add_parser( "save", help="Validate and persist company control state under one Goal runtime.", ) add_subcommand_format(save) save.add_argument("--goal-id", required=True, help="Goal that owns the company state.") - save.add_argument("--state-json", required=True, help="Legacy or current company state JSON.") + save.add_argument( + "--state-json", + required=True, + help="Path to a company_control_loop_request_v0 JSON object.", + ) save.add_argument( "--expected-revision", help="Exact revision returned by show/save. Required to replace existing state.", @@ -215,28 +209,19 @@ def handle_company_control_loop_command( ) else: request = _read_json_object(args.state_json) - method = ( - "work_item.company_control_loop.upgrade" - if command in {"upgrade", "save"} - else "work_item.company_control_loop.project" + projection = effect_runtime_result( + "work_item.company_control_loop.project", + request, ) - projection = effect_runtime_result(method, request) if command != "save": payload = {"ok": True, **projection} else: - state = projection.get("state") - if not isinstance(state, dict): - raise TypeError("company control upgrade did not return state") - preview = effect_runtime_result( - "work_item.company_control_loop.project", - state, - ) if not args.execute: payload = { "ok": True, "dry_run": True, "goal_id": args.goal_id, - "projection": preview, + "projection": projection, } else: if runtime_root is None: @@ -245,7 +230,7 @@ def handle_company_control_loop_command( "schema_version": "company_control_state_store_request_v0", "runtime_root": str(runtime_root), "goal_id": args.goal_id, - "state": state, + "state": request, "updated_at": datetime.now(UTC).isoformat(), } if args.expected_revision: diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index 172c085dc2..349c60903b 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -174,7 +174,6 @@ import { import { resolveRefreshRecommendation } from "./work_items/refresh_recommendation.ts"; import { projectCompanyControlLoop, - upgradeCompanyControlLoopState, } from "./work_items/company_control_loop.ts"; import { loadCompanyControlState, @@ -462,7 +461,6 @@ export function createEffectRuntimeHandlers( ["work_item.planning_inventory.detail", projectTodoPlanningInventoryDetail], ["work_item.refresh_recommendation.resolve", resolveRefreshRecommendation], ["work_item.company_control_loop.project", projectCompanyControlLoop], - ["work_item.company_control_loop.upgrade", upgradeCompanyControlLoopState], ["work_item.company_control_state.load", loadCompanyControlState], ["work_item.company_control_state.next_cycle", planCompanyControlNextCycle], ["work_item.company_control_state.reconcile", reconcileCompanyControlState], diff --git a/loopx/control_plane/work_items/company_control_loop.ts b/loopx/control_plane/work_items/company_control_loop.ts index 3f129f8c1b..612c59a2b0 100644 --- a/loopx/control_plane/work_items/company_control_loop.ts +++ b/loopx/control_plane/work_items/company_control_loop.ts @@ -14,11 +14,6 @@ import type { JsonObject } from "../effect_program.ts"; export const COMPANY_CONTROL_LOOP_REQUEST_SCHEMA_VERSION = "company_control_loop_request_v0"; export const COMPANY_CONTROL_LOOP_SCHEMA_VERSION = "company_control_loop_v0"; -export const COMPANY_CONTROL_LOOP_UPGRADE_SCHEMA_VERSION = - "company_control_loop_upgrade_v0"; -export const LEGACY_COMPANY_CONTROL_STATE_SCHEMA_VERSION = - "loopx_company_control_state_v0"; - const MAX_OUTCOMES = 128; const MAX_WORK_ITEMS = 256; const MAX_FEEDBACK_ITEMS = 256; @@ -344,108 +339,3 @@ export function projectCompanyControlLoop(value: unknown): JsonObject { replan_required: feedback.some((item) => item.disposition === "replan"), }; } - -/** - * Convert the executable reference implementation's persisted v0 state into - * the native request contract. The upgrade is preview-only and fails closed - * when legacy feedback points at Goal ids that cannot be proven to be Outcome - * ids. - */ -export function upgradeCompanyControlLoopState(value: unknown): JsonObject { - const source = requireJsonObject(value, "company_control_loop_state"); - if (source.schema_version === COMPANY_CONTROL_LOOP_REQUEST_SCHEMA_VERSION) { - projectCompanyControlLoop(source); - return { - schema_version: COMPANY_CONTROL_LOOP_UPGRADE_SCHEMA_VERSION, - source_schema_version: COMPANY_CONTROL_LOOP_REQUEST_SCHEMA_VERSION, - target_schema_version: COMPANY_CONTROL_LOOP_REQUEST_SCHEMA_VERSION, - changed: false, - state: structuredClone(source), - }; - } - if (source.schema_version !== LEGACY_COMPANY_CONTROL_STATE_SCHEMA_VERSION) { - throw new EffectRuntimeRequestError( - "company_control_loop_state.schema_version is unsupported", - ); - } - const outcomes = boundedArray( - source.outcomes, - "company_control_loop_state.outcomes", - MAX_OUTCOMES, - ).map((item, index) => { - const raw = requireJsonObject(item, `company_control_loop_state.outcomes[${index}]`); - return { - outcome_id: raw.outcome_id, - title: raw.title, - metric: raw.metric, - target: raw.target, - evidence_source: raw.evidence_source, - }; - }); - const outcomeIds = new Set(outcomes.map((item) => String(item.outcome_id))); - const feedback = boundedArray( - source.feedback ?? [], - "company_control_loop_state.feedback", - MAX_FEEDBACK_ITEMS, - ).map((item, index) => { - const raw = requireJsonObject(item, `company_control_loop_state.feedback[${index}]`); - const affected = raw.affected_outcome_ids ?? raw.affected_goal_ids; - const affectedIds = requireStringArray( - affected, - `company_control_loop_state.feedback[${index}].affected_outcome_ids`, - ); - if (affectedIds.some((id) => !outcomeIds.has(id))) { - throw new EffectRuntimeRequestError( - `company_control_loop_state.feedback[${index}] needs an explicit Outcome mapping`, - ); - } - return { - feedback_id: raw.feedback_id, - source: raw.source, - subject: raw.subject, - kind: raw.kind, - observed_at: raw.observed_at, - evidence_ref: raw.evidence_ref, - affected_outcome_ids: affectedIds, - }; - }); - const request: JsonObject = { - schema_version: COMPANY_CONTROL_LOOP_REQUEST_SCHEMA_VERSION, - direction: source.company_direction, - cycle: source.cycle ?? 0, - outcomes, - work_items: boundedArray( - source.work_items, - "company_control_loop_state.work_items", - MAX_WORK_ITEMS, - ).map((item, index) => { - const raw = requireJsonObject(item, `company_control_loop_state.work_items[${index}]`); - return { - work_item_id: raw.work_item_id, - outcome_id: raw.outcome_id, - title: raw.title, - acceptance: raw.acceptance, - authority_tier: raw.authority_tier, - ai_capable: raw.ai_capable, - ...(raw.prohibited === undefined ? {} : { prohibited: raw.prohibited }), - ...(raw.material_decision === undefined - ? {} - : { material_decision: raw.material_decision }), - ...(raw.human_identity_required === undefined - ? {} - : { human_identity_required: raw.human_identity_required }), - ...(raw.wait_for === undefined ? {} : { wait_for: raw.wait_for }), - target_key: raw.target_key, - }; - }), - feedback, - }; - projectCompanyControlLoop(request); - return { - schema_version: COMPANY_CONTROL_LOOP_UPGRADE_SCHEMA_VERSION, - source_schema_version: LEGACY_COMPANY_CONTROL_STATE_SCHEMA_VERSION, - target_schema_version: COMPANY_CONTROL_LOOP_REQUEST_SCHEMA_VERSION, - changed: true, - state: request, - }; -} diff --git a/tests/control_plane/test_company_control_loop_cli.py b/tests/control_plane/test_company_control_loop_cli.py index 9f8d8722ef..8edadff085 100644 --- a/tests/control_plane/test_company_control_loop_cli.py +++ b/tests/control_plane/test_company_control_loop_cli.py @@ -113,36 +113,6 @@ def test_company_control_loop_cli_rejects_non_object_json(tmp_path, capsys) -> N assert "must contain an object" in payload["error"] -def test_company_control_loop_cli_selects_upgrade_contract( - tmp_path, monkeypatch, capsys -) -> None: - state_path = tmp_path / "company.json" - state_path.write_text(json.dumps(_request()), encoding="utf-8") - calls: list[str] = [] - - def upgrade(method: str, params: dict[str, object]) -> dict[str, object]: - calls.append(method) - return { - "schema_version": "company_control_loop_upgrade_v0", - "source_schema_version": params["schema_version"], - "target_schema_version": params["schema_version"], - "changed": False, - "state": params, - } - - monkeypatch.setattr(company_control_loop, "effect_runtime_result", upgrade) - assert main([ - "--format", - "json", - "company-control-loop", - "upgrade", - "--state-json", - str(state_path), - ]) == 0 - json.loads(capsys.readouterr().out) - assert calls == ["work_item.company_control_loop.upgrade"] - - def test_company_control_loop_save_previews_then_writes_with_revision( tmp_path, monkeypatch, capsys ) -> None: @@ -152,8 +122,6 @@ def test_company_control_loop_save_previews_then_writes_with_revision( def runtime(method: str, params: dict[str, object]) -> dict[str, object]: calls.append(method) - if method.endswith("upgrade"): - return {"state": params} if method.endswith("project"): return {"schema_version": "company_control_loop_v0"} assert params["expected_revision"] == "a" * 64 @@ -173,16 +141,12 @@ def runtime(method: str, params: dict[str, object]) -> dict[str, object]: ] assert main(common) == 0 assert json.loads(capsys.readouterr().out)["dry_run"] is True - assert calls == [ - "work_item.company_control_loop.upgrade", - "work_item.company_control_loop.project", - ] + assert calls == ["work_item.company_control_loop.project"] calls.clear() assert main([*common, "--expected-revision", "a" * 64, "--execute"]) == 0 assert json.loads(capsys.readouterr().out)["written"] is True assert calls == [ - "work_item.company_control_loop.upgrade", "work_item.company_control_loop.project", "work_item.company_control_state.write", ] diff --git a/tests/control_plane_ts/company_control_loop.test.ts b/tests/control_plane_ts/company_control_loop.test.ts index 634873c32c..6272ed32dd 100644 --- a/tests/control_plane_ts/company_control_loop.test.ts +++ b/tests/control_plane_ts/company_control_loop.test.ts @@ -4,7 +4,6 @@ import test from "node:test"; import { COMPANY_CONTROL_LOOP_REQUEST_SCHEMA_VERSION, projectCompanyControlLoop, - upgradeCompanyControlLoopState, } from "../../loopx/control_plane/work_items/company_control_loop.ts"; function request(overrides: Record = {}) { @@ -167,49 +166,3 @@ test("company control loop rejects ambiguous identifiers and unsafe cycle intege /non-negative safe integer/, ); }); - -test("legacy reference state upgrades into the native request contract", () => { - const legacy = { - schema_version: "loopx_company_control_state_v0", - company_direction: "Improve durable customer value.", - cycle: 2, - outcomes: request().outcomes, - work_items: [ - { - ...work(), - route: "ai_execute", - route_reason: "legacy derived value", - status: "ready", - evidence_refs: [], - }, - ], - feedback: [], - events: [], - }; - const result = upgradeCompanyControlLoopState(legacy); - assert.equal(result.schema_version, "company_control_loop_upgrade_v0"); - assert.equal(result.changed, true); - assert.deepEqual(result.state, request({ cycle: 2, work_items: [work()] })); -}); - -test("legacy feedback with ambiguous Goal references fails closed", () => { - assert.throws( - () => upgradeCompanyControlLoopState({ - schema_version: "loopx_company_control_state_v0", - company_direction: "Improve durable customer value.", - cycle: 1, - outcomes: request().outcomes, - work_items: [], - feedback: [{ - feedback_id: "feedback_legacy", - source: "operator", - subject: "activation", - kind: "decision", - observed_at: "2026-09-17T00:00:00Z", - evidence_ref: "decision:42", - affected_goal_ids: ["goal_company"], - }], - }), - /needs an explicit Outcome mapping/, - ); -}); From bbdf631c0d52b2a7678c69df3903819180cc72ec Mon Sep 17 00:00:00 2001 From: KashiwaByte <471314513@qq.com> Date: Thu, 17 Sep 2026 02:42:49 +0800 Subject: [PATCH 16/16] refactor: neutralize outcome routing kernel contracts Signed-off-by: KashiwaByte <471314513@qq.com> --- docs/reference/company-control-loop.md | 18 +- docs/reference/monitor-configuration.md | 15 -- loopx/cli_commands/company_control_loop.py | 104 +++++--- .../control_plane/effect_runtime_handlers.ts | 26 +- loopx/control_plane/todos/monitor_metadata.ts | 10 +- ...ontrol_loop.ts => outcome_routing_plan.ts} | 92 +++---- ...trol_state.ts => outcome_routing_state.ts} | 235 ++++++++++++------ .../test_company_control_loop_cli.py | 100 +++++--- .../test_todo_mutation_authority.py | 22 -- .../control_plane_ts/monitor_metadata.test.ts | 15 +- ...2e.test.ts => outcome_routing_e2e.test.ts} | 42 ++-- ...p.test.ts => outcome_routing_plan.test.ts} | 36 +-- ....test.ts => outcome_routing_state.test.ts} | 166 ++++++++----- 13 files changed, 513 insertions(+), 368 deletions(-) rename loopx/control_plane/work_items/{company_control_loop.ts => outcome_routing_plan.ts} (75%) rename loopx/control_plane/work_items/{company_control_state.ts => outcome_routing_state.ts} (57%) rename tests/control_plane_ts/{company_control_loop_e2e.test.ts => outcome_routing_e2e.test.ts} (77%) rename tests/control_plane_ts/{company_control_loop.test.ts => outcome_routing_plan.test.ts} (82%) rename tests/control_plane_ts/{company_control_state.test.ts => outcome_routing_state.test.ts} (66%) diff --git a/docs/reference/company-control-loop.md b/docs/reference/company-control-loop.md index 25109853b3..013467764a 100644 --- a/docs/reference/company-control-loop.md +++ b/docs/reference/company-control-loop.md @@ -34,9 +34,14 @@ a blocked Todo becomes `replanning`. ## State lifecycle -Start with a `company_control_loop_request_v0` JSON object. It contains one +Start with a `outcome_routing_plan_request_v0` JSON object. It contains one direction, a cycle number, outcomes, work items, and feedback. +`company-control-loop` is the product profile. Its control-plane contracts, +effect IDs, state directory, and schemas use the domain-neutral +`outcome_routing_*` family so the shared work-item kernel does not acquire +company-specific vocabulary. + ```sh loopx company-control-loop project --state-json company.json loopx company-control-loop save \ @@ -75,12 +80,11 @@ loopx company-control-loop sync-todos \ --execute ``` -`target_key` links each work item to exactly one Todo. Existing links are -reused. Duplicate links and failed write readback stop the command. -Agent Todos retain the existing target identity behavior. Human work uses the -same identity only on the typed `user_gate` and `user_action` lanes. This is an -identity correlation rule, not execution authority, and monitor scheduling -fields remain restricted to agent continuous monitors. +The profile persists a revisioned `work_item_id` to `todo_id` binding after +Todo readback. Agent Todos may still use the kernel's existing `target_key` +identity. Human Todos remain ordinary `user_gate` and `user_action` records; +their correlation identity stays inside profile state. Failed readback or a +stale state revision stops the command. ## Reconcile and plan the next cycle diff --git a/docs/reference/monitor-configuration.md b/docs/reference/monitor-configuration.md index fd97e3efde..f05ccec898 100644 --- a/docs/reference/monitor-configuration.md +++ b/docs/reference/monitor-configuration.md @@ -41,15 +41,6 @@ successor may carry `target_key` as its route identity without becoming a Monitor, while cadence, due time, expiry and watch-only require `task_class=continuous_monitor`. -The default target-identity rule is intentionally narrow. Agent Todos may -carry `target_key` as before. User Todos may carry it only when their typed -lane is `user_gate` or `user_action`; this lets an external planner correlate -human decisions and actions with its work item without turning user Todos into -Monitors. Other user task classes still reject `target_key`, and every cadence, -due-time, expiry, watch-only, and observation field remains agent-monitor only. -Consumers must continue to enforce their own authority checks: a target key is -an identity link and grants no claim, lease, execution, or capability access. - Once a Monitor has observation evidence, its target identity cannot be changed or cleared by configuration. Create a new independent Monitor for a different target; do not reuse the former target's generations as new evidence. An @@ -87,12 +78,6 @@ Monitor 配置修改复用 `todo update`。晋升后由 TS 在同一个 canonica 而非调度字段:Monitor 后继 Todo 可以只带 target 而不成为 Monitor,频率、到期、 检查时间和 watch-only 仍要求 task_class=continuous_monitor。 -target 身份的默认规则只做窄幅扩展:Agent Todo 继续可以携带 `target_key`;User -Todo 仅在 typed lane 为 `user_gate` 或 `user_action` 时可以携带它,以便外部规划器 -把人类决策/执行结果关联回工作项。其他 User task class 仍拒绝 `target_key`,所有 -频率、下次检查、到期、watch-only 与观察字段仍只属于 Agent Monitor。`target_key` -只提供身份关联,不授予 claim、lease、执行或 capability 权限。 - 已有 claim/exclusion/lease 检查继续生效,lease proof 不会因配置而续期。Chat 委托 owner 动作和带 lease 的 polling 尚未闭合,文字理由不能替代可信授权。 提交成功但展示 pending 时,用原操作重试回执/投影;不能改旧 Markdown 当作回滚。 diff --git a/loopx/cli_commands/company_control_loop.py b/loopx/cli_commands/company_control_loop.py index de8a88c5a5..07982836a9 100644 --- a/loopx/cli_commands/company_control_loop.py +++ b/loopx/cli_commands/company_control_loop.py @@ -26,13 +26,13 @@ def register_company_control_loop_command( ) project = actions.add_parser( "project", - help="Project a company_control_loop_request_v0 JSON object without writing state.", + help="Project a outcome_routing_plan_request_v0 JSON object without writing state.", ) add_subcommand_format(project) project.add_argument( "--state-json", required=True, - help="Path to a company_control_loop_request_v0 JSON object.", + help="Path to a outcome_routing_plan_request_v0 JSON object.", ) save = actions.add_parser( "save", @@ -43,7 +43,7 @@ def register_company_control_loop_command( save.add_argument( "--state-json", required=True, - help="Path to a company_control_loop_request_v0 JSON object.", + help="Path to a outcome_routing_plan_request_v0 JSON object.", ) save.add_argument( "--expected-revision", @@ -162,9 +162,9 @@ def handle_company_control_loop_command( if runtime_root is None: raise ValueError("company control state requires a runtime root") projection = effect_runtime_result( - "work_item.company_control_state.load", + "work_item.outcome_routing_state.load", { - "schema_version": "company_control_state_store_request_v0", + "schema_version": "outcome_routing_state_store_request_v0", "runtime_root": str(runtime_root), "goal_id": args.goal_id, }, @@ -175,9 +175,9 @@ def handle_company_control_loop_command( payload = { "ok": True, **effect_runtime_result( - "work_item.company_control_state.next_cycle", + "work_item.outcome_routing_state.next_cycle", { - "schema_version": "company_control_next_cycle_request_v0", + "schema_version": "outcome_routing_next_cycle_request_v0", "goal_id": args.goal_id, "state": projection.get("state"), }, @@ -210,7 +210,7 @@ def handle_company_control_loop_command( else: request = _read_json_object(args.state_json) projection = effect_runtime_result( - "work_item.company_control_loop.project", + "work_item.outcome_routing_plan.project", request, ) if command != "save": @@ -227,7 +227,7 @@ def handle_company_control_loop_command( if runtime_root is None: raise ValueError("company control state requires a runtime root") write_request: dict[str, Any] = { - "schema_version": "company_control_state_store_request_v0", + "schema_version": "outcome_routing_state_store_request_v0", "runtime_root": str(runtime_root), "goal_id": args.goal_id, "state": request, @@ -236,7 +236,7 @@ def handle_company_control_loop_command( if args.expected_revision: write_request["expected_revision"] = args.expected_revision saved = effect_runtime_result( - "work_item.company_control_state.write", + "work_item.outcome_routing_state.write", write_request, ) payload = {"ok": True, "dry_run": False, **saved} @@ -280,6 +280,11 @@ def _sync_todos( limit=500, ) todos = [item for item in listing.get("todos", []) if isinstance(item, dict)] + by_id = { + str(todo.get("todo_id")): todo + for todo in todos + if todo.get("todo_id") + } by_target: dict[str, dict[str, Any]] = {} for todo in todos: target = str(todo.get("target_key") or "").strip() @@ -288,6 +293,11 @@ def _sync_todos( if target in by_target: raise ValueError(f"multiple LoopX Todos use target_key {target!r}") by_target[target] = todo + stored_bindings = { + str(binding.get("work_item_id")): binding + for binding in state.get("todo_bindings", []) + if isinstance(binding, dict) and binding.get("work_item_id") + } actions: list[dict[str, Any]] = [] seen_targets: set[str] = set() for raw in work_items: @@ -300,7 +310,10 @@ def _sync_todos( if not target or target in seen_targets: raise ValueError("company work target_key must be present and unique") seen_targets.add(target) - matched = by_target.get(target) + binding = stored_bindings.get(str(raw.get("work_item_id"))) + matched = by_id.get(str(binding.get("todo_id"))) if binding else None + if matched is None and todo_projection.get("role") == "agent": + matched = by_target.get(target) if matched: actions.append({ "work_item_id": raw.get("work_item_id"), @@ -319,7 +332,9 @@ def _sync_todos( if execute: role = str(todo_projection.get("role") or "") task_class = str(todo_projection.get("task_class") or "") - monitor_metadata: dict[str, Any] = {"target_key": target} + monitor_metadata: dict[str, Any] = {} + if role == "agent": + monitor_metadata["target_key"] = target if task_class == "continuous_monitor": monitor_metadata["watch_only"] = "true" created = add_goal_todo( @@ -356,17 +371,44 @@ def _sync_todos( project=project, limit=500, ) - readback_by_target = { - str(item.get("target_key")): item + readback_by_id = { + str(item.get("todo_id")): item for item in readback.get("todos", []) - if isinstance(item, dict) and item.get("target_key") + if isinstance(item, dict) and item.get("todo_id") } - missing = sorted(seen_targets - readback_by_target.keys()) + missing = sorted( + str(action.get("todo_id")) + for action in actions + if str(action.get("todo_id")) not in readback_by_id + ) if missing: - raise RuntimeError(f"LoopX Todo readback missing target keys: {missing}") - for action in actions: - item = readback_by_target[str(action["target_key"])] - action["todo_id"] = item.get("todo_id") + raise RuntimeError(f"LoopX Todo readback missing ids: {missing}") + binding_result = effect_runtime_result( + "work_item.outcome_routing_state.bind", + { + "schema_version": "outcome_routing_state_bind_request_v0", + "runtime_root": str(runtime_root), + "goal_id": goal_id, + "expected_revision": state.get("revision"), + "updated_at": datetime.now(UTC).isoformat(), + "todo_bindings": [ + { + "work_item_id": action["work_item_id"], + "target_key": action["target_key"], + "todo_id": action["todo_id"], + "role": action.get("role") or next( + str(item["todo_projection"].get("role")) + for item in work_items + if isinstance(item, dict) + and item.get("work_item_id") == action["work_item_id"] + and isinstance(item.get("todo_projection"), dict) + ), + } + for action in actions + ], + }, + ) + state = binding_result.get("state", state) return { "ok": True, "dry_run": not execute, @@ -396,10 +438,13 @@ def _reconcile_todos( work_items = company.get("work_items") if not isinstance(work_items, list): raise TypeError("persisted company work_items must be an array") - targets = { - str(item.get("target_key")) - for item in work_items - if isinstance(item, dict) and item.get("target_key") + bindings = [item for item in state.get("todo_bindings", []) if isinstance(item, dict)] + if not bindings: + raise ValueError("persisted outcome routing state has no Todo bindings; run sync-todos first") + bindings_by_todo = { + str(item.get("todo_id")): item + for item in bindings + if item.get("todo_id") } listing = list_goal_todos( registry_path=registry_path, @@ -414,9 +459,10 @@ def _reconcile_todos( for raw in listing.get("todos", []): if not isinstance(raw, dict): continue - target = str(raw.get("target_key") or "").strip() - if target not in targets: + binding = bindings_by_todo.get(str(raw.get("todo_id") or "")) + if binding is None: continue + target = str(binding.get("target_key") or "").strip() if target in seen_targets: raise ValueError(f"multiple LoopX Todos use target_key {target!r}") seen_targets.add(target) @@ -430,11 +476,11 @@ def _reconcile_todos( observation["evidence_ref"] = evidence.strip() observations.append(observation) if not observations: - raise ValueError("no LoopX Todos match persisted company work targets") + raise ValueError("no LoopX Todos match persisted outcome routing bindings") result = effect_runtime_result( - "work_item.company_control_state.reconcile", + "work_item.outcome_routing_state.reconcile", { - "schema_version": "company_control_state_reconcile_request_v0", + "schema_version": "outcome_routing_state_reconcile_request_v0", "runtime_root": str(runtime_root), "goal_id": goal_id, "expected_revision": state.get("revision"), diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index 349c60903b..373f1ed5a6 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -173,14 +173,15 @@ import { } from "./work_items/planning_inventory.ts"; import { resolveRefreshRecommendation } from "./work_items/refresh_recommendation.ts"; import { - projectCompanyControlLoop, -} from "./work_items/company_control_loop.ts"; + projectOutcomeRoutingPlan, +} from "./work_items/outcome_routing_plan.ts"; import { - loadCompanyControlState, - planCompanyControlNextCycle, - reconcileCompanyControlState, - writeCompanyControlState, -} from "./work_items/company_control_state.ts"; + bindOutcomeRoutingTodos, + loadOutcomeRoutingState, + planOutcomeRoutingNextCycle, + reconcileOutcomeRoutingState, + writeOutcomeRoutingState, +} from "./work_items/outcome_routing_state.ts"; import { validateInteractionProjectionHookInvocation, validateInteractionProjectionHookRegistration, @@ -460,11 +461,12 @@ export function createEffectRuntimeHandlers( ["work_item.planning_inventory.project", projectTodoPlanningInventory], ["work_item.planning_inventory.detail", projectTodoPlanningInventoryDetail], ["work_item.refresh_recommendation.resolve", resolveRefreshRecommendation], - ["work_item.company_control_loop.project", projectCompanyControlLoop], - ["work_item.company_control_state.load", loadCompanyControlState], - ["work_item.company_control_state.next_cycle", planCompanyControlNextCycle], - ["work_item.company_control_state.reconcile", reconcileCompanyControlState], - ["work_item.company_control_state.write", writeCompanyControlState], + ["work_item.outcome_routing_plan.project", projectOutcomeRoutingPlan], + ["work_item.outcome_routing_state.bind", bindOutcomeRoutingTodos], + ["work_item.outcome_routing_state.load", loadOutcomeRoutingState], + ["work_item.outcome_routing_state.next_cycle", planOutcomeRoutingNextCycle], + ["work_item.outcome_routing_state.reconcile", reconcileOutcomeRoutingState], + ["work_item.outcome_routing_state.write", writeOutcomeRoutingState], ["work_item.delivery_history.project", projectDeliveryHistory], ["work_item.delivery_response.project", projectDeliveryResponse], ["work_item.delivery_claim.validate", validateDeliveryClaim], diff --git a/loopx/control_plane/todos/monitor_metadata.ts b/loopx/control_plane/todos/monitor_metadata.ts index fd63f31c5d..50c38e0c02 100644 --- a/loopx/control_plane/todos/monitor_metadata.ts +++ b/loopx/control_plane/todos/monitor_metadata.ts @@ -188,15 +188,7 @@ export function planMonitorMetadata(value: unknown): MonitorMetadataPlan { if (nonTarget && (request.role !== "agent" || request.task_class !== "continuous_monitor")) { throw new EffectRuntimeRequestError("monitor schedule metadata requires --role agent --task-class continuous_monitor"); } - const targetAllowed = request.role === "agent" || ( - request.role === "user" && - ["user_gate", "user_action"].includes(String(request.task_class)) - ); - if (metadata.target_key != null && !targetAllowed) { - throw new EffectRuntimeRequestError( - "target_key requires an agent Todo or a user gate/action Todo", - ); - } + if (metadata.target_key != null && request.role !== "agent") throw new EffectRuntimeRequestError("target_key requires --role agent"); if (request.generated_at != null && request.task_class === "continuous_monitor" && metadata.next_due_at == null && metadata.cadence != null) { const due = schedule(text(request.generated_at), metadata.cadence).next_due_at; if (due) metadata = {...metadata, next_due_at: due}; diff --git a/loopx/control_plane/work_items/company_control_loop.ts b/loopx/control_plane/work_items/outcome_routing_plan.ts similarity index 75% rename from loopx/control_plane/work_items/company_control_loop.ts rename to loopx/control_plane/work_items/outcome_routing_plan.ts index 612c59a2b0..7e5ce15699 100644 --- a/loopx/control_plane/work_items/company_control_loop.ts +++ b/loopx/control_plane/work_items/outcome_routing_plan.ts @@ -11,15 +11,15 @@ import { import type { JsonObject } from "../effect_program.ts"; -export const COMPANY_CONTROL_LOOP_REQUEST_SCHEMA_VERSION = - "company_control_loop_request_v0"; -export const COMPANY_CONTROL_LOOP_SCHEMA_VERSION = "company_control_loop_v0"; +export const OUTCOME_ROUTING_PLAN_REQUEST_SCHEMA_VERSION = + "outcome_routing_plan_request_v0"; +export const OUTCOME_ROUTING_PLAN_SCHEMA_VERSION = "outcome_routing_plan_v0"; const MAX_OUTCOMES = 128; const MAX_WORK_ITEMS = 256; const MAX_FEEDBACK_ITEMS = 256; const PUBLIC_ID = /^[a-z][a-z0-9_-]{2,127}$/; -export const COMPANY_WORK_ROUTES = [ +export const OUTCOME_WORK_ROUTES = [ "ai_execute", "human_decide", "human_execute", @@ -27,15 +27,15 @@ export const COMPANY_WORK_ROUTES = [ "reject", ] as const; -export type CompanyWorkRoute = (typeof COMPANY_WORK_ROUTES)[number]; -export type CompanyAuthorityTier = "A" | "B" | "C" | "D"; +export type OutcomeWorkRoute = (typeof OUTCOME_WORK_ROUTES)[number]; +export type OutcomeAuthorityTier = "A" | "B" | "C" | "D"; -interface CompanyWorkItem extends JsonObject { +interface OutcomeWorkItem extends JsonObject { work_item_id: string; outcome_id: string; title: string; acceptance: string; - authority_tier: CompanyAuthorityTier; + authority_tier: OutcomeAuthorityTier; ai_capable: boolean; prohibited: boolean; material_decision: boolean; @@ -44,8 +44,8 @@ interface CompanyWorkItem extends JsonObject { target_key: string; } -interface RoutedCompanyWorkItem extends CompanyWorkItem { - route: CompanyWorkRoute; +interface RoutedOutcomeWorkItem extends OutcomeWorkItem { + route: OutcomeWorkRoute; route_reason: string; status: | "ready" @@ -95,7 +95,7 @@ function requireUniqueIds( } } -function companyWorkItem(value: unknown, label: string): CompanyWorkItem { +function outcomeWorkItem(value: unknown, label: string): OutcomeWorkItem { const raw = requireJsonObject(value, label); const waitFor = optionalNonEmptyString(raw.wait_for, `${label}.wait_for`); return { @@ -126,9 +126,9 @@ function companyWorkItem(value: unknown, label: string): CompanyWorkItem { }; } -export function routeCompanyWorkItem( - item: CompanyWorkItem, -): { route: CompanyWorkRoute; reason: string } { +export function routeOutcomeWorkItem( + item: OutcomeWorkItem, +): { route: OutcomeWorkRoute; reason: string } { if (item.prohibited || item.authority_tier === "D") { return { route: "reject", @@ -165,7 +165,7 @@ export function routeCompanyWorkItem( }; } -function routeStatus(route: CompanyWorkRoute): RoutedCompanyWorkItem["status"] { +function routeStatus(route: OutcomeWorkRoute): RoutedOutcomeWorkItem["status"] { switch (route) { case "ai_execute": return "ready"; case "human_decide": return "waiting_human_decision"; @@ -175,8 +175,8 @@ function routeStatus(route: CompanyWorkRoute): RoutedCompanyWorkItem["status"] { } } -function todoProjection(item: CompanyWorkItem, route: CompanyWorkRoute): JsonObject { - const mapping: Record = { +function todoProjection(item: OutcomeWorkItem, route: OutcomeWorkRoute): JsonObject { + const mapping: Record = { ai_execute: ["agent", "advancement_task"], human_decide: ["user", "user_gate"], human_execute: ["user", "user_action"], @@ -194,9 +194,9 @@ function todoProjection(item: CompanyWorkItem, route: CompanyWorkRoute): JsonObj }; } -function projectWorkItem(value: unknown, label: string): RoutedCompanyWorkItem { - const item = companyWorkItem(value, label); - const decision = routeCompanyWorkItem(item); +function projectWorkItem(value: unknown, label: string): RoutedOutcomeWorkItem { + const item = outcomeWorkItem(value, label); + const decision = routeOutcomeWorkItem(item); return { ...item, route: decision.route, @@ -239,98 +239,98 @@ function projectFeedback(value: unknown, label: string): JsonObject { } /** - * Validate one company-level planning snapshot and project each open unit of + * Validate one outcome-level planning snapshot and project each open unit of * work into LoopX's existing Todo lanes. This is a pure control-plane * contract: providers own collection and execution, while LoopX owns routing * precedence and the provider-neutral projection. */ -export function projectCompanyControlLoop(value: unknown): JsonObject { - const request = requireJsonObject(value, "company_control_loop_request"); - if (request.schema_version !== COMPANY_CONTROL_LOOP_REQUEST_SCHEMA_VERSION) { +export function projectOutcomeRoutingPlan(value: unknown): JsonObject { + const request = requireJsonObject(value, "outcome_routing_plan_request"); + if (request.schema_version !== OUTCOME_ROUTING_PLAN_REQUEST_SCHEMA_VERSION) { throw new EffectRuntimeRequestError( - `company_control_loop_request.schema_version must be ${COMPANY_CONTROL_LOOP_REQUEST_SCHEMA_VERSION}`, + `outcome_routing_plan_request.schema_version must be ${OUTCOME_ROUTING_PLAN_REQUEST_SCHEMA_VERSION}`, ); } - const cycle = requireInteger(request.cycle, "company_control_loop_request.cycle"); + const cycle = requireInteger(request.cycle, "outcome_routing_plan_request.cycle"); if (cycle < 0 || !Number.isSafeInteger(cycle)) { throw new EffectRuntimeRequestError( - "company_control_loop_request.cycle must be a non-negative safe integer", + "outcome_routing_plan_request.cycle must be a non-negative safe integer", ); } const outcomes = boundedArray( request.outcomes, - "company_control_loop_request.outcomes", + "outcome_routing_plan_request.outcomes", MAX_OUTCOMES, ).map((value, index) => { const raw = requireJsonObject( value, - `company_control_loop_request.outcomes[${index}]`, + `outcome_routing_plan_request.outcomes[${index}]`, ); return { outcome_id: publicId( raw.outcome_id, - `company_control_loop_request.outcomes[${index}].outcome_id`, + `outcome_routing_plan_request.outcomes[${index}].outcome_id`, ), title: requireNonEmptyString( raw.title, - `company_control_loop_request.outcomes[${index}].title`, + `outcome_routing_plan_request.outcomes[${index}].title`, ), metric: requireNonEmptyString( raw.metric, - `company_control_loop_request.outcomes[${index}].metric`, + `outcome_routing_plan_request.outcomes[${index}].metric`, ), target: requireNonEmptyString( raw.target, - `company_control_loop_request.outcomes[${index}].target`, + `outcome_routing_plan_request.outcomes[${index}].target`, ), evidence_source: requireNonEmptyString( raw.evidence_source, - `company_control_loop_request.outcomes[${index}].evidence_source`, + `outcome_routing_plan_request.outcomes[${index}].evidence_source`, ), }; }); - requireUniqueIds(outcomes, "outcome_id", "company outcome_id values"); + requireUniqueIds(outcomes, "outcome_id", "outcome_id values"); const outcomeIds = new Set(outcomes.map((outcome) => outcome.outcome_id)); const workItems = boundedArray( request.work_items, - "company_control_loop_request.work_items", + "outcome_routing_plan_request.work_items", MAX_WORK_ITEMS, ).map((item, index) => projectWorkItem( item, - `company_control_loop_request.work_items[${index}]`, + `outcome_routing_plan_request.work_items[${index}]`, )); - requireUniqueIds(workItems, "work_item_id", "company work_item_id values"); - requireUniqueIds(workItems, "target_key", "company work target_key values"); + requireUniqueIds(workItems, "work_item_id", "outcome work_item_id values"); + requireUniqueIds(workItems, "target_key", "outcome work target_key values"); for (const [index, item] of workItems.entries()) { if (!outcomeIds.has(item.outcome_id)) { throw new EffectRuntimeRequestError( - `company_control_loop_request.work_items[${index}].outcome_id must reference an outcome`, + `outcome_routing_plan_request.work_items[${index}].outcome_id must reference an outcome`, ); } } const feedback = boundedArray( request.feedback, - "company_control_loop_request.feedback", + "outcome_routing_plan_request.feedback", MAX_FEEDBACK_ITEMS, ).map((item, index) => projectFeedback( item, - `company_control_loop_request.feedback[${index}]`, + `outcome_routing_plan_request.feedback[${index}]`, )); - requireUniqueIds(feedback, "feedback_id", "company feedback_id values"); + requireUniqueIds(feedback, "feedback_id", "feedback_id values"); for (const [index, item] of feedback.entries()) { for (const outcomeId of item.affected_outcome_ids as string[]) { if (!outcomeIds.has(outcomeId)) { throw new EffectRuntimeRequestError( - `company_control_loop_request.feedback[${index}].affected_outcome_ids must reference outcomes`, + `outcome_routing_plan_request.feedback[${index}].affected_outcome_ids must reference outcomes`, ); } } } return { - schema_version: COMPANY_CONTROL_LOOP_SCHEMA_VERSION, + schema_version: OUTCOME_ROUTING_PLAN_SCHEMA_VERSION, direction: requireNonEmptyString( request.direction, - "company_control_loop_request.direction", + "outcome_routing_plan_request.direction", ), cycle, outcomes, diff --git a/loopx/control_plane/work_items/company_control_state.ts b/loopx/control_plane/work_items/outcome_routing_state.ts similarity index 57% rename from loopx/control_plane/work_items/company_control_state.ts rename to loopx/control_plane/work_items/outcome_routing_state.ts index a0aaab5999..db73ba55e8 100644 --- a/loopx/control_plane/work_items/company_control_state.ts +++ b/loopx/control_plane/work_items/outcome_routing_state.ts @@ -16,24 +16,26 @@ import { requireStringLiteral, } from "../runtime_decode.ts"; import { - COMPANY_CONTROL_LOOP_SCHEMA_VERSION, - projectCompanyControlLoop, -} from "./company_control_loop.ts"; + OUTCOME_ROUTING_PLAN_SCHEMA_VERSION, + projectOutcomeRoutingPlan, +} from "./outcome_routing_plan.ts"; -export const COMPANY_CONTROL_STATE_STORE_REQUEST_SCHEMA = - "company_control_state_store_request_v0"; -export const COMPANY_CONTROL_STATE_STORE_SCHEMA = - "company_control_state_store_v0"; -export const COMPANY_CONTROL_STATE_STORE_RESULT_SCHEMA = - "company_control_state_store_result_v0"; -export const COMPANY_CONTROL_STATE_RECONCILE_REQUEST_SCHEMA = - "company_control_state_reconcile_request_v0"; -export const COMPANY_CONTROL_STATE_RECONCILIATION_SCHEMA = - "company_control_state_reconciliation_v0"; -export const COMPANY_CONTROL_NEXT_CYCLE_REQUEST_SCHEMA = - "company_control_next_cycle_request_v0"; -export const COMPANY_CONTROL_NEXT_CYCLE_SCHEMA = - "company_control_next_cycle_v0"; +export const OUTCOME_ROUTING_STATE_STORE_REQUEST_SCHEMA = + "outcome_routing_state_store_request_v0"; +export const OUTCOME_ROUTING_STATE_STORE_SCHEMA = + "outcome_routing_state_store_v0"; +export const OUTCOME_ROUTING_STATE_STORE_RESULT_SCHEMA = + "outcome_routing_state_store_result_v0"; +export const OUTCOME_ROUTING_STATE_RECONCILE_REQUEST_SCHEMA = + "outcome_routing_state_reconcile_request_v0"; +export const OUTCOME_ROUTING_STATE_RECONCILIATION_SCHEMA = + "outcome_routing_state_reconciliation_v0"; +export const OUTCOME_ROUTING_STATE_BIND_REQUEST_SCHEMA = + "outcome_routing_state_bind_request_v0"; +export const OUTCOME_ROUTING_NEXT_CYCLE_REQUEST_SCHEMA = + "outcome_routing_next_cycle_request_v0"; +export const OUTCOME_ROUTING_NEXT_CYCLE_SCHEMA = + "outcome_routing_next_cycle_v0"; function stableValue(value: unknown): unknown { if (Array.isArray(value)) return value.map(stableValue); @@ -74,7 +76,7 @@ function safeGoalSegment(goalId: string): string { return `${label}-${digest}`; } -export function companyControlStatePath(runtimeRoot: string, goalId: string): string { +export function outcomeRoutingStatePath(runtimeRoot: string, goalId: string): string { if (!isAbsolute(runtimeRoot)) { throw new EffectRuntimeRequestError("runtime_root must be absolute"); } @@ -82,7 +84,7 @@ export function companyControlStatePath(runtimeRoot: string, goalId: string): st runtimeRoot, "goals", safeGoalSegment(goalId), - "company-control-loop", + "outcome-routing", "state.json", ); } @@ -92,47 +94,89 @@ function storeRequest(value: unknown): { goalId: string; path: string; } { - const request = requireJsonObject(value, "company_control_state_store params"); - if (request.schema_version !== COMPANY_CONTROL_STATE_STORE_REQUEST_SCHEMA) { - throw new EffectRuntimeRequestError("company control state store request schema mismatch"); + const request = requireJsonObject(value, "outcome_routing_state_store params"); + if (request.schema_version !== OUTCOME_ROUTING_STATE_STORE_REQUEST_SCHEMA) { + throw new EffectRuntimeRequestError("outcome routing state store request schema mismatch"); } const runtimeRoot = requireNonEmptyString(request.runtime_root, "runtime_root"); const goalId = requireNonEmptyString(request.goal_id, "goal_id"); - return { request, goalId, path: companyControlStatePath(runtimeRoot, goalId) }; + return { request, goalId, path: outcomeRoutingStatePath(runtimeRoot, goalId) }; } function decodeStoredState(value: unknown, goalId: string): JsonObject { - const stored = requireJsonObject(value, "stored company control state"); + const stored = requireJsonObject(value, "stored outcome routing state"); if ( - stored.schema_version !== COMPANY_CONTROL_STATE_STORE_SCHEMA || + stored.schema_version !== OUTCOME_ROUTING_STATE_STORE_SCHEMA || stored.goal_id !== goalId || typeof stored.revision !== "string" || !/^[a-f0-9]{64}$/.test(stored.revision) ) { - throw new EffectRuntimeRequestError("stored company control state is invalid"); + throw new EffectRuntimeRequestError("stored outcome routing state is invalid"); } const projection = requireJsonObject(stored.projection, "stored projection"); - if (projection.schema_version !== COMPANY_CONTROL_LOOP_SCHEMA_VERSION) { - throw new EffectRuntimeRequestError("stored company control projection schema is invalid"); + if (projection.schema_version !== OUTCOME_ROUTING_PLAN_SCHEMA_VERSION) { + throw new EffectRuntimeRequestError("stored outcome routing projection schema is invalid"); } + const bindings = stored.todo_bindings === undefined + ? [] + : requireBindings(stored.todo_bindings, projection); const reconciliation = stored.reconciliation === undefined ? null : requireJsonObject(stored.reconciliation, "stored reconciliation"); if ( reconciliation !== null && - reconciliation.schema_version !== COMPANY_CONTROL_STATE_RECONCILIATION_SCHEMA + reconciliation.schema_version !== OUTCOME_ROUTING_STATE_RECONCILIATION_SCHEMA ) { - throw new EffectRuntimeRequestError("stored company control reconciliation is invalid"); + throw new EffectRuntimeRequestError("stored outcome routing reconciliation is invalid"); } - const revisionContent = reconciliation === null - ? projection - : { projection, reconciliation }; + const revisionContent: JsonObject = { projection }; + if (bindings.length > 0) revisionContent.todo_bindings = bindings; + if (reconciliation !== null) revisionContent.reconciliation = reconciliation; if (revision(revisionContent) !== stored.revision) { - throw new EffectRuntimeRequestError("stored company control state revision does not match content"); + throw new EffectRuntimeRequestError("stored outcome routing state revision does not match content"); } return stored; } +function requireBindings(value: unknown, projection: JsonObject): JsonObject[] { + if (!Array.isArray(value)) { + throw new EffectRuntimeRequestError("outcome routing Todo bindings must be an array"); + } + const workItems = projection.work_items; + if (!Array.isArray(workItems)) { + throw new EffectRuntimeRequestError("stored outcome work_items must be an array"); + } + const targets = new Map(workItems.map((value) => { + const work = requireJsonObject(value, "stored outcome work item"); + return [ + requireNonEmptyString(work.work_item_id, "stored work_item_id"), + requireNonEmptyString(work.target_key, "stored target_key"), + ]; + })); + const workIds = new Set(); + const todoIds = new Set(); + return value.map((value, index) => { + const binding = requireJsonObject(value, `todo_bindings[${index}]`); + const workItemId = requireNonEmptyString(binding.work_item_id, `todo_bindings[${index}].work_item_id`); + const targetKey = requireNonEmptyString(binding.target_key, `todo_bindings[${index}].target_key`); + const todoId = requireNonEmptyString(binding.todo_id, `todo_bindings[${index}].todo_id`); + if (targets.get(workItemId) !== targetKey) { + throw new EffectRuntimeRequestError("Todo binding must match a projected work item and target"); + } + if (workIds.has(workItemId) || todoIds.has(todoId)) { + throw new EffectRuntimeRequestError("Todo bindings must have unique work_item_id and todo_id values"); + } + workIds.add(workItemId); + todoIds.add(todoId); + return { + work_item_id: workItemId, + target_key: targetKey, + todo_id: todoId, + role: requireStringLiteral(binding.role, ["agent", "user"] as const, `todo_bindings[${index}].role`), + }; + }); +} + async function readStoredState(path: string, goalId: string): Promise { try { return decodeStoredState(JSON.parse(await readFile(path, "utf8")), goalId); @@ -142,10 +186,10 @@ async function readStoredState(path: string, goalId: string): Promise { +export async function loadOutcomeRoutingState(value: unknown): Promise { const { goalId, path } = storeRequest(value); return { - schema_version: COMPANY_CONTROL_STATE_STORE_RESULT_SCHEMA, + schema_version: OUTCOME_ROUTING_STATE_STORE_RESULT_SCHEMA, operation: "load", goal_id: goalId, path, @@ -153,19 +197,19 @@ export async function loadCompanyControlState(value: unknown): Promise { +export async function writeOutcomeRoutingState(value: unknown): Promise { const { request, goalId, path } = storeRequest(value); const expectedRevision = optionalNonEmptyString( request.expected_revision, "expected_revision", ); - const projection = projectCompanyControlLoop(request.state); - const nextRevision = revision(projection); + const projection = projectOutcomeRoutingPlan(request.state); + const nextRevision = revision({ projection }); return await withFileMutationLock(path, async () => { const existing = await readStoredState(path, goalId); if (existing?.revision === nextRevision) { return { - schema_version: COMPANY_CONTROL_STATE_STORE_RESULT_SCHEMA, + schema_version: OUTCOME_ROUTING_STATE_STORE_RESULT_SCHEMA, operation: "write", goal_id: goalId, path, @@ -176,14 +220,14 @@ export async function writeCompanyControlState(value: unknown): Promise { + const request = requireJsonObject(value, "outcome_routing_state_bind params"); + if (request.schema_version !== OUTCOME_ROUTING_STATE_BIND_REQUEST_SCHEMA) { + throw new EffectRuntimeRequestError("outcome routing Todo bind request schema mismatch"); + } + const runtimeRoot = requireNonEmptyString(request.runtime_root, "runtime_root"); + const goalId = requireNonEmptyString(request.goal_id, "goal_id"); + const path = outcomeRoutingStatePath(runtimeRoot, goalId); + const expectedRevision = requireNonEmptyString(request.expected_revision, "expected_revision"); + const updatedAt = requireNonEmptyString(request.updated_at, "updated_at"); + return await withFileMutationLock(path, async () => { + const existing = await readStoredState(path, goalId); + if (!existing) throw new EffectRuntimeRequestError("persisted outcome routing state does not exist"); + if (existing.revision !== expectedRevision) { + throw new EffectRuntimeConflictError("outcome routing state revision changed"); + } + const projection = requireJsonObject(existing.projection, "stored projection"); + const todoBindings = requireBindings(request.todo_bindings, projection); + const revisionContent: JsonObject = { projection }; + if (todoBindings.length > 0) revisionContent.todo_bindings = todoBindings; + if (existing.reconciliation !== undefined) { + revisionContent.reconciliation = requireJsonObject(existing.reconciliation, "stored reconciliation"); + } + const nextRevision = revision(revisionContent); + if (nextRevision === existing.revision) { + return { schema_version: OUTCOME_ROUTING_STATE_STORE_RESULT_SCHEMA, operation: "bind", goal_id: goalId, path, state: existing, written: false, replayed: true }; + } + const nextState: JsonObject = { ...existing, ...revisionContent, revision: nextRevision, updated_at: updatedAt }; + await atomicWriteJson(path, nextState); + const readback = await readStoredState(path, goalId); + if (!readback || readback.revision !== nextRevision) throw new Error("outcome routing Todo binding readback failed"); + return { schema_version: OUTCOME_ROUTING_STATE_STORE_RESULT_SCHEMA, operation: "bind", goal_id: goalId, path, state: readback, written: true, replayed: false }; + }); +} + function todoReconciliation(value: unknown, projection: JsonObject): JsonObject { - const request = requireJsonObject(value, "company control reconciliation"); + const request = requireJsonObject(value, "outcome routing reconciliation"); if (!Array.isArray(request.observations)) { - throw new EffectRuntimeRequestError("company control observations must be an array"); + throw new EffectRuntimeRequestError("outcome routing observations must be an array"); } const workItems = projection.work_items; if (!Array.isArray(workItems)) { - throw new EffectRuntimeRequestError("stored company work_items must be an array"); + throw new EffectRuntimeRequestError("stored outcome work_items must be an array"); } const byTarget = new Map(); for (const item of workItems) { - const work = requireJsonObject(item, "stored company work item"); + const work = requireJsonObject(item, "stored outcome work item"); const target = requireNonEmptyString(work.target_key, "stored work target_key"); if (byTarget.has(target)) { - throw new EffectRuntimeRequestError("stored company work target_key must be unique"); + throw new EffectRuntimeRequestError("stored outcome work target_key must be unique"); } byTarget.set(target, work); } @@ -229,13 +308,13 @@ function todoReconciliation(value: unknown, projection: JsonObject): JsonObject const raw = requireJsonObject(value, `observations[${index}]`); const targetKey = requireNonEmptyString(raw.target_key, `observations[${index}].target_key`); if (seenTargets.has(targetKey)) { - throw new EffectRuntimeRequestError("company Todo observations must have unique target_key values"); + throw new EffectRuntimeRequestError("routed Todo observations must have unique target_key values"); } seenTargets.add(targetKey); const work = byTarget.get(targetKey); if (!work) { throw new EffectRuntimeRequestError( - `company Todo observation target_key ${JSON.stringify(targetKey)} is unknown`, + `routed Todo observation target_key ${JSON.stringify(targetKey)} is unknown`, ); } const todoStatus = requireStringLiteral( @@ -265,7 +344,7 @@ function todoReconciliation(value: unknown, projection: JsonObject): JsonObject }; }); return { - schema_version: COMPANY_CONTROL_STATE_RECONCILIATION_SCHEMA, + schema_version: OUTCOME_ROUTING_STATE_RECONCILIATION_SCHEMA, observations, replan_required: observations.some((item) => item.next_status === "replanning" || item.next_status === "awaiting_evidence" @@ -273,14 +352,14 @@ function todoReconciliation(value: unknown, projection: JsonObject): JsonObject }; } -export async function reconcileCompanyControlState(value: unknown): Promise { - const request = requireJsonObject(value, "company_control_state_reconcile params"); - if (request.schema_version !== COMPANY_CONTROL_STATE_RECONCILE_REQUEST_SCHEMA) { - throw new EffectRuntimeRequestError("company control reconciliation request schema mismatch"); +export async function reconcileOutcomeRoutingState(value: unknown): Promise { + const request = requireJsonObject(value, "outcome_routing_state_reconcile params"); + if (request.schema_version !== OUTCOME_ROUTING_STATE_RECONCILE_REQUEST_SCHEMA) { + throw new EffectRuntimeRequestError("outcome routing reconciliation request schema mismatch"); } const runtimeRoot = requireNonEmptyString(request.runtime_root, "runtime_root"); const goalId = requireNonEmptyString(request.goal_id, "goal_id"); - const path = companyControlStatePath(runtimeRoot, goalId); + const path = outcomeRoutingStatePath(runtimeRoot, goalId); const expectedRevision = requireNonEmptyString( request.expected_revision, "expected_revision", @@ -290,14 +369,18 @@ export async function reconcileCompanyControlState(value: unknown): Promise { const existing = await readStoredState(path, goalId); if (!existing) { - throw new EffectRuntimeRequestError("persisted company control state does not exist"); + throw new EffectRuntimeRequestError("persisted outcome routing state does not exist"); } if (existing.revision !== expectedRevision) { - throw new EffectRuntimeConflictError("company control state revision changed"); + throw new EffectRuntimeConflictError("outcome routing state revision changed"); } const projection = requireJsonObject(existing.projection, "stored projection"); const reconciliation = todoReconciliation(request, projection); - const nextRevision = revision({ projection, reconciliation }); + const revisionContent: JsonObject = { projection, reconciliation }; + if (Array.isArray(existing.todo_bindings) && existing.todo_bindings.length > 0) { + revisionContent.todo_bindings = existing.todo_bindings; + } + const nextRevision = revision(revisionContent); const nextState: JsonObject = { ...existing, revision: nextRevision, @@ -306,7 +389,7 @@ export async function reconcileCompanyControlState(value: unknown): Promise(); for (const value of observations) { @@ -371,19 +454,19 @@ export function planCompanyControlNextCycle(value: unknown): JsonObject { } const workItems = projection.work_items; if (!Array.isArray(workItems)) { - throw new EffectRuntimeRequestError("stored company work_items must be an array"); + throw new EffectRuntimeRequestError("stored outcome work_items must be an array"); } const nextWorkItems: JsonObject[] = []; const feedback: JsonObject[] = Array.isArray(projection.feedback) ? projection.feedback.map((item) => { - const prior = requireJsonObject(item, "stored company feedback"); + const prior = requireJsonObject(item, "stored routing feedback"); const { disposition: _disposition, ...requestFeedback } = prior; return requestFeedback; }) : []; let convergedCount = 0; for (const value of workItems) { - const work = requireJsonObject(value, "stored company work item"); + const work = requireJsonObject(value, "stored outcome work item"); const targetKey = requireNonEmptyString(work.target_key, "stored work target_key"); const observation = byTarget.get(targetKey); const nextStatus = observation?.next_status; @@ -438,16 +521,16 @@ export function planCompanyControlNextCycle(value: unknown): JsonObject { } } const nextState: JsonObject = { - schema_version: "company_control_loop_request_v0", + schema_version: "outcome_routing_plan_request_v0", direction: projection.direction, cycle: Number(projection.cycle) + 1, outcomes: projection.outcomes, work_items: nextWorkItems, feedback, }; - const nextProjection = projectCompanyControlLoop(nextState); + const nextProjection = projectOutcomeRoutingPlan(nextState); return { - schema_version: COMPANY_CONTROL_NEXT_CYCLE_SCHEMA, + schema_version: OUTCOME_ROUTING_NEXT_CYCLE_SCHEMA, goal_id: goalId, source_revision: stored.revision, converged_work_item_count: convergedCount, diff --git a/tests/control_plane/test_company_control_loop_cli.py b/tests/control_plane/test_company_control_loop_cli.py index 8edadff085..c2476aef1c 100644 --- a/tests/control_plane/test_company_control_loop_cli.py +++ b/tests/control_plane/test_company_control_loop_cli.py @@ -8,7 +8,7 @@ def _request() -> dict[str, object]: return { - "schema_version": "company_control_loop_request_v0", + "schema_version": "outcome_routing_plan_request_v0", "direction": "Improve durable customer value.", "cycle": 1, "outcomes": [ @@ -25,17 +25,21 @@ def _request() -> dict[str, object]: } -def _stored_projection(*work_items: dict[str, object]) -> dict[str, object]: +def _stored_projection( + *work_items: dict[str, object], + bindings: list[dict[str, object]] | None = None, +) -> dict[str, object]: return { - "schema_version": "company_control_state_store_result_v0", + "schema_version": "outcome_routing_state_store_result_v0", "operation": "load", "goal_id": "company-goal", "state": { "revision": "a" * 64, "projection": { - "schema_version": "company_control_loop_v0", + "schema_version": "outcome_routing_plan_v0", "work_items": list(work_items), }, + **({"todo_bindings": bindings} if bindings is not None else {}), }, } @@ -62,7 +66,7 @@ def _routed_work( } -def test_company_control_loop_cli_calls_typed_projection( +def test_outcome_routing_plan_cli_calls_typed_projection( tmp_path, monkeypatch, capsys ) -> None: state_path = tmp_path / "company.json" @@ -72,7 +76,7 @@ def test_company_control_loop_cli_calls_typed_projection( def project(method: str, params: dict[str, object]) -> dict[str, object]: calls.append((method, params)) return { - "schema_version": "company_control_loop_v0", + "schema_version": "outcome_routing_plan_v0", "direction": params["direction"], "cycle": params["cycle"], "outcomes": params["outcomes"], @@ -92,11 +96,11 @@ def project(method: str, params: dict[str, object]) -> dict[str, object]: str(state_path), ]) == 0 payload = json.loads(capsys.readouterr().out) - assert payload["schema_version"] == "company_control_loop_v0" - assert calls == [("work_item.company_control_loop.project", _request())] + assert payload["schema_version"] == "outcome_routing_plan_v0" + assert calls == [("work_item.outcome_routing_plan.project", _request())] -def test_company_control_loop_cli_rejects_non_object_json(tmp_path, capsys) -> None: +def test_outcome_routing_plan_cli_rejects_non_object_json(tmp_path, capsys) -> None: state_path = tmp_path / "company.json" state_path.write_text("[]", encoding="utf-8") @@ -113,7 +117,7 @@ def test_company_control_loop_cli_rejects_non_object_json(tmp_path, capsys) -> N assert "must contain an object" in payload["error"] -def test_company_control_loop_save_previews_then_writes_with_revision( +def test_outcome_routing_plan_save_previews_then_writes_with_revision( tmp_path, monkeypatch, capsys ) -> None: state_path = tmp_path / "company.json" @@ -123,10 +127,10 @@ def test_company_control_loop_save_previews_then_writes_with_revision( def runtime(method: str, params: dict[str, object]) -> dict[str, object]: calls.append(method) if method.endswith("project"): - return {"schema_version": "company_control_loop_v0"} + return {"schema_version": "outcome_routing_plan_v0"} assert params["expected_revision"] == "a" * 64 return { - "schema_version": "company_control_state_store_result_v0", + "schema_version": "outcome_routing_state_store_result_v0", "operation": "write", "written": True, "replayed": False, @@ -141,24 +145,24 @@ def runtime(method: str, params: dict[str, object]) -> dict[str, object]: ] assert main(common) == 0 assert json.loads(capsys.readouterr().out)["dry_run"] is True - assert calls == ["work_item.company_control_loop.project"] + assert calls == ["work_item.outcome_routing_plan.project"] calls.clear() assert main([*common, "--expected-revision", "a" * 64, "--execute"]) == 0 assert json.loads(capsys.readouterr().out)["written"] is True assert calls == [ - "work_item.company_control_loop.project", - "work_item.company_control_state.write", + "work_item.outcome_routing_plan.project", + "work_item.outcome_routing_state.write", ] -def test_company_control_loop_show_reads_goal_state(tmp_path, monkeypatch, capsys) -> None: +def test_outcome_routing_plan_show_reads_goal_state(tmp_path, monkeypatch, capsys) -> None: calls: list[tuple[str, dict[str, object]]] = [] def runtime(method: str, params: dict[str, object]) -> dict[str, object]: calls.append((method, params)) return { - "schema_version": "company_control_state_store_result_v0", + "schema_version": "outcome_routing_state_store_result_v0", "operation": "load", "goal_id": params["goal_id"], "state": None, @@ -171,11 +175,11 @@ def runtime(method: str, params: dict[str, object]) -> dict[str, object]: ]) == 0 payload = json.loads(capsys.readouterr().out) assert payload["operation"] == "load" - assert calls[0][0] == "work_item.company_control_state.load" + assert calls[0][0] == "work_item.outcome_routing_state.load" assert calls[0][1]["goal_id"] == "company-goal" -def test_company_control_loop_sync_todos_previews_existing_and_missing( +def test_outcome_routing_plan_sync_todos_previews_existing_and_missing( tmp_path, monkeypatch, capsys ) -> None: monkeypatch.setattr( @@ -219,14 +223,16 @@ def test_company_control_loop_sync_todos_previews_existing_and_missing( assert writes == [] -def test_company_control_loop_sync_todos_creates_and_verifies_readback( +def test_outcome_routing_plan_sync_todos_creates_and_verifies_readback( tmp_path, monkeypatch, capsys ) -> None: monkeypatch.setattr( company_control_loop, "effect_runtime_result", - lambda method, params: _stored_projection( - _routed_work( + lambda method, params: ( + {"state": {"revision": "b" * 64}} + if method.endswith(".bind") + else _stored_projection(_routed_work( "work_decision", "target_decision", role="user", @@ -238,14 +244,14 @@ def test_company_control_loop_sync_todos_creates_and_verifies_readback( "target_watch", task_class="continuous_monitor", action_kind="observe", - ), + )) ), ) listings = iter([ {"todos": []}, {"todos": [ - {"todo_id": "todo_decision", "target_key": "target_decision"}, - {"todo_id": "todo_watch", "target_key": "target_watch"}, + {"todo_id": "created_1"}, + {"todo_id": "created_2", "target_key": "target_watch"}, ]}, ]) monkeypatch.setattr( @@ -268,17 +274,18 @@ def add(**kwargs: object) -> dict[str, object]: payload = json.loads(capsys.readouterr().out) assert payload["readback_verified"] is True assert [item["todo_id"] for item in payload["actions"]] == [ - "todo_decision", "todo_watch", + "created_1", "created_2", ] assert writes[0]["blocks_agent"] == "agent-ceo" assert writes[0]["decision_scope"] == "direction:action:target_decision" + assert writes[0]["monitor_metadata"] == {} assert writes[1]["monitor_metadata"] == { "target_key": "target_watch", "watch_only": "true", } -def test_company_control_loop_sync_todos_fails_on_missing_readback( +def test_outcome_routing_plan_sync_todos_fails_on_missing_readback( tmp_path, monkeypatch, capsys ) -> None: monkeypatch.setattr( @@ -306,10 +313,10 @@ def test_company_control_loop_sync_todos_fails_on_missing_readback( ]) == 1 payload = json.loads(capsys.readouterr().out) assert payload["ok"] is False - assert "readback missing target keys" in payload["error"] + assert "readback missing ids" in payload["error"] -def test_company_control_loop_reconcile_todos_sends_evidence_to_typed_owner( +def test_outcome_routing_plan_reconcile_todos_sends_evidence_to_typed_owner( tmp_path, monkeypatch, capsys ) -> None: calls: list[tuple[str, dict[str, object]]] = [] @@ -318,10 +325,16 @@ def runtime(method: str, params: dict[str, object]) -> dict[str, object]: calls.append((method, params)) if method.endswith(".load"): return _stored_projection( - _routed_work("work_activation", "target_activation") + _routed_work("work_activation", "target_activation"), + bindings=[{ + "work_item_id": "work_activation", + "target_key": "target_activation", + "todo_id": "todo_activation", + "role": "agent", + }], ) return { - "schema_version": "company_control_state_store_result_v0", + "schema_version": "outcome_routing_state_store_result_v0", "operation": "reconcile", "dry_run": True, "written": False, @@ -354,7 +367,7 @@ def runtime(method: str, params: dict[str, object]) -> dict[str, object]: ]) == 0 payload = json.loads(capsys.readouterr().out) assert payload["dry_run"] is True - assert calls[1][0] == "work_item.company_control_state.reconcile" + assert calls[1][0] == "work_item.outcome_routing_state.reconcile" assert calls[1][1]["expected_revision"] == "a" * 64 assert calls[1][1]["execute"] is False assert calls[1][1]["observations"] == [{ @@ -365,14 +378,20 @@ def runtime(method: str, params: dict[str, object]) -> dict[str, object]: }] -def test_company_control_loop_reconcile_todos_rejects_duplicate_targets( +def test_outcome_routing_plan_reconcile_todos_uses_persisted_todo_identity( tmp_path, monkeypatch, capsys ) -> None: monkeypatch.setattr( company_control_loop, "effect_runtime_result", lambda method, params: _stored_projection( - _routed_work("work_activation", "target_activation") + _routed_work("work_activation", "target_activation"), + bindings=[{ + "work_item_id": "work_activation", + "target_key": "target_activation", + "todo_id": "todo_first", + "role": "agent", + }], ), ) monkeypatch.setattr( @@ -388,13 +407,12 @@ def test_company_control_loop_reconcile_todos_rejects_duplicate_targets( "--runtime-root", str(tmp_path / "runtime"), "company-control-loop", "reconcile-todos", "--goal-id", "company-goal", "--agent-id", "agent-ceo", "--execute", - ]) == 1 + ]) == 0 payload = json.loads(capsys.readouterr().out) - assert payload["ok"] is False - assert "multiple LoopX Todos" in payload["error"] + assert payload["ok"] is True -def test_company_control_loop_next_cycle_uses_persisted_reconciliation( +def test_outcome_routing_plan_next_cycle_uses_persisted_reconciliation( tmp_path, monkeypatch, capsys ) -> None: stored = _stored_projection( @@ -403,7 +421,7 @@ def test_company_control_loop_next_cycle_uses_persisted_reconciliation( state = stored["state"] assert isinstance(state, dict) state["reconciliation"] = { - "schema_version": "company_control_state_reconciliation_v0", + "schema_version": "outcome_routing_state_reconciliation_v0", "observations": [{ "work_item_id": "work_activation", "target_key": "target_activation", @@ -423,7 +441,7 @@ def runtime(method: str, params: dict[str, object]) -> dict[str, object]: if method.endswith(".load"): return stored return { - "schema_version": "company_control_next_cycle_v0", + "schema_version": "outcome_routing_next_cycle_v0", "goal_id": "company-goal", "goal_converged": True, "remaining_work_item_count": 0, @@ -436,5 +454,5 @@ def runtime(method: str, params: dict[str, object]) -> dict[str, object]: ]) == 0 payload = json.loads(capsys.readouterr().out) assert payload["goal_converged"] is True - assert calls[1][0] == "work_item.company_control_state.next_cycle" + assert calls[1][0] == "work_item.outcome_routing_state.next_cycle" assert calls[1][1]["state"] == state diff --git a/tests/control_plane/test_todo_mutation_authority.py b/tests/control_plane/test_todo_mutation_authority.py index 2953dc3874..c683835e94 100644 --- a/tests/control_plane/test_todo_mutation_authority.py +++ b/tests/control_plane/test_todo_mutation_authority.py @@ -708,28 +708,6 @@ def test_advancement_todo_preserves_public_target_key(tmp_path: Path) -> None: assert projected["target_key"] == "issue-fix:owner/repo:issue_42" -def test_user_gate_preserves_company_target_key(tmp_path: Path) -> None: - registry, state = _write_fixture(tmp_path) - todo = add_goal_todo( - registry_path=registry, - goal_id=GOAL_ID, - role="user", - text="Decide the activation direction.", - task_class="user_gate", - action_kind="human_decide", - agent_id=AUTHOR_AGENT, - blocks_agent=AUTHOR_AGENT, - decision_scope="direction:action:activation_decision", - monitor_metadata={"target_key": "activation_decision"}, - ) - projected = parse_active_state_todos(state.read_text(encoding="utf-8")) - user = next( - item for item in projected["user_todos"]["items"] - if item["todo_id"] == todo["todo_id"] - ) - assert user["target_key"] == "activation_decision" - - def test_capability_binding_follows_generated_agent_successor(tmp_path: Path) -> None: registry, state = _write_fixture(tmp_path) todo = add_goal_todo( diff --git a/tests/control_plane_ts/monitor_metadata.test.ts b/tests/control_plane_ts/monitor_metadata.test.ts index 5c2e12cd16..cdd42903a3 100644 --- a/tests/control_plane_ts/monitor_metadata.test.ts +++ b/tests/control_plane_ts/monitor_metadata.test.ts @@ -67,20 +67,7 @@ test("create/edit scope, boundedness and explicit clearing share one owner", () assert.doesNotThrow(() => planMonitorMetadata(request({metadata: {watch_only: null}, resume_when: "todo_done:todo_dependency"}))); assert.doesNotThrow(() => planMonitorMetadata(request({metadata: {watch_only: null}, enforce_boundedness: false}))); assert.throws(() => planMonitorMetadata(request({role: "user", metadata: {cadence: "1h"}})), /schedule metadata/); - assert.throws( - () => planMonitorMetadata(request({role: "user", metadata: {target_key: "fixture"}})), - /agent Todo or a user gate\/action Todo/, - ); - for (const task_class of ["user_gate", "user_action"]) { - assert.deepEqual( - planMonitorMetadata(request({ - role: "user", - task_class, - metadata: {target_key: "fixture"}, - })).metadata, - {target_key: "fixture"}, - ); - } + assert.throws(() => planMonitorMetadata(request({role: "user", metadata: {target_key: "fixture"}})), /target_key/); assert.throws(() => planMonitorMetadata(request({metadata: {cadence: "never"}})), /cadence/); for (const field of ["consecutive_no_change", "material_change_generation"]) { for (const value of ["-1", "9007199254740993", "1.5"]) { diff --git a/tests/control_plane_ts/company_control_loop_e2e.test.ts b/tests/control_plane_ts/outcome_routing_e2e.test.ts similarity index 77% rename from tests/control_plane_ts/company_control_loop_e2e.test.ts rename to tests/control_plane_ts/outcome_routing_e2e.test.ts index c541b1a884..fb47339b49 100644 --- a/tests/control_plane_ts/company_control_loop_e2e.test.ts +++ b/tests/control_plane_ts/outcome_routing_e2e.test.ts @@ -5,17 +5,17 @@ import { join } from "node:path"; import test from "node:test"; import { - COMPANY_CONTROL_STATE_RECONCILE_REQUEST_SCHEMA, - COMPANY_CONTROL_STATE_STORE_REQUEST_SCHEMA, - loadCompanyControlState, - planCompanyControlNextCycle, - reconcileCompanyControlState, - writeCompanyControlState, -} from "../../loopx/control_plane/work_items/company_control_state.ts"; + OUTCOME_ROUTING_STATE_RECONCILE_REQUEST_SCHEMA, + OUTCOME_ROUTING_STATE_STORE_REQUEST_SCHEMA, + loadOutcomeRoutingState, + planOutcomeRoutingNextCycle, + reconcileOutcomeRoutingState, + writeOutcomeRoutingState, +} from "../../loopx/control_plane/work_items/outcome_routing_state.ts"; function state() { return { - schema_version: "company_control_loop_request_v0", + schema_version: "outcome_routing_plan_request_v0", direction: "Improve durable customer value.", cycle: 1, outcomes: [{ @@ -62,18 +62,18 @@ function state() { function storeRequest(runtimeRoot: string, extra: Record) { return { - schema_version: COMPANY_CONTROL_STATE_STORE_REQUEST_SCHEMA, + schema_version: OUTCOME_ROUTING_STATE_STORE_REQUEST_SCHEMA, runtime_root: runtimeRoot, goal_id: "company-goal", ...extra, }; } -test("company CEO v0 closes AI, human, restart, escalation, and convergence scenarios", async (t) => { +test("outcome routing v0 closes AI, human, restart, escalation, and convergence scenarios", async (t) => { const runtimeRoot = await mkdtemp(join(tmpdir(), "loopx-company-e2e-")); t.after(() => rm(runtimeRoot, { recursive: true, force: true })); - const first = await writeCompanyControlState(storeRequest(runtimeRoot, { + const first = await writeOutcomeRoutingState(storeRequest(runtimeRoot, { state: state(), updated_at: "2026-09-17T00:00:00Z", })); @@ -83,11 +83,11 @@ test("company CEO v0 closes AI, human, restart, escalation, and convergence scen ["ai_execute", "human_decide", "human_execute"], ); - const restarted = await loadCompanyControlState(storeRequest(runtimeRoot, {})); + const restarted = await loadOutcomeRoutingState(storeRequest(runtimeRoot, {})); assert.deepEqual(restarted.state, first.state); - const failedCycle = await reconcileCompanyControlState({ - schema_version: COMPANY_CONTROL_STATE_RECONCILE_REQUEST_SCHEMA, + const failedCycle = await reconcileOutcomeRoutingState({ + schema_version: OUTCOME_ROUTING_STATE_RECONCILE_REQUEST_SCHEMA, runtime_root: runtimeRoot, goal_id: "company-goal", expected_revision: firstState.revision, @@ -113,8 +113,8 @@ test("company CEO v0 closes AI, human, restart, escalation, and convergence scen }, ], }); - const replan = planCompanyControlNextCycle({ - schema_version: "company_control_next_cycle_request_v0", + const replan = planOutcomeRoutingNextCycle({ + schema_version: "outcome_routing_next_cycle_request_v0", goal_id: "company-goal", state: failedCycle.state, }); @@ -125,13 +125,13 @@ test("company CEO v0 closes AI, human, restart, escalation, and convergence scen assert.equal(replannedState.feedback.at(-1).kind, "risk"); assert.equal(replannedState.work_items[0].work_item_id, "work_human_execution"); - const second = await writeCompanyControlState(storeRequest(runtimeRoot, { + const second = await writeOutcomeRoutingState(storeRequest(runtimeRoot, { state: replannedState, expected_revision: (failedCycle.state as Record).revision, updated_at: "2026-09-17T00:02:00Z", })); - const completedCycle = await reconcileCompanyControlState({ - schema_version: COMPANY_CONTROL_STATE_RECONCILE_REQUEST_SCHEMA, + const completedCycle = await reconcileOutcomeRoutingState({ + schema_version: OUTCOME_ROUTING_STATE_RECONCILE_REQUEST_SCHEMA, runtime_root: runtimeRoot, goal_id: "company-goal", expected_revision: (second.state as Record).revision, @@ -144,8 +144,8 @@ test("company CEO v0 closes AI, human, restart, escalation, and convergence scen evidence_ref: "record:customer-interview", }], }); - const converged = planCompanyControlNextCycle({ - schema_version: "company_control_next_cycle_request_v0", + const converged = planOutcomeRoutingNextCycle({ + schema_version: "outcome_routing_next_cycle_request_v0", goal_id: "company-goal", state: completedCycle.state, }); diff --git a/tests/control_plane_ts/company_control_loop.test.ts b/tests/control_plane_ts/outcome_routing_plan.test.ts similarity index 82% rename from tests/control_plane_ts/company_control_loop.test.ts rename to tests/control_plane_ts/outcome_routing_plan.test.ts index 6272ed32dd..1f2e8886bd 100644 --- a/tests/control_plane_ts/company_control_loop.test.ts +++ b/tests/control_plane_ts/outcome_routing_plan.test.ts @@ -2,13 +2,13 @@ import assert from "node:assert/strict"; import test from "node:test"; import { - COMPANY_CONTROL_LOOP_REQUEST_SCHEMA_VERSION, - projectCompanyControlLoop, -} from "../../loopx/control_plane/work_items/company_control_loop.ts"; + OUTCOME_ROUTING_PLAN_REQUEST_SCHEMA_VERSION, + projectOutcomeRoutingPlan, +} from "../../loopx/control_plane/work_items/outcome_routing_plan.ts"; function request(overrides: Record = {}) { return { - schema_version: COMPANY_CONTROL_LOOP_REQUEST_SCHEMA_VERSION, + schema_version: OUTCOME_ROUTING_PLAN_REQUEST_SCHEMA_VERSION, direction: "Improve durable customer value.", cycle: 3, outcomes: [{ @@ -37,11 +37,11 @@ function work(overrides: Record = {}) { }; } -test("company control loop routes AI work into an advancement Todo", () => { - const result = projectCompanyControlLoop(request({ work_items: [work()] })); +test("outcome routing loop routes AI work into an advancement Todo", () => { + const result = projectOutcomeRoutingPlan(request({ work_items: [work()] })); const item = (result.work_items as Record[])[0]; - assert.equal(result.schema_version, "company_control_loop_v0"); + assert.equal(result.schema_version, "outcome_routing_plan_v0"); assert.equal(item.route, "ai_execute"); assert.equal(item.status, "ready"); assert.deepEqual(item.todo_projection, { @@ -55,7 +55,7 @@ test("company control loop routes AI work into an advancement Todo", () => { }); test("routing precedence preserves authority, waiting, and human boundaries", () => { - const result = projectCompanyControlLoop(request({ + const result = projectOutcomeRoutingPlan(request({ work_items: [ work({ work_item_id: "work_rejected", target_key: "target_rejected", prohibited: true }), work({ work_item_id: "work_observe", target_key: "target_observe", wait_for: "provider result" }), @@ -78,7 +78,7 @@ test("routing precedence preserves authority, waiting, and human boundaries", () }); test("material feedback creates an explicit replan signal", () => { - const result = projectCompanyControlLoop(request({ + const result = projectOutcomeRoutingPlan(request({ feedback: [ { feedback_id: "feedback_metric_change", @@ -108,30 +108,30 @@ test("material feedback creates an explicit replan signal", () => { ); }); -test("company control loop rejects dangling outcome references and unsafe ids", () => { +test("outcome routing loop rejects dangling outcome references and unsafe ids", () => { assert.throws( - () => projectCompanyControlLoop(request({ + () => projectOutcomeRoutingPlan(request({ work_items: [work({ outcome_id: "outcome_missing" })], })), /outcome_id must reference an outcome/, ); assert.throws( - () => projectCompanyControlLoop(request({ + () => projectOutcomeRoutingPlan(request({ work_items: [work({ target_key: "../../private" })], })), /target_key must be a public-safe id/, ); }); -test("company control loop rejects ambiguous identifiers and unsafe cycle integers", () => { +test("outcome routing loop rejects ambiguous identifiers and unsafe cycle integers", () => { assert.throws( - () => projectCompanyControlLoop(request({ + () => projectOutcomeRoutingPlan(request({ outcomes: [request().outcomes[0], request().outcomes[0]], })), /outcome_id values must be unique/, ); assert.throws( - () => projectCompanyControlLoop(request({ + () => projectOutcomeRoutingPlan(request({ work_items: [ work({ work_item_id: "work_first" }), work({ work_item_id: "work_second" }), @@ -140,7 +140,7 @@ test("company control loop rejects ambiguous identifiers and unsafe cycle intege /target_key values must be unique/, ); assert.throws( - () => projectCompanyControlLoop(request({ + () => projectOutcomeRoutingPlan(request({ work_items: [ work({ target_key: "target_first" }), work({ target_key: "target_second" }), @@ -158,11 +158,11 @@ test("company control loop rejects ambiguous identifiers and unsafe cycle intege affected_outcome_ids: ["outcome_activation"], }; assert.throws( - () => projectCompanyControlLoop(request({ feedback: [feedback, feedback] })), + () => projectOutcomeRoutingPlan(request({ feedback: [feedback, feedback] })), /feedback_id values must be unique/, ); assert.throws( - () => projectCompanyControlLoop(request({ cycle: Number.MAX_SAFE_INTEGER + 1 })), + () => projectOutcomeRoutingPlan(request({ cycle: Number.MAX_SAFE_INTEGER + 1 })), /non-negative safe integer/, ); }); diff --git a/tests/control_plane_ts/company_control_state.test.ts b/tests/control_plane_ts/outcome_routing_state.test.ts similarity index 66% rename from tests/control_plane_ts/company_control_state.test.ts rename to tests/control_plane_ts/outcome_routing_state.test.ts index eb65671f67..03b815afc0 100644 --- a/tests/control_plane_ts/company_control_state.test.ts +++ b/tests/control_plane_ts/outcome_routing_state.test.ts @@ -5,18 +5,19 @@ import { join } from "node:path"; import test from "node:test"; import { - COMPANY_CONTROL_STATE_RECONCILE_REQUEST_SCHEMA, - COMPANY_CONTROL_STATE_STORE_REQUEST_SCHEMA, - companyControlStatePath, - loadCompanyControlState, - planCompanyControlNextCycle, - reconcileCompanyControlState, - writeCompanyControlState, -} from "../../loopx/control_plane/work_items/company_control_state.ts"; + bindOutcomeRoutingTodos, + OUTCOME_ROUTING_STATE_RECONCILE_REQUEST_SCHEMA, + OUTCOME_ROUTING_STATE_STORE_REQUEST_SCHEMA, + outcomeRoutingStatePath, + loadOutcomeRoutingState, + planOutcomeRoutingNextCycle, + reconcileOutcomeRoutingState, + writeOutcomeRoutingState, +} from "../../loopx/control_plane/work_items/outcome_routing_state.ts"; function state(direction = "Improve durable customer value.") { return { - schema_version: "company_control_loop_request_v0", + schema_version: "outcome_routing_plan_request_v0", direction, cycle: 1, outcomes: [{ @@ -49,18 +50,18 @@ function stateWithWork() { function request(runtimeRoot: string, extra: Record = {}) { return { - schema_version: COMPANY_CONTROL_STATE_STORE_REQUEST_SCHEMA, + schema_version: OUTCOME_ROUTING_STATE_STORE_REQUEST_SCHEMA, runtime_root: runtimeRoot, goal_id: "company-goal", ...extra, }; } -test("company control state writes atomically and reads back exact revision", async (t) => { +test("outcome routing state writes atomically and reads back exact revision", async (t) => { const runtimeRoot = await mkdtemp(join(tmpdir(), "loopx-company-state-")); t.after(() => rm(runtimeRoot, { recursive: true, force: true })); - const first = await writeCompanyControlState(request(runtimeRoot, { + const first = await writeOutcomeRoutingState(request(runtimeRoot, { state: state(), updated_at: "2026-09-17T00:00:00Z", })); @@ -68,11 +69,11 @@ test("company control state writes atomically and reads back exact revision", as const stored = first.state as Record; assert.match(String(stored.revision), /^[a-f0-9]{64}$/); - const loaded = await loadCompanyControlState(request(runtimeRoot)); + const loaded = await loadOutcomeRoutingState(request(runtimeRoot)); assert.deepEqual(loaded.state, first.state); - assert.equal(loaded.path, companyControlStatePath(runtimeRoot, "company-goal")); + assert.equal(loaded.path, outcomeRoutingStatePath(runtimeRoot, "company-goal")); - const replay = await writeCompanyControlState(request(runtimeRoot, { + const replay = await writeOutcomeRoutingState(request(runtimeRoot, { state: state(), updated_at: "2026-09-17T00:01:00Z", })); @@ -80,31 +81,31 @@ test("company control state writes atomically and reads back exact revision", as assert.equal(replay.replayed, true); }); -test("company control state requires revision matching for updates", async (t) => { +test("outcome routing state requires revision matching for updates", async (t) => { const runtimeRoot = await mkdtemp(join(tmpdir(), "loopx-company-state-")); t.after(() => rm(runtimeRoot, { recursive: true, force: true })); - const first = await writeCompanyControlState(request(runtimeRoot, { + const first = await writeOutcomeRoutingState(request(runtimeRoot, { state: state(), updated_at: "2026-09-17T00:00:00Z", })); const stored = first.state as Record; await assert.rejects( - writeCompanyControlState(request(runtimeRoot, { + writeOutcomeRoutingState(request(runtimeRoot, { state: state("Changed direction."), updated_at: "2026-09-17T00:01:00Z", })), /expected_revision is required/, ); await assert.rejects( - writeCompanyControlState(request(runtimeRoot, { + writeOutcomeRoutingState(request(runtimeRoot, { state: state("Changed direction."), expected_revision: "0".repeat(64), updated_at: "2026-09-17T00:01:00Z", })), /revision changed/, ); - const updated = await writeCompanyControlState(request(runtimeRoot, { + const updated = await writeOutcomeRoutingState(request(runtimeRoot, { state: state("Changed direction."), expected_revision: stored.revision, updated_at: "2026-09-17T00:01:00Z", @@ -116,27 +117,76 @@ test("company control state requires revision matching for updates", async (t) = ); }); -test("company control state path is bounded and rejects relative runtime roots", () => { - const left = companyControlStatePath("/runtime", "company goal"); - const right = companyControlStatePath("/runtime", "company-goal"); +test("Todo bindings are revisioned profile state with exact work identity", async (t) => { + const runtimeRoot = await mkdtemp(join(tmpdir(), "loopx-outcome-bind-")); + t.after(async () => await rm(runtimeRoot, { recursive: true, force: true })); + const first = await writeOutcomeRoutingState(request(runtimeRoot, { + state: { + ...stateWithWork(), + direction: "Route human work without widening shared Todo identity.", + }, + updated_at: "2026-09-17T00:00:00Z", + })); + const stored = first.state as Record; + const bound = await bindOutcomeRoutingTodos({ + schema_version: "outcome_routing_state_bind_request_v0", + runtime_root: runtimeRoot, + goal_id: "company-goal", + expected_revision: stored.revision, + updated_at: "2026-09-17T00:01:00Z", + todo_bindings: [{ + work_item_id: "work_activation", + target_key: "activation_delivery", + todo_id: "todo_human_decision", + role: "user", + }], + }); + assert.notEqual((bound.state as Record).revision, stored.revision); + assert.deepEqual((bound.state as Record).todo_bindings, [{ + work_item_id: "work_activation", + target_key: "activation_delivery", + todo_id: "todo_human_decision", + role: "user", + }]); + await assert.rejects( + bindOutcomeRoutingTodos({ + schema_version: "outcome_routing_state_bind_request_v0", + runtime_root: runtimeRoot, + goal_id: "company-goal", + expected_revision: (bound.state as Record).revision, + updated_at: "2026-09-17T00:02:00Z", + todo_bindings: [{ + work_item_id: "work_activation", + target_key: "wrong_target", + todo_id: "todo_human_decision", + role: "user", + }], + }), + /must match a projected work item and target/, + ); +}); + +test("outcome routing state path is bounded and rejects relative runtime roots", () => { + const left = outcomeRoutingStatePath("/runtime", "company goal"); + const right = outcomeRoutingStatePath("/runtime", "company-goal"); assert.notEqual(left, right); - assert.match(left, /company-control-loop\/state\.json$/); + assert.match(left, /outcome-routing\/state\.json$/); assert.throws( - () => companyControlStatePath("relative", "company-goal"), + () => outcomeRoutingStatePath("relative", "company-goal"), /runtime_root must be absolute/, ); }); -test("company control reconciliation previews and persists evidence-gated Todo status", async (t) => { +test("outcome routing reconciliation previews and persists evidence-gated Todo status", async (t) => { const runtimeRoot = await mkdtemp(join(tmpdir(), "loopx-company-state-")); t.after(() => rm(runtimeRoot, { recursive: true, force: true })); - const first = await writeCompanyControlState(request(runtimeRoot, { + const first = await writeOutcomeRoutingState(request(runtimeRoot, { state: stateWithWork(), updated_at: "2026-09-17T00:00:00Z", })); const original = first.state as Record; const reconcileRequest = { - schema_version: COMPANY_CONTROL_STATE_RECONCILE_REQUEST_SCHEMA, + schema_version: OUTCOME_ROUTING_STATE_RECONCILE_REQUEST_SCHEMA, runtime_root: runtimeRoot, goal_id: "company-goal", expected_revision: original.revision, @@ -149,16 +199,16 @@ test("company control reconciliation previews and persists evidence-gated Todo s evidence_ref: "artifact:activation-report", }], }; - const preview = await reconcileCompanyControlState(reconcileRequest); + const preview = await reconcileOutcomeRoutingState(reconcileRequest); assert.equal(preview.dry_run, true); assert.equal(preview.written, false); assert.equal( ((preview.state as Record).reconciliation.observations[0]).next_status, "done", ); - assert.deepEqual((await loadCompanyControlState(request(runtimeRoot))).state, first.state); + assert.deepEqual((await loadOutcomeRoutingState(request(runtimeRoot))).state, first.state); - const written = await reconcileCompanyControlState({ + const written = await reconcileOutcomeRoutingState({ ...reconcileRequest, execute: true, }); @@ -168,7 +218,7 @@ test("company control reconciliation previews and persists evidence-gated Todo s assert.equal(reconciled.reconciliation.replan_required, false); assert.equal(reconciled.reconciliation.observations[0].evidence_ref, "artifact:activation-report"); - const replay = await reconcileCompanyControlState({ + const replay = await reconcileOutcomeRoutingState({ ...reconcileRequest, expected_revision: reconciled.revision, updated_at: "2026-09-17T00:02:00Z", @@ -178,10 +228,10 @@ test("company control reconciliation previews and persists evidence-gated Todo s assert.equal(replay.replayed, true); }); -test("company control reconciliation requests replanning for blocked or unproven completion", async (t) => { +test("outcome routing reconciliation requests replanning for blocked or unproven completion", async (t) => { const runtimeRoot = await mkdtemp(join(tmpdir(), "loopx-company-state-")); t.after(() => rm(runtimeRoot, { recursive: true, force: true })); - const first = await writeCompanyControlState(request(runtimeRoot, { + const first = await writeOutcomeRoutingState(request(runtimeRoot, { state: stateWithWork(), updated_at: "2026-09-17T00:00:00Z", })); @@ -191,8 +241,8 @@ test("company control reconciliation requests replanning for blocked or unproven ["blocked", "replanning"], ["done", "awaiting_evidence"], ] as const) { - const result = await reconcileCompanyControlState({ - schema_version: COMPANY_CONTROL_STATE_RECONCILE_REQUEST_SCHEMA, + const result = await reconcileOutcomeRoutingState({ + schema_version: OUTCOME_ROUTING_STATE_RECONCILE_REQUEST_SCHEMA, runtime_root: runtimeRoot, goal_id: "company-goal", expected_revision: revision, @@ -210,16 +260,16 @@ test("company control reconciliation requests replanning for blocked or unproven } }); -test("company control reconciliation rejects stale revisions and unknown targets", async (t) => { +test("outcome routing reconciliation rejects stale revisions and unknown targets", async (t) => { const runtimeRoot = await mkdtemp(join(tmpdir(), "loopx-company-state-")); t.after(() => rm(runtimeRoot, { recursive: true, force: true })); - const first = await writeCompanyControlState(request(runtimeRoot, { + const first = await writeOutcomeRoutingState(request(runtimeRoot, { state: stateWithWork(), updated_at: "2026-09-17T00:00:00Z", })); const revision = (first.state as Record).revision; const base = { - schema_version: COMPANY_CONTROL_STATE_RECONCILE_REQUEST_SCHEMA, + schema_version: OUTCOME_ROUTING_STATE_RECONCILE_REQUEST_SCHEMA, runtime_root: runtimeRoot, goal_id: "company-goal", expected_revision: revision, @@ -227,7 +277,7 @@ test("company control reconciliation rejects stale revisions and unknown targets execute: false, }; await assert.rejects( - reconcileCompanyControlState({ + reconcileOutcomeRoutingState({ ...base, expected_revision: "0".repeat(64), observations: [], @@ -235,7 +285,7 @@ test("company control reconciliation rejects stale revisions and unknown targets /revision changed/, ); await assert.rejects( - reconcileCompanyControlState({ + reconcileOutcomeRoutingState({ ...base, observations: [{ target_key: "unknown_target", @@ -260,12 +310,12 @@ test("next company cycle converts evidence and blockers into feedback and replan ai_capable: true, target_key: "retention_risk", }); - const first = await writeCompanyControlState(request(runtimeRoot, { + const first = await writeOutcomeRoutingState(request(runtimeRoot, { state: input, updated_at: "2026-09-17T00:00:00Z", })); - const reconciled = await reconcileCompanyControlState({ - schema_version: COMPANY_CONTROL_STATE_RECONCILE_REQUEST_SCHEMA, + const reconciled = await reconcileOutcomeRoutingState({ + schema_version: OUTCOME_ROUTING_STATE_RECONCILE_REQUEST_SCHEMA, runtime_root: runtimeRoot, goal_id: "company-goal", expected_revision: (first.state as Record).revision, @@ -285,8 +335,8 @@ test("next company cycle converts evidence and blockers into feedback and replan }, ], }); - const next = planCompanyControlNextCycle({ - schema_version: "company_control_next_cycle_request_v0", + const next = planOutcomeRoutingNextCycle({ + schema_version: "outcome_routing_next_cycle_request_v0", goal_id: "company-goal", state: reconciled.state, }); @@ -309,12 +359,12 @@ test("next company cycle converts evidence and blockers into feedback and replan test("next company cycle reports goal convergence after all work has evidence", async (t) => { const runtimeRoot = await mkdtemp(join(tmpdir(), "loopx-company-state-")); t.after(() => rm(runtimeRoot, { recursive: true, force: true })); - const first = await writeCompanyControlState(request(runtimeRoot, { + const first = await writeOutcomeRoutingState(request(runtimeRoot, { state: stateWithWork(), updated_at: "2026-09-17T00:00:00Z", })); - const reconciled = await reconcileCompanyControlState({ - schema_version: COMPANY_CONTROL_STATE_RECONCILE_REQUEST_SCHEMA, + const reconciled = await reconcileOutcomeRoutingState({ + schema_version: OUTCOME_ROUTING_STATE_RECONCILE_REQUEST_SCHEMA, runtime_root: runtimeRoot, goal_id: "company-goal", expected_revision: (first.state as Record).revision, @@ -327,8 +377,8 @@ test("next company cycle reports goal convergence after all work has evidence", evidence_ref: "artifact:activation-report", }], }); - const next = planCompanyControlNextCycle({ - schema_version: "company_control_next_cycle_request_v0", + const next = planOutcomeRoutingNextCycle({ + schema_version: "outcome_routing_next_cycle_request_v0", goal_id: "company-goal", state: reconciled.state, }); @@ -342,12 +392,12 @@ test("next company cycle keeps derived feedback ids valid for maximum-length wor t.after(() => rm(runtimeRoot, { recursive: true, force: true })); const input = stateWithWork(); input.work_items[0].work_item_id = `w${"a".repeat(127)}`; - const first = await writeCompanyControlState(request(runtimeRoot, { + const first = await writeOutcomeRoutingState(request(runtimeRoot, { state: input, updated_at: "2026-09-17T00:00:00Z", })); - const reconciled = await reconcileCompanyControlState({ - schema_version: COMPANY_CONTROL_STATE_RECONCILE_REQUEST_SCHEMA, + const reconciled = await reconcileOutcomeRoutingState({ + schema_version: OUTCOME_ROUTING_STATE_RECONCILE_REQUEST_SCHEMA, runtime_root: runtimeRoot, goal_id: "company-goal", expected_revision: (first.state as Record).revision, @@ -361,8 +411,8 @@ test("next company cycle keeps derived feedback ids valid for maximum-length wor }], }); - const next = planCompanyControlNextCycle({ - schema_version: "company_control_next_cycle_request_v0", + const next = planOutcomeRoutingNextCycle({ + schema_version: "outcome_routing_next_cycle_request_v0", goal_id: "company-goal", state: reconciled.state, }); @@ -370,8 +420,8 @@ test("next company cycle keeps derived feedback ids valid for maximum-length wor assert.match(feedback.feedback_id, /^todo_feedback_[a-f0-9]{24}$/); assert.ok(feedback.feedback_id.length <= 128); assert.deepEqual( - planCompanyControlNextCycle({ - schema_version: "company_control_next_cycle_request_v0", + planOutcomeRoutingNextCycle({ + schema_version: "outcome_routing_next_cycle_request_v0", goal_id: "company-goal", state: reconciled.state, }).state,