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:
@@ -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."""
|
||||
|
||||
+38
-38
File diff suppressed because one or more lines are too long
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user