From 40861c63da12455eb2a61ee6cbf2ad81ef387681 Mon Sep 17 00:00:00 2001 From: kris Date: Mon, 30 Mar 2026 14:25:25 +0800 Subject: [PATCH] fix: filter codex subthreads during auto import --- README.md | 2 + .../api_and_service_inventory_cn.md | 2 + .../current_runtime_and_deploy_status_cn.md | 2 + local-agent/codex-session-discovery.mjs | 29 ++++++++++- src/lib/boss-data.ts | 50 +++++++++++++++++-- tests/device-import-draft.test.ts | 48 +++++++++++++++++- tests/local-agent-codex-discovery.test.mjs | 18 +++++++ 7 files changed, 145 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 11dcd3b..1b9496c 100644 --- a/README.md +++ b/README.md @@ -281,8 +281,10 @@ npm run aab:release - API 容灾当前不走服务器预置 Key,而是由用户在 APP 的 `我的 > AI 账号` 中自行配置 `OpenAI API` 账号 - 设备页当前只展示已接入生产链路的设备,历史演示脏数据已经从正式设备视图、运维视图和审计视图中剔除 - 本机 `local-agent` 现在会直接从 `~/.codex/state_5.sqlite / logs_1.sqlite / session_index.jsonl / .codex-global-state.json` 动态发现真实 Codex 线程,并在 heartbeat 里上报 `projectCandidates` +- 线程发现当前会优先保留每个 Codex 文件夹下的“主工作线程”;如果同一文件夹里同时存在 `worker / explorer` 这类子线程,会优先过滤掉这些子线程,避免会话首页被子代理线程冲成异常多条 - 对已经绑定的生产设备,服务端现在会在 heartbeat 时自动选中建议导入项、生成导入决议并直接应用;因此会话页会自动出现这台设备当前真实运行的 Codex 线程窗口 - 对已经绑定的生产设备,服务端现在会在 heartbeat 时自动选中建议导入项、生成导入决议并直接应用;如果某个项目下存在多个线程,会话首页会先显示项目归档项,而不是把所有线程平铺在首页 +- 对已经绑定的生产设备,如果某些自动导入线程已经不再出现在最新 `projectCandidates[]` 中,服务端会在下一次 heartbeat 自动清理这些过时会话,避免旧线程长期滞留首页 - 登录页当前已临时切到免验证模式,点击“登录”会直接进入会话首页 - 认证现在已经有最小会话链路:登录后会写入 `boss_session` Cookie,默认保持 30 天,`会话 / 设备 / 我的 / 线程` 页面以及主要 `/api/v1/*` 接口都要求有效会话 - 新增 `GET /api/auth/session`、`POST /api/auth/logout` 与 `POST /api/auth/restore` diff --git a/docs/architecture/api_and_service_inventory_cn.md b/docs/architecture/api_and_service_inventory_cn.md index 4b0e0d6..897e24f 100644 --- a/docs/architecture/api_and_service_inventory_cn.md +++ b/docs/architecture/api_and_service_inventory_cn.md @@ -752,7 +752,9 @@ - 当前行为:除了设备心跳,还会顺带触发 `thread-context` 上报和 Skill 同步 - 当前补充: - `local-agent` 会优先从 `~/.codex/state_5.sqlite / logs_1.sqlite / session_index.jsonl / .codex-global-state.json` 动态发现真实 Codex 线程,并把结果填进 `projects[] + projectCandidates[]` + - 线程发现会优先保留每个 Codex 文件夹下的主工作线程;如果同文件夹中存在 `worker / explorer` 子线程,会优先过滤这些子线程,避免误导入过多聊天窗口 - 对已绑定的生产设备,服务端会在 heartbeat 时自动应用建议导入项;对新设备则继续走 `deviceImportDraft` 的人工勾选与应用流程 + - 自动应用时,如果某些已导入线程已经不再出现在最新 `projectCandidates[]` 中,服务端会在同一轮 heartbeat 清理这些过时线程会话 ### 4.5 主 Agent 轮询任务 diff --git a/docs/architecture/current_runtime_and_deploy_status_cn.md b/docs/architecture/current_runtime_and_deploy_status_cn.md index c0e69b2..63cdc03 100644 --- a/docs/architecture/current_runtime_and_deploy_status_cn.md +++ b/docs/architecture/current_runtime_and_deploy_status_cn.md @@ -230,7 +230,9 @@ cd /Users/kris/code/boss - 新接入设备继续走 `import draft -> 勾选 -> review -> apply` - 已绑定的生产设备如果 heartbeat 带上真实 `projectCandidates[]`,服务端会自动选中建议项、生成导入决议并直接应用,让会话页自动出现当前运行中的 Codex 线程 - 本机 `mac-studio` 当前已经验证可通过 `local-agent` 直接从 `~/.codex/state_5.sqlite / logs_1.sqlite / session_index.jsonl / .codex-global-state.json` 扫描真实 Codex 线程,并通过 heartbeat 自动导入到会话列表 +- 线程发现当前会优先保留每个 Codex 文件夹下的主工作线程;如果同文件夹里存在 `worker / explorer` 子线程,会优先过滤这些子线程,避免把子代理线程误当成独立聊天窗口 - 会话首页当前已经不再简单平铺所有线程;如果某个设备导入了大量同文件夹线程,首页会优先显示项目归档项,降低会话页噪音 +- 已绑定生产设备的自动导入链现在还会在 heartbeat 时清理已经不再出现在最新 `projectCandidates[]` 里的旧线程会话,避免旧导入结果长期残留 - API 容灾当前由用户在 APP 的 `我的 > AI 账号` 页面自行配置 `OpenAI API` 账号,不再依赖服务器预置 Key - 原生 Android 的二级深层页虽然仍保留 `ProjectForwardActivity / ThreadDetailActivity / OpsCenterActivity` 等能力,但它们已经退出主 UI 正面;后续如再加入口,需继续遵守“一级微信式,复杂能力下沉”的规则 - Android 本地 Gradle 验证当前必须串行执行;如果并发跑 `testDebugUnitTest / compileDebugJavaWithJavac / assembleDebug`,会导致中间产物互踩并出现假失败 diff --git a/local-agent/codex-session-discovery.mjs b/local-agent/codex-session-discovery.mjs index 68363a2..765fa56 100644 --- a/local-agent/codex-session-discovery.mjs +++ b/local-agent/codex-session-discovery.mjs @@ -33,6 +33,15 @@ function fallbackDisplayName(thread, folderName) { return `${folderName} · ${suffix}`; } +function trimToDefined(value) { + const trimmed = typeof value === "string" ? value.trim() : ""; + return trimmed ? trimmed : null; +} + +function isPrimaryWorkspaceThread(thread) { + return !trimToDefined(thread.agentRole) && !trimToDefined(thread.agentNickname); +} + function loadThreadWorkspaceHints(globalStatePath) { if (!globalStatePath) return new Map(); try { @@ -193,7 +202,7 @@ export async function discoverCodexProjectCandidates(options = {}) { } const seenThreadIds = new Set(); - const candidates = []; + const groupedCandidates = new Map(); for (const thread of threads) { if (!thread?.id || seenThreadIds.has(thread.id)) continue; const latestActivitySeconds = latestLogByThread.get(thread.id) ?? thread.updatedAtSeconds; @@ -213,7 +222,7 @@ export async function discoverCodexProjectCandidates(options = {}) { sanitizeDisplayName(thread.title, fallbackDisplayName(thread, folderName)), ); - candidates.push({ + const candidate = { folderName, folderRef: folderPath, threadId: thread.id, @@ -222,7 +231,23 @@ export async function discoverCodexProjectCandidates(options = {}) { codexThreadRef: thread.id, lastActiveAt: toIsoFromUnixSeconds(latestActivitySeconds) ?? now.toISOString(), suggestedImport: true, + }; + const folderKey = folderPath || folderName; + const bucket = groupedCandidates.get(folderKey) ?? []; + bucket.push({ + candidate, + latestActivitySeconds, + primary: isPrimaryWorkspaceThread(thread), }); + groupedCandidates.set(folderKey, bucket); + } + + const candidates = []; + for (const entries of groupedCandidates.values()) { + entries.sort((left, right) => right.latestActivitySeconds - left.latestActivitySeconds); + const primaryEntries = entries.filter((entry) => entry.primary); + const chosenEntries = primaryEntries.length > 0 ? primaryEntries : entries.slice(0, 1); + candidates.push(...chosenEntries.map((entry) => entry.candidate)); } candidates.sort((a, b) => b.lastActiveAt.localeCompare(a.lastActiveAt)); diff --git a/src/lib/boss-data.ts b/src/lib/boss-data.ts index cad12f6..5131a25 100644 --- a/src/lib/boss-data.ts +++ b/src/lib/boss-data.ts @@ -5455,6 +5455,7 @@ export async function upsertDeviceHeartbeat(payload: { deviceId: payload.deviceId, appliedBy: "system:auto_sync", draftId: autoSyncDraft.draftId, + pruneMissingCandidates: true, }); draft = applied.draft; } @@ -5824,12 +5825,50 @@ function buildImportedThreadProject(device: Device, candidate: DeviceImportCandi }); } +function candidateThreadSignature(candidate: DeviceImportCandidate) { + return ( + trimToDefined(candidate.codexThreadRef) ?? + trimToDefined(candidate.threadId) ?? + `${candidate.folderName}:${candidate.threadDisplayName}` + ); +} + +function projectThreadSignature(project: Project) { + return ( + trimToDefined(project.threadMeta.codexThreadRef) ?? + trimToDefined(project.threadMeta.threadId) ?? + `${project.threadMeta.folderName}:${project.threadMeta.threadDisplayName}` + ); +} + +function pruneStaleAutoImportedProjectsForDevice( + state: BossState, + device: Device, + selectedCandidates: DeviceImportCandidate[], +) { + const activeSignatures = new Set(selectedCandidates.map((candidate) => candidateThreadSignature(candidate))); + const reservedProjectIds = new Set(["master-agent", "boss-console", "audit-collab"]); + + state.projects = state.projects.filter((project) => { + if (reservedProjectIds.has(project.id)) return true; + if (project.isGroup) return true; + if (!project.createdByAgent) return true; + if (!project.deviceIds.includes(device.id)) return true; + if (project.deviceIds.length !== 1) return true; + if (!trimToDefined(project.threadMeta.codexFolderRef) && !trimToDefined(project.threadMeta.folderName)) { + return true; + } + return activeSignatures.has(projectThreadSignature(project)); + }); +} + function applyDeviceImportResolutionInState( state: BossState, input: { deviceId: string; appliedBy: string; draftId?: string; + pruneMissingCandidates?: boolean; }, ) { const draft = @@ -5844,6 +5883,9 @@ function applyDeviceImportResolutionInState( const device = state.devices.find((item) => item.id === input.deviceId); if (!device) throw new Error("DEVICE_NOT_FOUND"); + const selectedCandidates = draft.candidates.filter((candidate) => + draft.selectedCandidateIds.includes(candidate.candidateId), + ); const importedProjects: Project[] = []; for (const item of resolution.items) { const candidate = draft.candidates.find((entry) => entry.candidateId === item.candidateId); @@ -5890,10 +5932,12 @@ function applyDeviceImportResolutionInState( importedProjects.push({ ...targetProject }); } + if (input.pruneMissingCandidates) { + pruneStaleAutoImportedProjectsForDevice(state, device, selectedCandidates); + } + device.projects = dedupeStrings( - draft.candidates - .filter((candidate) => draft.selectedCandidateIds.includes(candidate.candidateId)) - .map((candidate) => candidate.folderName), + selectedCandidates.map((candidate) => candidate.folderName), ); resolution.status = "applied"; diff --git a/tests/device-import-draft.test.ts b/tests/device-import-draft.test.ts index b27ca37..e701700 100644 --- a/tests/device-import-draft.test.ts +++ b/tests/device-import-draft.test.ts @@ -537,7 +537,7 @@ test("existing bound production devices auto-sync suggested candidates into conv assert.equal(payload.importDraft?.status, "applied"); assert.equal(payload.importDraft?.selectedCandidateIds.length, 2); - const nextState = await readState(); + let nextState = await readState(); const yuandiProject = nextState.projects.find( (project) => project.threadMeta.codexThreadRef === "session-yuandi-1", ); @@ -551,4 +551,50 @@ test("existing bound production devices auto-sync suggested candidates into conv const device = nextState.devices.find((item) => item.id === "mac-studio"); assert.deepEqual(device?.projects, ["yuandi", "wenshenapp"]); + + const followupHeartbeat = await deviceHeartbeatRoute( + new NextRequest("http://127.0.0.1:3000/api/device-heartbeat", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + deviceId: "mac-studio", + token: "boss-mac-studio-token", + name: "Mac Studio", + avatar: "M", + account: "17600003315", + status: "online", + quota5h: 68, + quota7d: 81, + projects: ["Boss 移动控制台", "硬件审计协作"], + endpoint: "mac://kris.local", + projectCandidates: [ + { + folderName: "yuandi", + folderRef: "/Users/kris/code/yuandi", + threadId: "session-yuandi-1", + threadDisplayName: "Epicurus", + codexFolderRef: "/Users/kris/code/yuandi", + codexThreadRef: "session-yuandi-1", + lastActiveAt: "2026-03-30T12:55:56+08:00", + suggestedImport: true, + }, + ], + }), + }), + ); + assert.equal(followupHeartbeat.status, 200); + + nextState = await readState(); + const remainingYuandi = nextState.projects.find( + (project) => project.threadMeta.codexThreadRef === "session-yuandi-1", + ); + const removedWenshen = nextState.projects.find( + (project) => project.threadMeta.codexThreadRef === "session-wenshenapp-1", + ); + assert.ok(remainingYuandi, "expected still-selected candidate to stay imported"); + assert.equal(removedWenshen, undefined, "auto-sync should prune stale imported threads that disappeared from candidates"); + assert.deepEqual( + nextState.devices.find((item) => item.id === "mac-studio")?.projects, + ["yuandi"], + ); }); diff --git a/tests/local-agent-codex-discovery.test.mjs b/tests/local-agent-codex-discovery.test.mjs index 4f07d5b..a49faac 100644 --- a/tests/local-agent-codex-discovery.test.mjs +++ b/tests/local-agent-codex-discovery.test.mjs @@ -83,6 +83,21 @@ test("discoverCodexProjectCandidates prefers Codex sqlite indexes and session na null, null, ); + insertThread.run( + "019d3bossworker", + path.join(codexRoot, "sessions/2026/03/30/rollout-boss-worker.jsonl"), + 1774845620, + 1774845630, + "desktop", + "openai", + "/Users/kris/code/boss", + "你是只读分析子线程", + "workspace-write", + "never", + 0, + "Sagan", + "explorer", + ); insertThread.run( "019d3yuandiexplorer", path.join(codexRoot, "sessions/2026/03/30/rollout-yuandi-explorer.jsonl"), @@ -137,6 +152,7 @@ test("discoverCodexProjectCandidates prefers Codex sqlite indexes and session na VALUES (?, 0, 'info', 'codex', ?, 0) `); insertLog.run(1774845618, "019d3bossmain"); + insertLog.run(1774845630, "019d3bossworker"); insertLog.run(1774845776, "019d3yuandiexplorer"); logsDb.close(); @@ -183,8 +199,10 @@ test("discoverCodexProjectCandidates prefers Codex sqlite indexes and session na assert.equal(discovered.projectCandidates.length, 2); const bossSession = discovered.projectCandidates.find((item) => item.threadId === "019d3bossmain"); + const bossWorker = discovered.projectCandidates.find((item) => item.threadId === "019d3bossworker"); const yuandiSession = discovered.projectCandidates.find((item) => item.threadId === "019d3yuandiexplorer"); assert.ok(bossSession); + assert.equal(bossWorker, undefined, "subagent/explorer threads should be filtered when a primary thread exists for the same folder"); assert.ok(yuandiSession); assert.equal(bossSession?.folderName, "boss"); assert.equal(bossSession?.codexFolderRef, "/Users/kris/code/boss");