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:
LZH-YS1998
2026-07-07 16:18:01 +08:00
parent ee79331d48
commit 12817a4e60
12 changed files with 516 additions and 157 deletions
+127 -29
View File
@@ -6,16 +6,27 @@ Channel/message format uses snake_case to match what collabSync.ts expects.
from __future__ import annotations
import asyncio
import json
import re
import sqlite3
import time
import uuid
from typing import Any
from typing import Any, Awaitable, Callable
import aiosqlite
from opc.layer3_agent.adapters.codex_adapter import CodexAdapter
_LOCKED_ERROR_MARKERS = ("database is locked", "database table is locked")
_WRITE_RETRY_ATTEMPTS = 3
_WRITE_RETRY_BASE_DELAY_SECONDS = 0.25
def _is_locked_error(exc: BaseException) -> bool:
return isinstance(exc, sqlite3.OperationalError) and any(
marker in str(exc).lower() for marker in _LOCKED_ERROR_MARKERS
)
class ChatStore:
"""Chat channels + messages in ui_state.db.
@@ -319,6 +330,28 @@ class ChatStore:
"metadata": metadata,
}
async def _retry_locked(self, operation: Callable[[], Awaitable[Any]]) -> Any:
"""Run a write operation, retrying briefly on transient sqlite lock errors.
Another process sharing ui_state.db (a second server, the CLI) can hold
the write lock past busy_timeout; a short backoff usually clears it.
"""
last_error: BaseException | None = None
for attempt in range(_WRITE_RETRY_ATTEMPTS):
try:
return await operation()
except sqlite3.OperationalError as exc:
if not _is_locked_error(exc):
raise
last_error = exc
try:
await self._db.rollback()
except Exception:
pass
await asyncio.sleep(_WRITE_RETRY_BASE_DELAY_SECONDS * (2 ** attempt))
assert last_error is not None
raise last_error
async def initialize(self) -> None:
"""Create tables if not exist."""
await self._db.execute("""
@@ -488,18 +521,13 @@ class ChatStore:
now = time.time()
parts = participants or []
cursor = await self._db.execute(
"SELECT created_at FROM channels WHERE channel_id = ? AND project_id = ?",
"SELECT type, name, office_id, participants, created_at FROM channels "
"WHERE channel_id = ? AND project_id = ?",
(cid, project_id),
)
existing = await cursor.fetchone()
created_at = float(existing[0]) if existing and existing[0] is not None else now
await self._db.execute(
"INSERT OR REPLACE INTO channels (channel_id, type, name, office_id, participants, created_at, project_id) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(cid, channel_type, name, office_id, json.dumps(parts), created_at, project_id),
)
await self._db.commit()
return {
created_at = float(existing[4]) if existing and existing[4] is not None else now
channel = {
"channel_id": cid,
"type": channel_type,
"name": name,
@@ -508,6 +536,33 @@ class ChatStore:
"created_at": created_at,
"project_id": project_id,
}
if existing is not None:
# Callers (e.g. session_detail polling) invoke this on every
# request; skip the write when nothing changed so a read-only
# view does not generate a constant write load on ui_state.db.
try:
existing_parts = json.loads(existing[3]) if existing[3] else []
except (json.JSONDecodeError, TypeError):
existing_parts = None
unchanged = (
str(existing[0] or "") == channel_type
and str(existing[1] or "") == name
and (existing[2] or None) == (office_id or None)
and existing_parts == parts
)
if unchanged:
return channel
async def _write() -> None:
await self._db.execute(
"INSERT OR REPLACE INTO channels (channel_id, type, name, office_id, participants, created_at, project_id) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(cid, channel_type, name, office_id, json.dumps(parts), created_at, project_id),
)
await self._db.commit()
await self._retry_locked(_write)
return channel
async def insert_message(
self,
@@ -525,19 +580,23 @@ class ChatStore:
"""Insert a message. Returns message dict in backend format (snake_case)."""
mid = message_id or str(uuid.uuid4())
now = float(created_at) if created_at is not None else time.time()
await self._db.execute(
"INSERT INTO messages "
"(message_id, channel_id, sender, sender_name, content, timestamp, "
"reply_to_id, mentions, metadata, project_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
mid, channel_id, sender, sender_name, content, now,
reply_to_id,
json.dumps(mentions or []),
json.dumps(metadata or {}),
project_id,
),
)
await self._db.commit()
async def _write() -> None:
await self._db.execute(
"INSERT OR REPLACE INTO messages "
"(message_id, channel_id, sender, sender_name, content, timestamp, "
"reply_to_id, mentions, metadata, project_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
mid, channel_id, sender, sender_name, content, now,
reply_to_id,
json.dumps(mentions or []),
json.dumps(metadata or {}),
project_id,
),
)
await self._db.commit()
await self._retry_locked(_write)
return {
"message_id": mid,
"channel_id": channel_id,
@@ -1220,6 +1279,41 @@ class ChatStore:
project_id=project_id,
)
async def get_checkpoint_message(
self,
checkpoint_id: str,
*,
channel_id: str | None = None,
checkpoint_type: str | None = None,
project_id: str = "default",
) -> dict[str, Any] | None:
"""Read-only lookup of a checkpoint card message by checkpoint id."""
normalized_checkpoint_id = str(checkpoint_id or "").strip()
if not normalized_checkpoint_id:
return None
normalized_checkpoint_type = str(checkpoint_type or "").strip()
normalized_channel_id = str(channel_id or "").strip()
params: list[Any] = [project_id]
query = (
"SELECT message_id, channel_id, sender, sender_name, content, "
"timestamp, reply_to_id, mentions, metadata "
"FROM messages WHERE project_id = ?"
)
if normalized_channel_id:
query += " AND channel_id = ?"
params.append(normalized_channel_id)
query += " ORDER BY timestamp DESC"
cursor = await self._db.execute(query, tuple(params))
rows = await cursor.fetchall()
for row in rows:
metadata = json.loads(row[8]) if row[8] else {}
if str(metadata.get("checkpoint_id", "")).strip() != normalized_checkpoint_id:
continue
if normalized_checkpoint_type and str(metadata.get("checkpoint_type", "")).strip() != normalized_checkpoint_type:
continue
return self._row_to_message_dict(row)
return None
async def update_checkpoint_status(
self,
checkpoint_id: str,
@@ -1434,12 +1528,16 @@ class ChatStore:
"""
existing = await self.get_progress(task_id, project_id=project_id)
merged = (existing + new_entries)[-self._PROGRESS_MAX_ENTRIES:]
await self._db.execute(
"INSERT OR REPLACE INTO task_progress (task_id, entries, updated_at, project_id) "
"VALUES (?, ?, ?, ?)",
(task_id, json.dumps(merged, ensure_ascii=False, default=str), time.time(), project_id),
)
await self._db.commit()
async def _write() -> None:
await self._db.execute(
"INSERT OR REPLACE INTO task_progress (task_id, entries, updated_at, project_id) "
"VALUES (?, ?, ?, ?)",
(task_id, json.dumps(merged, ensure_ascii=False, default=str), time.time(), project_id),
)
await self._db.commit()
await self._retry_locked(_write)
async def get_progress(self, task_id: str, project_id: str = "default") -> list[dict[str, Any]]:
"""Read persisted progress entries for a task."""