diff --git a/README.md b/README.md index a24dc2b..2cb3538 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ - `POST http://127.0.0.1:3000/api/v1/projects/[projectId]/messages` 正常,普通单线程会话当前会返回 `conversation_reply` 任务,并等待绑定设备上的真实 Codex 线程回写 - `POST http://127.0.0.1:3000/api/auth/logout` 正常,退出后访问受保护 `/api/v1/*` 会返回 `401` - `GET http://127.0.0.1:3000/api/v1/user/ota/package` 正常,当前会返回最新 APK 包 -- 当前这台开发机在本轮 `launchctl` 重载后,`GET http://127.0.0.1:4317/health` 仍未恢复;代码已改成先起本地 health 监听、再异步执行首次 heartbeat / task poll,剩余问题已收敛到 launchd 环境差异排查 +- 当前这台开发机的 `launchd` 常驻 `local-agent` 已恢复:`GET http://127.0.0.1:4317/health` 现在可在数十毫秒内返回,且在手动 heartbeat 执行期间仍能正常回包 - `GET http://127.0.0.1:4317/api/v1/skills` 正常,已返回本机扫描到的 Codex Skill - `POST http://127.0.0.1:4317/api/v1/heartbeat` 正常,且会顺带触发 `thread-context` 上报 - `launchd` 已加载:`~/Library/LaunchAgents/com.hyzq.boss.local-agent.plist` @@ -94,7 +94,7 @@ Android APK: - 已生成 Android debug APK:`android/app/build/outputs/apk/debug/app-debug.apk` - 已生成 Android signed release APK:`android/app/build/outputs/apk/release/app-release.apk` - `npm run apk:release` 还会额外产出带版本号的文件:`android/app/build/outputs/apk/release/boss-android-v{versionName}-release.apk` -- 当前最新 release 构建版本:`2.5.6`(`versionCode=19`) +- 当前最新 release 构建版本:`2.5.7`(`versionCode=20`) - 当前 APK 已切到原生 Android 客户端:`MainActivity + BossApiClient + 原生 XML 布局` - 当前原生活动页已经覆盖:会话首页、项目详情、项目目标、版本记录、会话信息、群资料、发起群聊、消息转发、线程详情、设备详情、添加设备、账号与安全、设置、AI 账号、技能、运维中心、关于 - 当前原生一级体验已回退到微信式交互:`会话 / 设备 / 我的` 固定底部 tab,会话首页是简单聊天列表,`主 Agent / 审计对话` 以普通置顶会话样式排在最前;项目详情页是聊天优先,只保留 `项目目标 / 版本记录` 两个轻入口 @@ -203,6 +203,7 @@ device-agent 当前职责: - 对群聊线程分发任务,认领到的 `dispatch_execution` 任务会把原始线程结果和主 Agent 汇总一起回写到群聊消息账本 - `local-agent` 对 `conversation_reply / dispatch_execution` 当前会优先使用 `codex exec resume `,只有缺失真实线程引用时才退回 `--ephemeral` - `local-agent` 当前会先启动本地 `4317` 健康监听,再异步执行首次 heartbeat 和 task poll,避免控制面短暂阻塞时本地健康检查一起挂死 +- Codex 项目/线程扫描当前已搬到 worker 线程执行,避免 `.codex/logs_1.sqlite` 和 `state_5.sqlite` 的同步扫描阻塞主线程 HTTP 响应 - 如果某个历史群聊里已经没有真实线程成员,当前不会再表现成“发了没反应”,而是会在群里追加一条 `system_notice`,提示用户先重新整理群成员 - 设备导入审核当前也会落 `device_import_resolution` 任务轨迹,但决议内容仍是服务端 heuristic 版;下一阶段可再升级成真正通过 `local-agent -> codex exec` 参与理解 - 提供本地 `/health`、`/api/v1/device`、`/api/v1/skills`、`/api/v1/heartbeat` diff --git a/android/app/build.gradle b/android/app/build.gradle index 557fb9e..ed58804 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -36,8 +36,8 @@ android { applicationId "com.hyzq.boss" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 19 - versionName "2.5.6" + versionCode 20 + versionName "2.5.7" buildConfigField "String", "BOSS_API_BASE_URL", "\"https://boss.hyzq.net\"" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } diff --git a/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java b/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java index d590850..f74a492 100644 --- a/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java @@ -852,7 +852,7 @@ public class ProjectDetailActivity extends BossScreenActivity { ProjectChatUiState.ReplyWaitSpec waitSpec = ProjectChatUiState.resolveReplyWaitAfterDispatchConfirm(response.json); runOnUiThread(() -> { - projectApprovalState = "approval_required".equals(projectCollaborationMode) ? "approved" : "not_required"; + applyDispatchPlanActionResponse(response.json); if (waitSpec.shouldWait) { startReplyWait( waitSpec, @@ -887,9 +887,8 @@ public class ProjectDetailActivity extends BossScreenActivity { throw new IllegalStateException(response.message()); } runOnUiThread(() -> { - currentPendingDispatchPlan = null; composerSending = false; - projectApprovalState = "rejected"; + applyDispatchPlanActionResponse(response.json); updateComposerSendButtonState(); showMessage("已拒绝主 Agent 推荐"); reload(true); @@ -1935,6 +1934,24 @@ public class ProjectDetailActivity extends BossScreenActivity { } } + private void applyDispatchPlanActionResponse(@Nullable JSONObject response) { + if (response == null) { + return; + } + JSONObject collaborationGate = response.optJSONObject("collaborationGate"); + if (collaborationGate != null) { + projectCollaborationMode = collaborationGate.optString("collaborationMode", projectCollaborationMode); + projectApprovalState = collaborationGate.optString("approvalState", projectApprovalState); + } + JSONObject plan = response.optJSONObject("plan"); + if (plan != null) { + String status = plan.optString("status", ""); + if (!"pending_user_confirmation".equals(status)) { + currentPendingDispatchPlan = null; + } + } + } + private List collectMessageIds(@Nullable JSONArray messages) { ArrayList ids = new ArrayList<>(); if (messages == null) { diff --git a/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityUiTest.java b/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityUiTest.java index 5d3a0a0..2566c36 100644 --- a/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityUiTest.java +++ b/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityUiTest.java @@ -353,6 +353,45 @@ public class ProjectDetailActivityUiTest { assertEquals("group-1", nextIntent.getStringExtra(GroupInfoActivity.EXTRA_PROJECT_ID)); } + @Test + public void applyDispatchPlanActionResponseClearsPendingPlanAndTracksApprovalState() throws Exception { + Intent intent = new Intent() + .putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "group-1") + .putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "巡检协作群"); + TestProjectDetailActivity activity = Robolectric + .buildActivity(TestProjectDetailActivity.class, intent) + .setup() + .get(); + + ReflectionHelpers.setField(activity, "projectCollaborationMode", "approval_required"); + ReflectionHelpers.setField(activity, "projectApprovalState", "pending_user"); + ReflectionHelpers.setField( + activity, + "currentPendingDispatchPlan", + new JSONObject().put("planId", "dispatch-plan-1").put("status", "pending_user_confirmation") + ); + + JSONObject response = new JSONObject() + .put("plan", new JSONObject() + .put("planId", "dispatch-plan-1") + .put("status", "dispatched")) + .put("collaborationGate", new JSONObject() + .put("isGroup", true) + .put("collaborationMode", "approval_required") + .put("requiresMasterAgentApproval", true) + .put("approvalState", "approved")); + + ReflectionHelpers.callInstanceMethod( + activity, + "applyDispatchPlanActionResponse", + ReflectionHelpers.ClassParameter.from(JSONObject.class, response) + ); + + assertEquals("approval_required", ReflectionHelpers.getField(activity, "projectCollaborationMode")); + assertEquals("approved", ReflectionHelpers.getField(activity, "projectApprovalState")); + assertEquals(null, ReflectionHelpers.getField(activity, "currentPendingDispatchPlan")); + } + private static JSONObject buildGroupProjectPayload() throws Exception { JSONObject threadMeta = new JSONObject() .put("threadId", "group-thread-3") diff --git a/docs/architecture/current_runtime_and_deploy_status_cn.md b/docs/architecture/current_runtime_and_deploy_status_cn.md index f41bf40..6757d63 100644 --- a/docs/architecture/current_runtime_and_deploy_status_cn.md +++ b/docs/architecture/current_runtime_and_deploy_status_cn.md @@ -21,7 +21,7 @@ - 登录恢复接口:`POST http://127.0.0.1:3000/api/auth/restore` - 登出接口:`POST http://127.0.0.1:3000/api/auth/logout` - OTA 包下载接口:`GET http://127.0.0.1:3000/api/v1/user/ota/package` -- 本地 agent 健康检查:`http://127.0.0.1:4317/health`。当前这台开发机在本轮 `launchctl` 重载后仍未恢复,但代码已改成先启动本地 health 监听、再异步执行首次 heartbeat / task poll,剩余问题已收敛到 launchd 环境差异排查 +- 本地 agent 健康检查:`http://127.0.0.1:4317/health`。当前这台开发机的 `launchd` 常驻已经恢复,`/health` 可在数十毫秒内返回,并且在手动 heartbeat 执行期间也不会再被 Codex 线程扫描卡死 - 本地 Skill 扫描接口:`http://127.0.0.1:4317/api/v1/skills` - 本地 agent 手动 heartbeat:`POST http://127.0.0.1:4317/api/v1/heartbeat` - `launchd` 已安装:`~/Library/LaunchAgents/com.hyzq.boss.local-agent.plist` @@ -149,7 +149,7 @@ cd /Users/kris/code/boss - 当前已生成 Android debug APK:`android/app/build/outputs/apk/debug/app-debug.apk` - 当前已生成 Android signed release APK:`android/app/build/outputs/apk/release/app-release.apk` - 当前 release 构建还会额外生成带版本号的 APK:`android/app/build/outputs/apk/release/boss-android-v{versionName}-release.apk` -- 当前最新 release 构建版本:`2.5.5`(`versionCode=18`) +- 当前最新 release 构建版本:`2.5.7`(`versionCode=20`) - 当前 release keystore 位于本机 `android/keystores/boss-release.keystore`,签名参数位于 `android/signing/release-signing.properties` - `2.0.1` 已在本机连接的华为真机上复核通过,修复了 `Theme.SplashScreen` 导致的 `AppCompatActivity` 启动闪退 - `2.1.0` 已把 Web 一级页和主要二级页全部补成原生活动页:`MainActivity / ProjectDetailActivity / ProjectGoalsActivity / ProjectVersionsActivity / ProjectForwardActivity / ThreadDetailActivity / DeviceDetailActivity / DeviceEnrollmentActivity / SkillInventoryActivity / SecurityActivity / SettingsActivity / AiAccountsActivity / OpsCenterActivity / AboutActivity` @@ -176,6 +176,7 @@ cd /Users/kris/code/boss - 当前历史脏群如果不再包含真实线程成员,群聊消息不会再表现成“无响应”;服务端会在群内追加明确 `system_notice`,提示先重新添加线程成员 - 当前设备导入决议已经会先落 `device_import_resolution` master task 再写回结果,但决议内容仍是服务端 heuristic 版;下一阶段可再升级成真正通过 `local-agent -> codex exec` 参与理解的主 Agent 决议 - 当前 `local-agent` 已改成先启动本地 `4317` 健康监听,再异步跑首次 heartbeat 和 task poll,避免控制面短时阻塞时本地健康探针不可用 +- Codex 项目/线程扫描当前已搬到 worker 线程执行,避免 `.codex/logs_1.sqlite` 和 `state_5.sqlite` 的同步扫描阻塞主线程健康接口 - 原生 Android 当前对 `master-agent` 聊天不再依赖长时间同步等待;发送后会先显示“主 Agent 思考中”,右上角改成微信式 `...` 菜单,菜单项包含 `模型 / 推理强度 / 会话信息 / 刷新` ## 2. 服务器状态 diff --git a/local-agent/codex-session-discovery.mjs b/local-agent/codex-session-discovery.mjs index 765fa56..8d42de7 100644 --- a/local-agent/codex-session-discovery.mjs +++ b/local-agent/codex-session-discovery.mjs @@ -3,6 +3,7 @@ import { basename, resolve } from "node:path"; import { readFileSync } from "node:fs"; import { readFile, readdir } from "node:fs/promises"; import { DatabaseSync } from "node:sqlite"; +import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads"; function toIsoFromUnixSeconds(value) { if (!Number.isFinite(value) || value <= 0) return null; @@ -259,3 +260,43 @@ export async function discoverCodexProjectCandidates(options = {}) { projectCandidates: candidates, }; } + +export async function discoverCodexProjectCandidatesInWorker(options = {}) { + return await new Promise((resolvePromise, rejectPromise) => { + const worker = new Worker(new URL(import.meta.url), { + workerData: { + kind: "boss_codex_discovery", + options, + }, + }); + + worker.once("message", (payload) => { + if (payload?.ok) { + resolvePromise(payload.result); + return; + } + rejectPromise(new Error(payload?.error ?? "DISCOVERY_WORKER_FAILED")); + }); + + worker.once("error", rejectPromise); + + worker.once("exit", (code) => { + if (code === 0) { + return; + } + rejectPromise(new Error(`DISCOVERY_WORKER_EXIT_${code}`)); + }); + }); +} + +if (!isMainThread && workerData?.kind === "boss_codex_discovery") { + try { + const result = await discoverCodexProjectCandidates(workerData.options ?? {}); + parentPort?.postMessage({ ok: true, result }); + } catch (error) { + parentPort?.postMessage({ + ok: false, + error: error instanceof Error ? error.message : String(error), + }); + } +} diff --git a/local-agent/server.mjs b/local-agent/server.mjs index 4de7cf4..2f98e6f 100755 --- a/local-agent/server.mjs +++ b/local-agent/server.mjs @@ -5,7 +5,7 @@ import { createServer } from "node:http"; import { access, readFile, readdir, rm } from "node:fs/promises"; import os from "node:os"; import { join, resolve } from "node:path"; -import { discoverCodexProjectCandidates } from "./codex-session-discovery.mjs"; +import { discoverCodexProjectCandidatesInWorker } from "./codex-session-discovery.mjs"; import { buildCodexTaskExecution } from "./codex-task-runner.mjs"; async function loadConfig(configPath) { @@ -24,7 +24,7 @@ async function resolveHeartbeatProjects(config, runtime) { } try { - const discovered = await discoverCodexProjectCandidates({ + const discovered = await discoverCodexProjectCandidatesInWorker({ stateDbPath: config.codexStateDbPath, logsDbPath: config.codexLogsDbPath, sessionIndexPath: config.codexSessionIndexPath, diff --git a/src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/confirm/route.ts b/src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/confirm/route.ts index 27af590..72d6682 100644 --- a/src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/confirm/route.ts +++ b/src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/confirm/route.ts @@ -33,6 +33,7 @@ export async function POST( plan: result.plan, executions: result.executions, notice: result.notice, + collaborationGate: result.collaborationGate, }); } catch (error) { return NextResponse.json( diff --git a/src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/reject/route.ts b/src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/reject/route.ts index d3dd99f..df2ad09 100644 --- a/src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/reject/route.ts +++ b/src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/reject/route.ts @@ -23,6 +23,7 @@ export async function POST( ok: true, plan: result.plan, notice: result.notice, + collaborationGate: result.collaborationGate, }); } catch (error) { return NextResponse.json( diff --git a/src/lib/boss-data.ts b/src/lib/boss-data.ts index b00b076..035d2f9 100644 --- a/src/lib/boss-data.ts +++ b/src/lib/boss-data.ts @@ -316,6 +316,15 @@ export interface DispatchExecution { completedByDeviceId?: string; } +function buildCollaborationGate(project: Pick) { + return { + isGroup: project.isGroup, + collaborationMode: project.collaborationMode, + requiresMasterAgentApproval: project.isGroup && project.collaborationMode === "approval_required", + approvalState: project.approvalState, + }; +} + export interface ProjectAgentControls { modelOverride?: string; reasoningEffortOverride?: ReasoningEffort; @@ -4395,6 +4404,7 @@ export async function rejectDispatchPlan(input: { return { plan: { ...plan }, notice: notice ? { ...notice } : null, + collaborationGate: buildCollaborationGate(groupProject), }; }); @@ -4659,6 +4669,7 @@ export async function confirmDispatchPlanAndCreateExecutions(input: { plan: { ...plan }, executions: executions.map((execution) => ({ ...execution })), notice: createdNotice ? { ...createdNotice } : null, + collaborationGate: buildCollaborationGate(groupProject), }; }); diff --git a/tests/dispatch-plan-confirmation.test.ts b/tests/dispatch-plan-confirmation.test.ts index ca112fe..7776cc4 100644 --- a/tests/dispatch-plan-confirmation.test.ts +++ b/tests/dispatch-plan-confirmation.test.ts @@ -188,6 +188,12 @@ test("POST /api/v1/projects/[projectId]/dispatch-plans/[planId]/confirm confirms plan: { planId: string; status: string; confirmedTargetProjectIds: string[] }; executions: Array<{ planId: string; targetProjectId: string; status: string }>; notice: { kind: string; body: string } | null; + collaborationGate: { + isGroup: boolean; + collaborationMode: string; + requiresMasterAgentApproval: boolean; + approvalState: string; + }; }; assert.equal(payload.ok, true); assert.equal(payload.plan.planId, dispatchPlan.planId); @@ -200,6 +206,10 @@ test("POST /api/v1/projects/[projectId]/dispatch-plans/[planId]/confirm confirms assert.ok(payload.notice, "expected a confirmation notice in the response"); assert.equal(payload.notice?.kind, "system_notice"); assert.equal(payload.notice?.body, "已确认下发到 1 个线程:《北区试产线回归》。"); + assert.equal(payload.collaborationGate.isGroup, true); + assert.equal(payload.collaborationGate.collaborationMode, "development"); + assert.equal(payload.collaborationGate.requiresMasterAgentApproval, false); + assert.equal(payload.collaborationGate.approvalState, "not_required"); const nextState = await readState(); const notice = nextState.projects @@ -279,12 +289,22 @@ test("rejecting a dispatch plan marks approval_required groups as rejected and w ok: boolean; plan: { planId: string; status: string }; notice: { kind: string; body: string }; + collaborationGate: { + isGroup: boolean; + collaborationMode: string; + requiresMasterAgentApproval: boolean; + approvalState: string; + }; }; assert.equal(payload.ok, true); assert.equal(payload.plan.planId, dispatchPlan.planId); assert.equal(payload.plan.status, "rejected"); assert.equal(payload.notice.kind, "system_notice"); assert.equal(payload.notice.body, "已拒绝主 Agent 推荐,本次不会下发到任何线程。"); + assert.equal(payload.collaborationGate.isGroup, true); + assert.equal(payload.collaborationGate.collaborationMode, "approval_required"); + assert.equal(payload.collaborationGate.requiresMasterAgentApproval, true); + assert.equal(payload.collaborationGate.approvalState, "rejected"); const nextState = await readState(); const nextGroupProject = nextState.projects.find((project) => project.id === groupProject.id); diff --git a/tests/local-agent-codex-discovery-worker.test.mjs b/tests/local-agent-codex-discovery-worker.test.mjs new file mode 100644 index 0000000..aa41ec7 --- /dev/null +++ b/tests/local-agent-codex-discovery-worker.test.mjs @@ -0,0 +1,167 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { DatabaseSync } from "node:sqlite"; + +let runtimeRoot = ""; +let discoverCodexProjectCandidates; +let discoverCodexProjectCandidatesInWorker; + +async function setup() { + if (runtimeRoot) return; + runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-local-agent-discovery-worker-")); + ({ + discoverCodexProjectCandidates, + discoverCodexProjectCandidatesInWorker, + } = await import("../local-agent/codex-session-discovery.mjs")); +} + +test.after(async () => { + if (runtimeRoot) { + await rm(runtimeRoot, { recursive: true, force: true }); + } +}); + +test("discoverCodexProjectCandidatesInWorker returns the same folder/thread candidates as sync discovery", async () => { + await setup(); + + const codexRoot = path.join(runtimeRoot, ".codex"); + const now = new Date("2026-03-31T12:00:00+08:00"); + await mkdir(codexRoot, { recursive: true }); + const stateDbPath = path.join(codexRoot, "state_5.sqlite"); + const logsDbPath = path.join(codexRoot, "logs_1.sqlite"); + const sessionIndexPath = path.join(codexRoot, "session_index.jsonl"); + const globalStatePath = path.join(codexRoot, ".codex-global-state.json"); + + const stateDb = new DatabaseSync(stateDbPath); + stateDb.exec(` + CREATE TABLE threads ( + id TEXT PRIMARY KEY, + rollout_path TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + source TEXT NOT NULL, + model_provider TEXT NOT NULL, + cwd TEXT NOT NULL, + title TEXT NOT NULL, + sandbox_policy TEXT NOT NULL, + approval_mode TEXT NOT NULL, + tokens_used INTEGER NOT NULL DEFAULT 0, + has_user_event INTEGER NOT NULL DEFAULT 0, + archived INTEGER NOT NULL DEFAULT 0, + archived_at INTEGER, + git_sha TEXT, + git_branch TEXT, + git_origin_url TEXT, + cli_version TEXT NOT NULL DEFAULT '', + first_user_message TEXT NOT NULL DEFAULT '', + agent_nickname TEXT, + agent_role TEXT, + memory_mode TEXT NOT NULL DEFAULT 'enabled', + model TEXT, + reasoning_effort TEXT, + agent_path TEXT + ); + `); + const insertThread = stateDb.prepare(` + INSERT INTO threads ( + id, rollout_path, created_at, updated_at, source, model_provider, cwd, title, + sandbox_policy, approval_mode, tokens_used, has_user_event, archived, + cli_version, first_user_message, agent_nickname, agent_role, memory_mode, model, reasoning_effort + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 1, ?, '0.118.0', '', ?, ?, 'enabled', 'gpt-5.4', 'medium') + `); + insertThread.run( + "019d-worker-boss-main", + path.join(codexRoot, "sessions/2026/03/31/rollout-boss-main.jsonl"), + 1774929600, + 1774929612, + "desktop", + "openai", + "/Users/kris/code/boss", + "Boss 主线程", + "workspace-write", + "never", + 0, + null, + null, + ); + insertThread.run( + "019d-worker-meiyesaas", + path.join(codexRoot, "sessions/2026/03/31/rollout-meiyesaas.jsonl"), + 1774929630, + 1774929644, + "desktop", + "openai", + "/Users/kris/code/meiyesaas", + "美业 SaaS", + "workspace-write", + "never", + 0, + null, + null, + ); + stateDb.close(); + + const logsDb = new DatabaseSync(logsDbPath); + logsDb.exec(` + CREATE TABLE logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + ts_nanos INTEGER NOT NULL, + level TEXT NOT NULL, + target TEXT NOT NULL, + feedback_log_body TEXT, + module_path TEXT, + file TEXT, + line INTEGER, + thread_id TEXT, + process_uuid TEXT, + estimated_bytes INTEGER NOT NULL DEFAULT 0 + ); + `); + const insertLog = logsDb.prepare(` + INSERT INTO logs (ts, ts_nanos, level, target, thread_id, estimated_bytes) + VALUES (?, 0, 'info', 'codex', ?, 0) + `); + insertLog.run(1774929612, "019d-worker-boss-main"); + insertLog.run(1774929644, "019d-worker-meiyesaas"); + logsDb.close(); + + await writeFile( + sessionIndexPath, + [ + JSON.stringify({ + id: "019d-worker-boss-main", + thread_name: "Boss 主线程", + updated_at: "2026-03-31T04:00:12.000000Z", + }), + JSON.stringify({ + id: "019d-worker-meiyesaas", + thread_name: "美业 SaaS 主线程", + updated_at: "2026-03-31T04:00:44.000000Z", + }), + ].join("\n") + "\n", + "utf8", + ); + await writeFile( + globalStatePath, + JSON.stringify({ "thread-workspace-root-hints": {} }, null, 2), + "utf8", + ); + + const options = { + stateDbPath, + logsDbPath, + sessionIndexPath, + globalStatePath, + lookbackHours: 24, + now, + }; + + const syncResult = await discoverCodexProjectCandidates(options); + const workerResult = await discoverCodexProjectCandidatesInWorker(options); + + assert.deepEqual(workerResult, syncResult); +});