fix(office-ui): native progress parity, ui_state lock hardening, approval-card idempotency
Native agent progress panel (company mode): - ws_handler: filter runtime bookkeeping noise (turn/status/member_inbox_updated), keep tool_completed as tool_call, thinking summary previews content, preserve raw thinking_delta fragments (no strip; skip whitespace-only) - frontend progressLog: summarize thinking by content preview; merge thinking by detail only so the 'Thinking' label never splices into text - AgentProgressBlock: add bottom "Show more (N earlier steps)" toggle ui_state.db "database is locked" hardening: - ws_handler: isolate engine progress/kanban/runtime-event callbacks so UI persistence failures never crash work items - chat_store: busy_timeout, _retry_locked backoff, idempotent insert_message (INSERT OR REPLACE), create_channel read-before-write to stop poll writes - server: flock single-instance guard for `opc ui` per OPC home Approval card duplicate-click bug: - EscalationPanel: disable buttons on click with Submitting state and 30s reconnect fallback - ws_handler: stale-escalation branch checks real card status (new chat_store.get_checkpoint_message); already-resolved cards get an accurate "already handled (decision: X)" reply without being re-marked stale; dedup identical helper messages within 120s to stop reply spam Company mode prompt: - add soft guidance that the runtime monitors state and re-activates roles, so leaders need not poll work items after delegation/review Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ sets up the event adapter pipeline, and serves static files + WebSocket.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -47,6 +48,41 @@ def _is_under_path(path: Path, base: Path) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _acquire_single_instance_lock(opc_home: Path) -> Any | None:
|
||||
"""Prevent two office-UI servers from sharing one OPC home.
|
||||
|
||||
Two server processes writing the same ui_state.db contend for the sqlite
|
||||
write lock and surface as 'database is locked' failures mid-run. The lock
|
||||
is advisory (flock), scoped to this OPC home, and released automatically
|
||||
when the process exits — including on crash/SIGKILL, so a stale lock file
|
||||
can never block a fresh start.
|
||||
"""
|
||||
try:
|
||||
import fcntl
|
||||
except ImportError:
|
||||
return None # Non-POSIX platform: no flock available, skip the guard.
|
||||
|
||||
lock_path = opc_home / "office_ui.lock"
|
||||
lock_file = open(lock_path, "a+", encoding="utf-8")
|
||||
try:
|
||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError:
|
||||
lock_file.seek(0)
|
||||
holder_pid = lock_file.read().strip() or "unknown"
|
||||
lock_file.close()
|
||||
raise SystemExit(
|
||||
f"Another office-UI server (pid {holder_pid}) is already running against "
|
||||
f"{opc_home}. Two instances sharing one ui_state.db cause 'database is "
|
||||
"locked' failures that can crash in-flight agent runs. Stop the other "
|
||||
"instance first, or point this one at a different OPC home."
|
||||
)
|
||||
lock_file.seek(0)
|
||||
lock_file.truncate()
|
||||
lock_file.write(str(os.getpid()))
|
||||
lock_file.flush()
|
||||
return lock_file
|
||||
|
||||
|
||||
# ── Application factory ──────────────────────────────────────────────────
|
||||
|
||||
async def create_app(
|
||||
@@ -72,8 +108,12 @@ async def create_app(
|
||||
|
||||
# ── UI-state database (agents + chat) ─────────────────────────────
|
||||
opc_home = engine.opc_home
|
||||
instance_lock = _acquire_single_instance_lock(opc_home)
|
||||
db_path = opc_home / "ui_state.db"
|
||||
db = await aiosqlite.connect(str(db_path))
|
||||
# Wait for a concurrent writer (CLI, tooling) instead of failing after
|
||||
# sqlite's 5s default with 'database is locked'.
|
||||
await db.execute("PRAGMA busy_timeout=30000")
|
||||
|
||||
agent_store = AgentStore(db)
|
||||
await agent_store.initialize()
|
||||
@@ -129,6 +169,7 @@ async def create_app(
|
||||
app["engine"] = engine
|
||||
app["db"] = db
|
||||
app["ws_handler"] = ws_handler
|
||||
app["instance_lock"] = instance_lock
|
||||
|
||||
# ── Routes ────────────────────────────────────────────────────────
|
||||
app.router.add_get("/ws", ws_handler.handle_ws)
|
||||
@@ -230,6 +271,12 @@ async def _on_shutdown(app: aiohttp.web.Application) -> None:
|
||||
await engine.shutdown()
|
||||
if db:
|
||||
await db.close()
|
||||
instance_lock = app.get("instance_lock")
|
||||
if instance_lock is not None:
|
||||
try:
|
||||
instance_lock.close()
|
||||
except Exception:
|
||||
pass
|
||||
logger.info("Office-UI server shut down")
|
||||
|
||||
|
||||
@@ -263,3 +310,8 @@ def run_server(
|
||||
asyncio.run(_start())
|
||||
except KeyboardInterrupt:
|
||||
terminal_status("Shutting down Office UI", kind="warning")
|
||||
except SystemExit as exc:
|
||||
if exc.code and not isinstance(exc.code, int):
|
||||
terminal_status(str(exc.code), kind="error")
|
||||
raise SystemExit(1) from None
|
||||
raise
|
||||
|
||||
Reference in New Issue
Block a user