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

File diff suppressed because one or more lines are too long

View file

@ -118,6 +118,13 @@ function nonEmptyString(value) {
return typeof value === 'string' && value.trim() ? value.trim() : null; 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) { function senderOpenId(event) {
return nonEmptyString(event?.sender?.sender_id?.open_id) return nonEmptyString(event?.sender?.sender_id?.open_id)
?? nonEmptyString(event?.sender?.sender_id?.user_id); ?? nonEmptyString(event?.sender?.sender_id?.user_id);
@ -254,11 +261,10 @@ export class FeishuHarnessBridge {
#cardKeys = new Map(); #cardKeys = new Map();
/** The global event-mux watcher (one per bridge). */ /** The global event-mux watcher (one per bridge). */
#eventWatcher = null; #eventWatcher = null;
#eventWatchSignal = null; /** Serializes live completions and reconnect compensation. */
/** Turn dedup: `sessionId:turnId` already processed. */ #eventTail = Promise.resolve();
#handledTurns = new Set(); /** Earliest completion that still needs delivery for each watch. */
/** Session titles for completion cards (short in-memory cache). */ #failedWatchSeqs = new Map();
#sessionTitleCache = new Map();
constructor({ constructor({
client, client,
@ -314,9 +320,8 @@ export class FeishuHarnessBridge {
this.#approvals = new HarnessApprovalQueue({ label: 'Feishu', logger }); this.#approvals = new HarnessApprovalQueue({ label: 'Feishu', logger });
this.#signal = signal; this.#signal = signal;
ensureStatus(this.#status); ensureStatus(this.#status);
// Persisted watches (and bound-session push targets) must resume at // Persisted watches must resume at runtime start, not on the first
// runtime start, not on the first message. Guarded: harnesses without // message. Older hosts without the mux watcher simply skip this.
// the mux watcher (tests, older hosts) simply skip this.
if (typeof this.#harness?.watchHarnessEvents === 'function') { if (typeof this.#harness?.watchHarnessEvents === 'function') {
queueMicrotask(() => this.#ensureEventWatcher()); queueMicrotask(() => this.#ensureEventWatcher());
} }
@ -544,6 +549,7 @@ export class FeishuHarnessBridge {
)), )),
...this.#interactionTasks, ...this.#interactionTasks,
...this.#commandTasks, ...this.#commandTasks,
this.#eventTail,
]); ]);
} }
@ -592,8 +598,6 @@ export class FeishuHarnessBridge {
const text = message.content; const text = message.content;
const hasImages = hasInboundImages(message); const hasImages = hasInboundImages(message);
const commandText = event.message.message_type === 'text' && !hasImages ? text : null; 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) { if (!text && !hasImages) {
await this.#send(event.message.chat_id, '目前支持文字和图片消息。'); await this.#send(event.message.chat_id, '目前支持文字和图片消息。');
return; return;
@ -1279,24 +1283,35 @@ export class FeishuHarnessBridge {
#ensureEventWatcher() { #ensureEventWatcher() {
if (this.#eventWatcher) return; if (this.#eventWatcher) return;
if (typeof this.#harness?.watchHarnessEvents !== 'function') 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 { try {
this.#eventWatcher = this.#harness.watchHarnessEvents({ this.#eventWatcher = this.#harness.watchHarnessEvents({
signal: this.#eventWatchSignal.signal, signal,
onSessionEvent: (payload) => this.#onHarnessEvent(payload), 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) { } catch (error) {
this.#eventWatcher = null; this.#eventWatcher = null;
this.#logger.warn?.('[dsh-feishu] event watcher failed to start:', error.message); this.#logger.warn?.('[dsh-feishu] event watcher failed to start:', error.message);
} }
} }
/** Persist the chat delivery target for a conversation (bound pushes). */ #queueEventTask(task) {
#rememberChatTarget(key, chatId) { const next = this.#eventTail.then(task, task).catch((error) => {
if (typeof this.#state?.setChatTarget !== 'function' || !chatId) return; if (!this.#signal?.aborted) {
this.#state.setChatTarget(key, chatId).catch(() => undefined); 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 查看可用会话。' }; 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) { async #runWatch(key, chatId, target) {
this.#ensureEventWatcher(); this.#ensureEventWatcher();
if (typeof this.#state?.setWatch !== 'function') { if (typeof this.#state?.setWatch !== 'function') {
@ -1355,18 +1381,23 @@ export class FeishuHarnessBridge {
return; return;
} }
const existing = this.#state.watchEntries?.(key) ?? []; 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} 个会话。`); await this.#send(chatId, `每个聊天最多关注 ${MAX_WATCHES_PER_KEY} 个会话。`);
return; return;
} }
try { try {
const lastSeq = typeof existingEntry?.lastSeq === 'number'
? existingEntry.lastSeq
: await this.#latestSessionSeq(resolved.sessionId);
await this.#state.setWatch(key, { await this.#state.setWatch(key, {
sessionId: resolved.sessionId, sessionId: resolved.sessionId,
title: resolved.title, title: resolved.title,
chatId, chatId,
lastSeq: null, lastSeq,
}); });
await this.#send(chatId, `已关注会话「${String(resolved.title).replace(/\s+/gu, ' ')}」,任务完成会推送结果。`); await this.#send(chatId, `已关注会话「${String(resolved.title).replace(/\s+/gu, ' ')}」,任务完成会推送结果。`);
await this.#queueEventTask(() => this.#compensateSession(resolved.sessionId));
} catch (error) { } catch (error) {
await this.#send(chatId, `关注失败:${safeErrorText(error)}`); await this.#send(chatId, `关注失败:${safeErrorText(error)}`);
} }
@ -1384,6 +1415,7 @@ export class FeishuHarnessBridge {
} }
try { try {
await this.#state.removeWatch(key, entry.sessionId); await this.#state.removeWatch(key, entry.sessionId);
this.#failedWatchSeqs.delete(`${key}\0${entry.sessionId}`);
await this.#send(chatId, `已取消关注「${String(entry.title ?? '').replace(/\s+/gu, ' ')}」。`); await this.#send(chatId, `已取消关注「${String(entry.title ?? '').replace(/\s+/gu, ' ')}」。`);
} catch (error) { } catch (error) {
await this.#send(chatId, `取消失败:${safeErrorText(error)}`); await this.#send(chatId, `取消失败:${safeErrorText(error)}`);
@ -1396,111 +1428,103 @@ export class FeishuHarnessBridge {
await this.#sendCard(chatId, watchListCard(entries), { key }); await this.#sendCard(chatId, watchListCard(entries), { key });
} }
/** /** Queue live turn completions behind any reconnect compensation. */
* 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.
*/
#onHarnessEvent({ sessionId, event }) { #onHarnessEvent({ sessionId, event }) {
if (!sessionId || !event || typeof event !== 'object' || event.type !== 'turn/end') return; if (this.#signal?.aborted
const turnId = nonEmptyString(event.data?.turn) || !sessionId
?? nonEmptyString(event.data?.turnId) || !event
?? String(event.seq ?? ''); || typeof event !== 'object'
const dedupKey = `${sessionId}:${turnId}`; || event.type !== 'turn/end'
if (this.#handledTurns.has(dedupKey)) return; || !Number.isFinite(event.seq)) return;
this.#handledTurns.add(dedupKey); void this.#queueEventTask(async () => {
if (this.#handledTurns.size > 2000) { const hasFailedDelivery = (this.#state.keysWatching?.(sessionId) ?? [])
const oldest = this.#handledTurns.values().next().value; .some((key) => this.#failedWatchSeqs.has(`${key}\0${sessionId}`));
if (oldest !== undefined) this.#handledTurns.delete(oldest); if (hasFailedDelivery) await this.#compensateSession(sessionId);
} await this.#deliverCompletion(sessionId, event);
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);
} }
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 reason = event?.data?.reason?.kind ?? event?.data?.reason ?? null;
const targets = new Set(); for (const key of this.#state.keysWatching(sessionId)) {
if (typeof this.#state?.keysWatching === 'function') { if (this.#signal?.aborted) return;
for (const key of this.#state.keysWatching(sessionId)) { const entry = this.#state.watchEntry?.(key, sessionId);
const entry = this.#state.watchEntry?.(key, sessionId); const deliveryKey = `${key}\0${sessionId}`;
if (entry?.chatId) targets.add(entry.chatId); let failedSeq = this.#failedWatchSeqs.get(deliveryKey);
if (typeof failedSeq === 'number'
&& typeof entry?.lastSeq === 'number'
&& entry.lastSeq >= failedSeq) {
this.#failedWatchSeqs.delete(deliveryKey);
failedSeq = undefined;
} }
} if (!entry?.chatId
if (typeof this.#state?.sessionKeysFor === 'function') { || (typeof entry.lastSeq === 'number' && entry.lastSeq >= event.seq)
for (const key of this.#state.sessionKeysFor(sessionId)) { || (typeof failedSeq === 'number' && event.seq > failedSeq)) continue;
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) {
try { 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) { } 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); this.#logger.warn?.('[dsh-feishu] completion push failed:', error.message);
} }
} }
} }
async #sessionTitleFor(sessionId) { async #compensateSession(sessionId) {
if (this.#sessionTitleCache.has(sessionId)) return this.#sessionTitleCache.get(sessionId); if (this.#signal?.aborted || typeof this.#harness?.rpc !== 'function') return;
let title = '暂无标题';
try { try {
const currentPath = typeof this.#harness?.currentWorkspace === 'function' const history = await this.#harness.rpc(
? this.#harness.currentWorkspace() 'session.history',
: null; { sessionId, maxMessages: 20 },
if (currentPath && typeof this.#harness?.listWorkspaceSessions === 'function') { 30_000,
const listed = await this.#harness.listWorkspaceSessions(currentPath); { signal: this.#signal },
const session = (listed?.sessions ?? []).find((candidate) => candidate.sessionId === sessionId); );
if (session) title = String(session.title ?? '').replace(/\s+/gu, ' ').trim() || '暂无标题'; 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 recent turn completions missed while the mux was disconnected. */
* 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.
*/
async #compensateMissedEvents() { async #compensateMissedEvents() {
if (typeof this.#harness?.rpc !== 'function') return;
const sessionIds = typeof this.#state?.watchedSessionIds === 'function' const sessionIds = typeof this.#state?.watchedSessionIds === 'function'
? this.#state.watchedSessionIds() ? this.#state.watchedSessionIds()
: []; : [];
for (const sessionId of sessionIds) { for (const sessionId of sessionIds) {
try { if (this.#signal?.aborted) return;
const history = await this.#harness.rpc('session.history', { sessionId, maxMessages: 20 }); await this.#compensateSession(sessionId);
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);
}
} }
} }

View file

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

View file

@ -6,7 +6,6 @@ const EMPTY_STATE = Object.freeze({
sessions: {}, sessions: {},
seenMessageIds: [], seenMessageIds: [],
watches: {}, watches: {},
chatTargets: {},
includeArchivedSessions: false, includeArchivedSessions: false,
}); });
@ -38,7 +37,6 @@ export class StateStore {
sessions: parsed.sessions && typeof parsed.sessions === 'object' ? parsed.sessions : {}, sessions: parsed.sessions && typeof parsed.sessions === 'object' ? parsed.sessions : {},
seenMessageIds: Array.isArray(parsed.seenMessageIds) ? parsed.seenMessageIds.slice(-1000) : [], seenMessageIds: Array.isArray(parsed.seenMessageIds) ? parsed.seenMessageIds.slice(-1000) : [],
watches: parsed.watches && typeof parsed.watches === 'object' ? parsed.watches : {}, watches: parsed.watches && typeof parsed.watches === 'object' ? parsed.watches : {},
chatTargets: parsed.chatTargets && typeof parsed.chatTargets === 'object' ? parsed.chatTargets : {},
includeArchivedSessions: typeof parsed.includeArchivedSessions === 'boolean' includeArchivedSessions: typeof parsed.includeArchivedSessions === 'boolean'
? parsed.includeArchivedSessions ? parsed.includeArchivedSessions
: false, : false,
@ -138,27 +136,7 @@ export class StateStore {
return [...ids]; return [...ids];
} }
/** Conversation keys whose bound session is the given id. */ // ── Session-list archived policy (per bot) ───────────────────
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) ───────────────────────────────
includesArchivedSessions() { includesArchivedSessions() {
return this.#state.includeArchivedSessions === true; return this.#state.includeArchivedSessions === true;

View file

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

View file

@ -2415,6 +2415,7 @@ import { StateStore } from '../../../src/channels/feishu/state-store.mjs';
function watchHarness({ sessionsByWorkspace = { 'C:/work': [] }, current = 'C:/work', history = [] } = {}) { function watchHarness({ sessionsByWorkspace = { 'C:/work': [] }, current = 'C:/work', history = [] } = {}) {
const listeners = []; const listeners = [];
let currentHistory = history;
return { return {
ensureRunning: async () => true, ensureRunning: async () => true,
currentWorkspace: () => current, currentWorkspace: () => current,
@ -2422,20 +2423,25 @@ function watchHarness({ sessionsByWorkspace = { 'C:/work': [] }, current = 'C:/w
listWorkspaceSessions: async (workspace) => ({ workspace, sessions: sessionsByWorkspace[workspace] ?? [] }), listWorkspaceSessions: async (workspace) => ({ workspace, sessions: sessionsByWorkspace[workspace] ?? [] }),
bindWorkspaceSession: async (_key, sessionId) => ({ sessionId, title: `Title ${sessionId}` }), bindWorkspaceSession: async (_key, sessionId) => ({ sessionId, title: `Title ${sessionId}` }),
switchWorkspace: async (path) => path, switchWorkspace: async (path) => path,
rpc: async (method, params) => (method === 'session.history' ? { events: history } : null), rpc: async (method, params) => (method === 'session.history' ? { events: currentHistory } : null),
watchHarnessEvents: ({ onSessionEvent, onReconnect }) => { watchHarnessEvents: ({ signal, onSessionEvent, onReconnect }) => {
listeners.push({ onSessionEvent, onReconnect }); listeners.push({ signal, onSessionEvent, onReconnect });
return Promise.resolve(); return new Promise((resolve) => {
if (signal.aborted) resolve();
else signal.addEventListener('abort', resolve, { once: true });
});
}, },
_listeners: listeners, _listeners: listeners,
_setHistory: (next) => { currentHistory = next; },
}; };
} }
async function watchStoreFixture(seedSessions = []) { async function watchStoreFixture(seedSessions = []) {
const store = new StateStore(join(tmpdir(), `dsh-im-watch-test-${Math.random().toString(36).slice(2)}.json`)); const path = join(tmpdir(), `dsh-im-watch-test-${Math.random().toString(36).slice(2)}.json`);
const store = new StateStore(path);
await store.load(); await store.load();
for (const [key, sessionId] of seedSessions) await store.setSession(key, sessionId); for (const [key, sessionId] of seedSessions) await store.setSession(key, sessionId);
return { store, state: store }; return { path, store, state: store };
} }
test('/watch resolves read-only: no binding, no workspace switch', async () => { test('/watch resolves read-only: no binding, no workspace switch', async () => {
@ -2495,15 +2501,16 @@ test('/watch finds a session in another workspace without switching', async () =
}); });
test('persisted watches resume the event watcher at runtime start', async () => { test('persisted watches resume the event watcher at runtime start', async () => {
const { state } = await watchStoreFixture(); const { path, state } = await watchStoreFixture();
await state.setWatch('p2p:ou_owner', { sessionId: 'kept-session', title: 'Kept', chatId: 'oc_chat', lastSeq: 3 }); await state.setWatch('p2p:ou_owner', { sessionId: 'kept-session', title: 'Kept', chatId: 'oc_chat', lastSeq: 3 });
const reloadedState = await new StateStore(path).load();
const harness = watchHarness(); const harness = watchHarness();
const bridge = new FeishuHarnessBridge({ const bridge = new FeishuHarnessBridge({
client: textClient(async () => {}), client: textClient(async () => {}),
channel: {}, channel: {},
harness, harness,
state, state: reloadedState,
status: bridgeStatus(), status: bridgeStatus(),
allowedSenderOpenIds: new Set(['ou_owner']), allowedSenderOpenIds: new Set(['ou_owner']),
}); });
@ -2514,12 +2521,11 @@ test('persisted watches resume the event watcher at runtime start', async () =>
test('reconnect compensation replays missed turn/end and dedups duplicates', async () => { test('reconnect compensation replays missed turn/end and dedups duplicates', async () => {
const { state } = await watchStoreFixture(); const { state } = await watchStoreFixture();
await state.setWatch('p2p:ou_owner', { sessionId: 'watched-session', title: 'Watched', chatId: 'oc_chat', lastSeq: null }); await state.setWatch('p2p:ou_owner', { sessionId: 'watched-session', title: 'Watched', chatId: 'oc_chat', lastSeq: 9 });
const harness = watchHarness({ const harness = watchHarness({
sessionsByWorkspace: { 'C:/work': [{ sessionId: 'watched-session', title: 'Watched' }] },
history: [ history: [
{ type: 'turn/end', seq: 10, data: { turn: 't1', reason: { kind: 'completed' } } }, { event: { type: 'turn/end', seq: 11, data: { turn: 't2', reason: { kind: 'stopped' } } } },
{ type: 'turn/end', seq: 11, data: { turn: 't2', reason: { kind: 'completed' } } }, { event: { type: 'turn/end', seq: 10, data: { turn: 't1', reason: { kind: 'completed' } } } },
], ],
}); });
const cards = []; const cards = [];
@ -2536,22 +2542,194 @@ test('reconnect compensation replays missed turn/end and dedups duplicates', asy
await new Promise((resolve) => setTimeout(resolve, 10)); await new Promise((resolve) => setTimeout(resolve, 10));
assert.equal(harness._listeners.length, 1); assert.equal(harness._listeners.length, 1);
// Live event: t1 arrives before any replay. // Real history wraps events and may return them out of order.
harness._listeners[0].onSessionEvent({ sessionId: 'watched-session', event: { type: 'turn/end', seq: 10, data: { turn: 't1', reason: { kind: 'completed' } } } }); harness._listeners[0].onReconnect();
await bridge.waitForIdle(); await bridge.waitForIdle();
assert.equal(cards.length, 1, 'one completion for the live event'); assert.equal(cards.length, 2);
assert.match(JSON.stringify(cards[0]), /已完成/);
assert.match(JSON.stringify(cards[1]), /已停止/);
assert.equal(state.watchEntry('p2p:ou_owner', 'watched-session').lastSeq, 11);
// Reconnect: history replays t1 (dedup) and t2 (new). // Reconnect and an overlapping live frame are both deduplicated by lastSeq.
await harness._listeners[0].onReconnect(); harness._listeners[0].onReconnect();
await new Promise((resolve) => setTimeout(resolve, 20)); harness._listeners[0].onSessionEvent({
assert.equal(cards.length, 2, 'exactly one new completion from compensation (t1 deduped)'); sessionId: 'watched-session',
assert.match(JSON.stringify(cards[1]), /watched-session/); event: { type: 'turn/end', seq: 11, data: { turn: 't2', reason: { kind: 'stopped' } } },
});
await bridge.waitForIdle();
assert.equal(cards.length, 2);
});
test('/watch baselines existing history and completion-card buttons keep their route', async () => {
const { state } = await watchStoreFixture();
const work = realpathSync(tmpdir());
const oldCompletion = {
event: { type: 'turn/end', seq: 10, data: { turn: 'old', reason: { kind: 'completed' } } },
};
const harness = watchHarness({
current: work,
sessionsByWorkspace: { [work]: [{ sessionId: 'watched-session', title: 'Watched' }] },
history: [oldCompletion],
});
const sent = [];
const bridge = new FeishuHarnessBridge({
client: cardClient(async (outgoing) => sent.push(outgoing)),
channel: {},
harness,
state,
status: bridgeStatus(),
allowedSenderOpenIds: new Set(['ou_owner']),
});
await bridge.accept(event('watch-baseline', '/watch 1', { senderOpenId: 'ou_owner' }));
await bridge.waitForIdle();
assert.equal(state.watchEntry('p2p:ou_owner', 'watched-session').lastSeq, 10);
assert.equal(cards(sent).length, 0, 'a new watch must not replay an older completion');
harness._listeners[0].onReconnect();
await bridge.waitForIdle();
assert.equal(cards(sent).length, 0);
harness._setHistory([
oldCompletion,
{ event: { type: 'turn/end', seq: 11, data: { turn: 'new', reason: { kind: 'completed' } } } },
]);
harness._listeners[0].onReconnect();
await bridge.waitForIdle();
assert.equal(cards(sent).length, 1);
// The text confirmation is om_card_1, so the completion is om_card_2.
await bridge.onCardAction(cardActionEvent('om_card_2', 'sessions', 'ou_owner'));
await bridge.waitForIdle();
assert.equal(cards(sent).length, 2);
assert.equal(cards(sent).at(-1).content.header.title.content, '📂 会话列表');
});
test('a failed completion push keeps its watermark and later activity retries it', async () => {
const { state } = await watchStoreFixture();
await state.setWatch('p2p:ou_owner', {
sessionId: 'cross-workspace-session',
title: 'Cross Workspace Title',
chatId: 'oc_chat',
lastSeq: 10,
});
const completion = {
event: { type: 'turn/end', seq: 11, data: { turn: 'retry', reason: { kind: 'completed' } } },
};
const laterCompletion = {
event: { type: 'turn/end', seq: 12, data: { turn: 'later', reason: { kind: 'completed' } } },
};
const harness = watchHarness({ history: [laterCompletion, completion] });
const cardsSent = [];
let failNext = true;
const bridge = new FeishuHarnessBridge({
client: cardClient(async ({ msgType, content }) => {
if (msgType !== 'interactive') return;
if (failNext) {
failNext = false;
throw new Error('temporary Feishu failure');
}
cardsSent.push(content);
}),
channel: {},
harness,
state,
status: bridgeStatus(),
allowedSenderOpenIds: new Set(['ou_owner']),
logger: { warn: () => undefined },
});
await eventually(() => harness._listeners.length === 1);
harness._listeners[0].onSessionEvent({
sessionId: 'cross-workspace-session',
event: completion.event,
});
await bridge.waitForIdle();
assert.equal(state.watchEntry('p2p:ou_owner', 'cross-workspace-session').lastSeq, 10);
assert.equal(cardsSent.length, 0);
// A later live completion recovers the earlier failure through history;
// no socket reconnect is required to unstick this watch.
harness._listeners[0].onSessionEvent({
sessionId: 'cross-workspace-session',
event: laterCompletion.event,
});
await bridge.waitForIdle();
assert.equal(state.watchEntry('p2p:ou_owner', 'cross-workspace-session').lastSeq, 12);
assert.equal(cardsSent.length, 2);
assert.match(JSON.stringify(cardsSent[0]), /Cross Workspace Title/);
});
test('legacy watches establish a baseline without replaying old completions', async () => {
const { state } = await watchStoreFixture();
await state.setWatch('p2p:ou_owner', {
sessionId: 'legacy-session',
title: 'Legacy',
chatId: 'oc_chat',
lastSeq: null,
});
const harness = watchHarness({
history: [{ event: { type: 'turn/end', seq: 20, data: { turn: 'old' } } }],
});
const cardsSent = [];
const bridge = new FeishuHarnessBridge({
client: cardClient(async ({ msgType, content }) => {
if (msgType === 'interactive') cardsSent.push(content);
}),
channel: {},
harness,
state,
status: bridgeStatus(),
allowedSenderOpenIds: new Set(['ou_owner']),
});
await eventually(() => harness._listeners.length === 1);
harness._listeners[0].onReconnect();
await bridge.waitForIdle();
assert.equal(cardsSent.length, 0);
assert.equal(state.watchEntry('p2p:ou_owner', 'legacy-session').lastSeq, 20);
});
test('runtime abort stops the old event watcher before a new bridge starts', async () => {
const firstHarness = watchHarness();
const firstController = new AbortController();
const { state: firstState } = await watchStoreFixture();
new FeishuHarnessBridge({
client: textClient(async () => {}),
channel: {},
harness: firstHarness,
state: firstState,
status: bridgeStatus(),
allowedSenderOpenIds: new Set(['ou_owner']),
signal: firstController.signal,
});
await eventually(() => firstHarness._listeners.length === 1);
assert.equal(firstHarness._listeners[0].signal.aborted, false);
firstController.abort();
assert.equal(firstHarness._listeners[0].signal.aborted, true);
const secondHarness = watchHarness();
const secondController = new AbortController();
const { state: secondState } = await watchStoreFixture();
new FeishuHarnessBridge({
client: textClient(async () => {}),
channel: {},
harness: secondHarness,
state: secondState,
status: bridgeStatus(),
allowedSenderOpenIds: new Set(['ou_owner']),
signal: secondController.signal,
});
await eventually(() => secondHarness._listeners.length === 1);
assert.equal(secondHarness._listeners[0].signal.aborted, false);
secondController.abort();
}); });
test('archived sessions are hidden by default; /archived on reveals them', async () => { test('archived sessions are hidden by default; /archived on reveals them', async () => {
const { state } = await watchStoreFixture(); const { state } = await watchStoreFixture();
const work = join(tmpdir(), 'dsh-im-archived-test-work'); const workRaw = join(tmpdir(), 'dsh-im-archived-test-work');
mkdirSync(work, { recursive: true }); mkdirSync(workRaw, { recursive: true });
const work = realpathSync(workRaw);
const harness = watchHarness({ const harness = watchHarness({
current: work, current: work,
sessionsByWorkspace: { sessionsByWorkspace: {

View file

@ -16,13 +16,13 @@ function buttons(value, result = []) {
return result; return result;
} }
test('menu exposes repair as number-only text instead of a callback button', () => { test('menu appends watchlist and keeps repair number-only', () => {
const card = JSON.parse(menuCard()); const card = JSON.parse(menuCard());
assert.match(JSON.stringify(card), /6 · 修复卡片按钮/); assert.match(JSON.stringify(card), /6 · 修复卡片按钮/);
const actions = buttons(card).flatMap((button) => ( const actions = buttons(card).flatMap((button) => (
button.behaviors?.map((behavior) => behavior?.value?.action) ?? [] button.behaviors?.map((behavior) => behavior?.value?.action) ?? []
)); ));
assert.deepEqual(actions, ['sessions', 'workspaces', 'new', 'status', 'help']); assert.deepEqual(actions, ['sessions', 'workspaces', 'new', 'status', 'help', 'watchlist']);
assert.equal(actions.includes('repair'), false); assert.equal(actions.includes('repair'), false);
}); });

View file

@ -159,6 +159,84 @@ test('interaction watcher uses the real Harness wire protocol and leaves approva
assert.equal(socket.readyState, 3); assert.equal(socket.readyState, 3);
}); });
test('global event watcher reconnects without resolving until abort', async () => {
const sockets = [];
const socketUrls = [];
const client = new HarnessClient({
baseUrl: 'http://127.0.0.1:3080/base',
workspace: '/tmp/dsh-feishu-workspace',
interactionReconnectDelayMs: 0,
createWebSocket: (url) => {
const socket = new FakeSocket();
sockets.push(socket);
socketUrls.push(url);
queueMicrotask(() => socket.open());
return socket;
},
});
const controller = new AbortController();
const events = [];
let reconnects = 0;
let settled = false;
const watching = client.watchHarnessEvents({
signal: controller.signal,
onReconnect: () => { reconnects += 1; },
onSessionEvent: (payload) => events.push(payload),
});
watching.finally(() => { settled = true; });
await eventually(() => reconnects === 1);
sockets[0].frame({
type: 'server-request',
rpcId: 'event-one',
method: 'session/event',
payload: {
type: 'session/event',
sessionId: 'session-one',
event: { type: 'turn/end', seq: 1 },
},
});
sockets[0].frame({
type: 'server-request',
rpcId: 'invalid-method',
method: 'different/method',
payload: {
type: 'session/event',
sessionId: 'ignored',
event: { type: 'turn/end', seq: 2 },
},
});
await eventually(() => events.length === 1);
sockets[0].close();
await eventually(() => reconnects === 2);
assert.equal(settled, false, 'a dropped socket must not complete the watcher');
sockets[1].frame({
type: 'server-request',
rpcId: 'event-two',
method: 'session/event',
payload: {
type: 'session/event',
sessionId: 'session-two',
event: { type: 'turn/end', seq: 3 },
},
});
await eventually(() => events.length === 2);
assert.deepEqual(socketUrls, [
'ws://127.0.0.1:3080/api/events.mux',
'ws://127.0.0.1:3080/api/events.mux',
]);
assert.deepEqual(events.map(({ sessionId, event }) => [sessionId, event.seq]), [
['session-one', 1],
['session-two', 3],
]);
controller.abort();
await watching;
assert.equal(sockets[1].readyState, 3);
assert.equal(settled, true);
});
test('HarnessClient lists only absolute workspace paths', async () => { test('HarnessClient lists only absolute workspace paths', async () => {
const client = new HarnessClient({ const client = new HarnessClient({
baseUrl: 'http://127.0.0.1:3080', baseUrl: 'http://127.0.0.1:3080',