From eb2c7a8e01c7ad440f63f58d34d1aa8800072444 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 17 Sep 2026 12:04:06 +0000 Subject: [PATCH 1/2] Fix org self-growth loop 500 when employees are already active. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The loop's _promote always tried draft→market→…→active. Colleagues already at active (or other illegal pairs) raised ValueError and the API surfaced INTERNAL_ERROR. Skip already-reached and illegal edges, record them in the proof notes, and map leftover ValueError to 400. Co-authored-by: XYAI Labs --- src/octop/api/routers/org_module.py | 19 ++-- src/octop/i18n/en.json | 5 +- src/octop/i18n/zh.json | 5 +- .../modules/org_os/lifecycle/__init__.py | 9 +- .../modules/org_os/lifecycle/transitions.py | 25 +++++ src/octop/modules/org_os/loop/run.py | 58 ++++++++++-- src/octop/modules/org_os/notes.py | 15 +++ tests/unit/test_lifecycle.py | 14 ++- tests/unit/test_org_loop.py | 92 ++++++++++++++++++- tests/unit/test_org_notes.py | 4 + 10 files changed, 226 insertions(+), 20 deletions(-) diff --git a/src/octop/api/routers/org_module.py b/src/octop/api/routers/org_module.py index c09786e5..ebe3b280 100644 --- a/src/octop/api/routers/org_module.py +++ b/src/octop/api/routers/org_module.py @@ -574,14 +574,17 @@ async def run_loop( from octop.modules.org_os.loop.run import run_growth_loop service = _service(server) - proof = run_growth_loop( - service.home, - tenant_id=body.tenant_id or service.tenant_id() or "1", - blueprint_path=Path(body.blueprint_path) if body.blueprint_path else None, - policies_path=Path(body.policies_path) if body.policies_path else None, - sidecar_url=body.base_url or service.sidecar_url(), - config_path=service.config_path, - ) + try: + proof = run_growth_loop( + service.home, + tenant_id=body.tenant_id or service.tenant_id() or "1", + blueprint_path=Path(body.blueprint_path) if body.blueprint_path else None, + policies_path=Path(body.policies_path) if body.policies_path else None, + sidecar_url=body.base_url or service.sidecar_url(), + config_path=service.config_path, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc payload = proof.to_dict() return localize_mapping(payload, resolve_request_locale(request), "notes") diff --git a/src/octop/i18n/en.json b/src/octop/i18n/en.json index f0d33994..d8307ea1 100644 --- a/src/octop/i18n/en.json +++ b/src/octop/i18n/en.json @@ -747,7 +747,10 @@ "corpus_pointer": "attached cloud corpus pointer → {path}", "corpus_distilled": "distilled {count} corpus files → {path}", "corpus_empty": "no knowledge mount; producing a corpus-ready expert workspace", - "permissions_unreachable": "sidecar permissions unreachable; pass --policies or retry when signed in" + "permissions_unreachable": "sidecar permissions unreachable; pass --policies or retry when signed in", + "loop_skip_already": "skipped promotion for {slug}: already {lifecycle}", + "loop_skip_illegal": "skipped illegal transition for {slug}: cannot move {current} → {target}", + "loop_skip_unknown": "skipped promotion for {slug}: unknown colleague" }, "sidecar": { "already": "sidecar already reachable", diff --git a/src/octop/i18n/zh.json b/src/octop/i18n/zh.json index 5f075ec5..4023f223 100644 --- a/src/octop/i18n/zh.json +++ b/src/octop/i18n/zh.json @@ -747,7 +747,10 @@ "corpus_pointer": "已挂接云知识库指针 → {path}", "corpus_distilled": "已蒸馏 {count} 个语料文件 → {path}", "corpus_empty": "没有知识库挂载;正在生成可供语料使用的专家工作区", - "permissions_unreachable": "边车权限接口不可达;请传入策略或登录后再试" + "permissions_unreachable": "边车权限接口不可达;请传入策略或登录后再试", + "loop_skip_already": "已跳过 {slug} 的晋升:当前已是 {lifecycle}", + "loop_skip_illegal": "已跳过 {slug} 的非法迁移:无法从 {current} 转到 {target}", + "loop_skip_unknown": "已跳过 {slug} 的晋升:未知同事" }, "sidecar": { "already": "边车已可达", diff --git a/src/octop/modules/org_os/lifecycle/__init__.py b/src/octop/modules/org_os/lifecycle/__init__.py index 4e2bbeb2..97a3ee5b 100644 --- a/src/octop/modules/org_os/lifecycle/__init__.py +++ b/src/octop/modules/org_os/lifecycle/__init__.py @@ -5,12 +5,19 @@ ColleagueRecord, LifecycleStore, ) -from octop.modules.org_os.lifecycle.transitions import register_compiled, transition +from octop.modules.org_os.lifecycle.transitions import ( + already_at_or_beyond, + can_transition, + register_compiled, + transition, +) __all__ = [ "LIFECYCLE_STATES", "ColleagueRecord", "LifecycleStore", + "already_at_or_beyond", + "can_transition", "register_compiled", "transition", ] diff --git a/src/octop/modules/org_os/lifecycle/transitions.py b/src/octop/modules/org_os/lifecycle/transitions.py index a70a99ef..88aeff30 100644 --- a/src/octop/modules/org_os/lifecycle/transitions.py +++ b/src/octop/modules/org_os/lifecycle/transitions.py @@ -27,6 +27,31 @@ "offboard": frozenset(), } +# Happy-path ladder used by the self-growth loop. ``offboard`` is terminal. +PROMOTION_ORDER: tuple[str, ...] = ("draft", "market", "recruit", "shadow", "active") + + +def can_transition(current: str, target: str) -> bool: + """Return True when *target* is a no-op or an allowed FSM edge.""" + if target not in LIFECYCLE_STATES: + return False + if current == target: + return True + return target in ALLOWED.get(current, frozenset()) + + +def already_at_or_beyond(current: str, target: str) -> bool: + """True when *current* is at or past *target* on the happy-path ladder. + + ``offboard`` is terminal and treated as beyond every promotion target. + """ + if current == "offboard": + return True + try: + return PROMOTION_ORDER.index(current) >= PROMOTION_ORDER.index(target) + except ValueError: + return False + def _apply_flags(record: ColleagueRecord) -> None: record.read_only = record.lifecycle in {"shadow", "offboard"} diff --git a/src/octop/modules/org_os/loop/run.py b/src/octop/modules/org_os/loop/run.py index 5a8659a6..42643a8f 100644 --- a/src/octop/modules/org_os/loop/run.py +++ b/src/octop/modules/org_os/loop/run.py @@ -17,7 +17,11 @@ gate_tool_call, ) from octop.modules.org_os.lifecycle.store import LifecycleStore -from octop.modules.org_os.lifecycle.transitions import transition +from octop.modules.org_os.lifecycle.transitions import ( + already_at_or_beyond, + can_transition, + transition, +) from octop.modules.org_os.loop.fixtures import ( blueprint_fixture, extra_blueprint_fixtures, @@ -76,13 +80,49 @@ def _wait_control_plane(url: str, *, home: Path, attempts: int = 12) -> bool: return False -def _promote(store: LifecycleStore, slug: str) -> dict[str, str]: +def _promote(store: LifecycleStore, slug: str) -> tuple[dict[str, str], list[str]]: + """Walk *slug* toward ``active`` along allowed edges only. + + Already-deployed colleagues (e.g. ``active``) and illegal pairs such as + ``active → market`` are recorded as skips instead of raising. + """ states = ("market", "recruit", "shadow", "active") - seen: dict[str, str] = {"draft": "draft"} + record = store.get(slug) + if record is None: + return {}, [f"skipped promotion for {slug}: unknown colleague"] + + initial = record.lifecycle + seen: dict[str, str] = {} + notes: list[str] = [] + skipped_states: list[str] = [] + if initial == "draft": + seen["draft"] = "draft" + for state in states: - record = transition(store, slug, state, reason="org loop run") + current = record.lifecycle + if current == state: + seen[state] = current + continue + if already_at_or_beyond(current, state): + seen[state] = current + skipped_states.append(state) + continue + if not can_transition(current, state): + seen[state] = current + notes.append(f"skipped illegal transition for {slug}: cannot move {current} → {state}") + continue + try: + record = transition(store, slug, state, reason="org loop run") + except ValueError as exc: + notes.append(f"skipped illegal transition for {slug}: {exc}") + seen[state] = record.lifecycle + continue seen[state] = record.lifecycle - return seen + + seen[record.lifecycle] = record.lifecycle + if skipped_states: + notes.append(f"skipped promotion for {slug}: already {initial}") + return seen, notes def run_growth_loop( @@ -150,8 +190,11 @@ def run_growth_loop( store = LifecycleStore(home, tid) lifecycle: dict[str, str] = {} spawned: list[SpawnedAgent] = [] - for slug in imported.employees: - lifecycle.update(_promote(store, slug)) + promotion_notes: list[str] = [] + for slug in dict.fromkeys(imported.employees): + advanced, skipped = _promote(store, slug) + lifecycle.update(advanced) + promotion_notes.extend(skipped) record = store.get(slug) if record is None: continue @@ -218,6 +261,7 @@ def run_growth_loop( *imported.notes, *applied.notes, *feedback.notes, + *promotion_notes, "Loop produced colleagues, published an asset pack, applied department employees, and imported back.", ], ) diff --git a/src/octop/modules/org_os/notes.py b/src/octop/modules/org_os/notes.py index a955b491..f2924b8d 100644 --- a/src/octop/modules/org_os/notes.py +++ b/src/octop/modules/org_os/notes.py @@ -202,6 +202,21 @@ "org.notes.permissions_unreachable", (), ), + ( + re.compile(r"^skipped promotion for (.+): already (.+)$"), + "org.notes.loop_skip_already", + ("slug", "lifecycle"), + ), + ( + re.compile(r"^skipped illegal transition for (.+): cannot move (.+) → (.+)$"), + "org.notes.loop_skip_illegal", + ("slug", "current", "target"), + ), + ( + re.compile(r"^skipped promotion for (.+): unknown colleague$"), + "org.notes.loop_skip_unknown", + ("slug",), + ), ) diff --git a/tests/unit/test_lifecycle.py b/tests/unit/test_lifecycle.py index a06fac30..e2235021 100644 --- a/tests/unit/test_lifecycle.py +++ b/tests/unit/test_lifecycle.py @@ -8,7 +8,12 @@ from octop.modules.org_os.compiler.compile import compile_blueprint from octop.modules.org_os.lifecycle.store import LifecycleStore -from octop.modules.org_os.lifecycle.transitions import register_compiled, transition +from octop.modules.org_os.lifecycle.transitions import ( + already_at_or_beyond, + can_transition, + register_compiled, + transition, +) _FIXTURE = Path(__file__).resolve().parents[1] / "fixtures" / "agent-blueprint.v1.json" @@ -54,6 +59,13 @@ def test_illegal_skip_rejected(tmp_path: Path) -> None: store, slug, _workspace = _spawn(tmp_path) with pytest.raises(ValueError, match="cannot move"): transition(store, slug, "active") + assert can_transition("draft", "active") is False + assert can_transition("active", "market") is False + assert can_transition("active", "active") is True + assert can_transition("shadow", "active") is True + assert already_at_or_beyond("active", "market") is True + assert already_at_or_beyond("draft", "market") is False + assert already_at_or_beyond("offboard", "active") is True def test_offboard_revokes_and_archives_memory(tmp_path: Path) -> None: diff --git a/tests/unit/test_org_loop.py b/tests/unit/test_org_loop.py index b75e59d2..0623e680 100644 --- a/tests/unit/test_org_loop.py +++ b/tests/unit/test_org_loop.py @@ -4,14 +4,41 @@ import json from pathlib import Path +from typing import Any + +import pytest from octop.modules.org_os.catalog import catalog_keys -from octop.modules.org_os.loop.run import run_growth_loop +from octop.modules.org_os.lifecycle.store import LifecycleStore +from octop.modules.org_os.lifecycle.transitions import ( + can_transition, + register_compiled, + transition, +) +from octop.modules.org_os.loop.run import _promote, run_growth_loop from octop.modules.org_os.runtime.routing import ColleagueRouter, resolve_routed_agent from octop.modules.org_os.runtime.spawn import list_spawned_agents from tests.support.openxyos_harness import ControlPlaneState, start_control_plane +def _register(tmp_path: Path, slug: str, name: str) -> LifecycleStore: + workspace = tmp_path / "tenants" / "loop" / "employees" / slug + workspace.mkdir(parents=True) + store = LifecycleStore(tmp_path, "loop") + register_compiled(store, slug=slug, name=name, workspace=workspace, lifecycle="draft") + return store + + +def _walk(store: LifecycleStore, slug: str, *states: str) -> None: + for state in states: + record = store.get(slug) + if record is None or record.lifecycle == state: + continue + if not can_transition(record.lifecycle, state): + continue + transition(store, slug, state) + + def test_growth_loop_with_fixtures(tmp_path: Path) -> None: proof = run_growth_loop(tmp_path, tenant_id="loop") assert proof.ok is True @@ -41,6 +68,69 @@ def test_growth_loop_with_fixtures(tmp_path: Path) -> None: assert saved["ok"] is True +def test_promote_skips_already_active_without_raising(tmp_path: Path) -> None: + store = _register(tmp_path, "ops-coordinator", "Ops Coordinator") + _walk(store, "ops-coordinator", "market", "recruit", "shadow", "active") + + seen, notes = _promote(store, "ops-coordinator") + + record = store.get("ops-coordinator") + assert record is not None + assert record.lifecycle == "active" + assert seen["active"] == "active" + assert any("already active" in note for note in notes) + + +def test_promote_skips_illegal_offboard_without_raising(tmp_path: Path) -> None: + store = _register(tmp_path, "policy-analyst", "Policy Analyst") + _walk(store, "policy-analyst", "offboard") + + seen, notes = _promote(store, "policy-analyst") + + record = store.get("policy-analyst") + assert record is not None + assert record.lifecycle == "offboard" + assert seen["offboard"] == "offboard" + assert any("already offboard" in note for note in notes) + + +def test_promote_from_shadow_reaches_active(tmp_path: Path) -> None: + store = _register(tmp_path, "policy-analyst", "Policy Analyst") + _walk(store, "policy-analyst", "market", "recruit", "shadow") + + seen, notes = _promote(store, "policy-analyst") + + record = store.get("policy-analyst") + assert record is not None + assert record.lifecycle == "active" + assert seen["active"] == "active" + assert any("already shadow" in note for note in notes) + + +def test_growth_loop_succeeds_when_employees_already_active( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from octop.modules.org_os.loop import run as loop_mod + + real_import = loop_mod.import_openxyos_assets + + def _import_then_activate(*args: Any, **kwargs: Any) -> Any: + result = real_import(*args, **kwargs) + home = Path(args[0] if args else kwargs["home"]) + tid = str(kwargs.get("tenant_id") or "loop") + store = LifecycleStore(home, tid) + for slug in result.employees: + _walk(store, slug, "market", "recruit", "shadow", "active") + return result + + monkeypatch.setattr(loop_mod, "import_openxyos_assets", _import_then_activate) + proof = run_growth_loop(tmp_path, tenant_id="loop") + assert proof.ok is True + assert proof.lifecycle.get("active") == "active" + assert any("already active" in note for note in proof.notes) + assert (tmp_path / "asset-packs" / "loop-proof.json").is_file() + + def test_growth_loop_applies_to_live_control_plane(tmp_path: Path) -> None: state = ControlPlaneState() url, server = start_control_plane(state) diff --git a/tests/unit/test_org_notes.py b/tests/unit/test_org_notes.py index c543eda2..d4d92b35 100644 --- a/tests/unit/test_org_notes.py +++ b/tests/unit/test_org_notes.py @@ -10,6 +10,8 @@ def test_localize_loop_notes_zh() -> None: "Local mirror is the durable record. HTTP apply is best-effort.", "control plane accepted the FreeOS ingest", "Loop produced colleagues, published an asset pack, applied department employees, and imported back.", + "skipped promotion for ops-coordinator: already active", + "skipped illegal transition for policy-analyst: cannot move active → market", ], "zh", ) @@ -19,6 +21,8 @@ def test_localize_loop_notes_zh() -> None: assert "本地镜像" in notes[3] assert "控制面已接受" in notes[4] assert "循环已生成同事" in notes[5] + assert "ops-coordinator" in notes[6] and "active" in notes[6] + assert "policy-analyst" in notes[7] and "market" in notes[7] def test_localize_unknown_note_passthrough() -> None: From fca66570c7ac80974a90e84efae06687ba143f6b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 17 Sep 2026 12:05:14 +0000 Subject: [PATCH 2/2] Document the org loop active-employee 500 fix in the changelog. Co-authored-by: XYAI Labs --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e84b6fec..75598fa8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### 修复 +- 组织页「运行自增长循环」在员工已是 `active`(或生命周期不允许再跳到 `market`)时不再 500。循环只沿合法边晋升,已到达或非法的步骤记为 skip,接口返回成功的循环证明而不是 `INTERNAL_ERROR`。 - Windows 覆盖安装时,openXYOS 预配不再因为旧 Node 锁住 `%LOCALAPPDATA%\FreeOS\openxyos` 里的文件、`tar.exe` 返回非 0 就直接退出码 3。安装前只停止 FreeOS 自己的 openXYOS(与启动脚本相同的 `Stop-OpenXYOSNode`:`start.pid` / live 与 `$INSTDIR\openxyos` 路径),先解到 LocalAppData 临时目录再合入工作目录,并把 tar 的 stdout/stderr 写入 `provision.log`。tar 非 0 但布局可修复时记警告并继续;只有 extract+heal 之后布局仍不完整才退出 3。幂等短路要求完整布局、`.install-ready`,以及 3780 上是我们的 FreeOS openXYOS,而不是任意 livez。 - 组织页嵌入本机 openXYOS 不再因为边车「已连接」却空白:`CORS_ORIGIN` 即使漏了 `http://127.0.0.1:3780` / `localhost` / `[::1]`,服务端也会合并监听自源,模块脚本和登录不再 500。FreeOS 启动会先停掉 live 目录里旧的 Node,再用当前 `start-sidecar.ps1` 拉起;嵌套的 `openxyos\dist` 会在启动前提升到 live 根。若 livez 正常但自源请求失败,状态栏和预览区会明确提示,而不是一片白。 - Windows 安装成功后删除 `$INSTDIR\openxyos-runtime` 目录和 `openxyos-runtime.zip`(以及 `%LOCALAPPDATA%\FreeOS` 下同名暂存)。预配失败时保留这些文件以便排查。最终工作目录 `$INSTDIR\openxyos` 与 `%LOCALAPPDATA%\FreeOS\openxyos` 不受影响。