fix: filter codex subthreads during auto import

This commit is contained in:
kris
2026-03-30 14:25:25 +08:00
parent 03ac40f427
commit 40861c63da
7 changed files with 145 additions and 6 deletions

View File

@@ -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`

View File

@@ -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 轮询任务

View File

@@ -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`,会导致中间产物互踩并出现假失败

View File

@@ -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));

View File

@@ -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";

View File

@@ -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"],
);
});

View File

@@ -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");