Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 不受影响。
Expand Down
19 changes: 11 additions & 8 deletions src/octop/api/routers/org_module.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
5 changes: 4 additions & 1 deletion src/octop/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 4 additions & 1 deletion src/octop/i18n/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "边车已可达",
Expand Down
9 changes: 8 additions & 1 deletion src/octop/modules/org_os/lifecycle/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
25 changes: 25 additions & 0 deletions src/octop/modules/org_os/lifecycle/transitions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down
58 changes: 51 additions & 7 deletions src/octop/modules/org_os/loop/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.",
],
)
Expand Down
15 changes: 15 additions & 0 deletions src/octop/modules/org_os/notes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",),
),
)


Expand Down
14 changes: 13 additions & 1 deletion tests/unit/test_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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:
Expand Down
92 changes: 91 additions & 1 deletion tests/unit/test_org_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions tests/unit/test_org_notes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)
Expand All @@ -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:
Expand Down
Loading