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."""
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="data:," />
<title>OpenOPC Pixel Office</title>
<script type="module" crossorigin src="./assets/index-DoyFIcGP.js"></script>
<script type="module" crossorigin src="./assets/index-CYE7dSgZ.js"></script>
<link rel="modulepreload" crossorigin href="./assets/phaser-DFK5Ua9d.js">
<link rel="stylesheet" crossorigin href="./assets/index-CMqG6mW8.css">
</head>
@@ -155,7 +155,7 @@ export function AgentProgressBlock({ entries, agentStatus, currentTool, toolElap
const cfg = ENTRY_CONFIG[entry.type] || ENTRY_CONFIG.status_change
return (
<div key={progressEntryKey(entry, i)} className={`ptl-entry${isLast ? ' ptl-entry-last' : ''}`}>
<div key={progressEntryKey(entry, hiddenCount + i)} className={`ptl-entry${isLast ? ' ptl-entry-last' : ''}`}>
<div className="ptl-connector">
<div className="ptl-dot" style={{ color: cfg.color }}>
{cfg.icon}
@@ -183,13 +183,19 @@ export function AgentProgressBlock({ entries, agentStatus, currentTool, toolElap
</div>
)}
{/* ── Collapse button (when expanded) ────────────── */}
{/* ── Expand/collapse toggle (below timeline) ────── */}
{expanded && filteredEntries.length > COLLAPSED_COUNT && (
<button className="ptl-expand" onClick={() => setExpanded(false)}>
<IconChevron down />
<span>Show less</span>
</button>
)}
{!expanded && hiddenCount > 0 && (
<button className="ptl-expand" onClick={() => setExpanded(true)}>
<IconChevron />
<span>Show more ({hiddenCount} earlier step{hiddenCount > 1 ? 's' : ''})</span>
</button>
)}
</div>
)
}
@@ -1,7 +1,11 @@
import React, { useCallback, useMemo } from 'react'
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import type { ChatMessageMeta, HumanEscalationOption } from '../types/chat'
import { MarkdownBody } from './MarkdownBody'
// If the server never confirms (click lost on a dropped connection), re-enable
// the buttons after this long so the user can retry.
const SUBMIT_CONFIRM_TIMEOUT_MS = 30000
interface EscalationPanelProps {
meta: ChatMessageMeta
onReply: (text: string) => void
@@ -57,10 +61,20 @@ export const EscalationPanel = React.memo(function EscalationPanel({
const worktreePath = String(meta.worktree_path ?? '').trim()
const hasRuntimeState = activeSubagents.length > 0 || permissionRequests.length > 0 || !!worktreePath
const [submittedOptionId, setSubmittedOptionId] = useState('')
const isSubmitting = !!submittedOptionId && !isResponded
useEffect(() => {
if (!isSubmitting) return
const timer = window.setTimeout(() => setSubmittedOptionId(''), SUBMIT_CONFIRM_TIMEOUT_MS)
return () => window.clearTimeout(timer)
}, [isSubmitting, submittedOptionId])
const handleReply = useCallback((option: HumanEscalationOption) => {
if (isResponded) return
if (isResponded || isSubmitting) return
setSubmittedOptionId(option.id)
onReply(option.label || option.id)
}, [isResponded, onReply])
}, [isResponded, isSubmitting, onReply])
return (
<div className="ckpt-panel ckpt-escalation">
@@ -109,15 +123,22 @@ export const EscalationPanel = React.memo(function EscalationPanel({
{options.map((option) => (
<button
key={option.id}
className={`ckpt-btn ${option.id.includes('deny') ? 'ckpt-btn-deny' : 'ckpt-btn-approve'}`}
className={`ckpt-btn ${option.id.includes('deny') ? 'ckpt-btn-deny' : 'ckpt-btn-approve'}${isSubmitting ? ' ckpt-btn-submitting' : ''}`}
onClick={() => handleReply(option)}
disabled={isSubmitting}
>
{option.label || option.id}
{isSubmitting && submittedOptionId === option.id ? 'Submitting…' : (option.label || option.id)}
</button>
))}
</div>
)}
{isSubmitting && (
<div className="ckpt-escalation-hint">
Decision sent waiting for server confirmation
</div>
)}
{!isResponded && meta.default_action && (
<div className="ckpt-escalation-hint">
Default on timeout: <code>{meta.default_action}</code>
@@ -28,9 +28,41 @@ for (const [seq, detail] of [
}
assert.equal(log.length, 1)
assert.equal(log[0]?.summary, 'Thinking')
assert.equal(log[0]?.summary, '我先联网抓取')
assert.equal(log[0]?.detail, '我先联网抓取')
// Token-sized streaming fragments keep their whitespace when merged, and
// entries without detail never splice their summary label into the text.
let spacedLog = appendProgressEntry([], {
timestamp: 100,
type: 'thinking',
summary: 'The user',
detail: 'The user',
turnId: 'rt-2:1',
itemId: 'rt-2:1:thinking',
seq: 1,
})
spacedLog = appendProgressEntry(spacedLog, {
timestamp: 101,
type: 'thinking',
summary: 'wants to',
detail: ' wants to',
turnId: 'rt-2:1',
itemId: 'rt-2:1:thinking',
seq: 2,
})
spacedLog = appendProgressEntry(spacedLog, {
timestamp: 102,
type: 'thinking',
summary: 'Thinking',
turnId: 'rt-2:1',
itemId: 'rt-2:1:thinking',
seq: 3,
})
assert.equal(spacedLog.length, 1)
assert.equal(spacedLog[0]?.detail, 'The user wants to')
assert.equal(spacedLog[0]?.summary, 'The user wants to')
const unchanged = appendProgressEntry(log, {
timestamp: 5,
type: 'thinking',
@@ -27,9 +27,9 @@ function mergeText(left: string, right: string, kind: 'thinking' | 'tool_call'):
}
function summarizeThinking(detail: string, fallback: string): string {
void detail
void fallback
return 'Thinking'
const text = detail.trim().replace(/\s+/g, ' ')
if (!text) return fallback || 'Thinking'
return text.length > 120 ? `${text.slice(0, 120).trimEnd()}...` : text
}
function normalizeProgressEntry(entry: ProgressEntry): ProgressEntry {
@@ -84,7 +84,10 @@ function isDuplicateProgress(left: ProgressEntry, right: ProgressEntry): boolean
function mergeProgress(left: ProgressEntry, right: ProgressEntry): ProgressEntry {
if (left.type === 'thinking') {
const detail = mergeText(left.detail ?? left.summary, right.detail ?? right.summary, 'thinking')
// Merge detail text only: summary is a label/preview ("Thinking",
// truncated excerpt), so falling back to it would splice label text
// into the middle of the merged thinking stream.
const detail = mergeText(left.detail ?? '', right.detail ?? '', 'thinking')
return {
timestamp: right.timestamp,
type: 'thinking',
+52
View File
@@ -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
@@ -50,6 +50,9 @@ class OfficeServiceFactory:
on_escalation=self.on_escalation,
)
self.db = await aiosqlite.connect(str(self.engine.opc_home / "ui_state.db"))
# Wait for a concurrent writer (e.g. a running office-UI server)
# instead of failing after sqlite's 5s default with 'database is locked'.
await self.db.execute("PRAGMA busy_timeout=30000")
agent_store = AgentStore(self.db)
await agent_store.initialize()
chat_store = ChatStore(self.db)
+138 -32
View File
@@ -195,6 +195,14 @@ _TASK_MODE_DEBUG_ONLY_PROGRESS_TYPES: frozenset[str] = frozenset({
})
# Company mode shares the task-mode noise list; runtime bookkeeping events
# (turns, status snapshots, cost ticks) carry no reviewable content and drown
# out thinking/tool entries in the per-role activity feed.
_COMPANY_MODE_HIDDEN_RUNTIME_PROGRESS_TYPES: frozenset[str] = (
_TASK_MODE_HIDDEN_RUNTIME_PROGRESS_TYPES | frozenset({"member_inbox_updated"})
)
_TASK_MODE_VISIBLE_RUNTIME_PROGRESS_TYPES: frozenset[str] = frozenset({
"thinking_delta",
"tool_started",
@@ -618,22 +626,39 @@ class WSHandler:
def _progress_callback_for_engine(self, engine: Any) -> Any:
async def _progress(text: str, **kw: Any) -> None:
await self.on_progress(
text,
_runtime_engine=engine,
_project_id=self._normalize_project_id(getattr(engine, "project_id", None)),
**kw,
)
# UI progress is a best-effort display copy: a failure here (e.g.
# a locked ui_state.db) must never crash the agent execution that
# emitted the progress line.
try:
await self.on_progress(
text,
_runtime_engine=engine,
_project_id=self._normalize_project_id(getattr(engine, "project_id", None)),
**kw,
)
except asyncio.CancelledError:
raise
except Exception:
logger.opt(exception=True).warning(
"UI progress handling failed; agent execution continues"
)
return _progress
def _runtime_event_callback_for_engine(self, engine: Any) -> Any:
async def _runtime_event(event: Any) -> None:
await self.on_opc_event(
event,
runtime_engine=engine,
project_id=self._normalize_project_id(getattr(engine, "project_id", None)),
)
try:
await self.on_opc_event(
event,
runtime_engine=engine,
project_id=self._normalize_project_id(getattr(engine, "project_id", None)),
)
except asyncio.CancelledError:
raise
except Exception:
logger.opt(exception=True).warning(
"UI runtime-event handling failed; agent execution continues"
)
setattr(_runtime_event, "_opc_ui_handler_id", id(self))
setattr(_runtime_event, "_opc_ui_project_id", self._normalize_project_id(getattr(engine, "project_id", None)))
@@ -653,7 +678,14 @@ class WSHandler:
def _kanban_callback_for_engine(self, engine: Any) -> Any:
async def _kanban_changed() -> None:
await self.on_kanban_changed(engine=engine)
try:
await self.on_kanban_changed(engine=engine)
except asyncio.CancelledError:
raise
except Exception:
logger.opt(exception=True).warning(
"UI kanban refresh failed; agent execution continues"
)
return _kanban_changed
@@ -2552,9 +2584,12 @@ class WSHandler:
if not runtime_type:
return None
is_task_mode = WSHandler._runtime_payload_is_task_mode(payload)
if is_task_mode and runtime_type in _TASK_MODE_HIDDEN_RUNTIME_PROGRESS_TYPES:
return None
if is_task_mode and runtime_type not in _TASK_MODE_VISIBLE_RUNTIME_PROGRESS_TYPES:
if is_task_mode:
if runtime_type in _TASK_MODE_HIDDEN_RUNTIME_PROGRESS_TYPES:
return None
if runtime_type not in _TASK_MODE_VISIBLE_RUNTIME_PROGRESS_TYPES:
return None
elif runtime_type in _COMPANY_MODE_HIDDEN_RUNTIME_PROGRESS_TYPES:
return None
summary = runtime_type.replace("_", " ").title()
@@ -2570,8 +2605,14 @@ class WSHandler:
return None
elif runtime_type == "thinking_delta":
entry_type = "thinking"
detail = str(payload.get("text", "") or "").strip()
summary = "Thinking"
# Keep the raw fragment: streaming deltas are token-sized, so
# stripping them destroys the whitespace between tokens once the
# fragments are merged back into one entry.
detail = str(payload.get("text", "") or "")
if not detail.strip():
return None
preview = " ".join(detail.split())
summary = preview[:120].rstrip() + ("..." if len(preview) > 120 else "")
elif runtime_type == "member_claimed_work_item":
entry_type = "work_item_started"
priority = str(payload.get("message_priority", "") or "").strip().lower()
@@ -2595,10 +2636,8 @@ class WSHandler:
summary = str(payload.get("tool_name", "") or "tool")
detail = str(payload.get("text", "") or payload.get("message", "") or "").strip()
elif runtime_type == "tool_completed":
entry_type = "tool_call" if is_task_mode else "status_change"
entry_type = "tool_call"
summary = str(payload.get("tool_name", "") or "tool")
if not is_task_mode:
summary = f"{summary} completed"
detail = str(payload.get("result_summary", "") or payload.get("result_preview", "") or "").strip()
elif runtime_type == "status_snapshot":
entry_type = "status_change"
@@ -2726,7 +2765,7 @@ class WSHandler:
"detail": detail[:4000] if detail else None,
}
tool_call_id = str(payload.get("tool_call_id", "") or "").strip()
if is_task_mode and tool_call_id and entry_type in {"tool_call", "autonomy"}:
if tool_call_id and entry_type in {"tool_call", "autonomy"}:
turn_id = str(payload.get("turn_id", "") or "").strip()
prefix = "permission" if entry_type == "autonomy" else "tool"
entry.setdefault("item_id", f"{turn_id}:{prefix}:{tool_call_id}" if turn_id else f"{prefix}:{tool_call_id}")
@@ -4818,6 +4857,40 @@ class WSHandler:
)
await self.broadcast({"type": "session_message", "payload": msg})
async def _recent_identical_helper_exists(
self,
channel_id: str,
content: str,
*,
project_id: str,
window_seconds: float = 120.0,
scan_limit: int = 10,
) -> bool:
"""True when an identical assistant helper was posted very recently.
Used to collapse rapid duplicate user clicks into a single helper
reply instead of one warning per click.
"""
try:
recent = await self.chat_store.get_channel_messages(
channel_id, limit=scan_limit, project_id=project_id,
)
except Exception:
return False
now = time.time()
for item in reversed(recent):
if str(item.get("sender", "")) != "assistant":
continue
if str(item.get("content", "")) != content:
continue
try:
created_at = float(item.get("created_at", 0) or 0)
except (TypeError, ValueError):
continue
if now - created_at <= window_seconds:
return True
return False
async def _mark_human_escalation_checkpoint_status(
self,
escalation_id: str,
@@ -5503,21 +5576,54 @@ class WSHandler:
if stale_human_escalation:
if _looks_like_escalation_reply(content):
stale_checkpoint_id = explicit_escalation_id or explicit_checkpoint_id
await self._mark_human_escalation_checkpoint_status(
stale_checkpoint_id,
status="stale",
project_id=pid,
channel_id=channel_id,
reason="reply_to_inactive_escalation",
)
# Duplicate clicks on an approval card that was JUST resolved
# (e.g. the user's own first click) are a normal occurrence
# when the server is slow: answer idempotently instead of
# flipping the card to "stale" and spamming inactive warnings.
card = None
try:
card = await self.chat_store.get_checkpoint_message(
stale_checkpoint_id,
channel_id=channel_id,
checkpoint_type="human_escalation",
project_id=pid,
)
except Exception:
logger.opt(exception=True).debug(
"Failed to load checkpoint card for stale escalation reply"
)
card_meta = dict((card or {}).get("metadata", {}) or {})
card_status = str(card_meta.get("checkpoint_status", "") or "").strip().lower()
if card_status in {"resolved", "responded"}:
resolution_reply = str(
card_meta.get("checkpoint_resolution_reply", "") or ""
).strip()
helper_text = (
"This approval was already handled"
+ (f" (decision: {resolution_reply})" if resolution_reply else "")
+ ". No further action is needed."
)
else:
await self._mark_human_escalation_checkpoint_status(
stale_checkpoint_id,
status="stale",
project_id=pid,
channel_id=channel_id,
reason="reply_to_inactive_escalation",
)
helper_text = (
"That approval request is no longer active. "
"The approval card has been marked inactive in the session history."
)
if await self._recent_identical_helper_exists(
channel_id, helper_text, project_id=pid
):
return
helper = await self.chat_store.insert_message(
channel_id=channel_id,
sender="assistant",
sender_name="OPC",
content=(
"That approval request is no longer active. "
"The approval card has been marked inactive in the session history."
),
content=helper_text,
project_id=pid,
metadata={"type": "system"},
)