Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 51 additions & 2 deletions src/core/worker-pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,7 @@ import type {
WorkerToDaemon,
Session,
DisplayMode,
StreamStatus,
QueuedActivationTailEntry,
} from '../types.js';
import {
Expand Down Expand Up @@ -3302,6 +3303,37 @@ export function teardownAuthoritativePersistentBackingBeforeClose(
teardownAuthoritativePersistentBackingBeforeCloseImpl(target, false);
}

/** Render the live streaming-card JSON for `ds` (🖥️ header + usage line +
* 显示输出/终端/关闭会话 buttons). Factored so callers outside the normal
* screen-update flow — notably the Lark 恢复会话 button — can restore the SAME
* card the session had while running, instead of a stripped-down variant.
* `status` defaults to the session's last known screen status. */
export function buildStreamingCardJson(ds: DaemonSession, status?: StreamStatus): string {
const botCfg = getBot(ds.larkAppId).config;
const effectiveCliId = sessionCliId(ds, botCfg);
return buildStreamingCard(
ds.session.sessionId,
sessionAnchorId(ds),
readableTerminalUrlFor(ds),
ds.currentTurnTitle || ds.session.title || sessionCliDisplayName(ds, botCfg),
ds.lastScreenContent ?? '',
status ?? ds.lastScreenStatus ?? 'starting',
effectiveCliId,
ds.displayMode ?? 'hidden',
ds.streamCardNonce,
ds.currentImageKey,
!!ds.adoptedFrom,
false,
localeForBot(ds.larkAppId),
cardUsageLimit(ds),
writableTerminalLinkFor(ds),
isLocalCliOpenReady(ds, { cliId: effectiveCliId }),
getDaemonStreamingCardUsageSnapshot(ds, effectiveCliId),
sessionRuntimeDisplayName(ds, botCfg),
codexServiceTierBadge(effectiveCliId, ds.codexServiceTier),
);
}

/**
* Idempotent close: kill worker if alive, mark Session status='closed' + closedAt,
* publish session.exited (if a live worker was killed) and session.update
Expand All @@ -3319,7 +3351,17 @@ export type CloseSessionResult =

export async function closeSession(
sessionId: string,
opts?: { awaitWorkerExit?: boolean },
): Promise<CloseSessionResult> {
// `awaitWorkerExit` (default true): whether to block on the worker process
// actually exiting before returning. A busy CLI wedges in node-pty teardown
// and only dies at the ~7s SIGKILL backstop, so callers behind a tight ACK
// window (the Lark card "关闭会话" button, whose callback must ACK inside ~3s
// or the client surfaces the "code: 300000" toast) pass false: the logical
// close below is fully synchronous, and killWorker already armed the kill
// backstop, so the worker WILL die in the background — the caller need not
// wait for it. Bridge-marker cleanup still runs, deferred behind the fence.
const awaitWorkerExit = opts?.awaitWorkerExit ?? true;
const ds = findActiveBySessionId(sessionId);
const stored = sessionStore.getOwnedSession(sessionId);
// Prove fail-closed ZMX teardown before any registry/store mutation. Repo
Expand Down Expand Up @@ -3450,8 +3492,15 @@ export async function closeSession(
}

if (wasOpen && hadLiveWorker) {
await closeFenceFor(sessionId, closeWorkerGeneration);
sessionStore.cleanupSessionBridgeSendMarkersNow(sessionId);
if (awaitWorkerExit) {
await closeFenceFor(sessionId, closeWorkerGeneration);
sessionStore.cleanupSessionBridgeSendMarkersNow(sessionId);
} else {
// Don't block the caller on the worker exiting (killWorker already armed
// the SIGKILL backstop). Defer bridge-marker cleanup behind the same
// fence so a mid-flight send is still credited until the worker ACKs/exits.
sessionStore.cleanupSessionBridgeSendMarkers(sessionId);
}
}

// All authoritative map/status/store/event state above transitions
Expand Down
68 changes: 64 additions & 4 deletions src/im/lark/card-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ import { ttadkConfigModelChoices } from '../../setup/cli-selection.js';
import { logger } from '../../utils/logger.js';
import * as sessionStore from '../../services/session-store.js';
import { loadFrozenCards, saveFrozenCards } from '../../services/frozen-card-store.js';
import { forkWorker, sendWorkerInput, sendWorkerSessionInput, killWorker, closeSession as closeWorkerPoolSession, teardownAuthoritativePersistentBackingBeforeClose, scheduleCardPatch, parkStreamCard, clearUsageLimitState, cardUsageLimit, writableTerminalLinkFor, workerHasInitialized, sessionSupportsWebTerminal, readableTerminalUrlFor, resolvePrivateCardAudience, deliverWriteLinkCard, deliverEphemeralOrReply, CARD_POSTING_SENTINEL, requestSessionRestart, isSessionTransferring, getDaemonStreamingCardUsageSnapshot, withActiveSessionKeyLock, type WorkerSessionReplyOptions } from '../../core/worker-pool.js';
import { forkWorker, sendWorkerInput, sendWorkerSessionInput, killWorker, closeSession as closeWorkerPoolSession, teardownAuthoritativePersistentBackingBeforeClose, scheduleCardPatch, parkStreamCard, clearUsageLimitState, cardUsageLimit, writableTerminalLinkFor, workerHasInitialized, sessionSupportsWebTerminal, readableTerminalUrlFor, resolvePrivateCardAudience, deliverWriteLinkCard, deliverEphemeralOrReply, CARD_POSTING_SENTINEL, requestSessionRestart, isSessionTransferring, getDaemonStreamingCardUsageSnapshot, withActiveSessionKeyLock, buildStreamingCardJson, type WorkerSessionReplyOptions } from '../../core/worker-pool.js';
import { getSessionWorkingDir, buildNewTopicCliInput, getAvailableBots, persistStreamCardState, resumeSession, rememberLastCliInput, ensureSessionWhiteboard } from '../../core/session-manager.js';
import { markInitialUserTurnPending } from '../../core/initial-user-turn.js';
import { publishAttentionPatch, publishClosedSessionPatch, announcePendingRepoSession } from '../../core/session-activity.js';
Expand Down Expand Up @@ -2379,13 +2379,26 @@ export async function handleCardAction(data: CardActionData, deps: CardHandlerDe
// Build the closed card BEFORE closeWorkerPoolSession — it reads the
// live session's identity off `current`.
const card = buildClosedSessionCard(current, localeForBot(current.larkAppId));
// The clicked card IS the live streaming card in the common in-thread
// (non-private) case. When so, patch it in place via the callback return
// below instead of sending a separate closed card — a stray extra card
// (and its now-dead buttons) is what we're avoiding. Only when the
// clicked message is that streaming card and we're not in private mode.
const patchClickedCardInPlace = !!cardMessageId
&& cardMessageId === current.streamCardId
&& value?.visibility !== 'private'
&& !botCfg.privateCard;
try {
await closeWorkerPoolSession(targetSessionId);
// Don't await the worker exiting — a busy CLI only dies at the ~7s
// SIGKILL backstop, which blows past Lark's ~3s card-ACK window and
// surfaces the client-side "code: 300000" toast. The logical close is
// synchronous; the worker is killed in the background.
await closeWorkerPoolSession(targetSessionId, { awaitWorkerExit: false });
} catch (err) {
logger.error(`[${tag(current)}] Refused close because backing teardown was not verified: ${err}`);
return { status: 'teardown_failed' as const, err };
}
return { status: 'closed' as const, current, botCfg, card };
return { status: 'closed' as const, current, botCfg, card, patchClickedCardInPlace };
});
if (!closed) {
return { toast: { type: 'warning', content: t('card.action.session_gone', undefined, localeForBot(larkAppId)) } };
Expand All @@ -2398,7 +2411,7 @@ export async function handleCardAction(data: CardActionData, deps: CardHandlerDe
},
};
}
const { current, botCfg, card } = closed;
const { current, botCfg, card, patchClickedCardInPlace } = closed;
// The closed card carries session title / CLI name / workingDir / resume
// command. In private-card mode those must not leak to the group — send the
// closed card ephemeral to the same owner audience instead. No group
Expand All @@ -2414,6 +2427,14 @@ export async function handleCardAction(data: CardActionData, deps: CardHandlerDe
}
logger.info(`[${tag(current)}] Closed via card button (private close card → ${audience.length} owner(s))`);
} else {
if (patchClickedCardInPlace) {
// Return the closed card as the callback response → Lark patches the
// just-clicked streaming card in place (no delete, no separate send,
// no "code: 300000" race). The "等待输入" card becomes the closed card
// with its now-dead buttons removed.
logger.info(`[${tag(current)}] Closed via card button (in-place patch)`);
return JSON.parse(card);
}
await deliverEphemeralOrReply(current, operatorOpenId, card, 'interactive', () => sessionReply(rootId, card, 'interactive'));
logger.info(`[${tag(current)}] Closed via card button`);
}
Expand All @@ -2429,6 +2450,45 @@ export async function handleCardAction(data: CardActionData, deps: CardHandlerDe
if (result.ok) {
const cliName = sessionCliDisplayName(result.ds);
const resumeMsg = t('card.action.resume_success', { cliName }, localeForBot(result.ds.larkAppId));
// Restore the ORIGINAL live streaming card (🖥️ header + usage line +
// 显示输出/终端/操作链接/关闭会话) as a WITHDRAW-then-REPOST (old closed card
// recalled, fresh card posted at the thread bottom), AND send the
// "✅ 会话已恢复…" text follow-up — both are wanted.
// Ordering is load-bearing for two reasons:
// 1) ACK the callback FIRST (bare `return` → empty ACK), THEN
// post/delete in the background — deleting the just-clicked card
// inside the callback response races it and triggers client
// "code: 300000".
// 2) POST the fresh card BEFORE deleting the old one, so the thread
// never briefly shows zero cards (same invariant as park→recall).
// Skip in private-card mode (clicked card may be an ephemeral snapshot).
const botCfgResume = getBot(result.ds.larkAppId).config;
if (cardMessageId && value?.visibility !== 'private' && !botCfgResume.privateCard) {
const staleCardId = cardMessageId;
const resumedDs = result.ds;
void (async () => {
try {
const freshCardId = await sessionReply(rootId, buildStreamingCardJson(resumedDs), 'interactive');
resumedDs.streamCardId = freshCardId;
persistStreamCardState(resumedDs);
await deleteMessage(resumedDs.larkAppId, staleCardId).catch(() => { /* already withdrawn/expired */ });
// Also send the "✅ 会话已恢复…" text follow-up (the original resume
// behavior). Both are wanted: the live streaming card AND the text
// prompt telling the user to send a message to continue.
await deliverEphemeralOrReply(resumedDs, operatorOpenId, resumeMsg, 'text', () => sessionReply(rootId, resumeMsg));
logger.info(`[${targetSessionId.substring(0, 8)}] Resumed via card button (withdraw + repost streaming card + text)`);
} catch (err) {
logger.warn(`[${targetSessionId.substring(0, 8)}] resume card repost failed: ${err instanceof Error ? err.message : String(err)}`);
}
})();
// Bare `return` (→ undefined) so the dispatcher's shaper emits a
// genuine empty ACK `{}`. Returning `{}` here would instead be
// truthy and get wrapped as `{card:{type:raw,data:{}}}` — an
// in-place patch with an empty card body (invalid), racing the
// background deleteMessage above. Matches every other empty-ACK in
// this handler.
return; // fast empty ACK; card work happens in background
}
await deliverEphemeralOrReply(result.ds, operatorOpenId, resumeMsg, 'text', () => sessionReply(rootId, resumeMsg));
logger.info(`[${targetSessionId.substring(0, 8)}] Resumed via card button`);
} else if (result.error === 'not_found') {
Expand Down
78 changes: 78 additions & 0 deletions test/close-stream-card-untouched.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { EventEmitter } from 'node:events';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

// Mock the Lark client so we can observe deleteMessage without real API calls.
const { deleteMessage } = vi.hoisted(() => ({ deleteMessage: vi.fn(async () => undefined) }));
vi.mock('../src/im/lark/client.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/im/lark/client.js')>();
return { ...actual, deleteMessage };
});

