fix: filter codex subthreads during auto import
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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 轮询任务
|
||||
|
||||
|
||||
@@ -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`,会导致中间产物互踩并出现假失败
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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"],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user