fix(feishu): make watch completion delivery reliable

This commit is contained in:
xmanrui 2026-08-21 16:26:58 +08:00
parent 4e325c62e0
commit a199c65356
8 changed files with 634 additions and 328 deletions

View file

@ -118,6 +118,13 @@ function nonEmptyString(value) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function orderedHistoryEvents(history) {
return (Array.isArray(history?.events) ? history.events : [])
.map((entry) => entry?.event ?? entry)
.filter((entry) => entry && typeof entry === 'object' && Number.isFinite(entry.seq))
.sort((left, right) => left.seq - right.seq);
}
function senderOpenId(event) {
return nonEmptyString(event?.sender?.sender_id?.open_id)
?? nonEmptyString(event?.sender?.sender_id?.user_id);
@ -254,11 +261,10 @@ export class FeishuHarnessBridge {
#cardKeys = new Map();
/** The global event-mux watcher (one per bridge). */
#eventWatcher = null;
#eventWatchSignal = null;
/** Turn dedup: `sessionId:turnId` already processed. */
#handledTurns = new Set();
/** Session titles for completion cards (short in-memory cache). */
#sessionTitleCache = new Map();
/** Serializes live completions and reconnect compensation. */
#eventTail = Promise.resolve();
/** Earliest completion that still needs delivery for each watch. */
#failedWatchSeqs = new Map();
constructor({
client,
@ -314,9 +320,8 @@ export class FeishuHarnessBridge {
this.#approvals = new HarnessApprovalQueue({ label: 'Feishu', logger });
this.#signal = signal;
ensureStatus(this.#status);
// Persisted watches (and bound-session push targets) must resume at
// runtime start, not on the first message. Guarded: harnesses without
// the mux watcher (tests, older hosts) simply skip this.
// Persisted watches must resume at runtime start, not on the first
// message. Older hosts without the mux watcher simply skip this.
if (typeof this.#harness?.watchHarnessEvents === 'function') {
queueMicrotask(() => this.#ensureEventWatcher());
}
@ -544,6 +549,7 @@ export class FeishuHarnessBridge {
)),
...this.#interactionTasks,
...this.#commandTasks,
this.#eventTail,
]);
}
@ -592,8 +598,6 @@ export class FeishuHarnessBridge {
const text = message.content;
const hasImages = hasInboundImages(message);
const commandText = event.message.message_type === 'text' && !hasImages ? text : null;
// Keep the persistent delivery target fresh for bound-session pushes.
this.#rememberChatTarget(key, event.message.chat_id);
if (!text && !hasImages) {
await this.#send(event.message.chat_id, '目前支持文字和图片消息。');
return;
@ -1279,24 +1283,35 @@ export class FeishuHarnessBridge {
#ensureEventWatcher() {
if (this.#eventWatcher) return;
if (typeof this.#harness?.watchHarnessEvents !== 'function') return;
this.#eventWatchSignal = new AbortController();
if (this.#signal?.aborted) return;
const signal = this.#signal ?? new AbortController().signal;
try {
this.#eventWatcher = this.#harness.watchHarnessEvents({
signal: this.#eventWatchSignal.signal,
signal,
onSessionEvent: (payload) => this.#onHarnessEvent(payload),
onReconnect: () => void this.#compensateMissedEvents(),
onReconnect: () => {
void this.#queueEventTask(() => this.#compensateMissedEvents());
},
});
Promise.resolve(this.#eventWatcher).catch((error) => {
if (!signal.aborted) {
this.#logger.warn?.('[dsh-feishu] event watcher stopped:', error.message);
}
});
this.#eventWatcher?.catch?.(() => undefined);
} catch (error) {
this.#eventWatcher = null;
this.#logger.warn?.('[dsh-feishu] event watcher failed to start:', error.message);
}
}
/** Persist the chat delivery target for a conversation (bound pushes). */
#rememberChatTarget(key, chatId) {
if (typeof this.#state?.setChatTarget !== 'function' || !chatId) return;
this.#state.setChatTarget(key, chatId).catch(() => undefined);
#queueEventTask(task) {
const next = this.#eventTail.then(task, task).catch((error) => {
if (!this.#signal?.aborted) {
this.#logger.warn?.('[dsh-feishu] completion event failed:', error.message);
}
});
this.#eventTail = next;
return next;
}
/**
@ -1337,6 +1352,17 @@ export class FeishuHarnessBridge {
return { error: '没有找到这个会话,请用 /sessionlist 查看可用会话。' };
}
async #latestSessionSeq(sessionId) {
if (typeof this.#harness?.rpc !== 'function') return null;
const history = await this.#harness.rpc(
'session.history',
{ sessionId, maxMessages: 20 },
30_000,
{ signal: this.#signal },
);
return orderedHistoryEvents(history).at(-1)?.seq ?? -1;
}
async #runWatch(key, chatId, target) {
this.#ensureEventWatcher();
if (typeof this.#state?.setWatch !== 'function') {
@ -1355,18 +1381,23 @@ export class FeishuHarnessBridge {
return;
}
const existing = this.#state.watchEntries?.(key) ?? [];
if (!existing.some((entry) => entry.sessionId === resolved.sessionId) && existing.length >= MAX_WATCHES_PER_KEY) {
const existingEntry = existing.find((entry) => entry.sessionId === resolved.sessionId);
if (!existingEntry && existing.length >= MAX_WATCHES_PER_KEY) {
await this.#send(chatId, `每个聊天最多关注 ${MAX_WATCHES_PER_KEY} 个会话。`);
return;
}
try {
const lastSeq = typeof existingEntry?.lastSeq === 'number'
? existingEntry.lastSeq
: await this.#latestSessionSeq(resolved.sessionId);
await this.#state.setWatch(key, {
sessionId: resolved.sessionId,
title: resolved.title,
chatId,
lastSeq: null,
lastSeq,
});
await this.#send(chatId, `已关注会话「${String(resolved.title).replace(/\s+/gu, ' ')}」,任务完成会推送结果。`);
await this.#queueEventTask(() => this.#compensateSession(resolved.sessionId));
} catch (error) {
await this.#send(chatId, `关注失败:${safeErrorText(error)}`);
}
@ -1384,6 +1415,7 @@ export class FeishuHarnessBridge {
}
try {
await this.#state.removeWatch(key, entry.sessionId);
this.#failedWatchSeqs.delete(`${key}\0${entry.sessionId}`);
await this.#send(chatId, `已取消关注「${String(entry.title ?? '').replace(/\s+/gu, ' ')}」。`);
} catch (error) {
await this.#send(chatId, `取消失败:${safeErrorText(error)}`);
@ -1396,111 +1428,103 @@ export class FeishuHarnessBridge {
await this.#sendCard(chatId, watchListCard(entries), { key });
}
/**
* A global mux session event. Completion pushes fire on turn/end, with
* dedup on `sessionId:turnId` and a seq watermark persisted per watch so
* reconnect compensation can replay only what was missed.
*/
/** Queue live turn completions behind any reconnect compensation. */
#onHarnessEvent({ sessionId, event }) {
if (!sessionId || !event || typeof event !== 'object' || event.type !== 'turn/end') return;
const turnId = nonEmptyString(event.data?.turn)
?? nonEmptyString(event.data?.turnId)
?? String(event.seq ?? '');
const dedupKey = `${sessionId}:${turnId}`;
if (this.#handledTurns.has(dedupKey)) return;
this.#handledTurns.add(dedupKey);
if (this.#handledTurns.size > 2000) {
const oldest = this.#handledTurns.values().next().value;
if (oldest !== undefined) this.#handledTurns.delete(oldest);
}
if (typeof event.seq === 'number' && typeof this.#state?.setWatch === 'function') {
for (const key of (this.#state.keysWatching?.(sessionId) ?? [])) {
const entry = this.#state.watchEntry?.(key, sessionId);
if (entry && (typeof entry.lastSeq !== 'number' || entry.lastSeq < event.seq)) {
this.#state.setWatch(key, { ...entry, lastSeq: event.seq }).catch(() => undefined);
}
}
}
void this.#sendCompletion(sessionId, event);
if (this.#signal?.aborted
|| !sessionId
|| !event
|| typeof event !== 'object'
|| event.type !== 'turn/end'
|| !Number.isFinite(event.seq)) return;
void this.#queueEventTask(async () => {
const hasFailedDelivery = (this.#state.keysWatching?.(sessionId) ?? [])
.some((key) => this.#failedWatchSeqs.has(`${key}\0${sessionId}`));
if (hasFailedDelivery) await this.#compensateSession(sessionId);
await this.#deliverCompletion(sessionId, event);
});
}
async #sendCompletion(sessionId, event) {
async #deliverCompletion(sessionId, event) {
if (this.#signal?.aborted || typeof this.#state?.keysWatching !== 'function') return;
const reason = event?.data?.reason?.kind ?? event?.data?.reason ?? null;
const targets = new Set();
if (typeof this.#state?.keysWatching === 'function') {
for (const key of this.#state.keysWatching(sessionId)) {
const entry = this.#state.watchEntry?.(key, sessionId);
if (entry?.chatId) targets.add(entry.chatId);
for (const key of this.#state.keysWatching(sessionId)) {
if (this.#signal?.aborted) return;
const entry = this.#state.watchEntry?.(key, sessionId);
const deliveryKey = `${key}\0${sessionId}`;
let failedSeq = this.#failedWatchSeqs.get(deliveryKey);
if (typeof failedSeq === 'number'
&& typeof entry?.lastSeq === 'number'
&& entry.lastSeq >= failedSeq) {
this.#failedWatchSeqs.delete(deliveryKey);
failedSeq = undefined;
}
}
if (typeof this.#state?.sessionKeysFor === 'function') {
for (const key of this.#state.sessionKeysFor(sessionId)) {
const chatId = typeof this.#state?.chatTargetFor === 'function'
? this.#state.chatTargetFor(key)
: null;
if (chatId) targets.add(chatId);
}
}
if (targets.size === 0) return;
const title = await this.#sessionTitleFor(sessionId);
for (const chatId of targets) {
if (!entry?.chatId
|| (typeof entry.lastSeq === 'number' && entry.lastSeq >= event.seq)
|| (typeof failedSeq === 'number' && event.seq > failedSeq)) continue;
try {
await this.#sendCard(chatId, completionCard(sessionId, title, reason));
await this.#sendCard(
entry.chatId,
completionCard(sessionId, entry.title, reason),
{ key },
);
const current = this.#state.watchEntry?.(key, sessionId);
if (!current
|| current.chatId !== entry.chatId
|| (typeof current.lastSeq === 'number' && current.lastSeq >= event.seq)) continue;
await this.#state.setWatch(key, { ...current, lastSeq: event.seq });
if (failedSeq === event.seq) this.#failedWatchSeqs.delete(deliveryKey);
} catch (error) {
this.#failedWatchSeqs.set(
deliveryKey,
typeof failedSeq === 'number' ? Math.min(failedSeq, event.seq) : event.seq,
);
this.#logger.warn?.('[dsh-feishu] completion push failed:', error.message);
}
}
}
async #sessionTitleFor(sessionId) {
if (this.#sessionTitleCache.has(sessionId)) return this.#sessionTitleCache.get(sessionId);
let title = '暂无标题';
async #compensateSession(sessionId) {
if (this.#signal?.aborted || typeof this.#harness?.rpc !== 'function') return;
try {
const currentPath = typeof this.#harness?.currentWorkspace === 'function'
? this.#harness.currentWorkspace()
: null;
if (currentPath && typeof this.#harness?.listWorkspaceSessions === 'function') {
const listed = await this.#harness.listWorkspaceSessions(currentPath);
const session = (listed?.sessions ?? []).find((candidate) => candidate.sessionId === sessionId);
if (session) title = String(session.title ?? '').replace(/\s+/gu, ' ').trim() || '暂无标题';
const history = await this.#harness.rpc(
'session.history',
{ sessionId, maxMessages: 20 },
30_000,
{ signal: this.#signal },
);
const events = orderedHistoryEvents(history);
const latestSeq = events.at(-1)?.seq ?? -1;
const keys = typeof this.#state?.keysWatching === 'function'
? this.#state.keysWatching(sessionId)
: [];
// Watches created by older versions have no baseline. Establish one
// without replaying completions that predate the watch.
for (const key of keys) {
const entry = this.#state.watchEntry?.(key, sessionId);
if (entry && typeof entry.lastSeq !== 'number') {
await this.#state.setWatch(key, { ...entry, lastSeq: latestSeq });
}
}
for (const event of events) {
if (event.type === 'turn/end') await this.#deliverCompletion(sessionId, event);
}
} catch (error) {
if (!this.#signal?.aborted) {
this.#logger.warn?.(`[dsh-feishu] watch compensation failed for ${sessionId}:`, error.message);
}
} catch {
// Best-effort title; the card falls back to the id.
}
this.#sessionTitleCache.set(sessionId, title);
return title;
}
/**
* Replay turn/end events missed while the mux was disconnected. Reads
* each watched session's recent history and feeds unseen events through
* the normal handler, whose `sessionId:turnId` dedup makes the replay
* overlap-safe against live frames.
*/
/** Replay recent turn completions missed while the mux was disconnected. */
async #compensateMissedEvents() {
if (typeof this.#harness?.rpc !== 'function') return;
const sessionIds = typeof this.#state?.watchedSessionIds === 'function'
? this.#state.watchedSessionIds()
: [];
for (const sessionId of sessionIds) {
try {
const history = await this.#harness.rpc('session.history', { sessionId, maxMessages: 20 });
const events = history?.events ?? [];
for (const event of events) {
if (event?.type !== 'turn/end' || typeof event.seq !== 'number') continue;
const keys = typeof this.#state?.keysWatching === 'function'
? this.#state.keysWatching(sessionId)
: [];
const allSeen = keys.length > 0 && keys.every((key) => {
const entry = this.#state.watchEntry?.(key, sessionId);
return entry && typeof entry.lastSeq === 'number' && entry.lastSeq >= event.seq;
});
if (allSeen) continue;
this.#onHarnessEvent({ sessionId, event });
}
} catch (error) {
this.#logger.warn?.(`[dsh-feishu] watch compensation failed for ${sessionId}:`, error.message);
}
if (this.#signal?.aborted) return;
await this.#compensateSession(sessionId);
}
}

View file

@ -211,7 +211,15 @@ export function watchListCard(entries) {
* turn-end kind (completed / stopped / aborted).
*/
export function completionCard(sessionId, title, reason) {
const reasonText = reason === 'stopped' ? '已停止' : reason === 'aborted' ? '已中止' : '已完成';
const reasonText = reason === 'completed'
? '已完成'
: reason === 'stopped'
? '已停止'
: reason === 'aborted'
? '已中止'
: reason === 'cancelled'
? '已取消'
: '已结束';
return cardWith('✅ 任务完成', [
{ tag: 'div', text: markdown(`**${safeTitle(title)}**\n\`${sessionId}\``) },
{ tag: 'div', text: markdown(`**状态**:${reasonText}`) },

View file

@ -6,7 +6,6 @@ const EMPTY_STATE = Object.freeze({
sessions: {},
seenMessageIds: [],
watches: {},
chatTargets: {},
includeArchivedSessions: false,
});
@ -38,7 +37,6 @@ export class StateStore {
sessions: parsed.sessions && typeof parsed.sessions === 'object' ? parsed.sessions : {},
seenMessageIds: Array.isArray(parsed.seenMessageIds) ? parsed.seenMessageIds.slice(-1000) : [],
watches: parsed.watches && typeof parsed.watches === 'object' ? parsed.watches : {},
chatTargets: parsed.chatTargets && typeof parsed.chatTargets === 'object' ? parsed.chatTargets : {},
includeArchivedSessions: typeof parsed.includeArchivedSessions === 'boolean'
? parsed.includeArchivedSessions
: false,
@ -138,27 +136,7 @@ export class StateStore {
return [...ids];
}
/** Conversation keys whose bound session is the given id. */
sessionKeysFor(sessionId) {
return Object.entries(this.#state.sessions)
.filter(([, bound]) => bound === sessionId)
.map(([key]) => key);
}
// ── Persistent chat delivery targets for bound sessions ─────────────────
chatTargetFor(key) {
const target = this.#state.chatTargets[key];
return typeof target === 'string' && target.length > 0 ? target : null;
}
async setChatTarget(key, chatId) {
if (this.#state.chatTargets[key] === chatId) return;
this.#state.chatTargets[key] = chatId;
await this.#persist();
}
// ── Session-list archived policy (per bot) ───────────────────────────────
// ── Session-list archived policy (per bot) ───────────────────
includesArchivedSessions() {
return this.#state.includeArchivedSessions === true;

View file

@ -1231,75 +1231,115 @@ export class HarnessClient {
* provided) fires after every (re)connection so callers can compensate
* for events missed while offline.
*/
watchHarnessEvents({ signal, onSessionEvent, onReconnect }) {
async watchHarnessEvents({ signal, onSessionEvent, onReconnect } = {}) {
if (typeof onSessionEvent !== 'function') {
return Promise.reject(new TypeError('watchHarnessEvents requires onSessionEvent'));
throw new TypeError('watchHarnessEvents requires onSessionEvent');
}
if (!signal || typeof signal.addEventListener !== 'function') {
throw new TypeError('watchHarnessEvents requires an AbortSignal');
}
if (onReconnect !== undefined && typeof onReconnect !== 'function') {
throw new TypeError('onReconnect must be a function');
}
const url = new URL('/api/events.mux', this.#baseUrl);
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
let stopped = false;
const connect = () => {
if (stopped || signal.aborted) return Promise.resolve();
return new Promise((resolve) => {
const socket = this.#createWebSocket(url.toString());
const settle = () => {
socket.removeEventListener('open', handleOpen);
socket.removeEventListener('message', handleMessage);
socket.removeEventListener('close', handleClose);
socket.removeEventListener('error', handleError);
signal.removeEventListener('abort', handleAbort);
resolve();
};
const handleOpen = () => {
try {
onReconnect?.();
} catch (error) {
console.warn(`[${this.#logPrefix}] mux reconnect hook failed:`, error.message);
}
};
const handleMessage = (event) => {
try {
if (typeof event.data !== 'string') return;
const envelope = JSON.parse(event.data);
const payload = envelope?.payload;
if (envelope?.type !== 'server-request' || !payload || typeof payload !== 'object') return;
if (payload.type !== 'session/event') return;
if (typeof payload.sessionId !== 'string' || !payload.event || typeof payload.event !== 'object') return;
onSessionEvent({ sessionId: payload.sessionId, event: payload.event });
} catch (error) {
console.warn(`[${this.#logPrefix}] ignored a malformed global mux frame:`, error.message);
}
};
const handleClose = () => {
settle();
if (!stopped && !signal.aborted) {
setTimeout(() => { void connect(); }, 2000);
}
};
const handleError = () => {
try {
socket.close();
} catch {
// The close event drives reconnection; nothing to do here.
}
};
const handleAbort = () => {
stopped = true;
try {
socket.close();
} catch {
// Already closed.
}
};
socket.addEventListener('open', handleOpen);
socket.addEventListener('message', handleMessage);
socket.addEventListener('close', handleClose, { once: true });
socket.addEventListener('error', handleError, { once: true });
signal.addEventListener('abort', handleAbort, { once: true });
if (signal.aborted) handleAbort();
});
};
return connect();
while (!signal.aborted) {
try {
await this.#watchHarnessEventSocket(url.toString(), {
signal,
onSessionEvent,
onReconnect,
});
} catch (error) {
if (signal.aborted) return;
console.warn(`[${this.#logPrefix}] Harness event mux disconnected:`, error.message);
}
if (signal.aborted) return;
try {
await sleep(this.#interactionReconnectDelayMs, signal);
} catch {
if (signal.aborted) return;
throw new Error('Harness event mux reconnect wait failed');
}
}
}
#watchHarnessEventSocket(url, { signal, onSessionEvent, onReconnect }) {
return new Promise((resolve, reject) => {
let socket;
try {
socket = this.#createWebSocket(url);
} catch (error) {
reject(error);
return;
}
let opened = false;
let finished = false;
const close = () => {
try {
socket.close();
} catch {
// Already closed.
}
};
const finish = (error) => {
if (finished) return;
finished = true;
socket.removeEventListener('open', handleOpen);
socket.removeEventListener('message', handleMessage);
socket.removeEventListener('close', handleClose);
socket.removeEventListener('error', handleError);
signal.removeEventListener('abort', handleAbort);
if (error) reject(error);
else resolve();
};
const handleOpen = () => {
opened = true;
try {
onReconnect?.();
} catch (error) {
console.warn(`[${this.#logPrefix}] mux reconnect hook failed:`, error.message);
}
};
const handleMessage = (event) => {
try {
if (typeof event.data !== 'string') return;
const envelope = JSON.parse(event.data);
const payload = envelope?.payload;
if (envelope?.type !== 'server-request'
|| !payload
|| typeof payload !== 'object'
|| envelope.method !== payload.type
|| payload.type !== 'session/event'
|| typeof payload.sessionId !== 'string'
|| !payload.event
|| typeof payload.event !== 'object') return;
onSessionEvent({ sessionId: payload.sessionId, event: payload.event });
} catch (error) {
console.warn(`[${this.#logPrefix}] ignored a malformed global mux frame:`, error.message);
}
};
const handleClose = () => finish(opened ? null : new Error(
'Harness event mux WebSocket closed before opening',
));
const handleError = () => {
finish(new Error(opened
? 'Harness event mux WebSocket failed'
: 'Harness event mux WebSocket failed before opening'));
close();
};
const handleAbort = () => {
close();
finish();
};
socket.addEventListener('open', handleOpen);
socket.addEventListener('message', handleMessage);
socket.addEventListener('close', handleClose, { once: true });
socket.addEventListener('error', handleError, { once: true });
signal.addEventListener('abort', handleAbort, { once: true });
if (signal.aborted) handleAbort();
});
}
stopManagedProcess() {