import { config } from '../src/config.js';
import * as workerPool from '../src/core/worker-pool.js';
import { activeSessionKey } from '../src/core/types.js';
import * as sessionStore from '../src/services/session-store.js';

const tempDirs: string[] = [];

function makeDs(sessionId: string, appId: string, streamCardId: string) {
const session = sessionStore.getSession(sessionId)!;
const worker = Object.assign(new EventEmitter(), { killed: false, send: vi.fn() });
return {
session,
worker,
workerPort: 12345,
workerToken: 'wt',
workerViewToken: 'vt',
workerReady: true,
larkAppId: appId,
chatId: session.chatId,
chatType: 'group',
scope: 'thread',
spawnedAt: Date.now(),
cliVersion: 'test',
lastMessageAt: Date.now(),
hasHistory: true,
streamCardId,
initConfig: { backendType: 'tmux' },
} as any;
}

describe('closeSession leaves the streaming card alone', () => {
beforeEach(() => {
deleteMessage.mockClear();
});
afterEach(() => {
workerPool.setActiveSessionsRegistry(new Map());
sessionStore.init();
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});

// Close is not a card-cleanup step: the streaming card (and its buttons) is a
// resume-time concern. The Lark card close button patches the clicked card in
// place into the "会话已关闭" card; closeSession itself must never delete it.
it('does NOT delete the streaming card on close', async () => {
const dataDir = mkdtempSync(join(tmpdir(), 'botmux-close-card-'));
tempDirs.push(dataDir);
const prev = config.session.dataDir;
config.session.dataDir = dataDir;
sessionStore.init('app-close-card');
try {
const s = sessionStore.createSession('oc_closecard', 'om_closecard', 'closecard', 'group');
s.larkAppId = 'app-close-card';
sessionStore.updateSession(s);
const ds = makeDs(s.sessionId, 'app-close-card', 'om_stream_card');
workerPool.setActiveSessionsRegistry(new Map([[activeSessionKey(ds), ds]]));

await workerPool.closeSession(s.sessionId, { awaitWorkerExit: false });

expect(deleteMessage).not.toHaveBeenCalledWith('app-close-card', 'om_stream_card');
expect(sessionStore.getSession(s.sessionId)?.status).toBe('closed');
} finally {
config.session.dataDir = prev;
}
});
});
16 changes: 16 additions & 0 deletions test/event-dispatcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6830,6 +6830,22 @@ describe('card.action.trigger — ack-safe slow handlers', () => {
expect(mockUpdateMessage).not.toHaveBeenCalled();
});

it('wraps a truthy empty object into an invalid empty-body card patch (why resume must bare-return, not `return {}`)', async () => {
// Guards the resume-branch fix: a handler returning `{}` is truthy and gets
// shaped into `{card:{type:raw,data:{}}}` — an in-place patch with an empty
// card body, NOT a no-UI ACK. The resume branch must bare-return (→ undefined)
// to land on the genuine empty-ACK `{}` asserted in the test above.
handlers.handleCardAction.mockResolvedValue({});

const result = await capturedHandlers['card.action.trigger']({
action: { value: { action: 'repo_switch', root_id: 'root-empty-obj' } },
operator: { open_id: USER_OPEN_ID },
context: { open_message_id: 'om_empty_obj_card' },
});

expect(result).toEqual({ card: { type: 'raw', data: {} } });
});

it('still returns a valid empty ACK when a card handler rejects', async () => {
handlers.handleCardAction.mockRejectedValue(new Error('handler boom'));

Expand Down