Skip to content
Merged
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
10 changes: 10 additions & 0 deletions docs/architecture/rfcs/loopx-overall-roadmap-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 的证据缺口;已有外发使用
Expand Down
21 changes: 21 additions & 0 deletions docs/guides/personal-workspace-user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<details>
<summary>可选:从终端检查页面和状态是否可读</summary>

Expand Down
10 changes: 10 additions & 0 deletions examples/macos-dashboard-launchagent-status-smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
2 changes: 1 addition & 1 deletion loopx/capabilities/native_chat/codex_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
11 changes: 6 additions & 5 deletions loopx/chat_acp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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":
Expand Down Expand Up @@ -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,
Expand All @@ -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
)
Expand Down Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions loopx/chat_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
16 changes: 10 additions & 6 deletions loopx/chat_ingress.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions loopx/chat_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 []),
Expand Down
2 changes: 1 addition & 1 deletion loopx/chat_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 7 additions & 3 deletions loopx/cli_commands/support_control_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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),
Expand Down Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions scripts/macos-dashboard-launchagent.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
88 changes: 88 additions & 0 deletions tests/test_chat_long_running.py
Original file line number Diff line number Diff line change
@@ -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")
5 changes: 3 additions & 2 deletions tests/test_chat_startup_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand All @@ -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


Expand Down
Loading