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:
@@ -40,6 +40,7 @@ _MULTI_TEAM_ORG_GUIDELINES = """
|
||||
- The WorkItem is the collaboration source of truth. Use WorkItem IDs for collaboration; never use runtime Task IDs as WorkItem IDs.
|
||||
- `workspace_root` and `comms_workspace_root` are guaranteed. `output_root` may be blank; if needed, choose a suitable subfolder under `workspace_root` and communicate it in handoffs or delegation briefs.
|
||||
- Kanban state is advanced by the runtime from completion reports and review verdicts. Do not manually flip board states.
|
||||
- There is no need to poll work items for progress: the runtime watches state changes and re-activates whichever roles need to act once delegated or dependent work completes. So after you have confirmed that your delegation succeeded or that your review verdict advanced the work, end your current run if nothing else requires your own work — the system monitors on your behalf.
|
||||
- Use mailbox tools only for coordination, questions, blockers, or handoffs.
|
||||
- Cross-team collaboration is request-based; only direct managers delegate executable work.
|
||||
- If you are the root final decider, only your finished turn is the authoritative owner-facing result.
|
||||
|
||||
@@ -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"},
|
||||
)
|
||||
|
||||
@@ -87,22 +87,23 @@ class WSHandlerProgressParsingTests(unittest.TestCase):
|
||||
|
||||
self.assertIsNone(entry)
|
||||
|
||||
def test_runtime_status_snapshot_maps_to_status_change_entry(self) -> None:
|
||||
entry = WSHandler._runtime_event_to_progress_entry(
|
||||
{
|
||||
"type": "status_snapshot",
|
||||
"current_tool": "shell_exec",
|
||||
"context_remaining_pct": 64,
|
||||
"turn_cost_usd": 0.0123,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertIsNotNone(entry)
|
||||
assert entry is not None
|
||||
self.assertEqual(entry["type"], "status_change")
|
||||
self.assertEqual(entry["summary"], "Runtime status")
|
||||
self.assertIn("tool=shell_exec", entry["detail"])
|
||||
self.assertIn("context=36% used", entry["detail"])
|
||||
def test_company_mode_hides_runtime_bookkeeping_events(self) -> None:
|
||||
for runtime_type in (
|
||||
"turn_started",
|
||||
"turn_completed",
|
||||
"status_snapshot",
|
||||
"context_usage",
|
||||
"cost_update",
|
||||
"member_inbox_updated",
|
||||
):
|
||||
entry = WSHandler._runtime_event_to_progress_entry(
|
||||
{
|
||||
"type": runtime_type,
|
||||
"current_tool": "shell_exec",
|
||||
"turn_cost_usd": 0.0123,
|
||||
},
|
||||
)
|
||||
self.assertIsNone(entry, runtime_type)
|
||||
|
||||
def test_runtime_member_claimed_work_item_maps_to_started_entry(self) -> None:
|
||||
entry = WSHandler._runtime_event_to_progress_entry(
|
||||
@@ -124,10 +125,10 @@ class WSHandlerProgressParsingTests(unittest.TestCase):
|
||||
self.assertNotIn("legacy_title", entry)
|
||||
self.assertTrue(entry["is_company_runtime"])
|
||||
|
||||
def test_runtime_context_usage_prefers_token_count_over_remaining_pct(self) -> None:
|
||||
def test_runtime_context_warning_prefers_token_count_over_remaining_pct(self) -> None:
|
||||
entry = WSHandler._runtime_event_to_progress_entry(
|
||||
{
|
||||
"type": "context_usage",
|
||||
"type": "context_warning",
|
||||
"context_tokens": 3200,
|
||||
"context_window": 8000,
|
||||
"context_remaining_pct": 70,
|
||||
@@ -137,23 +138,77 @@ class WSHandlerProgressParsingTests(unittest.TestCase):
|
||||
self.assertIsNotNone(entry)
|
||||
assert entry is not None
|
||||
self.assertEqual(entry["type"], "status_change")
|
||||
self.assertEqual(entry["summary"], "Context usage")
|
||||
self.assertEqual(entry["summary"], "Context usage high")
|
||||
self.assertEqual(entry["detail"], "3200/8000 tokens | 40% used")
|
||||
|
||||
def test_runtime_cost_update_maps_to_status_change_entry(self) -> None:
|
||||
def test_company_mode_tool_completed_stays_tool_call_with_stable_item_id(self) -> None:
|
||||
entry = WSHandler._runtime_event_to_progress_entry(
|
||||
{
|
||||
"type": "cost_update",
|
||||
"turn_cost_usd": 0.0123,
|
||||
"session_cost_usd": 0.0456,
|
||||
"type": "tool_completed",
|
||||
"turn_id": "rt-1:2",
|
||||
"tool_call_id": "call-1",
|
||||
"tool_name": "web_search",
|
||||
"result_summary": "3 results",
|
||||
"work_item_projection_id": "engineering_execution",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertIsNotNone(entry)
|
||||
assert entry is not None
|
||||
self.assertEqual(entry["type"], "status_change")
|
||||
self.assertEqual(entry["summary"], "Cost update")
|
||||
self.assertIn("turn=$0.0123", entry["detail"])
|
||||
self.assertEqual(entry["type"], "tool_call")
|
||||
self.assertEqual(entry["summary"], "web_search")
|
||||
self.assertEqual(entry["detail"], "3 results")
|
||||
self.assertEqual(entry["item_id"], "rt-1:2:tool:call-1")
|
||||
self.assertEqual(entry["stream_id"], "rt-1:2:tool:call-1")
|
||||
self.assertEqual(entry["tool_call_id"], "call-1")
|
||||
self.assertTrue(entry["is_company_runtime"])
|
||||
|
||||
def test_thinking_delta_preserves_fragment_whitespace(self) -> None:
|
||||
entry = WSHandler._runtime_event_to_progress_entry(
|
||||
{
|
||||
"type": "thinking_delta",
|
||||
"turn_id": "rt-1:1",
|
||||
"item_id": "rt-1:1:thinking",
|
||||
"seq": 2,
|
||||
"text": " wants to analyze",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertIsNotNone(entry)
|
||||
assert entry is not None
|
||||
self.assertEqual(entry["detail"], " wants to analyze")
|
||||
self.assertEqual(entry["summary"], "wants to analyze")
|
||||
|
||||
def test_thinking_delta_whitespace_only_fragment_is_skipped(self) -> None:
|
||||
entry = WSHandler._runtime_event_to_progress_entry(
|
||||
{
|
||||
"type": "thinking_delta",
|
||||
"turn_id": "rt-1:1",
|
||||
"item_id": "rt-1:1:thinking",
|
||||
"seq": 3,
|
||||
"text": " \n ",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertIsNone(entry)
|
||||
|
||||
def test_company_mode_thinking_summary_previews_content(self) -> None:
|
||||
entry = WSHandler._runtime_event_to_progress_entry(
|
||||
{
|
||||
"type": "thinking_delta",
|
||||
"turn_id": "rt-1:1",
|
||||
"item_id": "rt-1:1:thinking",
|
||||
"seq": 1,
|
||||
"text": "先梳理竞品清单,再对比功能矩阵。",
|
||||
"work_item_projection_id": "engineering_execution",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertIsNotNone(entry)
|
||||
assert entry is not None
|
||||
self.assertEqual(entry["type"], "thinking")
|
||||
self.assertEqual(entry["summary"], "先梳理竞品清单,再对比功能矩阵。")
|
||||
self.assertEqual(entry["detail"], "先梳理竞品清单,再对比功能矩阵。")
|
||||
|
||||
def test_task_mode_low_value_runtime_events_are_hidden(self) -> None:
|
||||
for runtime_type in (
|
||||
@@ -195,7 +250,7 @@ class WSHandlerProgressParsingTests(unittest.TestCase):
|
||||
self.assertIsNotNone(entry)
|
||||
assert entry is not None
|
||||
self.assertEqual(entry["type"], "thinking")
|
||||
self.assertEqual(entry["summary"], "Thinking")
|
||||
self.assertEqual(entry["summary"], "我先检查。")
|
||||
self.assertEqual(entry["detail"], "我先检查。")
|
||||
self.assertEqual(entry["turn_id"], "rt-1:1")
|
||||
self.assertEqual(entry["item_id"], "rt-1:1:thinking")
|
||||
@@ -256,24 +311,6 @@ class WSHandlerProgressParsingTests(unittest.TestCase):
|
||||
self.assertEqual(entry["summary"], "Needs input")
|
||||
self.assertEqual(entry["detail"], "task_user_input")
|
||||
|
||||
def test_runtime_member_inbox_updated_maps_to_status_change_entry(self) -> None:
|
||||
entry = WSHandler._runtime_event_to_progress_entry(
|
||||
{
|
||||
"type": "member_inbox_updated",
|
||||
"actionable_inbox_count": 2,
|
||||
"protocol_backlog_count": 1,
|
||||
"notification_backlog_count": 3,
|
||||
"resident_status": "idle",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertIsNotNone(entry)
|
||||
assert entry is not None
|
||||
self.assertEqual(entry["type"], "status_change")
|
||||
self.assertEqual(entry["summary"], "Resident inbox updated")
|
||||
self.assertIn("chat=2", entry["detail"])
|
||||
self.assertIn("protocol=1", entry["detail"])
|
||||
|
||||
def test_runtime_worker_notification_maps_error_to_work_item_failed_entry(self) -> None:
|
||||
entry = WSHandler._runtime_event_to_progress_entry(
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user