Harden read-only thread handling and refresh Android releases
This commit is contained in:
@@ -226,6 +226,8 @@ device-agent 当前职责:
|
||||
- `local-agent` 对 `dispatch_execution` 当前会按 `orchestrationBackendId` 分流:默认继续走 `codex exec resume`;当任务显式选择 `omx-team` 且本机 `omxEnabled + omxCommand/omxArgs` 可用时,会改走 `OMX Team Runtime` JSON 协议执行
|
||||
- `local-agent` 当前的任务完成回写已通过 `RemoteRuntimeAdapter` 标准化,`conversation_reply / dispatch_execution` 的完成结果都会先归一到统一远程执行结果结构,再进入主 Agent 完成路由
|
||||
- `RemoteRuntimeAdapter` 当前还会拦截固定模式的线程内部环境提示(如“当前会话环境只读 / cwd 我可以在命令里指向 …”),并改写成系统失败提示,不再把这类脏文本直接回写到单聊或群聊
|
||||
- 当前设备模型已支持同一台 Mac / Windows 同时接入 Codex `GUI + CLI` 双能力;设备详情页会同时展示两种能力状态,并允许切换默认执行模式
|
||||
- 当前同项目 `GUI / CLI` 并行写入风险已接入项目/文件夹级冲突控制:默认阻断,用户可仅对当前异常项目/文件夹选择 `禁止 / 允许本次 / 永久放行`
|
||||
- `local-agent` 当前会先启动本地 `4317` 健康监听,再异步执行首次 heartbeat 和 task poll,避免控制面短暂阻塞时本地健康检查一起挂死
|
||||
- Codex 项目/线程扫描当前已搬到 worker 线程执行,避免 `.codex/logs_1.sqlite` 和 `state_5.sqlite` 的同步扫描阻塞主线程 HTTP 响应
|
||||
- 如果某个历史群聊里已经没有真实线程成员,当前不会再表现成“发了没反应”,而是会在群里追加一条 `system_notice`,提示用户先重新整理群成员
|
||||
|
||||
@@ -118,7 +118,7 @@ public class AboutActivity extends BossScreenActivity {
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"当前版本",
|
||||
user == null ? ota.optString("currentVersion", "-") : user.optString("version", ota.optString("currentVersion", "-")),
|
||||
resolveInstalledVersionLabel(user, ota, BuildConfig.VERSION_NAME),
|
||||
"已安装版本",
|
||||
null,
|
||||
null
|
||||
@@ -171,6 +171,23 @@ public class AboutActivity extends BossScreenActivity {
|
||||
return "发现新版本 " + availableRelease.optString("version", "未知版本");
|
||||
}
|
||||
|
||||
private static String resolveInstalledVersionLabel(
|
||||
@Nullable JSONObject user,
|
||||
JSONObject ota,
|
||||
@Nullable String packageVersionName
|
||||
) {
|
||||
if (packageVersionName != null && !packageVersionName.isEmpty()) {
|
||||
return packageVersionName;
|
||||
}
|
||||
if (user != null) {
|
||||
String userVersion = user.optString("version", "");
|
||||
if (!userVersion.isEmpty()) {
|
||||
return userVersion;
|
||||
}
|
||||
}
|
||||
return ota.optString("currentVersion", "-");
|
||||
}
|
||||
|
||||
private static String buildOtaStatusMeta(JSONObject ota) {
|
||||
JSONObject availableRelease = ota.optJSONObject("availableRelease");
|
||||
if (availableRelease == null) {
|
||||
|
||||
@@ -14,6 +14,8 @@ import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
@@ -25,6 +27,11 @@ public class GroupCreateActivity extends BossScreenActivity {
|
||||
private final List<CandidateConversation> candidates = new ArrayList<>();
|
||||
private final Set<String> selectedProjectIds = new LinkedHashSet<>();
|
||||
private final Set<String> lastCandidateProjectIds = new LinkedHashSet<>();
|
||||
private static final Set<String> AUTO_JOIN_GROUP_TITLES = new HashSet<>(Arrays.asList(
|
||||
"主agent",
|
||||
"硬件审计协作",
|
||||
"boss移动控制台"
|
||||
));
|
||||
|
||||
private String sourceProjectId;
|
||||
private String sourceProjectName;
|
||||
@@ -193,21 +200,41 @@ public class GroupCreateActivity extends BossScreenActivity {
|
||||
if (conversations == null) {
|
||||
return result;
|
||||
}
|
||||
boolean hasSourceProject = sourceProjectId != null && !sourceProjectId.isEmpty();
|
||||
for (int i = 0; i < conversations.length(); i++) {
|
||||
JSONObject item = conversations.optJSONObject(i);
|
||||
if (item == null) continue;
|
||||
String projectId = item.optString("projectId", "");
|
||||
if (projectId.isEmpty()
|
||||
|| (hasSourceProject && sourceProjectId.equals(projectId))
|
||||
|| item.optBoolean("isGroup", false)) {
|
||||
continue;
|
||||
if (isEligibleForManualGroupSelection(item, sourceProjectId)) {
|
||||
result.add(item);
|
||||
}
|
||||
result.add(item);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static boolean isEligibleForManualGroupSelection(@Nullable JSONObject item, @Nullable String sourceProjectId) {
|
||||
if (item == null) {
|
||||
return false;
|
||||
}
|
||||
String projectId = item.optString("projectId", "");
|
||||
if (projectId.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
if (sourceProjectId != null && !sourceProjectId.isEmpty() && sourceProjectId.equals(projectId)) {
|
||||
return false;
|
||||
}
|
||||
if (item.optBoolean("isGroup", false)) {
|
||||
return false;
|
||||
}
|
||||
if (!"single_device".equals(item.optString("conversationType", "single_device"))) {
|
||||
return false;
|
||||
}
|
||||
return !AUTO_JOIN_GROUP_TITLES.contains(normalizeConversationTitle(item));
|
||||
}
|
||||
|
||||
private static String normalizeConversationTitle(JSONObject item) {
|
||||
String title = item.optString("projectTitle", item.optString("threadTitle", ""));
|
||||
return title == null ? "" : title.replaceAll("\\s+", "").toLowerCase();
|
||||
}
|
||||
|
||||
static WechatSurfaceMapper.ConversationRow toCandidateConversationRow(JSONObject item, boolean selected) {
|
||||
return new WechatSurfaceMapper.ConversationRow(
|
||||
item.optString("projectTitle", item.optString("threadTitle", "未命名会话")),
|
||||
|
||||
@@ -144,17 +144,27 @@ public class GroupCreateActivityUiTest {
|
||||
JSONArray conversations = new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("projectId", "thread-2")
|
||||
.put("projectTitle", "硬件审计协作")
|
||||
.put("projectTitle", "查询树莓派二代")
|
||||
.put("folderLabel", "Mac Studio")
|
||||
.put("lastMessagePreview", "检查摄像头供电链路")
|
||||
.put("lastMessagePreview", "检查树莓派二代供电链路")
|
||||
.put("latestReplyLabel", "09:28")
|
||||
.put("conversationType", "single_device")
|
||||
.put("isGroup", false))
|
||||
.put(new JSONObject()
|
||||
.put("projectId", "thread-3")
|
||||
.put("projectTitle", "Boss 移动控制台")
|
||||
.put("projectTitle", "Boss 线程修复")
|
||||
.put("folderLabel", "Boss")
|
||||
.put("lastMessagePreview", "统一顶部按钮样式")
|
||||
.put("latestReplyLabel", "09:31")
|
||||
.put("conversationType", "single_device")
|
||||
.put("isGroup", false))
|
||||
.put(new JSONObject()
|
||||
.put("projectId", "thread-4")
|
||||
.put("projectTitle", "主Agent")
|
||||
.put("folderLabel", "Boss")
|
||||
.put("lastMessagePreview", "系统自动加入")
|
||||
.put("latestReplyLabel", "09:32")
|
||||
.put("conversationType", "single_device")
|
||||
.put("isGroup", false));
|
||||
return new JSONObject().put("conversations", conversations);
|
||||
}
|
||||
|
||||
@@ -187,6 +187,24 @@ public class WechatSurfaceMapperTest {
|
||||
assertEquals("版本 v1.2.8\n1. 优化设备状态刷新\n2. 修复主 Agent 会话排序\n3. 提升 OTA 回收稳定性", content);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aboutActivity_prefersInstalledPackageVersionOverServerVersion() throws Exception {
|
||||
JSONObject ota = new StubJSONObject().withString("currentVersion", "v1.4.1");
|
||||
JSONObject user = new StubJSONObject().withString("version", "v1.4.1");
|
||||
|
||||
java.lang.reflect.Method method = AboutActivity.class.getDeclaredMethod(
|
||||
"resolveInstalledVersionLabel",
|
||||
JSONObject.class,
|
||||
JSONObject.class,
|
||||
String.class
|
||||
);
|
||||
method.setAccessible(true);
|
||||
|
||||
String installedVersion = (String) method.invoke(null, user, ota, "2.5.3");
|
||||
|
||||
assertEquals("2.5.3", installedVersion);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aboutActivity_rejectsStaleDownloadedApkWhenAvailableReleaseChanged() throws Exception {
|
||||
JSONObject availableRelease = new StubJSONObject()
|
||||
|
||||
@@ -1071,6 +1071,7 @@
|
||||
- 当前补充:
|
||||
- `local-agent` 会优先从 `~/.codex/state_5.sqlite / logs_1.sqlite / session_index.jsonl / .codex-global-state.json` 动态发现真实 Codex 线程,并把结果填进 `projects[] + projectCandidates[]`
|
||||
- 线程发现会优先保留每个 Codex 文件夹下的主工作线程;如果同文件夹中存在 `worker / explorer` 子线程,会优先过滤这些子线程,避免误导入过多聊天窗口
|
||||
- 如果某条线程在 Codex 本地状态库里的 `sandbox_policy.type=read-only`,`local-agent` 不会把它作为候选线程上报;这样可以避免历史只读线程再次被自动导入到会话首页
|
||||
- 对已绑定的生产设备,服务端会在 heartbeat 时自动应用建议导入项;对新设备则继续走 `deviceImportDraft` 的人工勾选与应用流程
|
||||
- 自动应用时,如果某些已导入线程已经不再出现在最新 `projectCandidates[]` 中,服务端会在同一轮 heartbeat 清理这些过时线程会话
|
||||
|
||||
@@ -1080,6 +1081,8 @@
|
||||
- 认领到任务后会执行本机 `codex exec`
|
||||
- `conversation_reply` 当前会优先走 `codex exec resume <targetCodexThreadRef>`,把任务恢复到真实 Codex 线程;只有缺失真实线程引用时才退回 `--ephemeral`
|
||||
- `dispatch_execution` 当前默认也走 `codex exec resume`,但当任务显式选择 `omx-team` 且本机 `omxEnabled + omxCommand/omxArgs` 可用时,会改走 `OMX Team Runtime` JSON 协议
|
||||
- `codex exec resume` 前当前还会做目标线程绑定预检;若目标线程缺失、已归档、cwd 不匹配或为只读会话,会直接失败并返回标准化错误,不继续把任务派进错误线程
|
||||
- 如果历史 `worker / explorer` 子线程需要转回可开发线程,除了数据库权限本身,还必须显式补发新的解锁指令覆盖其旧的“只读勘察 / 不改文件”上下文;否则前台看起来像可写,实际执行仍可能被旧上下文限制
|
||||
- 执行完成后会调用 `POST /api/v1/master-agent/tasks/[taskId]/complete`
|
||||
- 对群聊下发链路,认领到的 `dispatch_execution` 任务会带 `dispatchExecutionId / targetProjectId / targetThreadId`
|
||||
- 对普通单线程聊天,认领到的 `conversation_reply` 任务会带 `targetProjectId / targetThreadId / targetCodexThreadRef`
|
||||
|
||||
@@ -38,6 +38,8 @@
|
||||
- Web:`GET /conversations/[projectId]/thread-status`
|
||||
- Android:`ThreadStatusActivity`
|
||||
- 当前 `conversation_reply / dispatch_execution` 的线程执行结果会先经过 `RemoteRuntimeAdapter` 标准化;如果线程返回的是固定模式的内部环境提示(如“当前会话环境只读 / cwd …”),会直接转成失败,不再把原文写回会话消息
|
||||
- 当前设备模型已支持同一台 Mac / Windows 同时接入 Codex `GUI + CLI` 双能力;Web / Android 设备详情页都会展示两种能力状态,并允许切换默认执行模式
|
||||
- 当前同项目 `GUI / CLI` 并行写入风险已接入项目/文件夹级冲突控制:默认阻断,用户只能对当前异常项目/文件夹选择 `禁止 / 允许本次 / 永久放行`
|
||||
|
||||
本地已知运行方式:
|
||||
|
||||
|
||||
@@ -39,6 +39,21 @@ function trimToDefined(value) {
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
function parseSandboxPolicyType(value) {
|
||||
const raw = trimToDefined(value);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return trimToDefined(parsed?.type) ?? raw;
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
function isReadOnlySandboxPolicy(value) {
|
||||
return parseSandboxPolicyType(value) === "read-only";
|
||||
}
|
||||
|
||||
function isPrimaryWorkspaceThread(thread) {
|
||||
return !trimToDefined(thread.agentRole) && !trimToDefined(thread.agentNickname);
|
||||
}
|
||||
@@ -104,7 +119,7 @@ function loadThreadsFromStateDb(stateDbPath) {
|
||||
try {
|
||||
return db
|
||||
.prepare(
|
||||
"SELECT id, cwd, updated_at, archived, title, agent_nickname, agent_role FROM threads WHERE archived = 0 ORDER BY updated_at DESC",
|
||||
"SELECT id, cwd, updated_at, archived, title, sandbox_policy, agent_nickname, agent_role FROM threads WHERE archived = 0 ORDER BY updated_at DESC",
|
||||
)
|
||||
.all()
|
||||
.map((row) => ({
|
||||
@@ -113,6 +128,7 @@ function loadThreadsFromStateDb(stateDbPath) {
|
||||
updatedAtSeconds: Number(row.updated_at),
|
||||
archived: Boolean(row.archived),
|
||||
title: String(row.title ?? ""),
|
||||
sandboxPolicy: typeof row.sandbox_policy === "string" ? row.sandbox_policy : "",
|
||||
agentNickname: typeof row.agent_nickname === "string" ? row.agent_nickname : "",
|
||||
agentRole: typeof row.agent_role === "string" ? row.agent_role : "",
|
||||
}));
|
||||
@@ -206,6 +222,9 @@ export async function discoverCodexProjectCandidates(options = {}) {
|
||||
const groupedCandidates = new Map();
|
||||
for (const thread of threads) {
|
||||
if (!thread?.id || seenThreadIds.has(thread.id)) continue;
|
||||
if (isReadOnlySandboxPolicy(thread.sandboxPolicy)) {
|
||||
continue;
|
||||
}
|
||||
const latestActivitySeconds = latestLogByThread.get(thread.id) ?? thread.updatedAtSeconds;
|
||||
if (!Number.isFinite(latestActivitySeconds) || latestActivitySeconds < cutoffSeconds) {
|
||||
continue;
|
||||
|
||||
@@ -10,6 +10,24 @@ function trimToDefined(value) {
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function parseSandboxPolicyType(value) {
|
||||
const raw = trimToDefined(value);
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return trimToDefined(parsed?.type) || raw;
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
function isReadOnlySandboxPolicy(value) {
|
||||
return parseSandboxPolicyType(value) === "read-only";
|
||||
}
|
||||
|
||||
function resolveResumeTarget(config, task) {
|
||||
const targetThreadRef = trimToDefined(task?.targetCodexThreadRef || task?.targetThreadId);
|
||||
const targetFolderRef = trimToDefined(
|
||||
@@ -74,7 +92,7 @@ function inspectCodexThreadBinding(config, targetThreadRef, targetFolderRef) {
|
||||
const db = new DatabaseSync(stateDbPath, { readonly: true });
|
||||
try {
|
||||
const row = db
|
||||
.prepare("SELECT id, cwd, archived FROM threads WHERE id = ? LIMIT 1")
|
||||
.prepare("SELECT id, cwd, archived, sandbox_policy FROM threads WHERE id = ? LIMIT 1")
|
||||
.get(targetThreadRef);
|
||||
if (!row || row.archived) {
|
||||
return {
|
||||
@@ -82,6 +100,14 @@ function inspectCodexThreadBinding(config, targetThreadRef, targetFolderRef) {
|
||||
};
|
||||
}
|
||||
|
||||
const sandboxPolicyType = parseSandboxPolicyType(row.sandbox_policy);
|
||||
if (isReadOnlySandboxPolicy(row.sandbox_policy)) {
|
||||
return {
|
||||
status: "read_only",
|
||||
sandboxPolicyType,
|
||||
};
|
||||
}
|
||||
|
||||
const workspaceHints = loadThreadWorkspaceHints(
|
||||
trimToDefined(config?.codexGlobalStatePath || defaultCodexPath(".codex-global-state.json")),
|
||||
);
|
||||
@@ -148,6 +174,21 @@ export async function prepareCodexTaskExecution(config, task, outputFile) {
|
||||
};
|
||||
}
|
||||
|
||||
if (bindingInspection.status === "read_only") {
|
||||
return {
|
||||
ok: false,
|
||||
error: buildStructuredTaskBindingError(
|
||||
"LOCAL_AGENT_CODEX_THREAD_READ_ONLY",
|
||||
`LOCAL_AGENT_CODEX_THREAD_READ_ONLY: 目标线程当前是只读会话,已拒绝 codex exec resume。thread=${targetThreadRef} sandbox=${bindingInspection.sandboxPolicyType ?? "read-only"}`,
|
||||
{
|
||||
targetThreadRef,
|
||||
targetCodexFolderRef: resumeTarget.targetFolderRef,
|
||||
sandboxPolicyType: bindingInspection.sandboxPolicyType,
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const folderStat = await stat(resumeTarget.cwd);
|
||||
if (!folderStat.isDirectory()) {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"artifactType": "aab",
|
||||
"fileName": "boss-android-v2.5.5-release.aab",
|
||||
"urlPath": "/downloads/boss-android-v2.5.5-release.aab",
|
||||
"sizeBytes": 2928280,
|
||||
"updatedAt": "2026-03-30T20:15:46Z",
|
||||
"sha256": "5bc794884a621a2e970bf1a235bf07d0338bcec4205963ca442b70fcd75f9f23",
|
||||
"versionName": "2.5.5",
|
||||
"versionCode": 18,
|
||||
"fileName": "boss-android-v2.5.11-release.aab",
|
||||
"urlPath": "/downloads/boss-android-v2.5.11-release.aab",
|
||||
"sizeBytes": 3170704,
|
||||
"updatedAt": "2026-04-06T05:25:20Z",
|
||||
"sha256": "7f4b8de9508c79af5442b9e200952dfb146bcb3586d79db90e2d2c243b77aa21",
|
||||
"versionName": "2.5.11",
|
||||
"versionCode": 24,
|
||||
"buildFlavor": "release"
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"fileName": "boss-android-v2.5.5-release.apk",
|
||||
"fileName": "boss-android-v2.5.11-release.apk",
|
||||
"urlPath": "/api/v1/user/ota/package",
|
||||
"sizeBytes": 3108637,
|
||||
"updatedAt": "2026-03-30T20:41:16Z",
|
||||
"sha256": "43dde41b42b2bbc4256256edb3059803b3f9959da4e546b1d9c7addeafa03350",
|
||||
"versionName": "2.5.5",
|
||||
"versionCode": 18,
|
||||
"sizeBytes": 3351549,
|
||||
"updatedAt": "2026-04-06T05:25:05Z",
|
||||
"sha256": "67a66ea104696d639d576738cdef4ef485efb76d12d63b1b138e59dab091ef00",
|
||||
"versionName": "2.5.11",
|
||||
"versionCode": 24,
|
||||
"buildFlavor": "release"
|
||||
}
|
||||
|
||||
BIN
public/downloads/boss-android-v2.5.11-release.aab
Normal file
BIN
public/downloads/boss-android-v2.5.11-release.aab
Normal file
Binary file not shown.
BIN
public/downloads/boss-android-v2.5.11-release.apk
Normal file
BIN
public/downloads/boss-android-v2.5.11-release.apk
Normal file
Binary file not shown.
@@ -12,6 +12,19 @@ export async function POST(request: NextRequest) {
|
||||
status?: "online" | "abnormal" | "offline";
|
||||
quota5h?: number;
|
||||
quota7d?: number;
|
||||
capabilities?: {
|
||||
gui?: {
|
||||
connected?: boolean;
|
||||
lastSeenAt?: string;
|
||||
lastActiveProjectId?: string;
|
||||
};
|
||||
cli?: {
|
||||
connected?: boolean;
|
||||
lastSeenAt?: string;
|
||||
lastActiveProjectId?: string;
|
||||
};
|
||||
};
|
||||
preferredExecutionMode?: "gui" | "cli";
|
||||
projects?: string[];
|
||||
projectCandidates?: Array<{
|
||||
folderName?: string;
|
||||
@@ -47,6 +60,8 @@ export async function POST(request: NextRequest) {
|
||||
status: body.status,
|
||||
quota5h: body.quota5h ?? 0,
|
||||
quota7d: body.quota7d ?? 0,
|
||||
capabilities: body.capabilities,
|
||||
preferredExecutionMode: body.preferredExecutionMode,
|
||||
projects: body.projects,
|
||||
projectCandidates: (body.projectCandidates ?? []).filter(
|
||||
(candidate) =>
|
||||
|
||||
@@ -212,3 +212,133 @@ test("discoverCodexProjectCandidates prefers Codex sqlite indexes and session na
|
||||
assert.equal(yuandiSession?.threadDisplayName, "Epicurus");
|
||||
assert.equal(yuandiSession?.codexFolderRef, "/Users/kris/code/yuandi");
|
||||
});
|
||||
|
||||
test("discoverCodexProjectCandidates excludes read-only threads even when they are the newest primary thread", async () => {
|
||||
await setup();
|
||||
|
||||
const codexRoot = path.join(runtimeRoot, ".codex-readonly");
|
||||
const now = new Date("2026-04-05T12: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 (?, ?, ?, ?, 'desktop', 'openai', ?, ?, ?, 'never', 0, 1, 0, '0.118.0', '', '', '', 'enabled', 'gpt-5.4', 'medium')
|
||||
`);
|
||||
insertThread.run(
|
||||
"019d-boss-writable",
|
||||
path.join(codexRoot, "sessions/2026/04/05/rollout-boss-writable.jsonl"),
|
||||
1775322000,
|
||||
1775322060,
|
||||
"/Users/kris/code/boss",
|
||||
"Boss 可写线程",
|
||||
'{"type":"workspace-write"}',
|
||||
);
|
||||
insertThread.run(
|
||||
"019d-boss-readonly",
|
||||
path.join(codexRoot, "sessions/2026/04/05/rollout-boss-readonly.jsonl"),
|
||||
1775322120,
|
||||
1775322180,
|
||||
"/Users/kris/code/boss",
|
||||
"Boss 只读线程",
|
||||
'{"type":"read-only"}',
|
||||
);
|
||||
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(1775322060, "019d-boss-writable");
|
||||
insertLog.run(1775322180, "019d-boss-readonly");
|
||||
logsDb.close();
|
||||
|
||||
await writeFile(
|
||||
sessionIndexPath,
|
||||
[
|
||||
JSON.stringify({
|
||||
id: "019d-boss-writable",
|
||||
thread_name: "Boss 可写线程",
|
||||
updated_at: "2026-04-05T04:21:00.000000Z",
|
||||
}),
|
||||
JSON.stringify({
|
||||
id: "019d-boss-readonly",
|
||||
thread_name: "Boss 只读线程",
|
||||
updated_at: "2026-04-05T04:23:00.000000Z",
|
||||
}),
|
||||
].join("\n") + "\n",
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
globalStatePath,
|
||||
JSON.stringify({ "thread-workspace-root-hints": {} }, null, 2),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const discovered = await discoverCodexProjectCandidates({
|
||||
stateDbPath,
|
||||
logsDbPath,
|
||||
sessionIndexPath,
|
||||
globalStatePath,
|
||||
lookbackHours: 24,
|
||||
now,
|
||||
});
|
||||
|
||||
assert.deepEqual(discovered.projects, ["boss"]);
|
||||
assert.equal(discovered.projectCandidates.length, 1);
|
||||
assert.equal(discovered.projectCandidates[0]?.threadId, "019d-boss-writable");
|
||||
assert.equal(discovered.projectCandidates[0]?.threadDisplayName, "Boss 可写线程");
|
||||
});
|
||||
|
||||
@@ -63,7 +63,7 @@ async function createCodexStateDb(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 (?, ?, ?, ?, 'desktop', 'openai', ?, ?, 'workspace-write', 'never', 0, 1, 0, '0.118.0', '', '', '', 'enabled', 'gpt-5.4', 'medium')
|
||||
) VALUES (?, ?, ?, ?, 'desktop', 'openai', ?, ?, ?, 'never', 0, 1, 0, '0.118.0', '', '', '', 'enabled', 'gpt-5.4', 'medium')
|
||||
`);
|
||||
for (const thread of threads) {
|
||||
insertThread.run(
|
||||
@@ -73,6 +73,7 @@ async function createCodexStateDb(threads) {
|
||||
1774845618,
|
||||
thread.cwd,
|
||||
thread.title ?? thread.id,
|
||||
thread.sandboxPolicy ?? '{"type":"workspace-write"}',
|
||||
);
|
||||
}
|
||||
db.close();
|
||||
@@ -283,3 +284,37 @@ test("conversation reply preflight fails closed when target cwd mismatches the l
|
||||
assert.match(result.error.message, /LOCAL_AGENT_CODEX_THREAD_BINDING_MISMATCH/);
|
||||
assert.match(result.error.message, /project-live/);
|
||||
});
|
||||
|
||||
test("conversation reply preflight fails closed when target Codex thread is read-only", async () => {
|
||||
const root = await ensureRuntimeRoot();
|
||||
const readonlyCwd = path.join(root, "project-readonly");
|
||||
await mkdir(readonlyCwd, { recursive: true });
|
||||
const stateDbPath = await createCodexStateDb([
|
||||
{
|
||||
id: "019d-thread-readonly",
|
||||
cwd: readonlyCwd,
|
||||
title: "Read-only thread",
|
||||
sandboxPolicy: '{"type":"read-only"}',
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await prepareCodexTaskExecution(
|
||||
{
|
||||
masterAgentWorkdir: "/Users/kris/code/boss",
|
||||
masterAgentSandbox: "workspace-write",
|
||||
codexStateDbPath: stateDbPath,
|
||||
},
|
||||
{
|
||||
taskType: "conversation_reply",
|
||||
executionPrompt: "请继续开发",
|
||||
targetCodexThreadRef: "019d-thread-readonly",
|
||||
targetCodexFolderRef: readonlyCwd,
|
||||
},
|
||||
"/tmp/reply.txt",
|
||||
);
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.error.code, "LOCAL_AGENT_CODEX_THREAD_READ_ONLY");
|
||||
assert.match(result.error.message, /LOCAL_AGENT_CODEX_THREAD_READ_ONLY/);
|
||||
assert.match(result.error.message, /read-only/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user