From 4f28d48b6a5bcfe0a5d56d32f74cb5855a6f8125 Mon Sep 17 00:00:00 2001 From: "lusendong.6789" Date: Fri, 13 Nov 2026 14:52:42 +0800 Subject: [PATCH] fix(traex): reuse runtime thread and retain native Goal continuity Signed-off-by: lusendong.6789 Co-authored-by: TRAE CLI --- .../rfcs/monorepo-distribution-split-v0.md | 27 ++++++ .../monorepo-distribution-split-v0.zh-CN.md | 22 +++++ loopx/cli_commands/_host_thread.py | 1 + loopx/host_loop_activation.py | 11 ++- .../references/start-and-connect.md | 4 +- .../test_start_goal_compact_projection.py | 82 +++++++++++++++++-- tests/test_core_distribution.py | 15 ++-- tests/test_host_loop_activation.py | 1 + 8 files changed, 148 insertions(+), 15 deletions(-) diff --git a/docs/architecture/rfcs/monorepo-distribution-split-v0.md b/docs/architecture/rfcs/monorepo-distribution-split-v0.md index a74df0d2e7..c2691e0234 100644 --- a/docs/architecture/rfcs/monorepo-distribution-split-v0.md +++ b/docs/architecture/rfcs/monorepo-distribution-split-v0.md @@ -3737,3 +3737,30 @@ model-token, service-cost and human-attention accounting. The Host remains synthetic. Full V1–V5/V10, real model adoption/recovery, progressive product control, R1/M3, independent Standard packages and original-goal regression remain open. Preserve all nine original requirements and earlier frozen failures. + +### 2026-11-13 — TraeX continuation consumes its runtime thread + +The public `start-goal` entry now reads TraeX's `TRAECLI_THREAD_ID` through the +existing host-thread adapter. A verified `traex-cli` binding reuses its Agent +without another `--thread-id` or `--agent-id`; an explicit thread still takes +precedence. Missing, new, foreign-host and conflicting contexts retain the +existing identity fences. Trae App keeps its separate binding scope. + +Matched isolated File/SQLite CLI checks cover source, the standalone core built +from `09886ba7a`, and the candidate core: 36 calls retain exact objective text, +registry/state bytes and canonical provider revision across ordinary entry, +explicit control/override, unknown context and replay. The base still loses the +ambient thread; both repaired entrypoints reuse the bound Agent. Source +regression passes 215 cases, including foreign-host and conflict refusal; +the installed core/recovery/context suite passes 23 on Python 3.11.15, with +source on Python 3.13.5 and Node 26.0.0. All 1,887 packaged runtime files match +the reviewed source bytes. Initial fixture setup failures remain failed attempts. + +TraeX activation guidance reads native Goal tools or current host context first, +preserves an active original objective on continuation, and retains user stops. +Only an authorized fresh start with no existing native Goal may create one; +the visible `/goal` fallback remains for unavailable tools/context. This is an +entry and instruction repair, not proof that a model adopted the guidance. +No new lifecycle owner, model execution, preset or installed-host release is +introduced. Full R1/V1–V5/V10, progressive control, complete cost evidence and +the original-goal regression remain open; all original failures are retained. diff --git a/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md b/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md index 9e6bb10e19..43c987a293 100644 --- a/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md +++ b/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md @@ -2915,3 +2915,25 @@ Node module/loopback/exit 事件,但不能提供 OS 级完整闭包、完整 I token、service cost 或人工注意力核算。Host 仍为 synthetic。完整 V1–V5/V10、 真实模型采用/恢复、渐进产品控制、R1/M3、独立 Standard packages 与原目标回归 仍开放,保留原九项要求和此前冻结失败。 + +### 2026-11-13 — TraeX 续接读取运行时线程 + +公开 `start-goal` 入口现在通过既有 host-thread adapter 读取 TraeX 的 +`TRAECLI_THREAD_ID`。已验证的 `traex-cli` 绑定自动复用原 Agent,无需再次 +传入 `--thread-id` 或 `--agent-id`;显式线程仍优先。缺失、新线程、跨 Host 与 +冲突上下文保留既有身份边界,Trae App 的绑定作用域仍独立。 + +隔离真实 File/SQLite CLI 对照覆盖源码、从 `09886ba7a` 构建的独立 core 与 +候选 core:36 次调用在普通入口、显式对照/覆盖、未知上下文及重放中保留 +目标原文、registry/state 字节与 canonical provider revision。基线仍丢失 +ambient thread,两个修复后入口均复用已绑定 Agent。源码回归通过 215 项, +包含跨 Host 与冲突拒绝;已安装 core/恢复/context suite 在 Python 3.11.15 +通过 23 项,源码使用 Python 3.13.5,Node 为 26.0.0。1,887 个打包 runtime +文件均与评审源码字节一致。最初夹具设置失败继续保留为失败尝试。 + +TraeX 激活指引先读取原生 Goal 工具或当前 Host 上下文,在续接时保留已有 +active Goal 的原始目标和用户停止状态。仅当获授权的新启动确认不存在原生 +Goal 时才可创建;工具/上下文不可用时仍保留可见 `/goal` 手工恢复路径。 +这取得入口与指令修复证据,不能证明模型采用了指引。未新增生命周期 owner、 +模型执行、preset 或已安装 Host 发布。完整 R1/V1–V5/V10、渐进控制、完整成本 +证据与原目标回归仍开放,保留全部原始失败。 diff --git a/loopx/cli_commands/_host_thread.py b/loopx/cli_commands/_host_thread.py index dde98cef63..e17c59cc9e 100644 --- a/loopx/cli_commands/_host_thread.py +++ b/loopx/cli_commands/_host_thread.py @@ -16,6 +16,7 @@ "codex-ide-plugin": "CODEX_THREAD_ID", "codex-cli-tui": "CODEX_THREAD_ID", "trae_app": "TRAECLI_THREAD_ID", + "traex-cli": "TRAECLI_THREAD_ID", KIRO_CLI_AGENT_TYPE: KIRO_CLI_SESSION_ID_ENV, } diff --git a/loopx/host_loop_activation.py b/loopx/host_loop_activation.py index c12a000ad5..20558dcc73 100644 --- a/loopx/host_loop_activation.py +++ b/loopx/host_loop_activation.py @@ -1116,23 +1116,26 @@ def _traex_activation(commands: dict[str, str]) -> dict[str, Any]: "host_mutation": { "owner": "TraeX CLI TUI", "host_command": "/goal ", + "preferred_tool": "create_goal", "cli_can_mutate_directly": False, "requires_host_feature_flag": "[features] goals = true in ~/.trae/traecli.toml", "missing_host_tool_gate": ( - "TraeX /goal is unavailable; if goal mode is disabled, show the exact " + "Native Goal tools and active Goal context are unavailable; show the exact " "`/goal ` text for the user to paste after enabling " - "`[features] goals = true`. Do not claim another host-loop surface " + "`[features] goals = true` if goal mode is disabled. Do not claim another host-loop surface " "without a verified LoopX adapter." ), }, "activation_steps": [ "Run the visible-goal prompt JSON command after project state and todos are written.", "Read task_body from the JSON payload.", - "Set the visible TraeX goal to `/goal `; enable `[features] goals = true` first if goal mode is off.", + "Read the current native Goal with get_goal when available, otherwise use the current host-provided Goal context. Tool availability is an observation, not permission to change a Goal.", + "For continuation inside the verified same-thread LoopX Goal/Agent binding, retain the active native Goal and its original objective. Do not call create_goal or replace that objective with task_body. Preserve paused, blocked and completed states; never resume them without user direction.", + "For an authorized fresh start, when the host confirms no native Goal exists, call create_goal with objective=task_body if available, then verify the Goal with get_goal or current host context. If native tools and active Goal context are unavailable, use the missing_host_tool_gate manual /goal path; enable `[features] goals = true` only if goal mode is off.", "Keep delivery in the visible TUI turn; gate each continuation through LoopX quota should-run and do not switch to hidden headless execution.", ], "success_criteria": [ - "The visible TraeX TUI has `/goal ` active for this goal.", + "The visible TraeX TUI has a verified active native Goal for this work: retain its original objective on continuation, or verify task_body after fresh activation. A preview is not activation or model adoption.", "Future goal turns enter through LoopX quota/status/state before delivery work.", ], } diff --git a/skills/loopx-project/references/start-and-connect.md b/skills/loopx-project/references/start-and-connect.md index 32b6d5b45b..957255f89a 100644 --- a/skills/loopx-project/references/start-and-connect.md +++ b/skills/loopx-project/references/start-and-connect.md @@ -42,7 +42,9 @@ explicit route switch. Include `--goal-id ` when known. Codex App automatically reads the stable ambient `CODEX_THREAD_ID`, while Trae App reads -`TRAECLI_THREAD_ID`; other hosts that expose a stable opaque thread id should +`TRAECLI_THREAD_ID`. TraeX CLI also reads `TRAECLI_THREAD_ID`, with its own +`traex-cli` binding scope; it never reuses a `trae_app` binding merely because +the opaque thread id matches. Other hosts that expose a stable opaque thread id should pass it as `--thread-id ` on every `/loopx` invocation. If that thread is already bound, reuse the returned `--agent-id ` on start, heartbeat, quota, refresh-state, diff --git a/tests/control_plane/test_start_goal_compact_projection.py b/tests/control_plane/test_start_goal_compact_projection.py index 285ba8e4e5..972aed762d 100644 --- a/tests/control_plane/test_start_goal_compact_projection.py +++ b/tests/control_plane/test_start_goal_compact_projection.py @@ -1199,14 +1199,18 @@ def test_cli_codex_app_reuses_ambient_thread_binding( assert payload["guided_transaction"].get("blocked_by") is None -def test_cli_trae_app_reuses_ambient_thread_binding( - tmp_path: Path, monkeypatch +@pytest.mark.parametrize( + ("host_surface", "prompt_flag"), + (("trae_app", "--trae_app"), ("traex-cli", "--runtime-profile generic_cli")), +) +def test_cli_trae_hosts_reuse_ambient_thread_binding( + tmp_path: Path, monkeypatch, host_surface: str, prompt_flag: str ) -> None: project = _write_connected_project(tmp_path) registry_path = project / ".loopx" / "registry.json" registry = json.loads(registry_path.read_text(encoding="utf-8")) registry["goals"][0]["coordination"]["thread_agent_bindings"] = [ - {"thread_id": "trae-thread", "host_surface": "trae_app", "agent_id": AGENT_ID} + {"thread_id": "trae-thread", "host_surface": host_surface, "agent_id": AGENT_ID} ] registry_path.write_text(json.dumps(registry, indent=2) + "\n", encoding="utf-8") monkeypatch.setenv("TRAECLI_THREAD_ID", "trae-thread") @@ -1224,7 +1228,7 @@ def test_cli_trae_app_reuses_ambient_thread_binding( "--goal-id", GOAL_ID, "--host-surface", - "trae_app", + host_surface, "--goal-text", GOAL_TEXT, ] @@ -1235,11 +1239,79 @@ def test_cli_trae_app_reuses_ambient_thread_binding( assert payload["thread_id"] == "trae-thread" assert payload["agent_id"] == AGENT_ID assert payload["thread_agent_binding"]["status"] == "bound" - assert "--trae_app" in payload["command_pack"]["commands"][ + assert payload["goal_text"] == GOAL_TEXT + assert prompt_flag in payload["command_pack"]["commands"][ "heartbeat_prompt" ] +@pytest.mark.parametrize( + ("ambient", "bound_host", "explicit", "conflict", "expected_gate"), + ( + ("bound-thread", "traex-cli", None, False, None), + ("other-thread", "traex-cli", "bound-thread", False, None), + ("new-thread", "traex-cli", None, False, "fresh_agent_registration_required"), + (None, "traex-cli", None, False, "thread_binding_selection_required"), + ("bound-thread", "trae_app", None, False, "fresh_agent_registration_required"), + ("bound-thread", "traex-cli", None, True, "thread_binding_conflict"), + ), +) +def test_cli_traex_thread_continuity_preserves_identity_fences( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ambient: str | None, + bound_host: str, + explicit: str | None, + conflict: bool, + expected_gate: str | None, +) -> None: + project = _write_connected_project(tmp_path) + registry_path = project / ".loopx" / "registry.json" + registry = json.loads(registry_path.read_text(encoding="utf-8")) + coordination = registry["goals"][0]["coordination"] + bindings = [ + {"thread_id": "bound-thread", "host_surface": bound_host, "agent_id": AGENT_ID} + ] + if conflict: + coordination["registered_agents"].append("conflicting-agent") + bindings.append( + {"thread_id": "bound-thread", "host_surface": bound_host, + "agent_id": "conflicting-agent"} + ) + coordination["thread_agent_bindings"] = bindings + registry_path.write_text(json.dumps(registry, indent=2) + "\n", encoding="utf-8") + before = registry_path.read_bytes() + # Another host's ambient id cannot fill a missing TraeX id. + monkeypatch.setenv("CODEX_THREAD_ID", "bound-thread") + if ambient is None: + monkeypatch.delenv("TRAECLI_THREAD_ID", raising=False) + else: + monkeypatch.setenv("TRAECLI_THREAD_ID", ambient) + argv = [ + "--format", "json", "start-goal", "--guided", "--project", str(project), + "--goal-id", GOAL_ID, "--host-surface", "traex-cli", "--goal-text", GOAL_TEXT, + ] + if explicit is not None: + argv += ["--thread-id", explicit] + output = io.StringIO() + with contextlib.redirect_stdout(output): + exit_code = cli_main(argv) + assert exit_code == 0 + payload = json.loads(output.getvalue()) + assert payload["goal_text"] == GOAL_TEXT + assert payload.get("thread_id") == (explicit or ambient) + assert registry_path.read_bytes() == before + gate = payload["guided_transaction"].get("identity_selection_gate") + if expected_gate is None: + assert gate is None + assert payload["agent_id"] == AGENT_ID + assert payload["thread_agent_binding"]["status"] == "bound" + else: + assert gate["state"] == expected_gate + assert payload["agent_id"] is None + assert payload["guided_transaction"]["blocked_by"] == "agent_identity_selection" + + def test_start_goal_binds_selected_lane_before_todo_writeback( tmp_path: Path, ) -> None: diff --git a/tests/test_core_distribution.py b/tests/test_core_distribution.py index ffc3b0ad5a..afd9894cd6 100644 --- a/tests/test_core_distribution.py +++ b/tests/test_core_distribution.py @@ -132,16 +132,19 @@ def test_installed_core_identity_help_and_unavailable_workspace(core_python, tmp assert not list(tmp_path.iterdir()) -def test_installed_core_guided_start_identity_and_readback(core_python, tmp_path, monkeypatch): +@pytest.mark.parametrize("host_surface", ("shell", "traex-cli")) +def test_installed_core_guided_start_identity_and_readback(core_python, tmp_path, monkeypatch, host_surface): from tests.control_plane.canonical_authority_fixture import isolate_sqlite_runtime isolate_sqlite_runtime(tmp_path, monkeypatch) + if host_surface == "traex-cli": + monkeypatch.setenv("TRAECLI_THREAD_ID", "core-session") project, runtime = tmp_path / "project", tmp_path / "runtime" project.mkdir() prefix = ["--runtime-root", str(runtime), "--format", "json"] objective = 'Produce an inventory report; preserve "quoted" source names and 总计.' start = [*prefix, "start-goal", "--guided", "--project", str(project), - "--goal-id", GOAL, "--host-surface", "shell", "--goal-text", objective] + "--goal-id", GOAL, "--host-surface", host_surface, "--goal-text", objective] def command(*arguments, expected=0): return json.loads(_run(core_python, list(arguments), project, expected=expected).stdout) @@ -166,15 +169,17 @@ def command(*arguments, expected=0): "--agent-id", AGENT, "--require-new", "--execute", expected=1) assert collision["ok"] is False and collision["written"] is False binding = command(*prefix, "bind-agent-thread", "--goal-id", GOAL, - "--agent-id", AGENT, "--thread-id", "core-session", "--host-surface", "shell", "--execute") + "--agent-id", AGENT, "--thread-id", "core-session", "--host-surface", host_surface, "--execute") assert binding["global_sync"]["ok"] is True assert binding["registration_readback"]["verified"] is True - resumed = command(*start, "--thread-id", "core-session") + resumed = command(*start) if host_surface == "traex-cli" else command(*start, "--thread-id", "core-session") assert resumed["agent_id"] == AGENT assert resumed["project_connection"]["goal_found"] is True assert "identity_selection_gate" not in resumed["guided_transaction"] + assert resumed["goal_text"] == objective + assert resumed["thread_id"] == "core-session" pack = command(*prefix, "bootstrap-command-pack", "--project", str(project), - "--goal-id", GOAL, "--agent-id", AGENT, "--host-surface", "shell", "--goal-text", objective) + "--goal-id", GOAL, "--agent-id", AGENT, "--host-surface", host_surface, "--goal-text", objective) assert pack["goal_start_contract"]["goal_text"] == objective assert pack["goal_start_contract"]["explicit_invocation_confirms_project_local_state_writes"] is True diff --git a/tests/test_host_loop_activation.py b/tests/test_host_loop_activation.py index 5ee6ddd239..4217609c42 100644 --- a/tests/test_host_loop_activation.py +++ b/tests/test_host_loop_activation.py @@ -779,6 +779,7 @@ def test_traex_cli_is_an_exact_visible_goal_host_on_the_generic_cli_loop() -> No assert packet["activation_method"] == "set_visible_goal" assert packet["host_mutation"]["owner"] == "TraeX CLI TUI" assert packet["host_mutation"]["host_command"] == "/goal " + assert packet["host_mutation"]["preferred_tool"] == "create_goal" assert packet["host_mutation"]["requires_host_feature_flag"] == ( "[features] goals = true in ~/.trae/traecli.toml" )