From 289912d3fb90ee7199ad49dee7aaae106e0e00d1 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 1 Nov 2026 13:52:37 +0800 Subject: [PATCH] fix(chat): keep active work and accepted queued input alive Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../rfcs/loopx-overall-roadmap-v0.md | 10 +++ .../rfcs/loopx-overall-roadmap-v0.zh-CN.md | 8 ++ docs/guides/personal-workspace-user-guide.md | 21 +++++ ...acos-dashboard-launchagent-status-smoke.py | 10 +++ .../capabilities/native_chat/codex_adapter.py | 2 +- loopx/chat_acp.py | 11 +-- loopx/chat_agent.py | 4 +- loopx/chat_ingress.py | 16 ++-- loopx/chat_runtime.py | 4 +- loopx/chat_server.py | 2 +- loopx/cli_commands/support_control_chat.py | 10 ++- scripts/macos-dashboard-launchagent.sh | 4 +- tests/test_chat_long_running.py | 88 +++++++++++++++++++ tests/test_chat_startup_options.py | 5 +- 14 files changed, 171 insertions(+), 24 deletions(-) create mode 100644 tests/test_chat_long_running.py diff --git a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md index 0a591ec476..4293bd87f1 100644 --- a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md +++ b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md @@ -972,6 +972,16 @@ Current status is design proposal; no G1 or default-screen promotion. ### R3: Semantic Requests and Automatic Return +**Interactive lifetime checkpoint.** Ordinary Codex/ACP Chat turns have no +implicit absolute cutoff while their existing idle/startup checks and Stop +remain active. Accepted queued input has no default TTL; explicitly requested +limits, queue capacity, source validation and bounded managed-host segments keep +their owners. Synthetic long-activity, explicit-limit, idle, queue and service +configuration tests qualify this slice. Existing installed settings and immutable +old failures are retained; installed activation and effect-aware recovery of an +original timed-out request remain separate acceptance, not inferred replay. + + **Bound-conversation status presentation.** The shared display renderer leads with canonical execution/recovery state, project and queue/permission facts; the Lark adapter reserves full commands and diagnostics for help, retaining a 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 b029f8b231..12e952f3cb 100644 --- a/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md +++ b/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md @@ -723,6 +723,14 @@ Goal 成果页可打开正文;原管家对话仅在已确认团队计划的回 ### R3:语义请求与自动回报 +**交互任务寿命 checkpoint。** 普通 Codex/ACP Chat 不再设置隐式总时长截止, +既有无活动/启动检测与停止仍有效。已接收排队输入默认不设 TTL;显式期限、 +队列容量、来源校验和有界 managed-host segment 仍由原 owner 负责。 +合成长活动、显式期限、无活动、排队与服务配置回归只验收此切片。 +保留安装态既有设置及不可变旧失败;实际启用与核对效果后的原超时请求恢复 +仍是独立验收,不据此推断已重放。 + + **S1/S5 注意力 checkpoint。** 本地管家/Goal Turn 无须 Lark 连接,即收到 canonical 阻塞与具体用户请求。共用 TS 读模型合成同一 Todo 的阻塞/决策;正文 adapter 和 阻塞收集服务本地与外部投递。这闭合 Turn preparation 的证据缺口;已有外发使用 diff --git a/docs/guides/personal-workspace-user-guide.md b/docs/guides/personal-workspace-user-guide.md index acdae36b6c..e30f731715 100644 --- a/docs/guides/personal-workspace-user-guide.md +++ b/docs/guides/personal-workspace-user-guide.md @@ -32,6 +32,27 @@ loopx dashboard `loopx dashboard --no-open`,并访问命令实际打印的 URL。默认地址为 `http://127.0.0.1:8767/chat/`。 +普通 Codex / ACP 交互任务默认不设总时长截止;有持续进展的工作可以继续。 +启动连接和无活动检测仍有期限,停止操作仍可中断。需要限时可显式传入 +`--hard-timeout-seconds 1800`;`--hard-timeout-seconds 0` 取消该截止。 +已有 macOS 服务保留原设置,使用 `LOOPX_CHAT_HARD_TIMEOUT_SECONDS=0` +经既有安装/启动管理器更新;有已接收任务时先等其结束,不强杀服务。 +已接收的排队消息默认保留到执行或停止,显式 TTL 仍会到期。 +这不改变 Goal、租约、权限、实验预算或有界 managed-host segment 的期限; +持续执行仍消耗宿主模型与工具额度。旧超时记录保留,恢复时先核对已产生的效果, +继续尚未完成的部分,避免重复外发、委派或其他操作。 + +English: Interactive Codex/ACP turns have no default wall-clock cutoff. Startup, +idle detection and Stop remain active. Set `--hard-timeout-seconds 1800` for an +explicit deadline, or `0` to disable it. Existing managed macOS installations +preserve their setting; update through their existing service owner using +`LOOPX_CHAT_HARD_TIMEOUT_SECONDS=0` and wait for accepted work to settle before +activation. Accepted queued input no longer expires implicitly; explicit TTLs +still apply. Goal/lease/permission boundaries, experiment budgets and bounded +managed-host segments remain in force. Continued execution still consumes host +allowance. Retain old timeout records and reconcile effects before continuing +unfinished work. +
可选:从终端检查页面和状态是否可读 diff --git a/examples/macos-dashboard-launchagent-status-smoke.py b/examples/macos-dashboard-launchagent-status-smoke.py index 6909cda325..9081eae577 100644 --- a/examples/macos-dashboard-launchagent-status-smoke.py +++ b/examples/macos-dashboard-launchagent-status-smoke.py @@ -659,6 +659,16 @@ def main() -> int: assert not changed_root.exists(), "selection must not create or migrate a Session store" assert native_state.read_text() == '{"session":"retained"}' + # An explicit disabled interactive wall limit survives upgrade; idle + # and startup protection remain independent, and no store is changed. + run_script(fake_bin, home, ["install"], schema_version=2, + extra_env={"LOOPX_CHAT_HARD_TIMEOUT_SECONDS": "0"}) + run_script(fake_bin, home, ["restart"], schema_version=2) + unlimited = plistlib.loads(chat_plist.read_bytes())["EnvironmentVariables"] + assert unlimited["LOOPX_CHAT_HARD_TIMEOUT_SECONDS"] == "0" + assert float(unlimited["LOOPX_CHAT_IDLE_TIMEOUT_SECONDS"]) == 700 + assert native_state.read_text() == '{"session":"retained"}' + # Legacy generated plists used only a shell export. Preserve quoted # paths across upgrades without ever executing their command contents. legacy = plistlib.loads(chat_plist.read_bytes()) diff --git a/loopx/capabilities/native_chat/codex_adapter.py b/loopx/capabilities/native_chat/codex_adapter.py index 3603bac183..4400be2b24 100644 --- a/loopx/capabilities/native_chat/codex_adapter.py +++ b/loopx/capabilities/native_chat/codex_adapter.py @@ -299,7 +299,7 @@ def start( resume_thread_id: str | None = None, startup_timeout_sec: float = 30.0, idle_timeout_sec: float = 180.0, - hard_timeout_sec: float = 900.0, + hard_timeout_sec: float | None = None, execution_mode: bool = False, runtime_profile: str = "restricted", sandbox: str | None = None, diff --git a/loopx/chat_acp.py b/loopx/chat_acp.py index 171d50369c..8c9309fde6 100644 --- a/loopx/chat_acp.py +++ b/loopx/chat_acp.py @@ -65,7 +65,7 @@ class ACPStdioAdapter: execution_mode: bool = False startup_timeout_sec: float = 30.0 idle_timeout_sec: float = 180.0 - hard_timeout_sec: float = 900.0 + hard_timeout_sec: float | None = None # Grace window for the agent to exit on stdin EOF and release any # per-session lock before LoopX signals the process. _graceful_exit_timeout_sec: float = 5.0 @@ -89,7 +89,7 @@ def start( resume_thread_id: str | None = None, startup_timeout_sec: float | None = None, idle_timeout_sec: float = 180.0, - hard_timeout_sec: float = 900.0, + hard_timeout_sec: float | None = None, execution_mode: bool = False, config_values: dict[str, str] | None = None, ) -> "ACPStdioAdapter": @@ -390,7 +390,7 @@ def _request( params: dict[str, Any], *, request_id: int | None = None, - timeout_sec: float, + timeout_sec: float | None, on_notification: Callable[[dict[str, Any]], None] | None = None, on_activity: Callable[[], None] | None = None, idle_timeout_sec: float | None = None, @@ -403,7 +403,7 @@ def _request( self._write( {"jsonrpc": "2.0", "id": selected_id, "method": method, "params": params} ) - deadline = time.monotonic() + timeout_sec + deadline = time.monotonic() + timeout_sec if timeout_sec is not None else float("inf") idle_deadline = ( time.monotonic() + idle_timeout_sec if idle_timeout_sec is not None else deadline ) @@ -517,7 +517,8 @@ def on_activity() -> None: ) except TimeoutError as exc: elapsed = time.monotonic() - started_at - error_code = "hard_timeout" if elapsed >= self.hard_timeout_sec else "idle_timeout" + error_code = ("hard_timeout" if self.hard_timeout_sec is not None + and elapsed >= self.hard_timeout_sec else "idle_timeout") summary = ( "ACP Chat turn reached its hard time limit." if error_code == "hard_timeout" diff --git a/loopx/chat_agent.py b/loopx/chat_agent.py index eb453575b5..eb0bffbe0f 100644 --- a/loopx/chat_agent.py +++ b/loopx/chat_agent.py @@ -682,7 +682,7 @@ class CodexChatAgentSession: service_tier: str | None = None response_timeout_sec: float = 30.0 idle_timeout_sec: float = 180.0 - hard_timeout_sec: float | None = 900.0 + hard_timeout_sec: float | None = None next_request_id: int = 5 current_turn_id: str = "" model_catalog_compatibility_applied: bool = False @@ -726,7 +726,7 @@ def start( objective: str, response_timeout_sec: float = 30.0, idle_timeout_sec: float = 180.0, - hard_timeout_sec: float | None = 900.0, + hard_timeout_sec: float | None = None, resume_thread_id: str | None = None, execution_mode: bool = False, isolate_process_tree: bool = False, diff --git a/loopx/chat_ingress.py b/loopx/chat_ingress.py index b758a62b7d..b05594b796 100644 --- a/loopx/chat_ingress.py +++ b/loopx/chat_ingress.py @@ -12,7 +12,7 @@ SESSION_QUEUE_MAX_PENDING = 20 -SESSION_QUEUE_TTL_SECONDS = 3600 +SESSION_QUEUE_TTL_SECONDS: int | None = None def chat_runtime_activation_lock(runtime_root: Path, *, timeout_seconds: float | None = None): @@ -80,12 +80,17 @@ def create_queued_turn( message: str, attachments: list[dict[str, Any]] | None = None, goal_instance_id: str | None = None, - ttl_seconds: int = SESSION_QUEUE_TTL_SECONDS, + ttl_seconds: int | None = SESSION_QUEUE_TTL_SECONDS, origin: str = "external", external_agent_target: dict[str, Any] | None = None, collaboration_request: dict[str, Any] | None = None, ) -> tuple[dict[str, Any], bool]: - """Persist one bounded follow-up without replacing the active Turn.""" + """Persist one follow-up without replacing the active Turn. + + Accepted user input has no implicit wall-clock expiry. Callers may + still supply an explicit TTL for time-sensitive work. Queue capacity, + source revalidation and Stop remain independent bounds. + """ from .chat_store import _opaque_id, _instant, _atomic_write_json, utc_now, CHAT_TURN_SCHEMA_VERSION @@ -159,9 +164,8 @@ def create_queued_turn( "error_code": None, "error": None, "created_at": now_text, - "expires_at": (now + timedelta(seconds=max(1, ttl_seconds))) - .isoformat() - .replace("+00:00", "Z"), + "expires_at": ((now + timedelta(seconds=max(1, ttl_seconds))) + .isoformat().replace("+00:00", "Z") if ttl_seconds is not None else None), "started_at": None, "first_event_at": None, "completed_at": None, diff --git a/loopx/chat_runtime.py b/loopx/chat_runtime.py index b5b8683809..4551c200a2 100644 --- a/loopx/chat_runtime.py +++ b/loopx/chat_runtime.py @@ -237,7 +237,7 @@ def __init__( kiro_cli_bin: str = KIRO_CLI_BIN, startup_timeout_sec: float | None = None, idle_timeout_sec: float = 180.0, - hard_timeout_sec: float = 900.0, + hard_timeout_sec: float | None = None, endpoint_registry: AgentEndpointRegistry | None = None, registry_path: Path | None = None, manager_scope_resolver: Callable[[dict[str, Any]], list[str] | None] | None = None, @@ -465,7 +465,7 @@ def _start_adapter( provider=str(profile["provider"]), model=model, reasoning_effort=reasoning_effort, - timeout_sec=self.hard_timeout_sec, + timeout_sec=self.hard_timeout_sec if self.hard_timeout_sec is not None else 900.0, # A segment is fresh, so this adapter carries the visible history # itself instead of relying on a host session to remember it. history=list(history or []), diff --git a/loopx/chat_server.py b/loopx/chat_server.py index 2a270dca67..19338aa874 100644 --- a/loopx/chat_server.py +++ b/loopx/chat_server.py @@ -1860,7 +1860,7 @@ def serve_chat( lark_cli_bin: str | None = None, startup_timeout_sec: float | None = None, idle_timeout_sec: float = 180.0, - hard_timeout_sec: float = 900.0, + hard_timeout_sec: float | None = None, assets_dir: Path | None = None, open_browser: bool = False, verbose: bool = False, diff --git a/loopx/cli_commands/support_control_chat.py b/loopx/cli_commands/support_control_chat.py index 5037a6ca30..191e41a537 100644 --- a/loopx/cli_commands/support_control_chat.py +++ b/loopx/cli_commands/support_control_chat.py @@ -7,6 +7,7 @@ from __future__ import annotations import argparse +import math from collections.abc import Callable from pathlib import Path from typing import Any @@ -34,6 +35,9 @@ def chat_server_options( explicit_global_registry(args.runtime_root, registry_path=registry_path) if args.global_registry else registry_path ) + hard_timeout = float(args.hard_timeout_seconds) + if not math.isfinite(hard_timeout) or hard_timeout < 0: + raise ValueError("hard timeout must be a nonnegative finite number (0 disables it)") scan_roots = [Path(item).expanduser() for item in args.scan_path] return { "project_workspace_grant": args.project_workspace_grant, @@ -55,7 +59,7 @@ def chat_server_options( if args.startup_timeout_seconds is not None else None ), "idle_timeout_sec": max(0.1, float(args.idle_timeout_seconds)), - "hard_timeout_sec": max(0.1, float(args.hard_timeout_seconds)), + "hard_timeout_sec": hard_timeout if hard_timeout else None, "assets_dir": Path(args.assets_dir).expanduser() if args.assets_dir else None, "open_browser": not bool(args.no_open), "verbose": bool(args.verbose), @@ -96,8 +100,8 @@ def register_chat_and_dashboard_commands( chat_parser.add_argument( "--hard-timeout-seconds", type=float, - default=900.0, - help="Absolute maximum seconds for one Agent turn.", + default=0.0, + help="Absolute maximum seconds for one interactive Agent turn; 0 (default) disables it. Idle and startup limits remain.", ) chat_parser.add_argument( "--assets-dir", diff --git a/scripts/macos-dashboard-launchagent.sh b/scripts/macos-dashboard-launchagent.sh index 61446b1238..879405a320 100755 --- a/scripts/macos-dashboard-launchagent.sh +++ b/scripts/macos-dashboard-launchagent.sh @@ -394,8 +394,8 @@ for variable, flag in ( seconds = float(value) except ValueError: raise SystemExit(f"{variable} must be a positive finite number") - if not math.isfinite(seconds) or seconds <= 0: - raise SystemExit(f"{variable} must be a positive finite number") + if not math.isfinite(seconds) or seconds < 0 or (seconds == 0 and flag != "--hard-timeout-seconds"): + raise SystemExit(f"{variable} must be finite and positive (hard timeout also accepts 0)") options[variable] = value variable = "LOOPX_CHAT_CODEX_MODEL_PROVIDER" # Explicit empty disables an installed override; absence preserves it. diff --git a/tests/test_chat_long_running.py b/tests/test_chat_long_running.py new file mode 100644 index 0000000000..71d3fe8bc5 --- /dev/null +++ b/tests/test_chat_long_running.py @@ -0,0 +1,88 @@ +"""Long-running interactive input keeps its real lifecycle and explicit limits.""" +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +import queue + +import pytest + +from loopx import chat_agent, chat_acp +from loopx.chat_store import ChatSessionStore +from loopx.cli import build_parser +from loopx.cli_commands.support_control_chat import chat_server_options + + +@pytest.mark.parametrize("hard, idle, expected", [(None, False, None), (5.0, False, "hard_timeout"), (None, True, "idle_timeout")]) +def test_native_activity_can_cross_old_wall_limit_but_limits_still_apply(monkeypatch, tmp_path, hard, idle, expected): + clock = [0.0] + monkeypatch.setattr(chat_agent.time, "monotonic", lambda: clock[0]) + host = chat_agent.CodexChatAgentSession(process=SimpleNamespace(poll=lambda: None), + messages=queue.Queue(), thread_id="original", work_dir=tmp_path, + idle_timeout_sec=5, hard_timeout_sec=hard) + monkeypatch.setattr(host, "_request", lambda *a, **kw: {"turn": {"id": "turn"}}) + count = [0] + + def event(*, deadline): + clock[0] += 6 if idle else 2 + if clock[0] >= deadline: + raise host._runtime_error("upstream wait elapsed") + count[0] += 1 + return {"method": "turn/completed", "params": {"threadId": "original", "turn": {"id": "turn", "status": "completed"}}} if count[0] == 1001 else { + "method": "item/agentMessage/delta", "params": {"threadId": "original", "turnId": "turn", "delta": "progress "}} + + monkeypatch.setattr(host, "_next_event", event) + if expected: + with pytest.raises(chat_agent.CodexChatTimeoutError) as caught: + host.send("continue the original request") + assert caught.value.error_code == expected + else: + host.send("continue the original request") + assert clock[0] > 1800 + + +def test_acp_progress_has_no_implicit_wall_limit(monkeypatch, tmp_path): + clock = [0.0] + monkeypatch.setattr(chat_acp.time, "monotonic", lambda: clock[0]) + host = chat_acp.ACPStdioAdapter(process=SimpleNamespace(poll=lambda: None), messages=queue.Queue(), + session_id="original", work_dir=tmp_path, agent_work_dir=tmp_path, agent_capabilities={}) + monkeypatch.setattr(host, "_write", lambda _: None) + count = [0] + + def message(_timeout): + clock[0] += .25 + count[0] += 1 + return {"id": 1, "result": {"done": True}} if count[0] > 8000 else {"method": "session/update"} + + monkeypatch.setattr(host, "_next_message", message) + assert host._request("session/prompt", {}, request_id=1, timeout_sec=None, idle_timeout_sec=5) == {"done": True} + assert clock[0] > 1800 + + +def test_queued_input_survives_long_wait_and_keeps_exact_identity(tmp_path, monkeypatch): + store = ChatSessionStore(tmp_path) + sid = store.create_session(goal_id="fixture", agent_id="codex", adapter_kind="codex_app_server", + upstream_thread_id="original", upstream_mode="chat")["session_id"] + turn, created = store.create_queued_turn(sid, client_turn_id="original", message="do not drop accepted input") + assert created and turn["expires_at"] is None + from loopx import chat_store + class Future(datetime): + @classmethod + def now(cls, tz=None): + return datetime.now(timezone.utc) + timedelta(days=2) + monkeypatch.setattr(chat_store, "datetime", Future) + replay, created = store.create_queued_turn(sid, client_turn_id="original", message=turn["message"]) + assert not created and replay["turn_id"] == turn["turn_id"] + claimed = store.claim_next_queued_turn(sid, host_claim_id="host") + assert claimed["turn_id"] == turn["turn_id"] and claimed["message"] == turn["message"] + + +@pytest.mark.parametrize("value,expected", [("0", None), ("1800", 1800.0), ("86400", 86400.0)]) +def test_cli_explicit_timeout_choice_reaches_shared_owner(tmp_path, value, expected): + args = build_parser().parse_args(["chat", "--hard-timeout-seconds", value]) + assert chat_server_options(args, registry_path=tmp_path / "registry.json")["hard_timeout_sec"] == expected + + +@pytest.mark.parametrize("value", ["-1", "nan", "inf"]) +def test_invalid_unbounded_timeout_spellings_do_not_disable_guards(tmp_path, value): + args = build_parser().parse_args(["chat", "--hard-timeout-seconds", value]) + with pytest.raises(ValueError, match="nonnegative finite"): + chat_server_options(args, registry_path=tmp_path / "registry.json") diff --git a/tests/test_chat_startup_options.py b/tests/test_chat_startup_options.py index 220a0da6fd..e54f5f9387 100644 --- a/tests/test_chat_startup_options.py +++ b/tests/test_chat_startup_options.py @@ -226,7 +226,7 @@ def test_shared_startup_options_preserve_adapter_defaults_and_explicit_limits(tm options = chat_server_options(args, registry_path=tmp_path / "registry.json") assert options["startup_timeout_sec"] == expected assert options["idle_timeout_sec"] == 180.0 - assert options["hard_timeout_sec"] == 900.0 + assert options["hard_timeout_sec"] is None inspect.signature(serve_chat).bind(**options) @@ -240,7 +240,8 @@ def test_shared_options_keep_workspace_permissions_and_explicit_timeout_floors(t assert options["project_workspace_grant"] == "workspace_read" assert options["project_filesystem_scope"] == "workspace_only" assert options["private_reactions"] is False - assert options["idle_timeout_sec"] == options["hard_timeout_sec"] == 0.1 + assert options["idle_timeout_sec"] == 0.1 + assert options["hard_timeout_sec"] is None assert options["limit"] == 0