backend-ts/src/logic-bug-review-2026-11-07.spec.ts
Rama main · réplica de lectura del repositorio original.
/**
* 2026-11-07 逻辑 BUG 审查——回归测试(BUG-1 ~ BUG-6,详见 docs/code-review/2026-11-07-logic-bug-review-01.md)。
*
* 本文件最初以「存在性证明」形态落地:用例断言正确行为、当时实现必然失败,默认以 it.fails
* 固化(expected-failure 通过,CI 保持绿色);把 bugIt 改成 it 再跑即得各 BUG 的真实失败输出。
* 2026-11-08 全部 BUG 修复后转正为常规断言——用例直接验证修复后的正确行为,任何回归都会红:
*
* cd backend-ts
* npx vitest run src/logic-bug-review-2026-11-07.spec.ts # 7 passed
*
* 注:BUG-1 的 rg 分支用例在装有 ripgrep 的环境下校验真实 rg 路径;无 rg 时走 JS 回退,
* 两条路径行为已对齐、同一断言同样成立。
*/
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, expect, it, vi } from 'vitest';
const bugIt = it;
import { GrepSearchTool } from './harness/tool/impl/grep-search-tool.js';
import { PathSandbox } from './harness/safety/path-sandbox.js';
import { DockerToolRouter } from './harness/container/docker-tool-router.js';
import { ContainerRegistry, type DockerCli } from './harness/container/container-registry.js';
import { useTmpDir } from './testing/tmp-dir.js';
import { summarize } from './session/util/tool-result-summarizer.js';
import { StreamingWsHandler, type WsHandlerDeps } from './session/ws/streaming-ws-handler.js';
import { StreamingWsRegistry, WS_OPEN } from './session/ws/streaming-ws-registry.js';
import { CompactionSignalBus } from './harness/core/compaction-signal-bus.js';
import type { WsSocket } from './session/ws/streaming-ws-registry.js';
import type { Session } from './domain/types.js';
import type { SignalWatcher, SignalWatcherCheck } from './signal/types.js';
import type { MysqlSignalCheckRepository, MysqlSignalWatcherRepository } from './signal/signal.repository.js';
import { SignalSecretCipher } from './signal/signal-cipher.js';
import type { WatcherDisableNotifier, WatcherInput } from './signal/watcher.service.js';
import { WatcherService } from './signal/watcher.service.js';
// ---------------------------------------------------------------------------
// BUG-1 grep_search rg 分支:pattern 以 `-` 开头被 rg 当成命令行 flag
// 位置:backend-ts/src/harness/tool/impl/grep-search-tool.ts:97(cmd.push(pattern, scope.rgTarget))
// 期望:按字面/正则 `-n` 搜索 → 命中含 `-n` 的行
// 实际:rg 解析 argv 时把 `-n` 吃成 line-numbers flag,rgTarget 被降级为 pattern,
// path 位空缺 → spawnSync 的 stdin 是管道 → rg 读空 stdin 立即 EOF →
// exit 1(无匹配,不算失败)→ 静默返回「0 命中成功」,模型在错误前提下继续推进。
// 若 pattern 恰为非法 flag(如 --foo)→ rg exit 2 → 合法搜索被误报「ripgrep 搜索失败」。
// 已实测:spawnSync('rg', ['--json','-n','.']) → status 1、bytes_searched 0;
// spawnSync('rg', ['--json','--foo','.']) → status 2「unrecognized flag --foo」。
// 修复(2026-11-08):cmd.push('-e', pattern, scope.rgTarget)(agent-cli 同型处同步修复)。
// ---------------------------------------------------------------------------
describe('BUG-1: grep_search rg 分支把以 - 开头的 pattern 当成 flag', () => {
bugIt('pattern 为 "-n" 时应只命中字面 "-n" 所在行,而不是变成 `.` 全量匹配', async () => {
const root = mkdtempSync(join(tmpdir(), 'mao-logicbug-grep-'));
try {
writeFileSync(join(root, 'a.txt'), 'hello -n world\nfoo bar\n');
const tool = new GrepSearchTool(new PathSandbox(root));
const out = JSON.parse(await tool.execute(JSON.stringify({ pattern: '-n' }), root)) as {
matches: Array<{ content: string; contextual?: boolean }>;
error?: string;
};
expect(out.error).toBeUndefined();
const real = out.matches.filter((m) => !m.contextual);
expect(real).toHaveLength(1);
expect(real[0]!.content).toContain('hello -n world');
} finally {
rmSync(root, { recursive: true, force: true });
}
});
});
// ---------------------------------------------------------------------------
// BUG-2 容器 read_bytes 对 0 字节文件误判失败
// 位置:backend-ts/src/harness/container/docker-tool-router.ts:131(!response.ok || !response.base64)
// 容器侧 container-tool-executor.ts:207 对空文件返回 { ok: true, base64: '' };
// `''` 是 base64 的合法编码结果,`!response.base64` 把空串当失败 → 抛「容器内文件读取失败」。
// 期望:与 host 模式一致返回空 Buffer。实际:send/edit_image 等读 0 字节文件直接报错。
// 修复(2026-11-08):判空改为 `response.base64 == null`(空串是 0 字节文件的合法编码)。
// ---------------------------------------------------------------------------
describe('BUG-2: 容器 read_bytes 对 0 字节文件误判失败', () => {
bugIt('ok=true 且 base64 为空串时应返回空 Buffer,而不是抛「容器内文件读取失败」', async () => {
const ensureRunning = vi.fn(async () => ({
userId: 7, containerName: 'mao-user-7-agent-5', state: 'running', startedAtMs: 1, lastUsedAtMs: 1,
inContainer: { shellSessions: 0, pendingTasks: 0, attachedTerminals: 0 },
connectionDead: false, mountedPaths: new Set<string>(), sessionWorkspaces: [],
}));
const request = vi.fn(async () => ({ v: 1, type: 'read_bytes_result', id: 'x', ok: true, base64: '' }));
const router = new DockerToolRouter({
containerRegistry: { ensureRunning } as never,
channelServer: { getConnection: () => ({ request }) } as never,
envelopeBuilder: { build: async () => ({ userId: 7 }) } as never,
runtimeModeLookup: { runtimeModeOf: async () => 'docker' },
globalMode: async () => 'docker',
});
const bytes = await router.readBytes({
userId: 7, agentId: 5, sessionId: 11, workspace: null, path: 'chat-files/empty.txt', maxBytes: 1024,
});
expect(bytes).toBeInstanceOf(Buffer);
expect(bytes!.length).toBe(0);
});
});
// ---------------------------------------------------------------------------
// BUG-3 哨兵复制(copyFrom)在请求体不带 headers 键时把源哨兵全部请求头静默丢掉
// 位置:backend-ts/src/signal/watcher.service.ts applyCopyInheritance:
// const headers = input.headers != null ? this.restoreMaskedHeaders(...) : null;
// undefined(键未下发)与显式 null 走同一分支 → 副本 headers=null。同函数对 body 的
// 处理是 `body === undefined` 时继承源行——headers 漏了同样的「未下发=继承」分支。
// 触发路径是官方文档示例:mao-cli signal-watcher create ... --copy-from 12(不带 --headers),
// CLI 与后端注释都承诺「未显式修改的敏感请求头由后端从源行解密继承」。
// 后果:副本探针无凭据 → 401 错误页被当成正常基线建立,哨兵静默失效。
// 修复(2026-11-08):headers 键未下发(undefined)时沿用源行(restoreMaskedHeaders 恢复掩码)。
// ---------------------------------------------------------------------------
describe('BUG-3: copyFrom 不带 headers 键时静默丢掉源哨兵全部请求头', () => {
const SECRET = 'test-signal-secret';
const cipher = new SignalSecretCipher(SECRET);
const sourceHeaders = { Authorization: 'Bearer SECRET', Accept: 'application/json' };
function watcherRow(overrides: Partial<SignalWatcher> = {}): SignalWatcher {
return {
id: 50, userId: 7, agentId: 3, name: 'src', url: 'https://example.com/api', method: 'POST',
headersJson: cipher.encrypt(JSON.stringify(sourceHeaders)), body: cipher.encrypt('{"k":"v"}'),
intervalMinutes: 15, compareMode: 'HASH', jsonPath: null, promptTemplate: 'changed {diff}',
status: 'ACTIVE', lastValueHash: null, lastValueSnippet: null, lastChangeOldSnippet: null,
lastChangeNewSnippet: null, lastCheckedAt: null, lastChangedAt: null, consecutiveFailures: 0,
triggerMode: 'NEW_SESSION', reuseSessionId: null, createdAt: null, updatedAt: null,
...overrides,
};
}
function makeService(initial: SignalWatcher) {
const watchers = new Map<number, SignalWatcher>([[initial.id!, initial]]);
const repo = {
insert: vi.fn(async (row: Omit<SignalWatcher, 'id'>) => {
const id = watchers.size + 1;
watchers.set(id, { ...row, id } as SignalWatcher);
return id;
}),
findById: vi.fn(async (id: number) => (watchers.get(id) ? { ...watchers.get(id)! } : null)),
findOwned: vi.fn(async (id: number, userId: number) => {
const row = watchers.get(id);
return row != null && row.userId === userId ? row : null;
}),
update: vi.fn(async (id: number, _userId: number, patch: Partial<SignalWatcher>) => {
const row = watchers.get(id);
if (row == null) return false;
Object.assign(row, patch);
return true;
}),
listDue: vi.fn(async () => []),
markChecked: vi.fn(async () => undefined),
recordFailure: vi.fn(async () => null),
delete: vi.fn(async () => true),
listByUser: vi.fn(async () => []),
listForAdmin: vi.fn(async () => ({ rows: [], total: 0 })),
};
const checkRepo = {
insert: vi.fn(async (row: Omit<SignalWatcherCheck, 'id'>) => {
const id = 100;
return id;
}),
listByWatcher: vi.fn(async () => []),
countByWatcher: vi.fn(async () => 0),
countRecentTriggers: vi.fn(async () => 0),
deleteByWatcher: vi.fn(async () => 0),
markTriggered: vi.fn(async () => undefined),
updateHash: vi.fn(async () => undefined),
updateValue: vi.fn(async () => undefined),
markChanged: vi.fn(async () => undefined),
markWarning: vi.fn(async () => undefined),
markError: vi.fn(async () => undefined),
remove: vi.fn(async () => undefined),
deleteBatch: vi.fn(async () => 0),
};
const service = new WatcherService({
watcherRepo: repo as unknown as MysqlSignalWatcherRepository,
checkRepo: checkRepo as unknown as MysqlSignalCheckRepository,
cipher,
openRun: { run: vi.fn() } as never,
probe: { execute: vi.fn() } as never,
disableNotifier: { notifyWatcherDisabled: vi.fn() } satisfies WatcherDisableNotifier,
getSession: vi.fn(async () => null),
});
return { service, watchers };
}
bugIt('headers 键未下发(CLI --copy-from 不带 --headers)时应从源行解密继承全部请求头', async () => {
const { service, watchers } = makeService(watcherRow());
const input: WatcherInput = {
name: '副本',
agentId: 3,
url: 'https://example.com/api',
method: 'GET',
headers: undefined, // 键未下发(undefined),非显式 null
body: null,
intervalMinutes: 15,
compareMode: 'HASH',
jsonPath: null,
promptTemplate: 'changed {diff}',
copyFrom: 50,
};
const view = await service.create(7, input);
const stored = watchers.get(view.id)!;
// 副本必须继承源哨兵的请求头(后端从源行解密),而不是空
expect(stored.headersJson).not.toBeNull();
expect(JSON.parse(cipher.decrypt(stored.headersJson!))).toEqual(sourceHeaders);
});
});
// ---------------------------------------------------------------------------
// BUG-4 重试入口不消费 pendingCancels:「停止」在重试提交前窗口被静默吞掉
// 位置:backend-ts/src/session/ws/streaming-ws-handler.ts handleRetryExecution。
// 五个执行入口(send_message / executePersistedUserPrompt / edit_and_resend /
// create_side_session / retry_execution)里只有 retry 没接 takePendingCancel;
// runRetryExecution 的 finally 还会无条件 pendingCancels.delete(sessionId)。
// 场景:会话 FAILED → 用户点「重试」→ 占 claim 后、注册 cancelFlag 前有多个 await
// (cleanupIncompleteTail 全量扫描、updatePhase(RESUMING) 等)→ 此窗口点「停止」→
// pendingCancels 登记 → 重试照常提交执行,取消被吞。
// 修复(2026-11-08):注册 cancelFlag 前消费入口时间之后的 pendingCancels,命中则收敛取消。
// ---------------------------------------------------------------------------
describe('BUG-4: handleRetryExecution 不消费 pendingCancels,提交前窗口的停止被吞掉', () => {
bugIt('重试入口注册 cancelFlag 前应消费 pendingCancels 且不提交执行', async () => {
const executor = {
tasks: [] as Array<() => void | Promise<void>>,
submit(fn: () => void | Promise<void>) { this.tasks.push(fn); return fn; },
async runAll() { while (this.tasks.length > 0) await this.tasks.shift()!(); },
};
const registry = {
getUserId: vi.fn(() => 7), send: vi.fn(), subscribe: vi.fn(), unsubscribe: vi.fn(),
clearActiveToolCalls: vi.fn(), setSessionThinking: vi.fn(), isSessionThinking: vi.fn(() => false),
sendToLocalClients: vi.fn(), sendToDeviceFamily: vi.fn(() => ({ targetCount: 1, successCount: 1, failureCount: 0 })),
getClientType: vi.fn(() => 'browser'), isConnectionAuthorized: vi.fn(() => true),
};
const sessionService = {
getSession: vi.fn(), updatePhase: vi.fn(async () => undefined), cleanupIncompleteTail: vi.fn(async () => 0),
getLastUserMessage: vi.fn(async () => null), listSubagentSessions: vi.fn(async () => []),
};
const harnessService = { executeFromEvent: vi.fn(async () => undefined), executePrepared: vi.fn(async () => undefined) };
const agentLoop = {
registerCancelFlag: vi.fn(() => { let v = false; return { get: () => v, set: (n: boolean) => { v = n; } }; }),
removeCancelFlag: vi.fn(), requestCancel: vi.fn(), getCancelFlag: vi.fn(() => undefined),
};
const handler = new StreamingWsHandler({
registry,
titleService: { scheduleForFirstUserMessage: vi.fn() },
harnessService,
sessionService,
taskTerminalService: { finishExecution: vi.fn() },
messageQueueService: { listPending: vi.fn(async () => []), dequeue: vi.fn(async () => null) },
onScheduledTaskQueueConsumed: vi.fn(async () => undefined),
localToolSessionRegistry: { isConnected: vi.fn(async () => true), setTargetForSession: vi.fn() },
askUserQuestionsRegistry: { failAllForSession: vi.fn(), getPendingForSession: vi.fn(() => []) },
embedPageToolRegistry: { failSession: vi.fn(), isEmbedSession: vi.fn(() => false) },
treeSignalPublisher: { publishIfSideTask: vi.fn(), publishForSession: vi.fn() },
approvalRegistry: { unregister: vi.fn(), has: vi.fn(() => true), getPendingForSession: vi.fn(() => []) },
activityService: { record: vi.fn() },
activityHeartbeat: { touch: vi.fn(), clear: vi.fn() },
sessionTodoMapper: { deleteBySessionId: vi.fn(), selectBySessionId: vi.fn(async () => []) },
agentLoop,
shellSessionManager: { closeByConversation: vi.fn() },
skillSyncService: { syncToSession: vi.fn(), getRemovedSkillNames: vi.fn(() => []) },
localSkillRegistry: { report: vi.fn(), clear: vi.fn() },
localAgentsMdRegistry: { report: vi.fn(), clear: vi.fn() },
mcpSyncService: { loadAgentServers: vi.fn(async () => []), buildSyncPayload: vi.fn(() => ({})), clearSession: vi.fn(), clearLocalSession: vi.fn(), resolveServerIdByName: vi.fn(), recordReport: vi.fn() },
mcpClientManager: { closeSession: vi.fn() },
agentMapper: { selectById: vi.fn(async () => ({ id: 5, name: 'Coder' })) },
llmModelMapper: { selectById: vi.fn(), selectDefault: vi.fn() },
jwtService: { getAccessTokenMetadata: vi.fn() },
agentExecutor: (fn: () => void | Promise<void>) => executor.submit(fn),
compactionSignalBus: new CompactionSignalBus(),
} as unknown as WsHandlerDeps);
const ws: WsSocket = { id: 'ws-logicbug', readyState: WS_OPEN, send: vi.fn(), close: vi.fn() };
const failed: Session = {
id: 11, userId: 7, agentId: 5, executionMode: 'CLOUD', phase: 'FAILED',
permissionLevel: 'READ_ONLY', status: 'ACTIVE',
};
sessionService.getSession.mockResolvedValue(failed);
// 模拟:重试占 claim 后、注册 cancelFlag 前有多个 await(cleanupIncompleteTail 全量扫描等),
// 就在该「提交前窗口」内用户点了「停止」→ pendingCancels 登记(时间戳不早于重试入口,
// 只能靠重试入口自身消费;入口之前的旧标记不应误杀本次重试)
const pendingCancels = (handler as unknown as { pendingCancels: Map<number, number> }).pendingCancels;
sessionService.cleanupIncompleteTail.mockImplementation(async () => {
pendingCancels.set(11, Date.now());
return 0;
});
await handler.handleTextMessage(ws, JSON.stringify({ type: 'retry_execution', sessionId: 11 }));
await executor.runAll();
// 正确行为:标记被消费,重试不提交执行
expect(harnessService.executeFromEvent).not.toHaveBeenCalled();
expect(harnessService.executePrepared).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// BUG-5 容器瞬时断开重连后 connectionDead 永不清除 → 健康容器被强制重建
// 位置:backend-ts/src/harness/container/container-registry.ts:639(markConnectionDead 只置 true);
// 全仓仅 createAndStart/dockerStart 成功路径清 false(:866/:942);reportAgentHello(:648-652,
// 容器内 agent 重连回调)从不清该标志。字段文档(:47)语义是「容器内 agent *未重连* 时置 true」。
// 后果:一次通道抖动(agent 已自动重连、容器健康)后,下一次 ensureRunning(每次工具调用都会走)
// 把健康容器 stop + rm + docker run,shell 会话/PTY/后台任务/MCP stdio 子进程全部丢失;
// 且 pty-bridge 特意给 20s 宽限保住的终端也被这次重建整批杀掉。
// 修复(2026-11-08):reportAgentHello(重连成功)清除 connectionDead;替换旧连接先从 map 摘除。
// ---------------------------------------------------------------------------
describe('BUG-5: 容器 agent 重连后 connectionDead 不清除,健康容器被强制重建', () => {
bugIt('markConnectionDead 后 reportAgentHello(重连成功)应清除标志:下次 ensureRunning 零 docker 动作', async () => {
const base = useTmpDir('mao-logicbug-registry-');
for (const d of ['workspace', 'users', 'runtime', 'userskills', 'skills', 'channels', 'dist', 'deps']) {
const { mkdirSync, writeFileSync } = await import('node:fs');
mkdirSync(join(base, d), { recursive: true });
if (d === 'dist') {
const dir = join(base, 'dist', 'harness', 'container');
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'runtime-build.json'), JSON.stringify({ schema: 1, buildId: 'b1', fileCount: 1 }));
}
}
const log: string[] = [];
const fakeDocker = (): DockerCli => ({
async run(args) {
log.push(args.join(' '));
if (args[0] === 'images') return 'mao-runtime:latest\n';
if (args[0] === 'version') return '27.0.0\n';
return 'ok\n';
},
});
const registry = new ContainerRegistry({
config: {
mode: 'docker',
container: {
idleMinutes: 60, maxLifetimeHours: 72, memoryMb: 512, cpus: 1, pidsLimit: 100,
image: 'mao-runtime:latest', network: 'bridge',
channelDir: join(base, 'channels'), distDir: join(base, 'dist'),
depsDir: join(base, 'deps'), tmpDir: join(base, 'tmp'), tmpRetentionDays: 7, startTimeoutMs: 1000,
},
},
workspaceRoot: join(base, 'workspace'),
userHomeDir: join(base, 'users'),
runtimeDir: join(base, 'runtime'),
userSkillsDir: join(base, 'userskills'),
skillsDir: join(base, 'skills'),
skillViewsDir: join(base, 'skill-views'),
uploadDir: join(base, 'uploads'),
shareDir: join(base, 'share'),
ensureChannelListening: async () => join(base, 'channels', 'runtime.sock'),
issueChannelToken: () => 'tok',
waitSocketReady: async () => undefined,
requestShutdown: async () => true,
}, fakeDocker());
await registry.ensureRunning(7, 5);
// 通道抖动:宿主机标记断开;随后容器内 agent 自动重连并上报 hello(运行时构建一致)
registry.markConnectionDead(7, 5);
registry.reportAgentHello(7, 5, 'b1');
log.length = 0;
await registry.ensureRunning(7, 5);
// 容器健康且已重连:不应 stop/rm/run 重建
expect(log.filter((l) => /^(run -d|rm -f|stop -t 10)/.test(l))).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// BUG-6 工具结果摘要的失败/超时语义失真
// 位置:backend-ts/src/session/util/tool-result-summarizer.ts
// a) summarizeWaitSubagents 只看 completed 计数,忽略结果里现成的 timed_out 字段
// (background-subagent-tools.ts:235 返回 timed_out)→ 等满 30 分钟超时且 0 个完成时,
// 摘要谎报「后台子代理已完成 (0 个结果)」。该摘要随 tc.summary 持久化进轨迹与活动记录。
// b) summarizeAskUserQuestions 对任意顶层 error 一律返回「超时或取消」→ 参数非法
// (questions 不能为空)、会话不存在、通道不支持等即时失败全被标成超时/取消。
// ---------------------------------------------------------------------------
describe('BUG-6: 工具结果摘要把失败/超时写成正常完成', () => {
bugIt('wait_subagents 超时(timed_out=true)摘要应如实反映超时', () => {
const summary = summarize('wait_subagents', '{}', JSON.stringify({
completed: 0, results: [], timed_out: true, all_completed: false,
}));
expect(summary).toContain('超时');
});
bugIt('ask_user_questions 参数非法(未进入等待)不应写成「超时或取消」', () => {
const summary = summarize('ask_user_questions', '{}', JSON.stringify({
error: 'questions 不能为空,请提供至少 1 个问题',
}));
expect(summary).not.toContain('超时或取消');
});
});