fix: harden production chat runtime

This commit is contained in:
kris
2026-03-31 20:20:07 +08:00
parent ec7081f6cc
commit 02fcc56332
12 changed files with 310 additions and 11 deletions

View File

@@ -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 <targetCodexThreadRef>`,只有缺失真实线程引用时才退回 `--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`

View File

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

View File

@@ -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<String> collectMessageIds(@Nullable JSONArray messages) {
ArrayList<String> ids = new ArrayList<>();
if (messages == null) {

View File

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

View File

@@ -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. 服务器状态

View File

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

View File

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

View File

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

View File

@@ -23,6 +23,7 @@ export async function POST(
ok: true,
plan: result.plan,
notice: result.notice,
collaborationGate: result.collaborationGate,
});
} catch (error) {
return NextResponse.json(

View File

@@ -316,6 +316,15 @@ export interface DispatchExecution {
completedByDeviceId?: string;
}
function buildCollaborationGate(project: Pick<Project, "isGroup" | "collaborationMode" | "approvalState">) {
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),
};
});

View File

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

View File

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