chore: checkpoint Boss app v2.5.11

This commit is contained in:
AI Bot
2026-06-08 12:22:50 +08:00
parent bddbe8b5ba
commit 3b51641d99
78 changed files with 5706 additions and 954 deletions

1
.gitignore vendored
View File

@@ -40,6 +40,7 @@ android/app/.project
android/app/.settings/
data/*.json
data/*.json.bak
data/backups/*.json
android/.gradle/
android/**/build/
android/local.properties

View File

@@ -172,11 +172,23 @@ public class BossApiClient {
}
public ApiResponse getProjectDetail(String projectId) throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId), null);
return requestWithRestoreRaw(
"GET",
"/api/v1/projects/" + encode(projectId),
null,
DEFAULT_CONNECT_TIMEOUT_MS,
CONVERSATIONS_READ_TIMEOUT_MS
);
}
public ApiResponse getProjectMessages(String projectId) throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/messages", null);
return requestWithRestoreRaw(
"GET",
"/api/v1/projects/" + encode(projectId) + "/messages",
null,
DEFAULT_CONNECT_TIMEOUT_MS,
CONVERSATIONS_READ_TIMEOUT_MS
);
}
public ApiResponse getDispatchPlans(String projectId) throws IOException, JSONException {
@@ -560,6 +572,14 @@ public class BossApiClient {
return requestWithRestore("GET", "/api/v1/devices/" + encode(deviceId) + "/skills", null);
}
public ApiResponse getSkillLifecycleRequests() throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/admin/skills/requests", null);
}
public ApiResponse createSkillLifecycleRequest(JSONObject payload) throws IOException, JSONException {
return requestWithRestore("POST", "/api/v1/admin/skills/requests", payload == null ? new JSONObject() : payload);
}
public ApiResponse getDeviceEnrollments() throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/devices/enrollments", null);
}

View File

@@ -29,6 +29,7 @@ public final class BossMarkdown {
private static final Pattern BULLET_PATTERN = Pattern.compile("^[-*]\\s+(.+)$");
private static final Pattern ORDERED_PATTERN = Pattern.compile("^(\\d+)\\.\\s+(.+)$");
private static final Pattern LABEL_SECTION_PATTERN = Pattern.compile("^([^:\\n]{1,24})[:]\\s*(.+)$");
private static final Pattern MARKDOWN_LINK_PATTERN = Pattern.compile("\\[([^\\]\\n]{1,90})\\]\\((https?://[^\\s)]+)\\)");
private static final Pattern INLINE_TOKEN_PATTERN = Pattern.compile("(\\*\\*([^*]+)\\*\\*)|(`([^`]+)`)");;
private static final LruCache<String, CharSequence> RENDER_CACHE = new LruCache<>(180);
@@ -45,7 +46,7 @@ public final class BossMarkdown {
}
Palette palette = Palette.resolve(context, outgoing);
SpannableStringBuilder builder = new SpannableStringBuilder();
String normalized = markdown.replace("\r\n", "\n").replace('\r', '\n');
String normalized = normalizeMarkdownLinks(markdown).replace("\r\n", "\n").replace('\r', '\n');
String[] lines = normalized.split("\n", -1);
boolean inCodeFence = false;
List<String> codeLines = new ArrayList<>();
@@ -117,6 +118,21 @@ public final class BossMarkdown {
return (outgoing ? "out" : "in") + "|" + uiMode + "|" + markdown;
}
private static String normalizeMarkdownLinks(String markdown) {
Matcher matcher = MARKDOWN_LINK_PATTERN.matcher(markdown);
StringBuffer buffer = new StringBuffer();
while (matcher.find()) {
String label = matcher.group(1) == null ? "链接" : matcher.group(1).trim();
label = label.replace("`", "").trim();
if (TextUtils.isEmpty(label)) {
label = "链接";
}
matcher.appendReplacement(buffer, Matcher.quoteReplacement(label));
}
matcher.appendTail(buffer);
return buffer.toString();
}
private static void appendHeading(SpannableStringBuilder builder, String text, int level, Palette palette) {
ensureBlockSeparation(builder, true);
int start = builder.length();

View File

@@ -136,6 +136,7 @@ final class BossRealtimeClient {
connection.setRequestProperty("Accept", "text/event-stream");
connection.setRequestProperty("Cache-Control", "no-cache");
connection.setRequestProperty("x-boss-native-app", "1");
connection.setRequestProperty("x-boss-realtime-capabilities", "message-patch-v1");
String cookie = apiClient.getSessionCookie();
if (!cookie.isEmpty()) {
connection.setRequestProperty("Cookie", cookie);

View File

@@ -1340,6 +1340,7 @@ public final class BossUi {
card.addView(titleRow);
String phase = progress == null ? "" : progress.optString("phase", "").trim();
String progressStatus = progress == null ? "" : progress.optString("status", "").trim();
if (!TextUtils.isEmpty(phase)) {
TextView phaseView = secondaryText(context, "当前状态:" + executionPhaseLabel(phase));
phaseView.setPadding(0, dp(context, 8), 0, 0);
@@ -1872,9 +1873,14 @@ public final class BossUi {
}
}
card.addView(divider(context));
card.addView(sectionTitle(context, "分支详情"));
JSONObject branch = progress == null ? null : progress.optJSONObject("branch");
boolean shouldShowBranchFallback = TextUtils.isEmpty(progressStatus) ||
"queued".equals(progressStatus) ||
"running".equals(progressStatus);
if (branch != null || shouldShowBranchFallback) {
card.addView(divider(context));
card.addView(sectionTitle(context, "分支详情"));
}
if (branch != null) {
String changeText = formatChangeText(branch);
if (!TextUtils.isEmpty(changeText)) {
@@ -1888,7 +1894,7 @@ public final class BossUi {
} else if ("available".equals(ghStatus)) {
card.addView(detailRow(context, "", "GitHub CLI 可用", "", false));
}
} else {
} else if (shouldShowBranchFallback) {
card.addView(detailRow(context, "", "Git 操作", "等待执行器回写", false));
}

View File

@@ -128,6 +128,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
private boolean pendingReload;
private boolean pendingReloadMessagesOnly;
private boolean pendingReloadForcedScrollToBottom;
private boolean pendingReloadShowRefreshIndicator;
private volatile boolean activityDestroyed;
private volatile boolean markConversationReadInFlight;
private @Nullable AlertDialog activeDialogGuardDialog;
@@ -141,7 +142,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
return;
}
if (!reloadInFlight && !isComposerBusy() && shouldAutoRefreshConversation()) {
reload(false);
reloadInBackground(false);
}
armConversationAutoRefresh();
}
@@ -543,6 +544,10 @@ public class ProjectDetailActivity extends BossScreenActivity {
if (event == null || !"project.messages.updated".equals(event.eventName)) {
return false;
}
JSONObject projectMessagesPatch = event.payload.optJSONObject("projectMessagesPatch");
if (projectMessagesPatch != null) {
return tryApplyRealtimeMessageWindowPatch(projectMessagesPatch);
}
JSONObject projectMessagesPayload = event.payload.optJSONObject("projectMessagesPayload");
if (projectMessagesPayload == null) {
return false;
@@ -565,6 +570,167 @@ public class ProjectDetailActivity extends BossScreenActivity {
return true;
}
private boolean tryApplyRealtimeMessageWindowPatch(JSONObject projectMessagesPatch) {
if (projectMessagesPatch == null || !"message_window".equals(projectMessagesPatch.optString("patchKind", ""))) {
return false;
}
if (currentRenderedProjectPayload == null || contentLayout == null) {
scheduleRealtimeReload(false);
return true;
}
if (selectionState != null && selectionState.multiSelecting) {
scheduleRealtimeReload(false);
return true;
}
JSONObject patchProject = projectMessagesPatch.optJSONObject("project");
JSONArray patchMessages = projectMessagesPatch.optJSONArray("messages");
if (patchProject == null || patchMessages == null) {
scheduleRealtimeReload(false);
return true;
}
JSONObject currentPayload = copyJson(currentRenderedProjectPayload);
JSONObject currentProject = currentPayload.optJSONObject("project");
if (currentProject == null || !TextUtils.equals(
currentProject.optString("id", "").trim(),
projectMessagesPatch.optString("projectId", "").trim()
)) {
scheduleRealtimeReload(false);
return true;
}
JSONArray currentMessages = currentProject.optJSONArray("messages");
if (currentMessages == null) {
scheduleRealtimeReload(false);
return true;
}
int expectedMessageCount = projectMessagesPatch.optInt("messageCount", currentMessages.length());
if (expectedMessageCount < currentMessages.length()) {
scheduleRealtimeReload(false);
return true;
}
PatchMergeResult mergeResult = mergeMessageWindowPatch(currentMessages, patchMessages, expectedMessageCount);
if (!mergeResult.ok) {
scheduleRealtimeReload(false);
return true;
}
try {
copyProjectPatchMetadata(currentProject, patchProject);
currentProject.put("messages", mergeResult.messages);
JSONArray devices = projectMessagesPatch.optJSONArray("devices");
if (devices != null) {
currentPayload.put("devices", devices);
}
} catch (org.json.JSONException ignored) {
scheduleRealtimeReload(false);
return true;
}
JSONObject nextPayload = currentPayload;
runOnUiThread(() -> {
renderNearBottom = isChatNearBottom();
renderForcedScrollToBottom = false;
renderLoadedProjectSnapshot(new ProjectSnapshot(nextPayload, null, null));
});
return true;
}
private PatchMergeResult mergeMessageWindowPatch(
JSONArray currentMessages,
JSONArray patchMessages,
int expectedMessageCount
) {
JSONArray nextMessages = new JSONArray();
for (int i = 0; i < currentMessages.length(); i++) {
JSONObject message = currentMessages.optJSONObject(i);
if (message != null) {
nextMessages.put(copyJson(message));
}
}
boolean changed = false;
Map<String, Integer> currentIndexById = new HashMap<>();
for (int i = 0; i < nextMessages.length(); i++) {
JSONObject message = nextMessages.optJSONObject(i);
String messageId = message == null ? "" : message.optString("id", "");
if (!TextUtils.isEmpty(messageId)) {
currentIndexById.put(messageId, i);
}
}
String currentLastId = latestMessageId(nextMessages);
int currentLastIndexInPatch = -1;
for (int i = 0; i < patchMessages.length(); i++) {
JSONObject patchMessage = patchMessages.optJSONObject(i);
String patchMessageId = patchMessage == null ? "" : patchMessage.optString("id", "");
if (TextUtils.isEmpty(patchMessageId)) {
continue;
}
Integer existingIndex = currentIndexById.get(patchMessageId);
if (existingIndex != null) {
JSONObject existing = nextMessages.optJSONObject(existingIndex);
if (existing == null || !TextUtils.equals(existing.toString(), patchMessage.toString())) {
try {
nextMessages.put(existingIndex, copyJson(patchMessage));
changed = true;
} catch (org.json.JSONException ignored) {
return PatchMergeResult.failed();
}
}
}
if (TextUtils.equals(currentLastId, patchMessageId)) {
currentLastIndexInPatch = i;
}
}
if (expectedMessageCount > nextMessages.length()) {
if (currentLastIndexInPatch < 0) {
return PatchMergeResult.failed();
}
for (int i = currentLastIndexInPatch + 1; i < patchMessages.length(); i++) {
JSONObject patchMessage = patchMessages.optJSONObject(i);
String patchMessageId = patchMessage == null ? "" : patchMessage.optString("id", "");
if (TextUtils.isEmpty(patchMessageId) || currentIndexById.containsKey(patchMessageId)) {
continue;
}
nextMessages.put(copyJson(patchMessage));
currentIndexById.put(patchMessageId, nextMessages.length() - 1);
changed = true;
}
}
if (nextMessages.length() != expectedMessageCount) {
return PatchMergeResult.failed();
}
return new PatchMergeResult(true, changed, nextMessages);
}
private String latestMessageId(@Nullable JSONArray messages) {
if (messages == null || messages.length() == 0) {
return "";
}
JSONObject message = messages.optJSONObject(messages.length() - 1);
return message == null ? "" : message.optString("id", "");
}
private void copyProjectPatchMetadata(JSONObject currentProject, JSONObject patchProject) {
putIfPresent(currentProject, patchProject, "name");
putIfPresent(currentProject, patchProject, "threadMeta");
putIfPresent(currentProject, patchProject, "unreadCount");
putIfPresent(currentProject, patchProject, "isGroup");
putIfPresent(currentProject, patchProject, "collaborationMode");
putIfPresent(currentProject, patchProject, "approvalState");
putIfPresent(currentProject, patchProject, "lightDispatchReminderEnabled");
putIfPresent(currentProject, patchProject, "lastMessageAt");
putIfPresent(currentProject, patchProject, "updatedAt");
}
private void putIfPresent(JSONObject target, JSONObject source, String key) {
if (target == null || source == null || !source.has(key)) {
return;
}
try {
target.put(key, source.opt(key));
} catch (org.json.JSONException ignored) {
}
}
private boolean trySkipUnchangedRealtimeMessagesPatch(JSONObject projectMessagesPayload) {
if (currentRenderedProjectPayload == null || projectMessagesPayload == null) {
return false;
@@ -939,7 +1105,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
void triggerRealtimeReload(boolean requireFullSnapshot) {
if (requireFullSnapshot) {
reload();
reloadInBackground(false);
return;
}
reloadMessagesOnly();
@@ -960,14 +1126,22 @@ public class ProjectDetailActivity extends BossScreenActivity {
}
private void reload(boolean forcedScrollToBottom) {
reloadSnapshot(forcedScrollToBottom, false);
reloadSnapshot(forcedScrollToBottom, false, true);
}
private void reloadInBackground(boolean forcedScrollToBottom) {
reloadSnapshot(forcedScrollToBottom, false, false);
}
private void reloadMessagesOnly() {
reloadSnapshot(false, true);
reloadSnapshot(false, true, false);
}
private void reloadSnapshot(boolean forcedScrollToBottom, boolean messagesOnly) {
reloadSnapshot(forcedScrollToBottom, messagesOnly, true);
}
private void reloadSnapshot(boolean forcedScrollToBottom, boolean messagesOnly, boolean showRefreshIndicator) {
if (shouldSkipAsyncUiWork()) {
return;
}
@@ -983,12 +1157,15 @@ public class ProjectDetailActivity extends BossScreenActivity {
}
pendingReload = true;
pendingReloadForcedScrollToBottom = pendingReloadForcedScrollToBottom || forcedScrollToBottom;
pendingReloadShowRefreshIndicator = pendingReloadShowRefreshIndicator || showRefreshIndicator;
return;
}
renderNearBottom = isChatNearBottom();
renderForcedScrollToBottom = forcedScrollToBottom;
reloadInFlight = true;
setRefreshing(true);
if (showRefreshIndicator) {
setRefreshing(true);
}
try {
executor.execute(() -> {
try {
@@ -1004,7 +1181,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
if (messagesOnly) {
reloadInFlight = false;
setRefreshing(false);
reload(forcedScrollToBottom);
reloadSnapshot(forcedScrollToBottom, false, showRefreshIndicator);
return;
}
handleProjectReloadFailure(error);
@@ -1014,7 +1191,9 @@ public class ProjectDetailActivity extends BossScreenActivity {
});
} catch (RejectedExecutionException ignored) {
reloadInFlight = false;
setRefreshing(false);
if (showRefreshIndicator) {
setRefreshing(false);
}
}
}
@@ -1062,10 +1241,12 @@ public class ProjectDetailActivity extends BossScreenActivity {
}
boolean forcedScrollToBottom = pendingReloadForcedScrollToBottom;
boolean messagesOnly = pendingReloadMessagesOnly;
boolean showRefreshIndicator = pendingReloadShowRefreshIndicator;
pendingReload = false;
pendingReloadMessagesOnly = false;
pendingReloadForcedScrollToBottom = false;
reloadSnapshot(forcedScrollToBottom, messagesOnly);
pendingReloadShowRefreshIndicator = false;
reloadSnapshot(forcedScrollToBottom, messagesOnly, showRefreshIndicator);
}
@Override
@@ -1271,7 +1452,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
&& !reloadInFlight
&& refreshLayout != null
&& !isComposerBusy()) {
reload();
reloadInBackground(false);
}
updateConversationAutoRefresh();
}
@@ -4110,4 +4291,20 @@ public class ProjectDetailActivity extends BossScreenActivity {
return kind;
}
}
private static final class PatchMergeResult {
final boolean ok;
final boolean changed;
final JSONArray messages;
PatchMergeResult(boolean ok, boolean changed, JSONArray messages) {
this.ok = ok;
this.changed = changed;
this.messages = messages == null ? new JSONArray() : messages;
}
static PatchMergeResult failed() {
return new PatchMergeResult(false, false, new JSONArray());
}
}
}

View File

@@ -1,12 +1,18 @@
package com.hyzq.boss;
import android.app.AlertDialog;
import android.content.Intent;
import android.os.Bundle;
import android.text.InputType;
import android.text.TextUtils;
import android.widget.Button;
import android.widget.EditText;
import android.widget.LinearLayout;
import androidx.annotation.Nullable;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.LinkedHashMap;
@@ -58,8 +64,18 @@ public class SkillInventoryActivity extends BossScreenActivity {
String targetDeviceId = resolveTargetDeviceId();
BossApiClient.ApiResponse response = apiClient.getDeviceSkills(targetDeviceId);
if (!response.ok()) throw new IllegalStateException(response.message());
JSONObject lifecyclePayload = null;
try {
BossApiClient.ApiResponse lifecycleResponse = apiClient.getSkillLifecycleRequests();
if (lifecycleResponse.ok()) {
lifecyclePayload = lifecycleResponse.json;
}
} catch (Exception ignored) {
lifecyclePayload = null;
}
deviceId = targetDeviceId;
runOnUiThread(() -> renderSkills(response.json));
JSONObject finalLifecyclePayload = lifecyclePayload;
runOnUiThread(() -> renderSkills(response.json, finalLifecyclePayload));
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
@@ -203,9 +219,14 @@ public class SkillInventoryActivity extends BossScreenActivity {
}
private void renderSkills(JSONObject payload) {
renderSkills(payload, null);
}
private void renderSkills(JSONObject payload, @Nullable JSONObject lifecyclePayload) {
replaceContent();
JSONObject device = payload.optJSONObject("device");
JSONArray skills = payload.optJSONArray("skills");
boolean canManageLifecycle = lifecyclePayload != null;
if (device != null) {
deviceName = device.optString("name", deviceId);
@@ -220,6 +241,10 @@ public class SkillInventoryActivity extends BossScreenActivity {
));
}
if (canManageLifecycle) {
appendSkillManagementWorkspace(lifecyclePayload);
}
if (skills == null || skills.length() == 0) {
appendContent(BossUi.buildEmptyCard(this, "当前设备还没有同步 Skill。"));
setRefreshing(false);
@@ -244,8 +269,217 @@ public class SkillInventoryActivity extends BossScreenActivity {
Button copyPath = BossUi.buildMiniActionButton(this, "复制路径", false);
copyPath.setOnClickListener(v -> BossUi.copyText(this, "Skill 路径", skill.optString("path", "")));
card.addView(BossUi.buildInlineActionRow(this, copyInvocation, copyPath));
if (canManageLifecycle) {
Button update = BossUi.buildMiniActionButton(this, "更新下发", true);
update.setOnClickListener(v -> queueSkillLifecycleRequest("update", skill, null, null, null, null, null));
Button rollback = BossUi.buildMiniActionButton(this, "回滚", false);
rollback.setOnClickListener(v -> showVersionedSkillRequestDialog("rollback", skill, "回滚", "rollbackToVersion"));
Button versionLock = BossUi.buildMiniActionButton(this, "版本锁定", false);
versionLock.setOnClickListener(v -> showVersionedSkillRequestDialog("version_lock", skill, "版本锁定", "lockedVersion"));
card.addView(BossUi.buildInlineActionRow(this, update, rollback, versionLock));
}
appendContent(card);
}
setRefreshing(false);
}
private void appendSkillManagementWorkspace(JSONObject lifecyclePayload) {
JSONArray requests = lifecyclePayload.optJSONArray("requests");
int requestCount = requests == null ? 0 : requests.length();
int queuedCount = countRequestsByStatus(requests, "queued");
int runningCount = countRunningRequests(requests);
LinearLayout card = new LinearLayout(this);
card.setOrientation(LinearLayout.VERTICAL);
card.addView(BossUi.buildWechatMenuRow(
this,
"Skill 管理分发",
"安装、更新、回滚、版本锁定和账号权限分配统一在这里处理。",
"Skill 请求状态:待执行 " + queuedCount + " · 执行中 " + runningCount + " · 最近请求 " + requestCount,
null,
null
));
Button installRemote = BossUi.buildMiniActionButton(this, "安装远端 Skill", true);
installRemote.setOnClickListener(v -> showInstallSkillDialog());
Button grantPermission = BossUi.buildMiniActionButton(this, "分配权限", false);
grantPermission.setOnClickListener(v -> startActivity(new Intent(this, AccessManagementActivity.class)));
card.addView(BossUi.buildInlineActionRow(this, installRemote, grantPermission));
if (requests != null && requests.length() > 0) {
int maxRows = Math.min(3, requests.length());
for (int index = 0; index < maxRows; index += 1) {
JSONObject request = requests.optJSONObject(index);
if (request == null) continue;
card.addView(BossUi.buildWechatMenuRow(
this,
"Skill 请求状态",
request.optString("action", "-") + " · " + request.optString("status", "-"),
request.optString("skillId", request.optString("sourceUrl", "-"))
+ " · " + request.optString("requestedAt", "-"),
null,
null
));
}
}
appendContent(card);
}
private void showInstallSkillDialog() {
LinearLayout form = new LinearLayout(this);
form.setOrientation(LinearLayout.VERTICAL);
int padding = BossUi.dp(this, 12);
form.setPadding(padding, padding, padding, 0);
EditText sourceUrl = buildSingleLineInput("Git URL 或可信来源 URL");
EditText targetVersion = buildSingleLineInput("目标版本,可选");
EditText checksum = buildSingleLineInput("SHA256 校验和,可选");
form.addView(sourceUrl);
form.addView(targetVersion);
form.addView(checksum);
new AlertDialog.Builder(this)
.setTitle("安装远端 Skill")
.setView(form)
.setNegativeButton("取消", null)
.setPositiveButton("下发", (dialog, which) -> {
String source = sourceUrl.getText().toString().trim();
if (TextUtils.isEmpty(source)) {
showMessage("请输入 Skill 来源 URL");
return;
}
queueSkillLifecycleRequest(
"install",
null,
source,
targetVersion.getText().toString().trim(),
checksum.getText().toString().trim(),
null,
null
);
})
.show();
}
private void showVersionedSkillRequestDialog(
String action,
JSONObject skill,
String title,
String versionField
) {
EditText input = buildSingleLineInput("请输入版本号");
new AlertDialog.Builder(this)
.setTitle(title)
.setView(input)
.setNegativeButton("取消", null)
.setPositiveButton("下发", (dialog, which) -> {
String version = input.getText().toString().trim();
if (TextUtils.isEmpty(version)) {
showMessage("请输入版本号");
return;
}
if ("rollbackToVersion".equals(versionField)) {
queueSkillLifecycleRequest(action, skill, null, null, null, version, null);
} else {
queueSkillLifecycleRequest(action, skill, null, null, null, null, version);
}
})
.show();
}
private EditText buildSingleLineInput(String hint) {
EditText input = new EditText(this);
input.setHint(hint);
input.setSingleLine(true);
input.setInputType(InputType.TYPE_CLASS_TEXT);
int verticalPadding = BossUi.dp(this, 8);
input.setPadding(0, verticalPadding, 0, verticalPadding);
return input;
}
private void queueSkillLifecycleRequest(
String action,
@Nullable JSONObject skill,
@Nullable String sourceUrl,
@Nullable String targetVersion,
@Nullable String checksum,
@Nullable String rollbackToVersion,
@Nullable String lockedVersion
) {
try {
JSONObject payload = new JSONObject();
payload.put("action", action);
payload.put("deviceId", deviceId == null ? "" : deviceId);
if (skill != null) {
putIfNotBlank(payload, "skillId", skill.optString("skillId", ""));
}
putIfNotBlank(payload, "sourceUrl", sourceUrl);
putIfNotBlank(payload, "targetVersion", targetVersion);
putIfNotBlank(payload, "checksum", checksum);
putIfNotBlank(payload, "rollbackToVersion", rollbackToVersion);
putIfNotBlank(payload, "lockedVersion", lockedVersion);
putIfNotBlank(payload, "note", "boss-app-skill-management");
submitSkillLifecycleRequest(payload);
} catch (JSONException error) {
showMessage("Skill 请求构建失败:" + error.getMessage());
}
}
private void putIfNotBlank(JSONObject payload, String key, @Nullable String value) throws JSONException {
if (!TextUtils.isEmpty(value)) {
payload.put(key, value);
}
}
private void submitSkillLifecycleRequest(JSONObject payload) {
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.createSkillLifecycleRequest(payload);
if (!response.ok()) throw new IllegalStateException(response.message());
runOnUiThread(() -> {
showMessage("Skill 请求已下发");
reload();
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
showMessage("Skill 请求失败:" + error.getMessage());
});
}
});
}
private static int countRequestsByStatus(@Nullable JSONArray requests, String status) {
if (requests == null) {
return 0;
}
int count = 0;
for (int index = 0; index < requests.length(); index += 1) {
JSONObject request = requests.optJSONObject(index);
if (request != null && status.equalsIgnoreCase(request.optString("status", ""))) {
count += 1;
}
}
return count;
}
private static int countRunningRequests(@Nullable JSONArray requests) {
if (requests == null) {
return 0;
}
int count = 0;
for (int index = 0; index < requests.length(); index += 1) {
JSONObject request = requests.optJSONObject(index);
if (request == null) continue;
String status = request.optString("status", "");
if ("claimed".equalsIgnoreCase(status)
|| "running".equalsIgnoreCase(status)
|| "processing".equalsIgnoreCase(status)) {
count += 1;
}
}
return count;
}
}

View File

@@ -260,6 +260,34 @@ public class BossApiClientDispatchPlansTest {
assertEquals("GET", connection.requestMethodValue);
}
@Test
public void getProjectDetailUsesExtendedReadTimeoutForChatPages() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/thread-1"));
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
BossApiClient.ApiResponse response = apiClient.getProjectDetail("thread-1");
assertEquals(200, response.statusCode);
assertEquals("/api/v1/projects/thread-1", apiClient.lastPath);
assertEquals("GET", connection.requestMethodValue);
assertEquals(12000, connection.connectTimeoutValue);
assertEquals(30000, connection.readTimeoutValue);
}
@Test
public void getProjectMessagesUsesExtendedReadTimeoutForRealtimeRefreshes() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/thread-1/messages"));
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
BossApiClient.ApiResponse response = apiClient.getProjectMessages("thread-1");
assertEquals(200, response.statusCode);
assertEquals("/api/v1/projects/thread-1/messages", apiClient.lastPath);
assertEquals("GET", connection.requestMethodValue);
assertEquals(12000, connection.connectTimeoutValue);
assertEquals(30000, connection.readTimeoutValue);
}
@Test
public void createMasterAgentMemoryWritesStructuredPayload() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/master-agent/memories"));

View File

@@ -57,6 +57,7 @@ public class ProjectDetailActivityRealtimeTest {
);
drainRealtimeDebounce(activity);
waitFor(() -> activity.messageReloadCount == 1);
assertEquals(0, activity.reloadCount);
assertEquals(1, activity.messageReloadCount);
}
@@ -144,7 +145,8 @@ public class ProjectDetailActivityRealtimeTest {
);
drainRealtimeDebounce(activity);
assertEquals(1, activity.reloadCount);
waitFor(() -> activity.loadCallCount == 1);
assertEquals(1, activity.loadCallCount);
assertEquals(0, activity.messageReloadCount);
}
@@ -172,7 +174,8 @@ public class ProjectDetailActivityRealtimeTest {
);
drainRealtimeDebounce(activity);
assertEquals(1, activity.reloadCount);
waitFor(() -> activity.loadCallCount == 1);
assertEquals(1, activity.loadCallCount);
assertEquals(0, activity.messageReloadCount);
}
@@ -211,6 +214,7 @@ public class ProjectDetailActivityRealtimeTest {
);
drainRealtimeDebounce(activity);
waitFor(() -> activity.messageReloadCount == 1);
assertEquals(0, activity.reloadCount);
assertEquals(1, activity.messageReloadCount);
}
@@ -441,7 +445,8 @@ public class ProjectDetailActivityRealtimeTest {
);
drainRealtimeDebounce(activity);
assertEquals(1, activity.reloadCount);
waitFor(() -> activity.loadCallCount == 1);
assertEquals(1, activity.loadCallCount);
}
@Test

View File

@@ -1532,6 +1532,42 @@ public class ProjectDetailActivityUiTest {
assertFalse(viewTreeContainsText(messageView, "Codex"));
}
@Test
public void failedExecutionProgressWithoutBranchDoesNotRenderExecutorWaitingFallback() throws Exception {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "juyuwan")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "juyuwan");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
JSONObject message = new JSONObject()
.put("id", "failed-progress-no-branch")
.put("sender", "master")
.put("senderLabel", "主 Agent")
.put("body", "执行进度:失败")
.put("kind", "execution_progress")
.put("sentAt", "2026-06-07T14:24:00+08:00")
.put("executionProgress", new JSONObject()
.put("title", "进度")
.put("controlMode", "codex_thread")
.put("status", "failed")
.put("phase", "terminal_failed")
.put("steps", new JSONArray()
.put(new JSONObject().put("text", "等待目标线程回复").put("status", "failed"))));
View messageView = ReflectionHelpers.callInstanceMethod(
activity,
"buildMessageView",
ReflectionHelpers.ClassParameter.from(JSONObject.class, message)
);
assertTrue(viewTreeContainsText(messageView, "当前状态:执行失败"));
assertFalse(viewTreeContainsText(messageView, "等待执行器回写"));
assertFalse(viewTreeContainsText(messageView, "分支详情"));
}
@Test
public void completedReplyResponseRendersImmediatelyWithoutReloadingProjectDetail() throws Exception {
Intent intent = new Intent()
@@ -1603,6 +1639,24 @@ public class ProjectDetailActivityUiTest {
assertEquals(0, fakeApiClient.projectDetailCallCount);
}
@Test
public void realtimeMessageReloadDoesNotShowSwipeRefreshSpinner() {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "juyuwan")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "juyuwan");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
ReflectionHelpers.setField(activity, "apiClient", new SlowProjectMessagesApiClient());
activity.triggerRealtimeReload(false);
Shadows.shadowOf(activity.getMainLooper()).idle();
SwipeRefreshLayout refreshLayout = activity.findViewById(R.id.screen_refresh_layout);
assertFalse(refreshLayout.isRefreshing());
}
@Test
public void masterAgentHeaderUsesWechatMoreMenuLabel() {
Intent intent = new Intent()
@@ -2458,6 +2512,30 @@ public class ProjectDetailActivityUiTest {
}
}
private static final class SlowProjectMessagesApiClient extends BossApiClient {
SlowProjectMessagesApiClient() {
super(new InMemorySharedPreferences(), "https://boss.hyzq.net");
}
@Override
public ApiResponse getProjectMessages(String projectId) throws java.io.IOException, org.json.JSONException {
try {
Thread.sleep(250L);
} catch (InterruptedException error) {
Thread.currentThread().interrupt();
}
return new ApiResponse(
200,
new JSONObject()
.put("ok", true)
.put("project", new JSONObject()
.put("id", projectId)
.put("name", "juyuwan")
.put("messages", new JSONArray()))
);
}
}
private static final class RecordingConversationActionApiClient extends BossApiClient {
int markConversationReadCount;
String lastMarkedProjectId;

View File

@@ -1,10 +1,15 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import android.content.Intent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import org.json.JSONObject;
import org.json.JSONArray;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
@@ -68,6 +73,61 @@ public class SkillInventoryActivityTest {
assertEquals(0, activity.reloadCount);
}
@Test
public void renderSkillsShowsManagementDispatchWorkspaceWhenLifecycleRequestsAreAvailable() throws Exception {
Intent intent = new Intent()
.putExtra(SkillInventoryActivity.EXTRA_DEVICE_ID, "device-1")
.putExtra(SkillInventoryActivity.EXTRA_DEVICE_NAME, "Mac Studio");
TestSkillInventoryActivity activity = Robolectric
.buildActivity(TestSkillInventoryActivity.class, intent)
.setup()
.get();
ReflectionHelpers.callInstanceMethod(
activity,
"renderSkills",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildSkillPayload()),
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildLifecyclePayload())
);
View content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content, "Skill 管理分发"));
assertTrue(viewTreeContainsText(content, "安装远端 Skill"));
assertTrue(viewTreeContainsText(content, "分配权限"));
assertTrue(viewTreeContainsText(content, "Skill 请求状态"));
assertTrue(viewTreeContainsText(content, "更新下发"));
assertTrue(viewTreeContainsText(content, "版本锁定"));
}
private static JSONObject buildSkillPayload() throws Exception {
return new JSONObject()
.put("device", new JSONObject()
.put("id", "device-1")
.put("name", "Mac Studio"))
.put("skills", new JSONArray()
.put(new JSONObject()
.put("skillId", "device-1:boss-server-debug")
.put("name", "boss-server-debug")
.put("description", "服务器排障")
.put("category", "ops")
.put("path", "/Users/kris/.codex/skills/boss-server-debug/SKILL.md")
.put("invocation", "使用 boss-server-debug")
.put("updatedAt", "2026-06-08T10:00:00+08:00")));
}
private static JSONObject buildLifecyclePayload() throws Exception {
return new JSONObject()
.put("ok", true)
.put("requests", new JSONArray()
.put(new JSONObject()
.put("requestId", "skill-request-1")
.put("action", "update")
.put("status", "queued")
.put("deviceId", "device-1")
.put("skillId", "device-1:boss-server-debug")
.put("requestedAt", "2026-06-08T10:01:00+08:00")));
}
public static class TestSkillInventoryActivity extends SkillInventoryActivity {
private boolean reloadEnabled;
private int reloadCount;
@@ -81,4 +141,23 @@ public class SkillInventoryActivityTest {
setRefreshing(false);
}
}
private static boolean viewTreeContainsText(View root, String expectedText) {
if (root instanceof TextView) {
CharSequence text = ((TextView) root).getText();
if (text != null && text.toString().contains(expectedText)) {
return true;
}
}
if (!(root instanceof ViewGroup)) {
return false;
}
ViewGroup group = (ViewGroup) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
if (viewTreeContainsText(group.getChildAt(index), expectedText)) {
return true;
}
}
return false;
}
}

View File

@@ -16,6 +16,7 @@ import {
createAdminBackup,
fetchAdminBackups,
fetchBossAdminBackoffice,
fetchSkillLifecycleRequests,
postAdminAccess,
postDeviceCodexRemoteControl,
postRiskAction,
@@ -24,6 +25,7 @@ import {
type BossAdminBackofficePayload,
type BossAdminBackupSnapshot,
type BossAdminBackupStatus,
type BossAdminSkillLifecycleRequest,
} from "./api/bossAdmin";
type AdminRecord = Record<string, unknown>;
@@ -39,6 +41,8 @@ const backupLoading = ref(false);
const backupSnapshots = ref<BossAdminBackupSnapshot[]>([]);
const backupStatus = ref<BossAdminBackupStatus | null>(null);
const backupReason = ref("manual");
const skillLifecycleLoading = ref(false);
const skillLifecycleRequests = ref<BossAdminSkillLifecycleRequest[]>([]);
const companyForm = reactive({
companyId: "",
@@ -105,6 +109,13 @@ const defaultBackofficeInsights: BossAdminBackofficePayload["insights"] = {
skillUsageAudit: [],
recoveryActions: [],
backupStatus: {},
dataSafetySummary: {},
taskRiskSummary: {},
taskSlaPanel: {
generatedAt: "",
summary: {},
rows: [],
},
capabilitySummary: {},
surface: "platform",
};
@@ -144,6 +155,23 @@ const notifications = computed(() => payload.value?.workbench.notifications ?? [
const riskTimeline = computed(() => payload.value?.audit.riskTimeline ?? []);
const auditLogs = computed(() => payload.value?.audit.permissionLogs ?? []);
const grants = computed(() => payload.value?.resourceGroups.grants ?? { devices: [], projects: [], skills: [] });
const taskSlaPanel = computed(() => insights.value.taskSlaPanel ?? defaultBackofficeInsights.taskSlaPanel);
const taskSlaRows = computed(() => taskSlaPanel.value.rows ?? []);
const taskSlaMetrics = computed(() => [
{ label: "运行任务", value: numberValue(taskSlaPanel.value.summary?.active), tone: "black" },
{ label: "SLA 超时", value: numberValue(taskSlaPanel.value.summary?.breached), tone: "red" },
{ label: "可自动恢复", value: numberValue(taskSlaPanel.value.summary?.autoRecoverable), tone: "green" },
{ label: "终态失败", value: numberValue(taskSlaPanel.value.summary?.terminal), tone: "orange" },
]);
const skillRequestMetrics = computed(() => [
{ label: "待执行", value: skillLifecycleRequests.value.filter((item) => text(item.status, "").toLowerCase() === "queued").length, tone: "orange" },
{
label: "执行中",
value: skillLifecycleRequests.value.filter((item) => ["claimed", "running", "processing"].includes(text(item.status, "").toLowerCase())).length,
tone: "green",
},
{ label: "最近请求", value: skillLifecycleRequests.value.length, tone: "black" },
]);
const currentSectionTitle = computed(() => menuTree.value.find((item) => item.key === activeKey.value)?.label ?? "总览");
const currentCompanyName = computed(() => text(payload.value?.currentCompany?.name, "当前企业"));
@@ -251,6 +279,9 @@ function selectMenu(key: string) {
if (key === "enterprise-backup" || key === "enterprise-risk-backup") {
void loadBackupSnapshots();
}
if (key === "enterprise-skill") {
void loadSkillLifecycleRequests();
}
}
function menuIcon(key: string) {
@@ -284,6 +315,32 @@ function riskColor(value: unknown) {
return "blue";
}
function slaColor(value: unknown) {
const level = text(value, "").toLowerCase();
if (level === "terminal" || level === "breached") return "red";
if (level === "recoverable") return "green";
if (level === "watch") return "orange";
return "blue";
}
function slaLabel(value: unknown) {
const level = text(value, "").toLowerCase();
if (level === "terminal") return "终态失败";
if (level === "breached") return "SLA 超时";
if (level === "recoverable") return "可恢复";
if (level === "watch") return "观察中";
return "正常";
}
function formatDurationMs(value: unknown) {
const ms = typeof value === "number" && Number.isFinite(value) ? value : 0;
if (ms <= 0) return "-";
const minutes = Math.floor(ms / 60_000);
if (minutes < 60) return `${minutes} 分钟`;
const hours = Math.floor(minutes / 60);
return `${hours} 小时 ${minutes % 60} 分钟`;
}
function formatBytes(value: unknown) {
const bytes = typeof value === "number" && Number.isFinite(value) ? value : 0;
if (bytes < 1024) return `${bytes} B`;
@@ -321,6 +378,9 @@ async function runMutation(label: string, task: () => Promise<unknown>) {
hide();
message.success(`${label}完成`);
await loadBackoffice(adminSurface.value);
if (activeKey.value === "enterprise-skill") {
await loadSkillLifecycleRequests();
}
} catch (err) {
hide();
message.error(`${label}失败:${err instanceof Error ? err.message : "UNKNOWN_ERROR"}`);
@@ -477,6 +537,61 @@ async function createSkillRequest() {
);
}
async function loadSkillLifecycleRequests() {
skillLifecycleLoading.value = true;
try {
const result = await fetchSkillLifecycleRequests();
skillLifecycleRequests.value = result.requests ?? [];
} catch (err) {
skillLifecycleRequests.value = [];
if (adminSurface.value === "platform") {
message.warning(`Skill 请求队列加载失败:${err instanceof Error ? err.message : "UNKNOWN_ERROR"}`);
}
} finally {
skillLifecycleLoading.value = false;
}
}
function fillSkillRequestForm(record: AdminRecord, action = "update") {
skillRequestForm.action = action;
skillRequestForm.skillId = text(record.skillId, "");
if (!skillRequestForm.deviceId && devices.value.length > 0) {
skillRequestForm.deviceId = text(devices.value[0].id, "");
}
skillRequestForm.sourceUrl = text(record.sourceUrl, "");
skillRequestForm.targetVersion = text(record.version, "");
}
async function quickSkillRequest(record: AdminRecord, action: "update" | "rollback" | "version_lock") {
const skillId = text(record.skillId, "");
const deviceId = skillRequestForm.deviceId || text(record.deviceId, "") || text(devices.value[0]?.id, "");
if (!skillId || !deviceId) {
message.warning("请先选择设备和 Skill");
fillSkillRequestForm(record, action);
return;
}
const payload: Record<string, unknown> = {
action,
deviceId,
skillId,
note: `quick-dispatch:${action}`,
};
if (action === "rollback") {
const rollbackToVersion = window.prompt("请输入要回滚到的版本");
if (!rollbackToVersion) return;
payload.rollbackToVersion = rollbackToVersion;
}
if (action === "version_lock") {
const lockedVersion = window.prompt("请输入要锁定的版本");
if (!lockedVersion) return;
payload.lockedVersion = lockedVersion;
}
await runMutation(
action === "update" ? "更新下发" : action === "rollback" ? "回滚" : "版本锁定",
() => postSkillLifecycleRequest(payload),
);
}
async function loadBackupSnapshots() {
backupLoading.value = true;
try {
@@ -511,6 +626,9 @@ watch(activeKey, (key) => {
if (key === "enterprise-backup" || key === "enterprise-risk-backup") {
void loadBackupSnapshots();
}
if (key === "enterprise-skill") {
void loadSkillLifecycleRequests();
}
});
onMounted(async () => {
@@ -518,6 +636,9 @@ onMounted(async () => {
if (activeKey.value === "enterprise-backup" || activeKey.value === "enterprise-risk-backup") {
await loadBackupSnapshots();
}
if (activeKey.value === "enterprise-skill") {
await loadSkillLifecycleRequests();
}
});
</script>
@@ -894,6 +1015,40 @@ onMounted(async () => {
</a-table-column>
</a-table>
</a-card>
<a-card title="任务 SLA 面板" :bordered="false">
<div class="boss-admin-metrics compact">
<div
v-for="item in taskSlaMetrics"
:key="item.label"
class="boss-admin-metric"
:class="item.tone"
>
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
<a-table class="boss-admin-form-gap" :data-source="taskSlaRows" row-key="taskId">
<a-table-column title="任务" data-index="taskType" />
<a-table-column title="状态">
<template #default="{ record }">
<a-tag :color="slaColor(record.slaLevel)">{{ slaLabel(record.slaLevel) }}</a-tag>
</template>
</a-table-column>
<a-table-column title="阶段" data-index="phase" />
<a-table-column title="设备" data-index="deviceId" />
<a-table-column title="尝试" data-index="attemptLabel" />
<a-table-column title="空闲">
<template #default="{ record }">
{{ formatDurationMs(record.idleMs) }}
</template>
</a-table-column>
<a-table-column title="建议动作">
<template #default="{ record }">
{{ text(record.recommendedAction) }}
</template>
</a-table-column>
</a-table>
</a-card>
</section>
<section v-else-if="activeKey === 'platform-audit'" class="boss-admin-section-grid">
@@ -1147,7 +1302,23 @@ onMounted(async () => {
</section>
<section v-else-if="activeKey === 'enterprise-skill'" class="boss-admin-section-grid">
<a-card title="创建 Skill 请求" :bordered="false">
<a-card class="boss-admin-hero" :bordered="false">
<p class="boss-admin-eyebrow">Skill 管理分发</p>
<h3>统一管理 Skill 安装更新回滚版本锁定和企业内权限分配</h3>
<div class="boss-admin-metrics compact">
<div
v-for="metric in skillRequestMetrics"
:key="metric.label"
class="boss-admin-metric"
:class="metric.tone"
>
<span>{{ metric.label }}</span>
<strong>{{ metric.value }}</strong>
</div>
</div>
</a-card>
<a-card title="快捷下发" :bordered="false">
<a-form layout="vertical" class="boss-admin-form">
<a-form-item label="动作">
<a-select v-model:value="skillRequestForm.action">
@@ -1186,7 +1357,10 @@ onMounted(async () => {
<a-input v-model:value="skillRequestForm.checksum" placeholder="sha256 checksum" />
<a-input v-model:value="skillRequestForm.note" class="boss-admin-form-gap" placeholder="备注" />
</a-form-item>
<a-button type="primary" block :loading="mutating" @click="createSkillRequest">创建 Skill 请求</a-button>
<a-space wrap>
<a-button type="primary" :loading="mutating" @click="createSkillRequest">创建 Skill 请求 / 安装远端 Skill</a-button>
<a-button :loading="skillLifecycleLoading" @click="loadSkillLifecycleRequests">刷新请求队列</a-button>
</a-space>
</a-form>
</a-card>
@@ -1197,6 +1371,45 @@ onMounted(async () => {
<a-table-column title="分类" data-index="category" />
<a-table-column title="设备数" data-index="deviceCount" />
<a-table-column title="更新时间" data-index="updatedAt" />
<a-table-column title="操作">
<template #default="{ record }">
<a-space wrap>
<a-button size="small" @click="fillSkillRequestForm(record, 'install')">安装远端 Skill</a-button>
<a-button size="small" type="primary" @click="quickSkillRequest(record, 'update')">更新下发</a-button>
<a-button size="small" @click="quickSkillRequest(record, 'rollback')">回滚</a-button>
<a-button size="small" @click="quickSkillRequest(record, 'version_lock')">版本锁定</a-button>
</a-space>
</template>
</a-table-column>
</a-table>
</a-card>
<a-card title="Skill 请求队列" :bordered="false">
<a-table
:loading="skillLifecycleLoading"
:data-source="skillLifecycleRequests"
row-key="requestId"
:pagination="{ pageSize: 8 }"
>
<a-table-column title="请求" data-index="requestId" />
<a-table-column title="动作" data-index="action" />
<a-table-column title="状态">
<template #default="{ record }">
<a-tag :color="statusColor(record.status)">{{ text(record.status) }}</a-tag>
</template>
</a-table-column>
<a-table-column title="设备" data-index="deviceId" />
<a-table-column title="Skill" data-index="skillId" />
<a-table-column title="版本">
<template #default="{ record }">
{{ text(record.targetVersion || record.rollbackToVersion || record.lockedVersion) }}
</template>
</a-table-column>
<a-table-column title="结果">
<template #default="{ record }">
{{ text(record.resultSummary || record.error) }}
</template>
</a-table-column>
<a-table-column title="时间" data-index="requestedAt" />
</a-table>
</a-card>
<a-card title="使用审计" :bordered="false">
@@ -1234,6 +1447,35 @@ onMounted(async () => {
</a-table-column>
</a-table>
</a-card>
<a-card title="任务 SLA 面板" :bordered="false">
<div class="boss-admin-metrics compact">
<div
v-for="item in taskSlaMetrics"
:key="item.label"
class="boss-admin-metric"
:class="item.tone"
>
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
<a-table class="boss-admin-form-gap" :data-source="taskSlaRows" row-key="taskId">
<a-table-column title="任务" data-index="taskType" />
<a-table-column title="状态">
<template #default="{ record }">
<a-tag :color="slaColor(record.slaLevel)">{{ slaLabel(record.slaLevel) }}</a-tag>
</template>
</a-table-column>
<a-table-column title="阶段" data-index="phase" />
<a-table-column title="设备" data-index="deviceId" />
<a-table-column title="尝试" data-index="attemptLabel" />
<a-table-column title="建议动作">
<template #default="{ record }">
{{ text(record.recommendedAction) }}
</template>
</a-table-column>
</a-table>
</a-card>
<a-card title="业务级回退" :bordered="false">
<div class="boss-admin-recovery-grid">
<div v-for="action in insights.recoveryActions" :key="action" class="boss-admin-recovery-card">
@@ -1286,7 +1528,7 @@ onMounted(async () => {
<a-button :loading="backupLoading" @click="loadBackupSnapshots">刷新快照</a-button>
</div>
</a-card>
<a-card title="快照清单" :bordered="false">
<a-card title="快照清单" class="boss-admin-wide-card" :bordered="false">
<a-table :loading="backupLoading" :data-source="backupSnapshots" row-key="snapshotId">
<a-table-column title="快照" data-index="snapshotId" />
<a-table-column title="创建时间" data-index="createdAt" />

View File

@@ -4,6 +4,32 @@ export interface BossAdminMenuItem {
children?: BossAdminMenuItem[];
}
export interface BossAdminTaskSlaRow extends Record<string, unknown> {
taskId: string;
riskId: string;
projectId: string;
deviceId: string;
taskType: string;
status: string;
phase: string;
summary: string;
slaLevel: "ok" | "watch" | "breached" | "recoverable" | "terminal";
severity: "info" | "warning" | "critical";
slaDueAt: string;
lastProgressAt: string;
attemptLabel: string;
stale: boolean;
recoverable: boolean;
autoRecoverable: boolean;
recommendedAction: string;
}
export interface BossAdminTaskSlaPanel {
generatedAt: string;
summary: Record<string, number>;
rows: BossAdminTaskSlaRow[];
}
export interface BossAdminBackofficePayload {
ok: boolean;
surface: "platform" | "enterprise";
@@ -27,6 +53,9 @@ export interface BossAdminBackofficePayload {
skillUsageAudit: Array<Record<string, unknown>>;
recoveryActions: string[];
backupStatus: Record<string, unknown>;
dataSafetySummary: Record<string, unknown>;
taskRiskSummary: Record<string, unknown>;
taskSlaPanel: BossAdminTaskSlaPanel;
capabilitySummary: Record<string, number>;
surface: "platform" | "enterprise";
};
@@ -87,6 +116,27 @@ export interface BossAdminBackupsPayload {
snapshots: BossAdminBackupSnapshot[];
}
export interface BossAdminSkillLifecycleRequest extends Record<string, unknown> {
requestId: string;
action: string;
status: string;
deviceId: string;
skillId?: string;
sourceUrl?: string;
targetVersion?: string;
rollbackToVersion?: string;
lockedVersion?: string;
requestedAt?: string;
completedAt?: string;
resultSummary?: string;
error?: string;
}
export interface BossAdminSkillLifecycleRequestsPayload {
ok: boolean;
requests: BossAdminSkillLifecycleRequest[];
}
async function requestJson<T>(url: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(url, {
credentials: "include",
@@ -133,6 +183,12 @@ export async function postSkillLifecycleRequest(payload: Record<string, unknown>
});
}
export async function fetchSkillLifecycleRequests(): Promise<BossAdminSkillLifecycleRequestsPayload> {
return requestJson<BossAdminSkillLifecycleRequestsPayload>("/api/v1/admin/skills/requests", {
method: "GET",
});
}
export async function postDeviceCodexRemoteControl(
deviceId: string,
payload: { action: "start" | "stop"; reason?: string },

View File

@@ -272,7 +272,7 @@ body {
.boss-admin-action-strip {
display: grid;
grid-template-columns: 220px 320px 1fr;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin-bottom: 16px;
}
@@ -390,10 +390,37 @@ body {
}
.ant-card {
min-width: 0;
overflow: hidden;
border-radius: 22px;
box-shadow: 0 18px 50px rgba(16, 32, 24, 0.07);
}
.boss-admin-wide-card {
grid-column: 1 / -1;
}
.ant-card .ant-card-body {
min-width: 0;
overflow: hidden;
}
.ant-table-wrapper {
max-width: 100%;
overflow-x: auto;
}
.ant-table-wrapper .ant-table {
min-width: 720px;
border-radius: 16px;
}
.ant-table-wrapper .ant-table-cell {
white-space: normal;
word-break: break-word;
}
.ant-table-wrapper .ant-btn,
.boss-admin-action-strip .ant-btn {
white-space: nowrap;
}

View File

@@ -13,10 +13,16 @@ boss.hyzq.net {
admin.boss.hyzq.net {
encode zstd gzip
handle /admin-web/* {
root * /opt/boss/public
file_server
}
@adminRoot path /
handle @adminRoot {
root * /opt/boss/public
rewrite * /admin-web/index.html
reverse_proxy 127.0.0.1:3000
file_server
}
reverse_proxy 127.0.0.1:3000

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@@ -180,6 +180,7 @@
- 当前已补 Codex App Server 版 Boss 用户消息镜像:普通单线程 `conversation_reply` 任务携带 `mirrorBossUserMessageToCodexDesktop=true` 时,`local-agent/codex-app-server-runner.mjs` 会在 `thread/resume` 后、`turn/start` 前调用 `thread/inject_items`,把 Boss APP 用户原文作为 `role=user` 的 Responses item 写入目标 Codex 线程模型可见历史;任务结果只回传 `threadHistorySync.threadId / injectedItemCount / source`,不回传消息 ID、内部 prompt 或用户原文。CLI rollout 镜像仍保留为 App Server 不可用前的 fallback 链路。
- 当前 boss-agent 已支持 Mac OTA`local-agent/boss-agent-ota-runner.mjs` 默认开启,每 5 分钟检查服务端最新包;状态页可手动检查或下载并安装,安装时保留原绑定配置,只更新版本号和本机 runtime 路径。最新验证版本为 `20260516221619`,已在 MacBook Air `macbook-air` 上确认 OTA 下载校验、暂存、覆盖安装后不会误切到默认 `config.cloud.json`。正式分发脚本已预留 Developer ID 公证路径:`BOSS_AGENT_NOTARIZE=1` 配合 notary profile 或 Apple ID 凭据。
- 当前量产治理已补设备撤权和任务可靠性底座:`revoke_device` 会清空设备 token、标记离线并阻断 heartbeat / 任务认领 / Skill 同步 / 日志上报 / boss-agent OTA`MasterAgentTask` claim 会记录 attempt 和 lease运行中任务可按租约重试超过上限转 `timed_out`,用户或管理员可通过 cancel 接口转 `canceled` 且迟到 complete 不覆盖终态。
- 当前任务 SLA 面板、失败自动恢复和后台告警已沉淀为独立交接文档:`docs/architecture/task_sla_auto_recovery_admin_alerts_cn.md`。该文档记录 `taskSlaPanel``adminNotifications`、pre-turn 安全自动恢复边界、本地验证结果和后续云部署检查清单;当前尚未部署云端,等待新的服务器入口后再按文档执行。
- 当前群聊 `dispatch_execution` 完成回写已补幂等,重复完成不会再向群聊重复追加结果
- 当前已支持微信式消息转发:长按消息可直接 `转发 / 多选 / 复制 / 删除`,单条消息转发显示为普通转发消息,多条消息转发显示为聊天记录卡片
- 当前已支持聊天附件主链:原生聊天框左侧 `+` 会打开底部抽屉,支持图片 / 视频 / 文件发送;图片 / PDF / 文本默认自动进入主 Agent 附件分析,视频 / Office / 大文件默认手动触发

View File

@@ -385,7 +385,7 @@
- `companies[]`:优先使用显式客户公司 / 租户,其次按账号域名或默认公司聚合
- `accounts[]`:脱敏账号列表,不包含 `passwordHash`
- `devices[]`设备在线状态、CLI/GUI 能力、项目数和风险数
- `risks[]`:离线设备、运维故障、线程上下文风险失败主 Agent 任务;运维故障和线程上下文风险会带出负责人和 SLA
- `risks[]`:离线设备、运维故障、线程上下文风险失败主 Agent 任务和任务 SLA 告警;运维故障和线程上下文风险会带出负责人和 SLA
- `notifications[]`:开放中的风险 SLA 通知,当前由 `/api/v1/admin/risks/scan` 生成
- `grantsSummary`:设备 / 项目 / Skill 授权数量与过期授权数量
@@ -400,6 +400,7 @@
- `users[]`:脱敏账号列表,不包含 `passwordHash / mfaSecret / authSessions`
- `roles`:内置角色与 `BOSS_PERMISSION_TEMPLATES`
- `resourceGroups`设备、项目线程、Skill 聚合目录和授权记录
- `insights.taskSlaPanel`MasterAgentTask 的 SLA 面板包含状态分布、SLA 截止、空闲时间、尝试次数、是否可自动恢复和建议动作
- `audit`:风险、通知、风险时间线和 `permissionAuditLogs`
- `yudaoMapping`Boss 账本字段到后台概念的映射,用于后续数据库化或模块拆分
- 当前定位:供 `https://admin.boss.hyzq.net/ -> apps/boss-admin-web` 消费;旧 `/admin` UI 已下线,不再消费 `/api/v1/admin/overview` 和旧数据 provider
@@ -423,7 +424,10 @@
- 当前行为:
- 扫描未关闭的 `opsFaults``threadContextAlerts`
- 同步检查运行态异常:在线设备 `Computer Use` 不可用会补 `BOSS.COMPUTER_USE.UNAVAILABLE` 运维故障,`boss-agent OTA` 失败日志会补 `BOSS_AGENT.OTA.FAILED` 运维故障
- 同步扫描 `MasterAgentTask` SLA基于 lease、最近进度、尝试次数和 recoverable 标记生成任务 SLA 告警
- 只对 `queued / claimed / executor_starting / recoverable_failed` 这类 pre-turn 安全阶段的可恢复任务自动重排队,避免已进入目标线程回复阶段的任务被重复执行
-`slaDueAt` 已早于当前时间时,写入 `adminNotifications[]`
- 任务 SLA 告警同样写入 `adminNotifications[]`,自动恢复会写入 `adminRiskTimeline[]``permissionAuditLogs[]`
- 同一个 `riskId` 只生成一条 `risk_sla_overdue` 通知,重复扫描不会重复膨胀账本
- 生成新通知时发布 `project.context_risk.updated`

View File

@@ -30,6 +30,8 @@
- OTA 包下载接口:`GET http://127.0.0.1:3000/api/v1/user/ota/package`
- boss-agent Mac OTA 接口:`GET http://127.0.0.1:3000/api/v1/boss-agent/ota?deviceId=...&currentVersion=...``GET http://127.0.0.1:3000/api/v1/boss-agent/ota/package`
- 本地 agent 健康检查:`http://127.0.0.1:4317/health`。当前这台开发机的 `launchd` 常驻已经恢复,`/health` 可在数十毫秒内返回,并且在手动 heartbeat 执行期间也不会再被 Codex 线程扫描卡死
- 2026-06-07 已补量产可靠性降载:`local-agent` 的 reliable outbox 会优先保留 `task.complete`,按任务合并重复 `task.progress`,并对同类 `app.log` 做去重和上限保护;`/health` 默认只返回轻量摘要,完整 runtime 只允许通过 `/health?verbose=1` 做诊断Android SSE 已新增 `message-patch-v1` 能力声明,服务端只对支持该能力的客户端下发 `projectMessagesPatch`,旧客户端继续使用完整 `projectMessagesPayload`
- 2026-06-07 已补任务 SLA 企业治理:新增 `src/lib/master-agent-task-sla.ts` 统一计算 MasterAgentTask 的 `ok / watch / breached / recoverable / terminal` 状态、SLA 截止时间、空闲时间、尝试次数和建议动作;`GET /api/v1/admin/backoffice` 会返回 `insights.taskSlaPanel`,独立 Web 管理后台的平台风险页和企业风险页都会展示任务 SLA 面板;`POST /api/v1/admin/risks/scan` 会对 SLA 超时、可恢复失败和终态失败幂等写入 `adminNotifications`,并把可安全重试的 pre-turn recoverable 任务自动重排队,写入 `adminRiskTimeline``permissionAuditLogs`
- 本地 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`
@@ -139,7 +141,7 @@ cd /Users/kris/code/boss
- 当前最高管理员授权管理接口已落地:`GET/POST /api/v1/admin/access` 可以查看脱敏账号、公司、设备、项目、Skill、授权、权限模板和审计日志并支持公司管理、公司启用/停用、账号/设备归属、设备吊销、批量导入预览、批量导入子账号、重置子账号密码、离职回收、创建/更新子账号、启用/停用子账号、授予设备/项目/Skill 权限、套用权限模板、撤销授权;停用公司会禁用该租户普通子账号并撤销会话,停用 / 回收 / 重置账号也会撤销该账号当前活跃会话,吊销设备会清空设备 token、置离线并阻断 heartbeat / 任务认领 / Skill 同步 / 日志上报 / boss-agent OTA普通账号访问返回 `403`
- 当前旧 Web `/admin` 管理 UI 已下线:`src/components/admin/boss-admin-app.tsx` 和旧 data provider 已移除,`/admin` 现在只做兼容跳转到根路径 `/`
- 当前企业级后台独立化第一批已部署到云:`apps/boss-admin-web` 作为 Vue + Vite + Ant Design Vue 独立 PC 后台,静态产物位于 `/admin-web/index.html``admin.boss.hyzq.net` 根路径由 Caddy 内部 rewrite 到该静态入口,不再跳转到 `/enterprise-admin`
- 当前后台风险处理接口已落地:`POST /api/v1/admin/risks/actions``highest_admin` 可用,支持对 `ops_fault` 指派负责人、设置 SLA、确认、关闭、创建或复用修复工单`thread_context_alert` 指派负责人、设置 SLA、确认和关闭`POST /api/v1/admin/risks/scan` 会扫描超时 SLA 并幂等写入 `adminNotifications`会把 Computer Use 不可用、boss-agent OTA 失败等运行态异常补成可治理 `opsFaults`,管理后台总览会展示开放风险通知;不支持的风险类型会明确返回 `RISK_ACTION_UNSUPPORTED`
- 当前后台风险处理接口已落地:`POST /api/v1/admin/risks/actions``highest_admin` 可用,支持对 `ops_fault` 指派负责人、设置 SLA、确认、关闭、创建或复用修复工单`thread_context_alert` 指派负责人、设置 SLA、确认和关闭`POST /api/v1/admin/risks/scan` 会扫描超时 SLA 并幂等写入 `adminNotifications`,会把 Computer Use 不可用、boss-agent OTA 失败等运行态异常补成可治理 `opsFaults`也会扫描 MasterAgentTask SLA 并对安全阶段可恢复失败自动重排队;管理后台总览会展示开放风险通知和任务 SLA 面板;不支持的风险类型会明确返回 `RISK_ACTION_UNSUPPORTED`
- 当前权限审计查询第一版已落地:`GET /api/v1/audits/permission-logs``highest_admin` 可读,支持按 `action / actorAccount / targetAccount / deviceId / projectId / skillId / cursor / limit` 查询 `permissionAuditLogs`并实时返回短时间大量授权、Skill lifecycle 失败、过期授权仍存在、admin route 拒绝访问等 deterministic 风险摘要;后台 mutation 审计已支持 `ipAddress / userAgent / requestId / beforeJson / afterJson`其中重置密码会记录安全化前后快照Web `/me/ops/audit` 会向最高管理员展示最近权限审计和风险摘要
- 当前 Skill 远程治理第一版可执行链路已落地:`GET/POST /api/v1/admin/skills/requests` 仅允许 `highest_admin` 创建和查看 `install / update / uninstall / rollback / version_lock` 请求;设备端通过 `/api/v1/devices/[deviceId]/skill-requests/claim``/complete` 认领回写local-agent 默认每 5 秒执行本机 Skill 安装 / 更新 / 卸载 / 回滚 / 版本锁,并同步最新 Skill 清单。远程安装或带 `sourceUrl` 的更新必须命中本机 `skillLifecycleAllowedSources``skillLifecycleTrustedSources`;配置为空时不允许远程新来源安装,但保留既有本地 Skill 的更新 / 回滚 / 卸载 / 版本锁。携带 `checksum / expectedChecksum` 的请求会校验 `manifest.json``SKILL.md` 的 sha256更新 / 卸载 / 回滚前会写入 `skillsDir/.boss-skill-backups` 并在失败时尽量恢复
- 当前授权管理前台已接入Web `/me/access` 与原生 Android `我的 > 用户与权限` 仅最高管理员可见,可创建子账号、授权设备/项目/Skill、套用 `只读观察员 / 项目开发者 / 设备操作者` 模板、查看同名 Skill 跨设备聚合并撤销单条授权
@@ -372,7 +374,7 @@ cd /Users/kris/code/boss
- 数据存储默认仍是文件型,但已经有 PostgreSQL store adapter、schema 和维护脚本生产切换前需先执行备份、dry-run 迁移和回滚演练
- 设备发现、项目扫描和额度采集仍是静态配置驱动的 MVP
- APP 实时日志当前已能同步到主 Agent 会话,但还没有单独的日志检索、分页和告警升级规则
- Skill 清单当前按设备同步和展示已经可用;远程治理目前只有最高管理员创建 lifecycle 请求和 list 状态,尚未真正下发到设备端执行安装 / 更新 / 卸载 / 回滚
- Skill 清单当前按设备同步和展示已经可用;远程治理已贯通最高管理员创建 lifecycle 请求、设备端认领、local-agent 执行安装 / 更新 / 卸载 / 回滚 / 版本锁、执行后同步 Skill 清单和完成回写。当前仍属于文件型状态与 Git 来源驱动的 MVP生产使用前需要配置设备侧 source allowlist / trusted sources、校验和策略和失败告警。
- 服务器侧主 Agent 实时回复依赖被绑定设备的 `local-agent` 在线并能执行 `codex exec`;如果设备离线,只能保留任务或走 API 容灾账号
- 设备导入主链的后端状态机已经跑通,并且已经分成两条:
- 新接入设备继续走 `import draft -> 勾选 -> review -> apply`

View File

@@ -0,0 +1,259 @@
# 任务 SLA 面板、失败自动恢复与后台告警开发记录
更新时间:`2026-06-07`
## 目标
把主 Agent / Codex 执行任务从“只在聊天窗口里看进度”升级为企业后台可治理对象,平台侧可以看到任务是否超时、是否可恢复、是否需要人工介入,并在安全前提下自动恢复可重试失败任务。
这批功能面向量产交付,核心目标是降低客户现场出现“任务卡住但后台无感知”的概率。
## 已完成能力
### 1. 任务 SLA 统一投影
新增 `src/lib/master-agent-task-sla.ts`,统一计算 `MasterAgentTask` 的 SLA 状态。
当前状态分为:
- `ok`:任务在 SLA 内,无需处理。
- `watch`:接近 SLA 或长时间无进展,需要观察。
- `breached`:任务已超过 SLA需要后台告警。
- `recoverable`:任务处于可恢复失败状态,可以安全重排队。
- `terminal`:任务已经终态失败,需要人工排障或工单处理。
投影字段包括:
- `taskId / riskId / notificationId`
- `projectId / deviceId / companyId`
- `taskType / status / phase`
- `requestedAt / claimedAt / lastProgressAt / leaseExpiresAt / slaDueAt`
- `elapsedMs / idleMs`
- `attemptCount / maxAttempts / attemptLabel`
- `stale / recoverable / autoRecoverable`
- `slaLevel / severity / recommendedAction`
### 2. 后台 BFF 增加任务 SLA 面板
`GET /api/v1/admin/backoffice` 已新增:
```json
{
"insights": {
"taskSlaPanel": {
"generatedAt": "...",
"summary": {
"total": 0,
"active": 0,
"ok": 0,
"watch": 0,
"breached": 0,
"recoverable": 0,
"terminal": 0,
"autoRecoverable": 0
},
"rows": []
}
}
}
```
旧字段 `insights.taskRiskSummary` 保留,避免影响既有前端和旧客户端。
### 3. 风险扫描写入后台告警
`POST /api/v1/admin/risks/scan` 现在会同时扫描三类风险:
- 已设置 `slaDueAt` 且超时的 `opsFaults`
- 已设置 `slaDueAt` 且超时的 `threadContextAlerts`
- `MasterAgentTask` 的 SLA 超时、可恢复失败和终态失败
任务 SLA 告警写入:
- `adminNotifications[]`
- `adminRiskTimeline[]`
- `permissionAuditLogs[]`
通知 ID 使用 `risk-sla-overdue:master-task:<taskId>`,同一个任务重复扫描不会重复膨胀账本。
### 4. 失败自动恢复策略
风险扫描会自动恢复满足条件的任务:
- `recoverable === true`
- 未超过 `maxAttempts`
- `nextRetryAt` 为空或已经到期
- 阶段属于安全 pre-turn 阶段
允许自动恢复的阶段:
- `queued`
- `claimed`
- `executor_starting`
- `recoverable_failed`
禁止自动恢复的阶段:
- `turn_started`
- `awaiting_reply`
- `completing`
- `completed`
- `timed_out`
- `canceled`
禁止这些阶段自动恢复的原因是:任务可能已经进入目标 Codex 线程或执行器真实工作阶段,重复下发可能导致同一轮用户指令被执行两次。
自动恢复动作会:
- 把任务重置为 `status=queued`
- 把任务阶段重置为 `phase=queued`
- 清除 `claimedAt / leaseExpiresAt / errorMessage / nextRetryAt`
- 写入新的 `execution_progress` 队列态
- 写入 `permissionAuditLogs.action=master_agent.task_retried`
- 写入 `adminRiskTimeline.action=task.auto_recovery_requeued`
- 发布 `master_agent.task.updated` 和对应会话刷新事件
### 5. 独立 Web 管理后台页面
`apps/boss-admin-web` 已在以下页面增加“任务 SLA 面板”:
- 平台后台:`全局风险`
- 企业后台:`风险与审计`
面板显示:
- 运行任务数
- SLA 超时数
- 可自动恢复数
- 终态失败数
- 任务类型、状态、阶段、设备、尝试次数、空闲时间和建议动作
## 涉及文件
核心实现:
- `src/lib/master-agent-task-sla.ts`
- `src/lib/boss-risk-notifications.ts`
- `src/lib/boss-data.ts`
- `src/lib/boss-admin-overview.ts`
- `src/app/api/v1/admin/backoffice/route.ts`
Web 管理后台:
- `apps/boss-admin-web/src/api/bossAdmin.ts`
- `apps/boss-admin-web/src/App.vue`
测试:
- `tests/admin-backoffice-bff-route.test.ts`
- `tests/admin-risk-sla-notifications-route.test.ts`
配套文档:
- `docs/architecture/current_runtime_and_deploy_status_cn.md`
- `docs/architecture/api_and_service_inventory_cn.md`
## 本地验证结果
已通过:
```bash
npx tsx --test tests/admin-backoffice-bff-route.test.ts tests/admin-risk-sla-notifications-route.test.ts tests/admin-overview-route.test.ts tests/master-agent-task-recovery-route.test.ts tests/master-agent-task-reliability.test.ts
npm run lint
npm run build
npm run admin:web:build
```
本地接口烟测已通过:
```bash
curl 'http://localhost:3000/api/v1/admin/backoffice?scope=platform' \
-H 'Cookie: boss_session=<highest_admin_session>'
```
确认结果:
- `insights.taskSlaPanel` 存在
- `riskAggregates` 中包含 `任务 SLA 告警`
- 当前本地生产账本没有未完成任务时,`taskSlaPanel.rows``0` 属于正常状态
## 后续云部署待办
这批功能尚未部署到云。等提供云服务器入口后,按以下步骤执行。
### 1. 部署前检查
- 确认服务器入口、账号、认证方式可用。
- 确认目标服务仍是 Boss Web 管理后台服务,不要误部署到 APP Web 版或其他站点。
- 确认当前云端 `data/boss-state.json` 已有备份。
- 确认 `admin.boss.hyzq.net` 仍指向独立 Web 管理后台 `/admin-web/index.html`
### 2. 服务器构建
建议在服务器或发布流水线执行:
```bash
npm run lint
npm run build
npm run admin:web:build
```
### 3. 发布文件
需要同步:
- Next standalone 产物
- `public/admin-web` 静态产物
- `src/lib/master-agent-task-sla.ts`
- 本次涉及的 `src/lib``src/app/api/v1/admin``apps/boss-admin-web` 文件
- 文档更新
### 4. 重启服务
重启目标服务后检查:
```bash
curl -fsS https://boss.hyzq.net/api/health
curl -fsS https://admin.boss.hyzq.net/admin-web/index.html
```
如果 `admin.boss.hyzq.net/` 是根路径 rewrite 到 `/admin-web/index.html`,还要检查:
```bash
curl -I https://admin.boss.hyzq.net/
```
### 5. 管理后台验证
`highest_admin` 登录后验证:
- 打开 `https://admin.boss.hyzq.net/`
- 进入 `全局风险`
- 能看到 `任务 SLA 面板`
- 面板无前端报错
- `GET /api/v1/admin/backoffice?scope=platform` 返回 `insights.taskSlaPanel`
### 6. 风险扫描验证
用最高管理员会话触发:
```bash
curl -X POST https://boss.hyzq.net/api/v1/admin/risks/scan \
-H 'Cookie: boss_session=<highest_admin_session>'
```
预期:
- 返回 `createdFaults[]`
- 返回 `created[]`
- 返回 `autoRecovered[]`
- 返回 `notifications[]`
如果没有卡住任务或超时任务,`created[] / autoRecovered[]` 为空是正常结果。
## 风险边界
- 自动恢复只处理 pre-turn 安全阶段,不对已经进入 Codex 真实回复阶段的任务做自动重试。
- 当前仍是文件账本 MVP企业级大规模部署前建议继续推进 PostgreSQL 状态存储和自动备份演练。
- 任务 SLA 当前是规则计算,不依赖数据库表;后续数据库化时应把 `taskSlaPanel` 继续作为投影层,不要把投影结果反写成主状态源。
- 后台告警当前进入 `adminNotifications`,外部通知派发仍依赖现有 `dispatchAdminRiskNotifications` 配置;如果客户需要企业微信 / 飞书 / 短信,需要另开通知渠道配置。

View File

@@ -0,0 +1,67 @@
# Boss Edge Reliability Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add the first production reliability shell for Boss task execution without changing the deployment topology.
**Architecture:** Keep Boss Cloud and the current `local-agent`, but make `local-agent` behave like a lightweight Boss Edge by adding a durable outbox and explicit task phases. Cloud-side task APIs keep leases and add watchdog cleanup so APP progress never stays ambiguous forever.
**Tech Stack:** Next.js API routes, file-backed Boss state, Node local-agent, Codex App Server runner, Node test runner.
---
### Task 1: Task Phase Contract
**Files:**
- Modify: `src/lib/boss-data.ts`
- Test: `src/lib/boss-data-reliability.test.ts`
- [ ] Add `MasterAgentTaskPhase` and normalized fields on `MasterAgentTask`: `phase`, `lastProgressAt`, `lastErrorCode`, `recoverable`, `nextRetryAt`.
- [ ] Update task normalization so old state files default `queued -> queued`, `running -> claimed`, terminal states preserve terminal phase.
- [ ] Update execution progress card generation to derive step status from phase when available.
- [ ] Test that `executor_starting`, `turn_started`, `awaiting_reply`, `completing`, and `recoverable_failed` map to visible progress steps.
### Task 2: Local Agent Durable Outbox
**Files:**
- Create: `local-agent/reliable-outbox.mjs`
- Modify: `local-agent/server.mjs`
- Test: `local-agent/reliable-outbox.test.mjs`
- [ ] Implement JSONL-backed outbox with append, list pending, mark sent, and compaction.
- [ ] Wrap `postMasterAgentTaskProgress`, `completeMasterAgentTask`, and `postAppLog` so payloads are persisted before network send.
- [ ] Replay pending records on startup and every heartbeat loop.
- [ ] Preserve idempotency keys using `taskId + event kind + phase + createdAt`.
### Task 3: Cloud Watchdog
**Files:**
- Modify: `src/lib/boss-data.ts`
- Test: `src/lib/boss-data-reliability.test.ts`
- [ ] Add a lightweight watchdog function invoked during claim, progress, complete, and heartbeat-derived writes.
- [ ] Expire stale user conversation tasks older than 1 hour while still queued.
- [ ] Convert stale running tasks without progress into `recoverable_failed` if turn has not started, otherwise `timed_out`.
- [ ] Ensure late complete cannot overwrite terminal states.
### Task 4: Executor Health Grading
**Files:**
- Modify: `src/lib/boss-data.ts`
- Modify: `local-agent/codex-app-server-runner.mjs`
- Test: `src/lib/boss-data-reliability.test.ts`
- [ ] Derive `codexAppServerHealth` as `available / degraded / unavailable` from heartbeat metadata and recent errors.
- [ ] Allow GUI-preferred task claim only when health is not `unavailable`.
- [ ] Mark app-server stdio closed and timeout errors as degraded for the next heartbeat.
### Task 5: Verification
**Files:**
- Modify: `docs/architecture/current_runtime_and_deploy_status_cn.md`
- [ ] Run `node --test local-agent/reliable-outbox.test.mjs local-agent/master-task-timeout.test.mjs`.
- [ ] Run `npx eslint src/lib/boss-data.ts local-agent/server.mjs local-agent/codex-app-server-runner.mjs local-agent/reliable-outbox.mjs`.
- [ ] Run `npm run build`.
- [ ] Run `npm run lint`.
- [ ] Document the B+ reliability shell and the local Edge direction in the runtime status doc.

View File

@@ -0,0 +1,107 @@
# Boss Edge Reliability Design
## Goal
把 Boss 的远程开发控制链路升级为“云端控制面 + 本地 Edge 执行面 + 可靠性外壳”。核心目标是避免企业客户在 APP 发起任务后看到长期卡住、丢消息、重复执行或错误泄露。
## Problem
本次 `juyuwan` 会话卡在第一步暴露了四个系统性问题:
- 本地 `local-agent` 被 Codex App Server stdio `EPIPE` 打断后会重启,但任务状态没有被本地 durable journal 接住。
- 云端任务状态只有粗粒度 `queued / running / completed / failed`APP 无法准确区分“等待执行器”“执行器已启动”“Codex turn 已启动”“完成回写中”。
- 实时 progress 回写失败只是日志告警,缺少本地 outbox 重放。
- 执行器可用性目前偏 heartbeat 描述,未形成任务调度前的健康分级。
## Recommended Architecture
采用 B+ 方案:
```text
Boss APP
-> 优先连接企业内网 Boss Edge
-> Edge 不可达时回退 Boss Cloud
Boss Edge
-> 接收本企业任务
-> 维护本地 task journal / outbox / progress stream
-> 调度 boss-agent / Codex App Server / Codex CLI / Computer Use
-> 与云端做结果、审计、备份同步
Boss Cloud
-> 账号、授权、企业后台、审计归档
-> OTA、Skill 分发、跨企业总控
-> 任务租约、watchdog、恢复策略
```
第一阶段不引入独立服务器进程,先让当前 `local-agent` 具备 Edge 行为:本地持久 outbox、执行阶段上报、重放、可恢复失败语义。后续企业部署时再拆成独立 `boss-edge` 服务。
## Reliability Contract
### Task phases
任务需要区分状态和阶段:
- `queued`:云端已创建,等待设备认领。
- `claimed`:设备已认领,尚未启动执行器。
- `executor_starting`:设备正在准备 Codex App Server / CLI / Computer Use。
- `turn_started`:目标 Codex turn 或本地执行动作已启动。
- `awaiting_reply`:执行器已接管,等待最终结果。
- `completing`:本地已拿到结果,正在回写云端。
- `completed`:云端已持久化最终结果。
- `recoverable_failed`:失败可重试,不允许静默卡住。
- `terminal_failed`:失败不可自动重试,需要用户或管理员处理。
- `timed_out`:任务超过租约或执行超时。
- `canceled`:用户或系统取消。
### Outbox
`local-agent` 所有关键回写先写本地 outbox再发送云端
- `task.progress`
- `task.complete`
- `app.log`
发送成功后标记 sent。网络失败、云端 5xx、进程重启后自动重放。云端 complete 必须保持幂等,迟到 complete 不覆盖终态。
### Watchdog
云端每次 claim、progress、complete 和 heartbeat 时都执行轻量 watchdog
- `queued` 超过 1 小时的用户对话任务转 `timed_out`,避免历史任务被修复后误执行。
- `running` 超过 lease 且无 progress 的任务转 `recoverable_failed``timed_out`
- `turn_started` 后失败不能自动转 CLI 重试,必须提示“可继续等待 / 中断 / 重新下发”。
### Health grading
设备能力从布尔值升级为分级:
- `available`:最近 heartbeat 正常App Server 初始化成功,目标线程操作可用。
- `degraded`:设备在线但 App Server discovery 有失败,允许低风险任务,重任务需降级提示。
- `unavailable`设备离线、未登录、App Server 断连或连续失败。
调度优先级:健康 Codex App Server -> CLI fallback -> 用户 API fallback -> 明确提示无可用模型渠道。
## Security Rules
- 不把系统提示词、内部 prompt、API key、本地绝对路径、原始命令输出、raw App Server item 写进用户可见错误。
- 后台只保存错误 code、阶段、设备、任务 ID、安全摘要。
- APP 只显示人话和下一步动作。
## First Implementation Slice
本批改造只做不改变部署形态的可靠性底座:
1.`MasterAgentTask` 增加 `phase / lastProgressAt / lastErrorCode / recoverable / nextRetryAt` 等字段。
2. 进度卡从 task phase 派生步骤状态,不再只靠默认 index。
3. `local-agent` 增加 outbox 文件和重放逻辑,覆盖 progress、complete 和 app-log。
4. 云端 claim/progress/complete 路径增加 watchdog 清理。
5. 补 Node 测试覆盖 EPIPE、outbox 重放、stale running、旧 queued 清理、重复 complete 幂等。
## Success Criteria
- APP 不再出现无限停在第一步;最差也会进入“执行器恢复中 / 可重试 / 已超时”。
- 本地 agent 重启后未发送的 progress/complete 会自动重放。
- 历史 queued 任务不会在修复后误执行。
- Codex turn 已启动后不会被自动重复下发。
- 所有错误输出经过脱敏,不泄露内部 prompt。

View File

@@ -0,0 +1,27 @@
function trimToDefined(value) {
const text = typeof value === "string" ? value.trim() : "";
return text || undefined;
}
function isActiveMasterTask(runtime = {}) {
const active = runtime.activeMasterTask;
return (
runtime.masterTaskBusy === true ||
active?.status === "running" ||
active?.status === "active"
);
}
export function shouldSkipCodexAppServerDiscovery({ config = {}, runtime = {} } = {}) {
if (config.codexAppServerDiscoveryWhileMasterTaskBusy === true) {
return { skip: false };
}
if (!isActiveMasterTask(runtime)) {
return { skip: false };
}
return {
skip: true,
reason: "master_task_running",
activeTaskId: trimToDefined(runtime.activeMasterTask?.taskId),
};
}

View File

@@ -0,0 +1,53 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
shouldSkipCodexAppServerDiscovery,
} from "./codex-app-server-discovery-guard.mjs";
test("codex app-server discovery is skipped while a master task is running", () => {
const decision = shouldSkipCodexAppServerDiscovery({
runtime: {
masterTaskBusy: true,
activeMasterTask: {
taskId: "mastertask-running",
status: "running",
startedAt: "2026-06-07T07:35:33.368Z",
},
},
});
assert.deepEqual(decision, {
skip: true,
reason: "master_task_running",
activeTaskId: "mastertask-running",
});
});
test("codex app-server discovery is allowed when explicit busy discovery is enabled", () => {
const decision = shouldSkipCodexAppServerDiscovery({
config: {
codexAppServerDiscoveryWhileMasterTaskBusy: true,
},
runtime: {
masterTaskBusy: true,
activeMasterTask: {
taskId: "mastertask-running",
status: "running",
},
},
});
assert.deepEqual(decision, { skip: false });
});
test("codex app-server discovery is allowed when no master task is active", () => {
const decision = shouldSkipCodexAppServerDiscovery({
runtime: {
masterTaskBusy: false,
activeMasterTask: null,
},
});
assert.deepEqual(decision, { skip: false });
});

View File

@@ -46,6 +46,39 @@ function resolveTaskTurnRef(task) {
return trimToDefined(task?.targetCodexTurnId || task?.targetTurnId);
}
function isActiveTurnStatus(status) {
const normalized = String(status ?? "").trim().toLowerCase().replace(/[\s_-]+/g, "");
return (
normalized === "active" ||
normalized === "running" ||
normalized === "streaming" ||
normalized === "inprogress"
);
}
function resolveActiveTurnRefFromThreadResult(threadResult) {
const activeTurns = asArray(threadResult?.thread?.turns)
.map((turn, index) => {
const id = trimToDefined(turn?.id ?? turn?.turnId);
if (!id) {
return null;
}
const status = extractDiscoveryTurnStatus(turn);
const completedAt = trimToDefined(turn?.completedAt);
if (completedAt || !isActiveTurnStatus(status)) {
return null;
}
const startedAt = Number(turn?.startedAt ?? turn?.createdAt ?? turn?.updatedAt ?? 0);
return {
id,
order: Number.isFinite(startedAt) && startedAt > 0 ? startedAt : index,
};
})
.filter(Boolean)
.sort((left, right) => right.order - left.order);
return activeTurns[0]?.id;
}
function resolveSourceThreadRef(task) {
return trimToDefined(task?.sourceCodexThreadRef || task?.sourceThreadId);
}
@@ -177,7 +210,7 @@ function waitForCompactNotificationSettle() {
function normalizeTimeoutMs(value) {
const numeric = Number(value);
return Number.isFinite(numeric) && numeric > 0 ? Math.floor(numeric) : 120_000;
return Number.isFinite(numeric) && numeric > 0 ? Math.floor(numeric) : 600_000;
}
function normalizePositiveInteger(value, fallback) {
@@ -471,6 +504,7 @@ function openStdioCodexAppServerTransport(runnerConfig, cwd, handlers) {
stdio: ["pipe", "pipe", "pipe"],
});
let stderr = "";
let closed = false;
const rl = readline.createInterface({ input: child.stdout });
rl.on("line", handlers.onLine);
child.stderr.on("data", (chunk) => {
@@ -478,18 +512,33 @@ function openStdioCodexAppServerTransport(runnerConfig, cwd, handlers) {
});
child.on("error", handlers.onError);
child.on("close", (code) => {
closed = true;
handlers.onClose({
code,
message: stderr.trim() || `CODEX_APP_SERVER_EXITED:${code ?? "unknown"}`,
});
});
child.stdin.on("error", (error) => {
closed = true;
handlers.onError(error);
});
return {
transport: "stdio",
send(line, callback) {
child.stdin.write(`${line}\n`, callback);
if (closed || child.stdin.destroyed || !child.stdin.writable) {
callback?.(new Error("CODEX_APP_SERVER_STDIN_CLOSED"));
return;
}
try {
child.stdin.write(`${line}\n`, callback);
} catch (error) {
callback?.(error);
handlers.onError(error);
}
},
close(signal = "SIGTERM") {
closed = true;
rl.close();
if (!child.killed) {
child.kill(signal);
@@ -3502,6 +3551,7 @@ export async function executeCodexAppServerTask(runnerConfig, task) {
if (!threadId) {
throw new Error("CODEX_APP_SERVER_THREAD_ID_MISSING");
}
const effectiveTurnRef = targetTurnRef || resolveActiveTurnRefFromThreadResult(threadResult);
if (isThreadRollbackTask(task)) {
const numTurns = resolveRollbackNumTurns(task);
@@ -3553,15 +3603,15 @@ export async function executeCodexAppServerTask(runnerConfig, task) {
request,
task,
targetThreadId: threadId,
targetTurnId: targetTurnRef,
targetTurnId: effectiveTurnRef,
hasExistingThreadRef: Boolean(targetThreadRef),
});
const turnControl = targetTurnRef ? "steer" : "start";
const turnResult = targetTurnRef
const turnControl = effectiveTurnRef ? "steer" : "start";
const turnResult = effectiveTurnRef
? await request("turn/steer", {
threadId,
expectedTurnId: targetTurnRef,
expectedTurnId: effectiveTurnRef,
input: [{ type: "text", text: prompt }],
})
: await request("turn/start", {
@@ -3571,7 +3621,7 @@ export async function executeCodexAppServerTask(runnerConfig, task) {
model: runnerConfig.model,
});
activeTurnStarted = true;
const activeTurnId = trimToDefined(turnResult?.turn?.id) || targetTurnRef;
const activeTurnId = trimToDefined(turnResult?.turn?.id) || effectiveTurnRef;
startActiveTurnInterruptPolling({ threadId, turnId: activeTurnId });
await turnCompleted;
if (progressEmits.length > 0) {

View File

@@ -590,22 +590,58 @@ export async function discoverCodexProjectCandidatesInWorker(options = {}) {
options,
},
});
const timeoutMs = Number(options.timeoutMs);
const effectiveTimeoutMs = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 0;
let settled = false;
let timeout;
const cleanup = () => {
if (timeout) {
clearTimeout(timeout);
}
};
const resolveOnce = (value) => {
if (settled) {
return;
}
settled = true;
cleanup();
resolvePromise(value);
};
const rejectOnce = (error) => {
if (settled) {
return;
}
settled = true;
cleanup();
rejectPromise(error);
};
if (effectiveTimeoutMs > 0) {
timeout = setTimeout(() => {
rejectOnce(new Error("DISCOVERY_WORKER_TIMEOUT"));
worker.terminate().catch(() => null);
}, effectiveTimeoutMs);
}
worker.once("message", (payload) => {
if (payload?.ok) {
resolvePromise(payload.result);
resolveOnce(payload.result);
return;
}
rejectPromise(new Error(payload?.error ?? "DISCOVERY_WORKER_FAILED"));
rejectOnce(new Error(payload?.error ?? "DISCOVERY_WORKER_FAILED"));
});
worker.once("error", rejectPromise);
worker.once("error", rejectOnce);
worker.once("exit", (code) => {
if (code === 0) {
cleanup();
return;
}
rejectPromise(new Error(`DISCOVERY_WORKER_EXIT_${code}`));
rejectOnce(new Error(`DISCOVERY_WORKER_EXIT_${code}`));
});
});
}

View File

@@ -2,6 +2,20 @@
"bindHost": "127.0.0.1",
"port": 4317,
"heartbeatIntervalMs": 15000,
"heartbeatTimeoutMs": 12000,
"heartbeatOutboxReplayLimit": 5,
"heartbeatOutboxRequestTimeoutMs": 1000,
"heartbeatOutboxReplayBudgetMs": 2500,
"heartbeatPostTimeoutMs": 4000,
"threadContextPostTimeoutMs": 1000,
"skillsPostTimeoutMs": 1000,
"reliableOutboxRequestTimeoutMs": 5000,
"codexSessionDiscoveryTimeoutMs": 3500,
"codexSessionDiscoveryWhileMasterTaskBusy": false,
"masterAgentClaimTimeoutPaddingMs": 5000,
"masterAgentControlStateTimeoutMs": 3000,
"skillLifecycleClaimTimeoutMs": 5000,
"skillLifecycleCompleteTimeoutMs": 5000,
"masterAgentPollIntervalMs": 1000,
"skillLifecycleEnabled": true,
"skillLifecyclePollIntervalMs": 5000,
@@ -30,11 +44,14 @@
"app-server"
],
"codexAppServerWorkdir": "/Users/kris/code/boss",
"codexAppServerTimeoutMs": 120000,
"codexAppServerTimeoutMs": 600000,
"codexAppServerDiscoveryEnabled": true,
"codexAppServerDiscoveryInlineInHeartbeat": false,
"codexAppServerDiscoveryWhileMasterTaskBusy": false,
"codexAppServerDiscoveryTtlMs": 300000,
"codexAppServerDiscoveryLimit": 20,
"codexAppServerFallbackToCli": true,
"masterAgentLongTaskProgressIntervalMs": 20000,
"codexRemoteControlEnabled": true,
"codexRemoteControlCommand": "codex",
"codexRemoteControlArgs": [

View File

@@ -2,6 +2,20 @@
"bindHost": "127.0.0.1",
"port": 4317,
"heartbeatIntervalMs": 15000,
"heartbeatTimeoutMs": 12000,
"heartbeatOutboxReplayLimit": 5,
"heartbeatOutboxRequestTimeoutMs": 1000,
"heartbeatOutboxReplayBudgetMs": 2500,
"heartbeatPostTimeoutMs": 4000,
"threadContextPostTimeoutMs": 1000,
"skillsPostTimeoutMs": 1000,
"reliableOutboxRequestTimeoutMs": 5000,
"codexSessionDiscoveryTimeoutMs": 3500,
"codexSessionDiscoveryWhileMasterTaskBusy": false,
"masterAgentClaimTimeoutPaddingMs": 5000,
"masterAgentControlStateTimeoutMs": 3000,
"skillLifecycleClaimTimeoutMs": 5000,
"skillLifecycleCompleteTimeoutMs": 5000,
"masterAgentPollIntervalMs": 1000,
"skillLifecycleEnabled": true,
"skillLifecyclePollIntervalMs": 5000,
@@ -34,9 +48,12 @@
"codexAppServerWorkdir": "/Users/kris/code/boss",
"codexAppServerTimeoutMs": 120000,
"codexAppServerDiscoveryEnabled": true,
"codexAppServerDiscoveryInlineInHeartbeat": false,
"codexAppServerDiscoveryWhileMasterTaskBusy": false,
"codexAppServerDiscoveryTtlMs": 300000,
"codexAppServerDiscoveryLimit": 20,
"codexAppServerFallbackToCli": true,
"masterAgentLongTaskProgressIntervalMs": 20000,
"codexRemoteControlEnabled": true,
"codexRemoteControlCommand": "codex",
"codexRemoteControlArgs": [

View File

@@ -0,0 +1,30 @@
export function normalizeFetchTimeoutMs(value, fallback = 5_000) {
const numeric = Number(value);
if (!Number.isFinite(numeric) || numeric <= 0) {
return fallback;
}
return Math.max(50, Math.min(60_000, Math.round(numeric)));
}
export async function fetchWithTimeout(url, init = {}, options = {}) {
const timeoutMs = normalizeFetchTimeoutMs(options.timeoutMs);
const timeoutMessage = String(options.timeoutMessage || "LOCAL_AGENT_FETCH_TIMEOUT");
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort(new Error(timeoutMessage));
}, timeoutMs);
try {
return await fetch(url, {
...init,
signal: init.signal ?? controller.signal,
});
} catch (error) {
if (controller.signal.aborted) {
throw new Error(timeoutMessage);
}
throw error;
} finally {
clearTimeout(timeout);
}
}

View File

@@ -0,0 +1,41 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createServer } from "node:http";
import { fetchWithTimeout } from "./fetch-timeout.mjs";
async function withServer(handler, run) {
const server = createServer(handler);
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
const baseUrl = `http://127.0.0.1:${address.port}`;
try {
return await run(baseUrl);
} finally {
await new Promise((resolve) => server.close(resolve));
}
}
test("fetchWithTimeout aborts stalled requests with a stable error message", async () => {
await withServer(() => {
// Keep the request open to simulate a stalled control-plane request.
}, async (baseUrl) => {
const started = Date.now();
await assert.rejects(
() => fetchWithTimeout(`${baseUrl}/stall`, {}, { timeoutMs: 20, timeoutMessage: "TEST_FETCH_TIMEOUT" }),
/TEST_FETCH_TIMEOUT/,
);
assert.ok(Date.now() - started < 1_000);
});
});
test("fetchWithTimeout returns normal responses before the timeout", async () => {
await withServer((_request, response) => {
response.writeHead(200, { "Content-Type": "text/plain" });
response.end("ok");
}, async (baseUrl) => {
const response = await fetchWithTimeout(`${baseUrl}/ok`, {}, { timeoutMs: 1_000 });
assert.equal(response.ok, true);
assert.equal(await response.text(), "ok");
});
});

View File

@@ -0,0 +1,97 @@
function trimText(value, maxLength = 160) {
const text = typeof value === "string" ? value.trim() : "";
if (!text) {
return undefined;
}
return text.length > maxLength ? `${text.slice(0, maxLength)}...` : text;
}
function summarizePoll(poll) {
if (!poll || typeof poll !== "object") {
return null;
}
return {
at: trimText(poll.at, 80),
ok: poll.ok === true,
status: Number.isFinite(Number(poll.status)) ? Number(poll.status) : undefined,
};
}
function summarizeActiveMasterTask(activeMasterTask) {
if (!activeMasterTask || typeof activeMasterTask !== "object") {
return null;
}
return {
taskId: trimText(activeMasterTask.taskId, 80),
status: trimText(activeMasterTask.status, 80),
startedAt: trimText(activeMasterTask.startedAt, 80),
completedAt: trimText(activeMasterTask.completedAt, 80),
};
}
function summarizeReliableOutbox(runtime) {
const replay = runtime?.lastReliableOutboxReplay;
return {
busy: runtime?.reliableOutboxReplayBusy === true,
startedAt: trimText(runtime?.lastReliableOutboxReplayStartedAt, 80),
replayedAt: trimText(runtime?.lastReliableOutboxReplayAt, 80),
attempted: Number.isFinite(Number(replay?.attempted)) ? Number(replay.attempted) : 0,
sent: Number.isFinite(Number(replay?.sent)) ? Number(replay.sent) : 0,
retained: Number.isFinite(Number(replay?.retained)) ? Number(replay.retained) : 0,
stoppedByBudget: replay?.stoppedByBudget === true,
error: trimText(replay?.error),
};
}
function summarizeCodexAppServer(runtime) {
return {
metadataAtMs: Number.isFinite(Number(runtime?.codexAppServerCapabilityMetadataAtMs))
? Number(runtime.codexAppServerCapabilityMetadataAtMs)
: undefined,
refreshBusy: runtime?.codexAppServerCapabilityMetadataRefreshBusy === true,
lastError: trimText(runtime?.codexAppServerCapabilityMetadataError),
skippedAt: trimText(runtime?.codexAppServerCapabilityMetadataSkippedAt, 80),
skipReason: trimText(runtime?.codexAppServerCapabilityMetadataSkipReason, 120),
};
}
export function buildLocalAgentHealthSummary(config = {}, runtime = {}) {
return {
ok: runtime.lastHeartbeatOk === true,
service: "boss-local-agent",
deviceId: trimText(config.deviceId, 120),
now: new Date().toISOString(),
heartbeat: {
at: trimText(runtime.lastHeartbeatAt, 80),
ok: runtime.lastHeartbeatOk === true,
status: Number.isFinite(Number(runtime.lastHeartbeatStatus))
? Number(runtime.lastHeartbeatStatus)
: undefined,
},
masterTask: {
busy: runtime.masterTaskBusy === true,
active: summarizeActiveMasterTask(runtime.activeMasterTask),
lastPoll: summarizePoll(runtime.lastMasterTaskPoll),
},
outbox: summarizeReliableOutbox(runtime),
skills: {
syncBusy: runtime.skillSyncBusy === true,
syncAt: trimText(runtime.lastSkillSyncAt, 80),
syncOk: runtime.lastSkillSyncOk === true,
syncStatus: Number.isFinite(Number(runtime.lastSkillSyncStatus))
? Number(runtime.lastSkillSyncStatus)
: undefined,
count: Array.isArray(runtime.lastSkills) ? runtime.lastSkills.length : 0,
lifecycleBusy: runtime.skillLifecycleBusy === true,
lastLifecyclePoll: summarizePoll(runtime.lastSkillLifecyclePoll),
},
projectDiscovery: {
at: trimText(runtime.lastProjectDiscoveryAt, 80),
ok: runtime.lastProjectDiscoveryOk === true,
summary: trimText(runtime.lastProjectDiscoverySummary, 160),
skippedAt: trimText(runtime.lastProjectDiscoverySkippedAt, 80),
skipReason: trimText(runtime.lastProjectDiscoverySkipReason, 120),
},
codexAppServer: summarizeCodexAppServer(runtime),
};
}

View File

@@ -0,0 +1,57 @@
import test from "node:test";
import assert from "node:assert/strict";
import { buildLocalAgentHealthSummary } from "./health-summary.mjs";
test("buildLocalAgentHealthSummary excludes heavy runtime bodies and secrets", () => {
const summary = buildLocalAgentHealthSummary(
{
deviceId: "mac-studio",
token: "secret-token",
},
{
lastHeartbeatAt: "2026-06-07T01:00:00.000Z",
lastHeartbeatOk: true,
lastHeartbeatStatus: 200,
lastHeartbeatBody: JSON.stringify({ large: "body", token: "secret-token" }),
masterTaskBusy: true,
activeMasterTask: {
taskId: "task-1",
status: "running",
detail: "very sensitive long detail",
},
lastMasterTaskPoll: {
at: "2026-06-07T01:00:01.000Z",
ok: true,
status: 200,
body: JSON.stringify({ task: { prompt: "internal prompt" } }),
},
lastSkillSyncBody: "skill body",
lastReliableOutboxReplay: {
attempted: 3,
sent: 2,
retained: 1,
stoppedByBudget: true,
},
lastSkills: [{ name: "a" }, { name: "b" }],
codexAppServerCapabilityMetadata: {
huge: "metadata",
},
codexAppServerCapabilityMetadataError: "temporary error",
},
);
const encoded = JSON.stringify(summary);
assert.equal(summary.ok, true);
assert.equal(summary.deviceId, "mac-studio");
assert.equal(summary.masterTask.busy, true);
assert.equal(summary.masterTask.active.taskId, "task-1");
assert.equal(summary.outbox.retained, 1);
assert.equal(summary.skills.count, 2);
assert.equal(encoded.includes("secret-token"), false);
assert.equal(encoded.includes("internal prompt"), false);
assert.equal(encoded.includes("very sensitive long detail"), false);
assert.equal(encoded.includes("lastHeartbeatBody"), false);
assert.equal(encoded.includes("lastMasterTaskPoll"), false);
assert.equal(encoded.includes("codexAppServerCapabilityMetadata"), false);
});

View File

@@ -0,0 +1,15 @@
export function recordHeartbeatRunnerError(runtime, error) {
const body = error instanceof Error ? error.message : String(error || "LOCAL_AGENT_HEARTBEAT_FAILED");
const result = {
ok: false,
status: 0,
body,
};
if (runtime && typeof runtime === "object") {
runtime.lastHeartbeatAt = new Date().toISOString();
runtime.lastHeartbeatOk = false;
runtime.lastHeartbeatStatus = 0;
runtime.lastHeartbeatBody = body;
}
return result;
}

View File

@@ -0,0 +1,17 @@
import test from "node:test";
import assert from "node:assert/strict";
import { recordHeartbeatRunnerError } from "./heartbeat-error-state.mjs";
test("heartbeat runner error is recorded as a visible runtime failure", () => {
const runtime = {};
const result = recordHeartbeatRunnerError(runtime, new Error("LOCAL_AGENT_HEARTBEAT_TIMEOUT"));
assert.equal(result.ok, false);
assert.equal(result.status, 0);
assert.equal(result.body, "LOCAL_AGENT_HEARTBEAT_TIMEOUT");
assert.equal(runtime.lastHeartbeatOk, false);
assert.equal(runtime.lastHeartbeatStatus, 0);
assert.equal(runtime.lastHeartbeatBody, "LOCAL_AGENT_HEARTBEAT_TIMEOUT");
assert.match(runtime.lastHeartbeatAt, /^\d{4}-\d{2}-\d{2}T/);
});

View File

@@ -0,0 +1,117 @@
function asArray(value) {
return Array.isArray(value) ? value : [];
}
function normalizeProjectCandidate(candidate) {
if (!candidate || typeof candidate !== "object") {
return null;
}
return { ...candidate };
}
function normalizeProjects(value) {
return asArray(value)
.map((item) => String(item ?? "").trim())
.filter(Boolean);
}
function normalizeProjectCandidates(value) {
return asArray(value)
.map(normalizeProjectCandidate)
.filter(Boolean);
}
function normalizeTimeoutMs(value, fallback = 3_500) {
const numeric = Number(value);
if (!Number.isFinite(numeric) || numeric <= 0) {
return fallback;
}
return Math.max(500, Math.min(30_000, Math.round(numeric)));
}
function normalizeHeartbeatProjects(value = {}) {
return {
projects: normalizeProjects(value.projects),
projectCandidates: normalizeProjectCandidates(value.projectCandidates),
guiConnected: value.guiConnected === true,
};
}
function shouldUseSnapshot(config = {}, runtime = {}) {
if (config.codexSessionDiscoveryWhileMasterTaskBusy === true) {
return false;
}
return runtime.masterTaskBusy === true || runtime.activeMasterTask?.status === "running";
}
export function resolveHeartbeatProjectsFromSnapshot({ config = {}, runtime = {} } = {}) {
if (!shouldUseSnapshot(config, runtime)) {
return { shouldUseSnapshot: false };
}
const snapshot = runtime.lastHeartbeatProjectsSnapshot;
if (snapshot && typeof snapshot === "object") {
return {
shouldUseSnapshot: true,
projects: normalizeProjects(snapshot.projects),
projectCandidates: normalizeProjectCandidates(snapshot.projectCandidates),
guiConnected: snapshot.guiConnected === true,
};
}
return {
shouldUseSnapshot: true,
projects: normalizeProjects(config.projects),
projectCandidates: normalizeProjectCandidates(config.projectCandidates),
guiConnected: false,
};
}
export async function runHeartbeatProjectDiscoveryWithTimeout({
timeoutMs,
fallback = {},
discover,
} = {}) {
if (typeof discover !== "function") {
throw new TypeError("discover must be a function");
}
const effectiveTimeoutMs = normalizeTimeoutMs(timeoutMs);
let timeout;
let timedOut = false;
try {
const value = await Promise.race([
Promise.resolve().then(discover),
new Promise((_, reject) => {
timeout = setTimeout(() => {
timedOut = true;
reject(new Error("CODEX_SESSION_DISCOVERY_TIMEOUT"));
}, effectiveTimeoutMs);
}),
]);
return {
timedOut: false,
value: normalizeHeartbeatProjects(value),
};
} catch (error) {
return {
timedOut,
error,
value: normalizeHeartbeatProjects(fallback),
};
} finally {
if (timeout) {
clearTimeout(timeout);
}
}
}
export function storeHeartbeatProjectsSnapshot(runtime, heartbeatProjects = {}) {
if (!runtime || typeof runtime !== "object") {
return;
}
const snapshot = normalizeHeartbeatProjects(heartbeatProjects);
runtime.lastHeartbeatProjectsSnapshot = {
...snapshot,
capturedAt: new Date().toISOString(),
};
}

View File

@@ -0,0 +1,104 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
resolveHeartbeatProjectsFromSnapshot,
runHeartbeatProjectDiscoveryWithTimeout,
storeHeartbeatProjectsSnapshot,
} from "./heartbeat-project-snapshot.mjs";
test("active master task heartbeat reuses the last discovered project snapshot", () => {
const runtime = {
masterTaskBusy: true,
lastHeartbeatProjectsSnapshot: {
projects: ["test"],
projectCandidates: [{ threadId: "thread-test", folderName: "test" }],
guiConnected: true,
capturedAt: "2026-06-07T10:39:00.000Z",
},
};
const result = resolveHeartbeatProjectsFromSnapshot({
config: {
projects: ["static"],
projectCandidates: [{ threadId: "static-thread", folderName: "static" }],
},
runtime,
});
assert.deepEqual(result, {
shouldUseSnapshot: true,
projects: ["test"],
projectCandidates: [{ threadId: "thread-test", folderName: "test" }],
guiConnected: true,
});
});
test("active master task heartbeat falls back to static projects without a snapshot", () => {
const result = resolveHeartbeatProjectsFromSnapshot({
config: {
projects: ["static"],
projectCandidates: [{ threadId: "static-thread", folderName: "static" }],
},
runtime: {
masterTaskBusy: true,
},
});
assert.deepEqual(result, {
shouldUseSnapshot: true,
projects: ["static"],
projectCandidates: [{ threadId: "static-thread", folderName: "static" }],
guiConnected: false,
});
});
test("idle heartbeat does not use the cached project snapshot", () => {
const result = resolveHeartbeatProjectsFromSnapshot({
config: {
projects: ["static"],
},
runtime: {
masterTaskBusy: false,
lastHeartbeatProjectsSnapshot: {
projects: ["cached"],
projectCandidates: [],
},
},
});
assert.equal(result.shouldUseSnapshot, false);
});
test("project snapshot stores only lightweight fields", () => {
const runtime = {};
storeHeartbeatProjectsSnapshot(runtime, {
projects: ["test"],
projectCandidates: [{ threadId: "thread-test", folderName: "test" }],
guiConnected: true,
privateField: "should-not-store",
});
assert.deepEqual(Object.keys(runtime.lastHeartbeatProjectsSnapshot).sort(), [
"capturedAt",
"guiConnected",
"projectCandidates",
"projects",
]);
assert.equal(runtime.lastHeartbeatProjectsSnapshot.privateField, undefined);
});
test("project discovery timeout falls back instead of blocking heartbeat", async () => {
const result = await runHeartbeatProjectDiscoveryWithTimeout({
timeoutMs: 10,
fallback: { projects: ["cached"], projectCandidates: [], guiConnected: false },
discover: () => new Promise(() => {}),
});
assert.equal(result.timedOut, true);
assert.deepEqual(result.value, {
projects: ["cached"],
projectCandidates: [],
guiConnected: false,
});
});

View File

@@ -0,0 +1,102 @@
function normalizeNumber(value, fallback) {
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : fallback;
}
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function formatElapsedSeconds(seconds) {
const safeSeconds = Math.max(0, Math.floor(seconds));
if (safeSeconds < 60) {
return `${safeSeconds}`;
}
const minutes = Math.floor(safeSeconds / 60);
const remainingSeconds = safeSeconds % 60;
return remainingSeconds > 0 ? `${minutes}${remainingSeconds}` : `${minutes} 分钟`;
}
function normalizeStepStatus(value, fallback = "pending") {
return value === "done" || value === "running" || value === "failed" || value === "pending"
? value
: fallback;
}
function normalizeSteps(steps) {
if (!Array.isArray(steps)) {
return [];
}
return steps
.map((step, index) => {
const text = typeof step?.text === "string" ? step.text.trim() : "";
if (!text) {
return null;
}
return {
id: typeof step?.id === "string" && step.id.trim() ? step.id.trim() : `step-${index + 1}`,
text,
status: normalizeStepStatus(step?.status),
};
})
.filter(Boolean)
.slice(0, 10);
}
function buildDefaultLongRunningSteps(elapsedSeconds) {
const elapsedText = formatElapsedSeconds(elapsedSeconds);
return [
{ id: "receive-task", text: "接收对话任务", status: "done" },
{ id: "locate-thread", text: "定位目标 Codex 线程", status: "done" },
{ id: "write-desktop-thread", text: "写入 Codex 桌面线程记录", status: "done" },
{ id: "await-thread-reply", text: `等待目标线程回复,已等待 ${elapsedText}`, status: "running" },
{ id: "write-back-boss", text: "回写 Boss 对话窗口", status: "pending" },
];
}
export function normalizeLongRunningProgressIntervalMs(value) {
const numeric = normalizeNumber(value, 20_000);
if (numeric <= 0) {
return 0;
}
return clamp(Math.floor(numeric), 5_000, 60_000);
}
export function buildLongRunningCodexProgressSnapshot({
task = {},
startedAtMs,
nowMs = Date.now(),
phase = "awaiting_reply",
baseProgress,
heartbeatCount = 0,
} = {}) {
const started = normalizeNumber(startedAtMs, nowMs);
const elapsedSeconds = Math.max(0, Math.round((nowMs - started) / 1000));
const liveSteps = normalizeSteps(baseProgress?.steps);
const steps = liveSteps.length > 0 ? liveSteps : buildDefaultLongRunningSteps(elapsedSeconds);
const warnings = Array.isArray(baseProgress?.warnings)
? baseProgress.warnings.filter(Boolean).slice(0, 8)
: [];
if (!warnings.some((warning) => warning?.id === "codex-turn-long-running")) {
warnings.unshift({
id: "codex-turn-long-running",
severity: "info",
message: `Codex 桌面线程仍在执行,已等待 ${formatElapsedSeconds(elapsedSeconds)}`,
});
}
return {
...(baseProgress && typeof baseProgress === "object" ? baseProgress : {}),
phase,
status: "running",
steps,
warnings,
longRunning: {
taskId: typeof task?.taskId === "string" ? task.taskId : undefined,
targetThreadDisplayName:
typeof task?.targetThreadDisplayName === "string" ? task.targetThreadDisplayName : undefined,
elapsedSeconds,
heartbeatCount: Math.max(0, Math.floor(normalizeNumber(heartbeatCount, 0))),
},
};
}

View File

@@ -0,0 +1,70 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
buildLongRunningCodexProgressSnapshot,
normalizeLongRunningProgressIntervalMs,
} from "./master-task-progress-heartbeat.mjs";
test("long-running codex progress snapshot exposes visible waiting state", () => {
const snapshot = buildLongRunningCodexProgressSnapshot({
task: {
taskId: "mastertask-slow",
targetThreadDisplayName: "juyuwan",
},
startedAtMs: Date.parse("2026-06-07T07:35:33.000Z"),
nowMs: Date.parse("2026-06-07T07:37:03.000Z"),
phase: "awaiting_reply",
heartbeatCount: 3,
});
assert.equal(snapshot.phase, "awaiting_reply");
assert.equal(snapshot.status, "running");
assert.equal(snapshot.longRunning.elapsedSeconds, 90);
assert.equal(snapshot.longRunning.heartbeatCount, 3);
assert.equal(snapshot.steps.length, 5);
assert.deepEqual(snapshot.steps.map((step) => step.status), [
"done",
"done",
"done",
"running",
"pending",
]);
assert.equal(snapshot.steps[3].text, "等待目标线程回复,已等待 1 分 30 秒");
assert.equal(snapshot.warnings[0].id, "codex-turn-long-running");
});
test("long-running codex progress snapshot preserves live app-server steps when available", () => {
const snapshot = buildLongRunningCodexProgressSnapshot({
task: {
taskId: "mastertask-streaming",
targetThreadDisplayName: "boss",
},
startedAtMs: 1_000,
nowMs: 21_000,
baseProgress: {
steps: [
{ id: "plan-1", text: "读取项目文档", status: "done" },
{ id: "plan-2", text: "运行验证命令", status: "running" },
],
streamEvents: {
status: "streaming",
agentDeltaCount: 2,
},
},
});
assert.deepEqual(snapshot.steps, [
{ id: "plan-1", text: "读取项目文档", status: "done" },
{ id: "plan-2", text: "运行验证命令", status: "running" },
]);
assert.equal(snapshot.streamEvents.agentDeltaCount, 2);
assert.equal(snapshot.longRunning.elapsedSeconds, 20);
});
test("long-running progress interval defaults to fast but bounded updates", () => {
assert.equal(normalizeLongRunningProgressIntervalMs(undefined), 20_000);
assert.equal(normalizeLongRunningProgressIntervalMs(1_000), 5_000);
assert.equal(normalizeLongRunningProgressIntervalMs(120_000), 60_000);
assert.equal(normalizeLongRunningProgressIntervalMs(0), 0);
});

View File

@@ -0,0 +1,378 @@
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import os from "node:os";
const MAX_OUTBOX_RECORDS = 500;
const MAX_APP_LOG_RECORDS = 120;
const RETRYABLE_STATUS_CODES = new Set([408, 409, 425, 429, 500, 502, 503, 504]);
const outboxWriteQueues = new Map();
function reliableOutboxPriority(record) {
switch (record?.kind) {
case "task.complete":
return 0;
case "task.progress":
return 10;
case "app.log":
return 30;
default:
return 20;
}
}
function recordCreatedMs(record) {
const value = Date.parse(record?.createdAt || "");
return Number.isFinite(value) ? value : 0;
}
function parseRecordBody(record) {
if (!record || record.body == null) {
return {};
}
if (typeof record.body === "object") {
return record.body;
}
if (typeof record.body !== "string") {
return {};
}
try {
const parsed = JSON.parse(record.body);
return parsed && typeof parsed === "object" ? parsed : {};
} catch {
return {};
}
}
function taskProgressCoalescingKey(record) {
if (record?.kind !== "task.progress") {
return "";
}
const body = parseRecordBody(record);
const taskId = typeof body.taskId === "string" ? body.taskId.trim() : "";
if (taskId) {
return taskId;
}
const url = typeof record.url === "string" ? record.url : "";
const match = url.match(/\/master-agent\/tasks\/([^/]+)\/progress(?:\?|$)/);
return match ? decodeURIComponent(match[1]) : "";
}
function taskCompletionCoalescingKey(record) {
if (record?.kind !== "task.complete") {
return "";
}
const body = parseRecordBody(record);
const taskId = typeof body.taskId === "string" ? body.taskId.trim() : "";
if (taskId) {
return taskId;
}
const url = typeof record.url === "string" ? record.url : "";
const match = url.match(/\/master-agent\/tasks\/([^/]+)\/complete(?:\?|$)/);
return match ? decodeURIComponent(match[1]) : "";
}
function appLogCoalescingKey(record) {
if (record?.kind !== "app.log") {
return "";
}
const body = parseRecordBody(record);
const category = typeof body.category === "string" ? body.category.trim() : "";
const message = typeof body.message === "string" ? body.message.trim() : "";
if (!category && !message) {
return "";
}
const projectId = typeof body.projectId === "string" ? body.projectId.trim() : "";
return [record.url || "", projectId, category, message].join("|");
}
function orderReliableOutboxRecordsForReplay(records) {
return [...records].sort((left, right) => {
const priorityDiff = reliableOutboxPriority(left) - reliableOutboxPriority(right);
if (priorityDiff !== 0) return priorityDiff;
return recordCreatedMs(left) - recordCreatedMs(right);
});
}
function compactReliableOutboxRecords(records) {
const active = records.filter((record) => record && record.id && record.status !== "sent");
const completionTaskKeys = new Set(
active.map(taskCompletionCoalescingKey).filter(Boolean),
);
const progressByTask = new Map();
const retained = [];
for (const record of active) {
const progressKey = taskProgressCoalescingKey(record);
if (!progressKey) {
retained.push(record);
continue;
}
if (completionTaskKeys.has(progressKey)) {
continue;
}
const previous = progressByTask.get(progressKey);
if (!previous || recordCreatedMs(record) >= recordCreatedMs(previous)) {
progressByTask.set(progressKey, record);
}
}
const keyedAppLogs = new Map();
const unkeyedAppLogs = [];
for (const record of retained.filter((item) => item.kind === "app.log")) {
const appLogKey = appLogCoalescingKey(record);
if (!appLogKey) {
unkeyedAppLogs.push(record);
continue;
}
const previous = keyedAppLogs.get(appLogKey);
if (!previous || recordCreatedMs(record) >= recordCreatedMs(previous)) {
keyedAppLogs.set(appLogKey, record);
}
}
const appLogs = [...keyedAppLogs.values(), ...unkeyedAppLogs]
.sort((left, right) => recordCreatedMs(right) - recordCreatedMs(left))
.slice(0, MAX_APP_LOG_RECORDS);
const compacted = [
...retained.filter((record) => record.kind !== "app.log"),
...progressByTask.values(),
...appLogs,
];
if (compacted.length <= MAX_OUTBOX_RECORDS) {
return compacted.sort((left, right) => recordCreatedMs(left) - recordCreatedMs(right));
}
const taskCompletions = compacted.filter((record) => record.kind === "task.complete");
const remainingBudget = Math.max(0, MAX_OUTBOX_RECORDS - taskCompletions.length);
const otherRecords = compacted
.filter((record) => record.kind !== "task.complete")
.sort((left, right) => {
const priorityDiff = reliableOutboxPriority(left) - reliableOutboxPriority(right);
if (priorityDiff !== 0) return priorityDiff;
return recordCreatedMs(right) - recordCreatedMs(left);
})
.slice(0, remainingBudget);
return [...taskCompletions, ...otherRecords].sort(
(left, right) => recordCreatedMs(left) - recordCreatedMs(right),
);
}
function normalizeTimeoutMs(value, fallback = 5_000) {
const numeric = Number(value);
if (!Number.isFinite(numeric) || numeric <= 0) {
return fallback;
}
return Math.max(50, Math.min(60_000, Math.round(numeric)));
}
function normalizeDurationBudgetMs(value) {
const numeric = Number(value);
if (!Number.isFinite(numeric) || numeric <= 0) {
return 0;
}
return Math.max(50, Math.min(60_000, Math.round(numeric)));
}
function nowIso() {
return new Date().toISOString();
}
function randomId(prefix) {
return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
export function resolveReliableOutboxPath(config = {}) {
if (config.reliableOutboxPath) {
return String(config.reliableOutboxPath);
}
return join(os.homedir(), ".boss-agent", `${config.deviceId || "device"}-outbox.json`);
}
async function readOutboxRecords(outboxPath) {
try {
const parsed = JSON.parse(await readFile(outboxPath, "utf8"));
return Array.isArray(parsed?.records) ? parsed.records : [];
} catch {
return [];
}
}
async function writeOutboxRecords(outboxPath, records) {
await mkdir(dirname(outboxPath), { recursive: true });
const compacted = compactReliableOutboxRecords(records);
const tmpPath = `${outboxPath}.${process.pid}.${Date.now()}.tmp`;
await writeFile(
tmpPath,
JSON.stringify({ version: 1, updatedAt: nowIso(), records: compacted }, null, 2),
);
await rename(tmpPath, outboxPath);
}
async function mutateOutboxRecords(outboxPath, mutator) {
const run = async () => {
const records = await readOutboxRecords(outboxPath);
const result = await mutator(records);
await writeOutboxRecords(outboxPath, result.records);
return result.value;
};
const previous = outboxWriteQueues.get(outboxPath) || Promise.resolve();
const next = previous.then(run, run);
outboxWriteQueues.set(outboxPath, next.catch(() => null));
return await next;
}
export async function appendReliableOutboxRecord(outboxPath, input) {
const record = {
id: input.id || randomId(input.kind || "outbox"),
kind: input.kind,
url: input.url,
method: input.method || "POST",
headers: input.headers || {},
body: input.body,
requestTimeoutMs: input.requestTimeoutMs,
status: "pending",
attemptCount: 0,
createdAt: nowIso(),
lastAttemptAt: undefined,
lastError: undefined,
};
return await mutateOutboxRecords(outboxPath, async (records) => ({
records: [...records, record],
value: record,
}));
}
export async function markReliableOutboxRecordSent(outboxPath, recordId) {
await mutateOutboxRecords(outboxPath, async (records) => ({
records: records.filter((record) => record.id !== recordId),
value: undefined,
}));
}
function shouldRetry(status) {
if (!status) return true;
return RETRYABLE_STATUS_CODES.has(status);
}
async function updateReliableOutboxRecordFailure(outboxPath, recordId, detail) {
await mutateOutboxRecords(outboxPath, async (records) => ({
records: records.map((record) => {
if (record.id !== recordId) return record;
return {
...record,
attemptCount: Number(record.attemptCount || 0) + 1,
lastAttemptAt: nowIso(),
lastError: String(detail || "OUTBOX_SEND_FAILED").slice(0, 240),
};
}),
value: undefined,
}));
}
export async function sendReliableOutboxRecord(record, options = {}) {
const timeoutMs = normalizeTimeoutMs(options.requestTimeoutMs ?? record.requestTimeoutMs);
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort(new Error("RELIABLE_OUTBOX_SEND_TIMEOUT"));
}, timeoutMs);
try {
const response = await fetch(record.url, {
method: record.method || "POST",
headers: record.headers || {},
body: typeof record.body === "string" ? record.body : JSON.stringify(record.body ?? {}),
signal: controller.signal,
});
const body = await response.text();
return {
ok: response.ok,
retryable: !response.ok && shouldRetry(response.status),
status: response.status,
body,
};
} catch (error) {
if (controller.signal.aborted) {
throw new Error("RELIABLE_OUTBOX_SEND_TIMEOUT");
}
throw error;
} finally {
clearTimeout(timeout);
}
}
export async function postThroughReliableOutbox(config, recordInput) {
if (config.reliableOutboxEnabled === false) {
return await sendReliableOutboxRecord({
...recordInput,
id: recordInput.id || randomId(recordInput.kind || "direct"),
requestTimeoutMs: recordInput.requestTimeoutMs ?? config.reliableOutboxRequestTimeoutMs,
});
}
const outboxPath = resolveReliableOutboxPath(config);
const record = await appendReliableOutboxRecord(outboxPath, {
...recordInput,
requestTimeoutMs: recordInput.requestTimeoutMs ?? config.reliableOutboxRequestTimeoutMs,
});
try {
const result = await sendReliableOutboxRecord(record, {
requestTimeoutMs: record.requestTimeoutMs ?? config.reliableOutboxRequestTimeoutMs,
});
if (result.ok || !result.retryable) {
await markReliableOutboxRecordSent(outboxPath, record.id);
} else {
await updateReliableOutboxRecordFailure(outboxPath, record.id, result.body);
}
return result;
} catch (error) {
await updateReliableOutboxRecordFailure(
outboxPath,
record.id,
error instanceof Error ? error.message : String(error),
);
return {
ok: false,
retryable: true,
status: 0,
body: error instanceof Error ? error.message : String(error),
};
}
}
export async function replayReliableOutbox(config, options = {}) {
if (config.reliableOutboxEnabled === false) {
return { attempted: 0, sent: 0, retained: 0 };
}
const outboxPath = resolveReliableOutboxPath(config);
const records = (await readOutboxRecords(outboxPath)).filter(
(record) => record?.status !== "sent",
);
const limit = Math.max(1, Math.min(Number(options.limit || 50), 100));
const startedAt = Date.now();
const maxDurationMs = normalizeDurationBudgetMs(options.maxDurationMs ?? config.reliableOutboxReplayBudgetMs);
const requestTimeoutMs = options.requestTimeoutMs ?? config.reliableOutboxRequestTimeoutMs;
let attempted = 0;
let sent = 0;
let stoppedByBudget = false;
for (const record of orderReliableOutboxRecordsForReplay(records).slice(0, limit)) {
if (maxDurationMs > 0 && Date.now() - startedAt >= maxDurationMs) {
stoppedByBudget = true;
break;
}
attempted += 1;
try {
const result = await sendReliableOutboxRecord(record, {
requestTimeoutMs: record.requestTimeoutMs ?? requestTimeoutMs,
});
if (result.ok || !result.retryable) {
await markReliableOutboxRecordSent(outboxPath, record.id);
sent += 1;
} else {
await updateReliableOutboxRecordFailure(outboxPath, record.id, result.body);
}
} catch (error) {
await updateReliableOutboxRecordFailure(
outboxPath,
record.id,
error instanceof Error ? error.message : String(error),
);
}
}
const retained = (await readOutboxRecords(outboxPath)).length;
return { attempted, sent, retained, stoppedByBudget };
}

View File

@@ -0,0 +1,330 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createServer } from "node:http";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import os from "node:os";
import { join } from "node:path";
import {
appendReliableOutboxRecord,
postThroughReliableOutbox,
replayReliableOutbox,
resolveReliableOutboxPath,
} from "./reliable-outbox.mjs";
async function withServer(handler, run) {
const server = createServer(handler);
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
const baseUrl = `http://127.0.0.1:${address.port}`;
try {
return await run(baseUrl);
} finally {
await new Promise((resolve) => server.close(resolve));
}
}
async function readRecords(outboxPath) {
const parsed = JSON.parse(await readFile(outboxPath, "utf8"));
return parsed.records;
}
test("postThroughReliableOutbox removes a record after successful send", async () => {
const root = await mkdtemp(join(os.tmpdir(), "boss-outbox-success-"));
const config = { deviceId: "test-device", reliableOutboxPath: join(root, "outbox.json") };
let received = 0;
await withServer((request, response) => {
received += 1;
response.writeHead(200, { "Content-Type": "application/json" });
response.end(JSON.stringify({ ok: true }));
}, async (baseUrl) => {
const result = await postThroughReliableOutbox(config, {
kind: "task.progress",
url: `${baseUrl}/progress`,
headers: { "Content-Type": "application/json" },
body: { ok: true },
});
assert.equal(result.ok, true);
assert.equal(received, 1);
assert.deepEqual(await readRecords(resolveReliableOutboxPath(config)), []);
});
await rm(root, { recursive: true, force: true });
});
test("postThroughReliableOutbox times out stalled requests and keeps them retryable", async () => {
const root = await mkdtemp(join(os.tmpdir(), "boss-outbox-timeout-"));
const config = {
deviceId: "test-device",
reliableOutboxPath: join(root, "outbox.json"),
reliableOutboxRequestTimeoutMs: 20,
};
await withServer(() => {
// Intentionally leave the request open to simulate a stalled network write.
}, async (baseUrl) => {
const started = Date.now();
const result = await postThroughReliableOutbox(config, {
kind: "task.progress",
url: `${baseUrl}/stall`,
headers: { "Content-Type": "application/json" },
body: { ok: true },
});
assert.equal(result.ok, false);
assert.equal(result.retryable, true);
assert.equal(result.status, 0);
assert.match(result.body, /RELIABLE_OUTBOX_SEND_TIMEOUT|aborted/i);
assert.ok(Date.now() - started < 1_000);
assert.equal((await readRecords(resolveReliableOutboxPath(config))).length, 1);
});
await rm(root, { recursive: true, force: true });
});
test("replayReliableOutbox respects a replay duration budget", async () => {
const root = await mkdtemp(join(os.tmpdir(), "boss-outbox-budget-"));
const config = {
deviceId: "test-device",
reliableOutboxPath: join(root, "outbox.json"),
};
await withServer(() => {
// Keep every replay request pending; the replay budget must stop the loop.
}, async (baseUrl) => {
const outboxPath = resolveReliableOutboxPath(config);
for (let index = 0; index < 3; index += 1) {
await appendReliableOutboxRecord(outboxPath, {
kind: "task.progress",
url: `${baseUrl}/stall-${index}`,
body: { index },
});
}
const started = Date.now();
const replay = await replayReliableOutbox(config, {
limit: 3,
requestTimeoutMs: 30,
maxDurationMs: 50,
});
assert.ok(Date.now() - started < 1_000);
assert.ok(replay.attempted >= 1);
assert.ok(replay.attempted < 3);
assert.equal(replay.sent, 0);
assert.equal(replay.retained, 3);
assert.equal(replay.stoppedByBudget, true);
});
await rm(root, { recursive: true, force: true });
});
test("postThroughReliableOutbox retains retryable failures and replay clears them", async () => {
const root = await mkdtemp(join(os.tmpdir(), "boss-outbox-retry-"));
const config = { deviceId: "test-device", reliableOutboxPath: join(root, "outbox.json") };
let fail = true;
let received = 0;
await withServer((request, response) => {
received += 1;
if (fail) {
response.writeHead(503, { "Content-Type": "text/plain" });
response.end("temporary failure");
return;
}
response.writeHead(200, { "Content-Type": "application/json" });
response.end(JSON.stringify({ ok: true }));
}, async (baseUrl) => {
const first = await postThroughReliableOutbox(config, {
kind: "task.complete",
url: `${baseUrl}/complete`,
headers: { "Content-Type": "application/json" },
body: { taskId: "task-1" },
});
assert.equal(first.ok, false);
assert.equal(first.retryable, true);
assert.equal((await readRecords(resolveReliableOutboxPath(config))).length, 1);
fail = false;
const replay = await replayReliableOutbox(config);
assert.equal(replay.attempted, 1);
assert.equal(replay.sent, 1);
assert.equal(replay.retained, 0);
assert.equal(received, 2);
});
await rm(root, { recursive: true, force: true });
});
test("replayReliableOutbox prioritizes task completion over progress and app logs", async () => {
const root = await mkdtemp(join(os.tmpdir(), "boss-outbox-priority-"));
const config = {
deviceId: "test-device",
reliableOutboxPath: join(root, "outbox.json"),
};
const received = [];
await withServer((request, response) => {
received.push(request.url);
response.writeHead(200, { "Content-Type": "application/json" });
response.end(JSON.stringify({ ok: true }));
}, async (baseUrl) => {
const outboxPath = resolveReliableOutboxPath(config);
await appendReliableOutboxRecord(outboxPath, {
kind: "task.progress",
url: `${baseUrl}/progress`,
body: { taskId: "task-2" },
});
await appendReliableOutboxRecord(outboxPath, {
kind: "app.log",
url: `${baseUrl}/app-log`,
body: { category: "noise" },
});
await appendReliableOutboxRecord(outboxPath, {
kind: "task.complete",
url: `${baseUrl}/complete`,
body: { taskId: "task-1" },
});
const replay = await replayReliableOutbox(config, { limit: 1 });
assert.equal(replay.attempted, 1);
assert.deepEqual(received, ["/complete"]);
const retained = await readRecords(outboxPath);
assert.equal(retained.some((record) => record.kind === "task.complete"), false);
assert.equal(retained.some((record) => record.kind === "task.progress"), true);
assert.equal(retained.some((record) => record.kind === "app.log"), true);
});
await rm(root, { recursive: true, force: true });
});
test("reliable outbox compaction preserves pending task completion records before low priority logs", async () => {
const root = await mkdtemp(join(os.tmpdir(), "boss-outbox-compact-priority-"));
const config = {
deviceId: "test-device",
reliableOutboxPath: join(root, "outbox.json"),
};
const outboxPath = resolveReliableOutboxPath(config);
await appendReliableOutboxRecord(outboxPath, {
kind: "task.complete",
url: "http://127.0.0.1/complete",
body: { taskId: "task-1" },
});
for (let index = 0; index < 510; index += 1) {
await appendReliableOutboxRecord(outboxPath, {
kind: "app.log",
url: `http://127.0.0.1/app-log-${index}`,
body: { index },
});
}
const records = await readRecords(outboxPath);
assert.equal(records.length, 121);
assert.equal(records.some((record) => record.kind === "task.complete"), true);
assert.equal(records.filter((record) => record.kind === "app.log").length, 120);
await rm(root, { recursive: true, force: true });
});
test("reliable outbox coalesces repeated task progress records for the same task", async () => {
const root = await mkdtemp(join(os.tmpdir(), "boss-outbox-progress-coalesce-"));
const config = {
deviceId: "test-device",
reliableOutboxPath: join(root, "outbox.json"),
};
const outboxPath = resolveReliableOutboxPath(config);
for (let index = 0; index < 12; index += 1) {
await appendReliableOutboxRecord(outboxPath, {
kind: "task.progress",
url: "http://127.0.0.1/api/v1/master-agent/tasks/task-1/progress",
body: { taskId: "task-1", index },
});
}
const records = await readRecords(outboxPath);
assert.equal(records.length, 1);
assert.equal(records[0].kind, "task.progress");
assert.equal(records[0].body.index, 11);
await rm(root, { recursive: true, force: true });
});
test("reliable outbox caps noisy app logs while retaining completion and latest progress records", async () => {
const root = await mkdtemp(join(os.tmpdir(), "boss-outbox-app-log-cap-"));
const config = {
deviceId: "test-device",
reliableOutboxPath: join(root, "outbox.json"),
};
const outboxPath = resolveReliableOutboxPath(config);
await appendReliableOutboxRecord(outboxPath, {
kind: "task.complete",
url: "http://127.0.0.1/complete",
body: { taskId: "task-1" },
});
for (let index = 0; index < 150; index += 1) {
await appendReliableOutboxRecord(outboxPath, {
kind: "app.log",
url: "http://127.0.0.1/app-log",
body: { index },
});
}
await appendReliableOutboxRecord(outboxPath, {
kind: "task.progress",
url: "http://127.0.0.1/api/v1/master-agent/tasks/task-2/progress",
body: { taskId: "task-2", index: 2 },
});
const records = await readRecords(outboxPath);
const appLogs = records.filter((record) => record.kind === "app.log");
assert.equal(records.some((record) => record.kind === "task.complete"), true);
assert.equal(records.some((record) => record.kind === "task.progress"), true);
assert.equal(appLogs.length, 120);
assert.equal(appLogs.some((record) => record.body.index === 149), true);
assert.equal(appLogs.some((record) => record.body.index === 0), false);
await rm(root, { recursive: true, force: true });
});
test("reliable outbox drops stale progress once a completion for the same task is pending", async () => {
const root = await mkdtemp(join(os.tmpdir(), "boss-outbox-progress-after-complete-"));
const config = {
deviceId: "test-device",
reliableOutboxPath: join(root, "outbox.json"),
};
const outboxPath = resolveReliableOutboxPath(config);
await appendReliableOutboxRecord(outboxPath, {
kind: "task.progress",
url: "http://127.0.0.1/api/v1/master-agent/tasks/task-1/progress",
body: { taskId: "task-1", phase: "awaiting_reply" },
});
await appendReliableOutboxRecord(outboxPath, {
kind: "task.complete",
url: "http://127.0.0.1/api/v1/master-agent/tasks/task-1/complete",
body: { taskId: "task-1", status: "completed" },
});
const records = await readRecords(outboxPath);
assert.equal(records.length, 1);
assert.equal(records[0].kind, "task.complete");
await rm(root, { recursive: true, force: true });
});
test("reliable outbox coalesces duplicate app logs by category and message", async () => {
const root = await mkdtemp(join(os.tmpdir(), "boss-outbox-app-log-coalesce-"));
const config = {
deviceId: "test-device",
reliableOutboxPath: join(root, "outbox.json"),
};
const outboxPath = resolveReliableOutboxPath(config);
for (let index = 0; index < 20; index += 1) {
await appendReliableOutboxRecord(outboxPath, {
kind: "app.log",
url: "http://127.0.0.1/app-log",
body: {
projectId: "project-1",
category: "local_agent.codex_app_server_progress_failed",
message: "Codex App Server 进度实时回写失败,完成回写仍会携带最终进度。",
detail: `attempt-${index}`,
},
});
}
const records = await readRecords(outboxPath);
assert.equal(records.length, 1);
assert.equal(records[0].kind, "app.log");
assert.equal(records[0].body.detail, "attempt-19");
await rm(root, { recursive: true, force: true });
});

View File

@@ -0,0 +1,19 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createSerializedRunner } from "./serialized-runner.mjs";
test("serialized runner releases active task after timeout", async () => {
let calls = 0;
const runner = createSerializedRunner(
() =>
new Promise(() => {
calls += 1;
}),
{ timeoutMs: 10, timeoutErrorMessage: "HEARTBEAT_TIMEOUT" },
);
await assert.rejects(() => runner(), /HEARTBEAT_TIMEOUT/);
await assert.rejects(() => runner(), /HEARTBEAT_TIMEOUT/);
assert.equal(calls, 2);
});

View File

@@ -1,4 +1,4 @@
export function createSerializedRunner(task) {
export function createSerializedRunner(task, options = {}) {
let activePromise = null;
return function runSerialized(...args) {
@@ -6,8 +6,25 @@ export function createSerializedRunner(task) {
return activePromise;
}
activePromise = Promise.resolve(task(...args))
const timeoutMs = Number(options.timeoutMs);
let timeout;
const taskPromise = Promise.resolve(task(...args));
const nextPromise = Number.isFinite(timeoutMs) && timeoutMs > 0
? Promise.race([
taskPromise,
new Promise((_, reject) => {
timeout = setTimeout(() => {
reject(new Error(options.timeoutErrorMessage || "SERIALIZED_RUNNER_TIMEOUT"));
}, timeoutMs);
}),
])
: taskPromise;
activePromise = nextPromise
.finally(() => {
if (timeout) {
clearTimeout(timeout);
}
activePromise = null;
});

View File

@@ -13,6 +13,21 @@ import {
getCodexAppServerRunnerConfig,
shouldUseCodexAppServerTaskRunner,
} from "./codex-app-server-runner.mjs";
import {
shouldSkipCodexAppServerDiscovery,
} from "./codex-app-server-discovery-guard.mjs";
import {
buildLongRunningCodexProgressSnapshot,
normalizeLongRunningProgressIntervalMs,
} from "./master-task-progress-heartbeat.mjs";
import {
resolveHeartbeatProjectsFromSnapshot,
runHeartbeatProjectDiscoveryWithTimeout,
storeHeartbeatProjectsSnapshot,
} from "./heartbeat-project-snapshot.mjs";
import {
recordHeartbeatRunnerError,
} from "./heartbeat-error-state.mjs";
import { appendBossUserMessageToCodexThreadRollout } from "./codex-thread-rollout-writer.mjs";
import {
executeOmxTeamTask,
@@ -65,7 +80,15 @@ import {
buildMasterAgentTaskCompletionRequestBody,
buildRemoteExecutionCompletionPayload,
} from "./master-task-completion.mjs";
import {
postThroughReliableOutbox,
replayReliableOutbox,
} from "./reliable-outbox.mjs";
import {
buildLocalAgentHealthSummary,
} from "./health-summary.mjs";
import { createSerializedRunner } from "./serialized-runner.mjs";
import { fetchWithTimeout } from "./fetch-timeout.mjs";
async function loadConfig(configPath) {
const raw = await readFile(resolve(configPath), "utf8");
@@ -75,6 +98,23 @@ async function loadConfig(configPath) {
async function resolveHeartbeatProjects(config, runtime) {
const staticProjects = Array.isArray(config.projects) ? config.projects : [];
const staticCandidates = Array.isArray(config.projectCandidates) ? config.projectCandidates : [];
const snapshotFallback = runtime.lastHeartbeatProjectsSnapshot && typeof runtime.lastHeartbeatProjectsSnapshot === "object"
? runtime.lastHeartbeatProjectsSnapshot
: {
projects: staticProjects,
projectCandidates: staticCandidates,
guiConnected: runtime.lastCodexGuiConnected === true,
};
const snapshotDecision = resolveHeartbeatProjectsFromSnapshot({ config, runtime });
if (snapshotDecision.shouldUseSnapshot) {
runtime.lastProjectDiscoverySkippedAt = new Date().toISOString();
runtime.lastProjectDiscoverySkipReason = "master_task_running";
return {
projects: snapshotDecision.projects,
projectCandidates: snapshotDecision.projectCandidates,
guiConnected: snapshotDecision.guiConnected,
};
}
if (config.codexSessionDiscoveryEnabled === false) {
return {
projects: staticProjects,
@@ -83,14 +123,38 @@ async function resolveHeartbeatProjects(config, runtime) {
}
try {
const discovered = await discoverCodexProjectCandidatesInWorker({
stateDbPath: config.codexStateDbPath,
logsDbPath: config.codexLogsDbPath,
sessionIndexPath: config.codexSessionIndexPath,
globalStatePath: config.codexGlobalStatePath,
sessionsDir: config.codexSessionsDir,
lookbackHours: config.codexSessionLookbackHours,
const discoveryTimeoutMs = config.codexSessionDiscoveryTimeoutMs ?? 3_500;
const discoveryResult = await runHeartbeatProjectDiscoveryWithTimeout({
timeoutMs: discoveryTimeoutMs,
fallback: snapshotFallback,
discover: () => discoverCodexProjectCandidatesInWorker({
stateDbPath: config.codexStateDbPath,
logsDbPath: config.codexLogsDbPath,
sessionIndexPath: config.codexSessionIndexPath,
globalStatePath: config.codexGlobalStatePath,
sessionsDir: config.codexSessionsDir,
lookbackHours: config.codexSessionLookbackHours,
timeoutMs: discoveryTimeoutMs,
}),
});
if (discoveryResult.error) {
runtime.lastProjectDiscoveryAt = new Date().toISOString();
runtime.lastProjectDiscoveryOk = false;
runtime.lastProjectDiscoverySummary = discoveryResult.error instanceof Error
? discoveryResult.error.message
: String(discoveryResult.error);
runtime.lastCodexGuiConnected = discoveryResult.value.guiConnected === true;
postAppLog(config, runtime, {
level: "warning",
category: "local_agent.codex_discovery_degraded",
message: "Codex 线程扫描超时或失败,已使用缓存项目继续心跳。",
detail: runtime.lastProjectDiscoverySummary,
mirrorToMaster: false,
}).catch(() => null);
return discoveryResult.value;
}
const discovered = discoveryResult.value;
const candidateMap = new Map();
for (const candidate of [...staticCandidates, ...discovered.projectCandidates]) {
candidateMap.set(candidate.codexThreadRef ?? candidate.threadId, candidate);
@@ -101,22 +165,24 @@ async function resolveHeartbeatProjects(config, runtime) {
runtime.lastProjectDiscoveryOk = true;
runtime.lastProjectDiscoverySummary = `${mergedCandidates.length} threads / ${mergedProjects.length} folders`;
runtime.lastCodexGuiConnected = discovered.guiConnected === true;
return {
const heartbeatProjects = {
projects: mergedProjects,
projectCandidates: mergedCandidates,
guiConnected: discovered.guiConnected === true,
};
storeHeartbeatProjectsSnapshot(runtime, heartbeatProjects);
return heartbeatProjects;
} catch (error) {
runtime.lastProjectDiscoveryAt = new Date().toISOString();
runtime.lastProjectDiscoveryOk = false;
runtime.lastProjectDiscoverySummary = error instanceof Error ? error.message : String(error);
await postAppLog(config, runtime, {
postAppLog(config, runtime, {
level: "error",
category: "local_agent.codex_discovery_failed",
message: "Codex 线程扫描失败,已退回静态项目配置。",
detail: runtime.lastProjectDiscoverySummary,
mirrorToMaster: true,
});
}).catch(() => null);
return {
projects: staticProjects,
projectCandidates: staticCandidates,
@@ -252,53 +318,60 @@ async function postHeartbeat(config, runtime, heartbeatProjects) {
...mergedProjectCandidates.map((candidate) => candidate.folderName).filter(Boolean),
]),
];
const response = await fetch(`${config.controlPlaneUrl.replace(/\/$/, "")}/api/device-heartbeat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
deviceId: config.deviceId,
token: runtime.issuedToken ?? config.token,
pairingCode: runtime.issuedToken ? undefined : config.pairingCode,
name: config.name,
avatar: config.avatar,
account: config.account,
status: config.status,
quota5h: config.quota5h,
quota7d: config.quota7d,
capabilities: {
gui: {
connected: guiConnected,
lastSeenAt: now,
lastActiveProjectId: "",
const response = await fetchWithTimeout(
`${config.controlPlaneUrl.replace(/\/$/, "")}/api/device-heartbeat`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
deviceId: config.deviceId,
token: runtime.issuedToken ?? config.token,
pairingCode: runtime.issuedToken ? undefined : config.pairingCode,
name: config.name,
avatar: config.avatar,
account: config.account,
status: config.status,
quota5h: config.quota5h,
quota7d: config.quota7d,
capabilities: {
gui: {
connected: guiConnected,
lastSeenAt: now,
lastActiveProjectId: "",
},
cli: {
connected: config.cliConnected !== false,
lastSeenAt: now,
lastActiveProjectId: "",
},
browserAutomation: {
connected: config.browserAutomationConnected !== false || Boolean(browserControlRuntime.enabled && browserControlRuntime.command),
lastSeenAt: now,
lastActiveProjectId: "",
},
computerUse: {
connected: computerUseConnected,
lastSeenAt: now,
lastActiveProjectId: "",
},
codexAppServer: {
connected: codexAppServerConnected,
lastSeenAt: now,
lastActiveProjectId: "",
metadata: codexAppServerMetadata,
},
},
cli: {
connected: config.cliConnected !== false,
lastSeenAt: now,
lastActiveProjectId: "",
},
browserAutomation: {
connected: config.browserAutomationConnected !== false || Boolean(browserControlRuntime.enabled && browserControlRuntime.command),
lastSeenAt: now,
lastActiveProjectId: "",
},
computerUse: {
connected: computerUseConnected,
lastSeenAt: now,
lastActiveProjectId: "",
},
codexAppServer: {
connected: codexAppServerConnected,
lastSeenAt: now,
lastActiveProjectId: "",
metadata: codexAppServerMetadata,
},
},
preferredExecutionMode,
projects: mergedProjects,
projectCandidates: mergedProjectCandidates,
endpoint: config.endpoint,
}),
});
preferredExecutionMode,
projects: mergedProjects,
projectCandidates: mergedProjectCandidates,
endpoint: config.endpoint,
}),
},
{
timeoutMs: config.heartbeatPostTimeoutMs ?? 4_000,
timeoutMessage: "LOCAL_AGENT_HEARTBEAT_POST_TIMEOUT",
},
);
const text = await response.text();
let json = null;
@@ -379,11 +452,48 @@ async function resolveCodexAppServerCapabilityConnected(codexAppServerRuntime) {
return canExecuteCommand(codexAppServerRuntime.command, codexAppServerRuntime.cwd || process.cwd());
}
function refreshCodexAppServerCapabilityMetadataInBackground(config, runtime, codexAppServerRuntime, now) {
if (runtime.codexAppServerCapabilityMetadataRefreshBusy) {
return;
}
runtime.codexAppServerCapabilityMetadataRefreshBusy = true;
runtime.codexAppServerCapabilityMetadataRefreshStartedAt = new Date(now).toISOString();
void (async () => {
try {
const metadata = await discoverCodexAppServerCapabilities(codexAppServerRuntime);
runtime.codexAppServerCapabilityMetadata = metadata;
runtime.codexAppServerCapabilityMetadataAtMs = Date.now();
runtime.codexAppServerCapabilityMetadataError = "";
runtime.codexAppServerCapabilityMetadataRefreshCompletedAt = new Date().toISOString();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
runtime.codexAppServerCapabilityMetadataError = message;
runtime.codexAppServerCapabilityMetadataRefreshFailedAt = new Date().toISOString();
await postAppLog(config, runtime, {
level: "warn",
category: "local_agent.codex_app_server_capability_discovery_failed",
message: "Codex App Server 能力清单发现失败,设备心跳继续上报连接状态。",
detail: message,
mirrorToMaster: false,
});
} finally {
runtime.codexAppServerCapabilityMetadataRefreshBusy = false;
}
})();
}
async function resolveCodexAppServerCapabilityMetadata(config, runtime, codexAppServerRuntime, connected) {
if (!connected || !codexAppServerRuntime?.enabled || codexAppServerRuntime.discoveryEnabled === false) {
return undefined;
}
const now = Date.now();
const discoveryGuard = shouldSkipCodexAppServerDiscovery({ config, runtime });
if (discoveryGuard.skip) {
runtime.codexAppServerCapabilityMetadataSkippedAt = new Date(now).toISOString();
runtime.codexAppServerCapabilityMetadataSkipReason = discoveryGuard.reason;
runtime.codexAppServerCapabilityMetadataSkipTaskId = discoveryGuard.activeTaskId;
return runtime.codexAppServerCapabilityMetadata;
}
const ttlMs = codexAppServerRuntime.discoveryTtlMs ?? 300_000;
if (
runtime.codexAppServerCapabilityMetadata &&
@@ -393,24 +503,31 @@ async function resolveCodexAppServerCapabilityMetadata(config, runtime, codexApp
return runtime.codexAppServerCapabilityMetadata;
}
try {
const metadata = await discoverCodexAppServerCapabilities(codexAppServerRuntime);
runtime.codexAppServerCapabilityMetadata = metadata;
runtime.codexAppServerCapabilityMetadataAtMs = now;
runtime.codexAppServerCapabilityMetadataError = "";
return metadata;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
runtime.codexAppServerCapabilityMetadataError = message;
await postAppLog(config, runtime, {
level: "warn",
category: "local_agent.codex_app_server_capability_discovery_failed",
message: "Codex App Server 能力清单发现失败,设备心跳继续上报连接状态。",
detail: message,
mirrorToMaster: false,
});
return runtime.codexAppServerCapabilityMetadata;
if (config.codexAppServerDiscoveryInlineInHeartbeat === true) {
try {
const metadata = await discoverCodexAppServerCapabilities(codexAppServerRuntime);
runtime.codexAppServerCapabilityMetadata = metadata;
runtime.codexAppServerCapabilityMetadataAtMs = now;
runtime.codexAppServerCapabilityMetadataError = "";
return metadata;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
runtime.codexAppServerCapabilityMetadataError = message;
postAppLog(config, runtime, {
level: "warn",
category: "local_agent.codex_app_server_capability_discovery_failed",
message: "Codex App Server 能力清单发现失败,设备心跳继续上报连接状态。",
detail: message,
mirrorToMaster: false,
}).catch(() => null);
return runtime.codexAppServerCapabilityMetadata;
}
}
refreshCodexAppServerCapabilityMetadataInBackground(config, runtime, codexAppServerRuntime, now);
runtime.codexAppServerCapabilityMetadataSkippedAt = new Date(now).toISOString();
runtime.codexAppServerCapabilityMetadataSkipReason = "background_refresh";
return runtime.codexAppServerCapabilityMetadata;
}
function deviceTokenHeaders(config, runtime) {
@@ -420,7 +537,7 @@ function deviceTokenHeaders(config, runtime) {
async function postThreadContext(config, runtime, snapshot) {
const workerId = snapshot.workerId ?? config.workerId ?? `${config.deviceId}-worker`;
const response = await fetch(
const response = await fetchWithTimeout(
`${config.controlPlaneUrl.replace(/\/$/, "")}/api/v1/workers/${workerId}/thread-context`,
{
method: "POST",
@@ -452,6 +569,10 @@ async function postThreadContext(config, runtime, snapshot) {
capturedAt: new Date().toISOString(),
}),
},
{
timeoutMs: config.threadContextPostTimeoutMs ?? 3_000,
timeoutMessage: "LOCAL_AGENT_THREAD_CONTEXT_POST_TIMEOUT",
},
);
return {
@@ -525,7 +646,7 @@ async function discoverSkills(config) {
}
async function postSkills(config, runtime, skills) {
const response = await fetch(
const response = await fetchWithTimeout(
`${config.controlPlaneUrl.replace(/\/$/, "")}/api/v1/devices/${config.deviceId}/skills`,
{
method: "POST",
@@ -535,6 +656,10 @@ async function postSkills(config, runtime, skills) {
},
body: JSON.stringify({ skills }),
},
{
timeoutMs: config.skillsPostTimeoutMs ?? 3_000,
timeoutMessage: "LOCAL_AGENT_SKILLS_POST_TIMEOUT",
},
);
return {
@@ -547,17 +672,18 @@ async function postSkills(config, runtime, skills) {
async function postAppLog(config, runtime, payload) {
try {
await fetch(`${config.controlPlaneUrl.replace(/\/$/, "")}/api/v1/app-logs`, {
method: "POST",
await postThroughReliableOutbox(config, {
kind: "app.log",
url: `${config.controlPlaneUrl.replace(/\/$/, "")}/api/v1/app-logs`,
headers: {
"Content-Type": "application/json",
...deviceTokenHeaders(config, runtime),
},
body: JSON.stringify({
body: {
deviceId: config.deviceId,
source: "local_agent",
...payload,
}),
},
});
} catch {
// Ignore log transport failures to avoid blocking the agent loop.
@@ -571,7 +697,7 @@ async function claimMasterAgentTask(config, runtime) {
const waitMs = Number.isFinite(configuredWaitMs)
? Math.max(0, Math.min(30_000, Math.floor(configuredWaitMs)))
: 25_000;
const response = await fetch(
const response = await fetchWithTimeout(
`${config.controlPlaneUrl.replace(/\/$/, "")}/api/v1/master-agent/tasks/claim`,
{
method: "POST",
@@ -581,6 +707,10 @@ async function claimMasterAgentTask(config, runtime) {
},
body: JSON.stringify({ deviceId: config.deviceId, waitMs }),
},
{
timeoutMs: waitMs + Number(config.masterAgentClaimTimeoutPaddingMs ?? 5_000),
timeoutMessage: "LOCAL_AGENT_MASTER_TASK_CLAIM_TIMEOUT",
},
);
return {
@@ -591,52 +721,41 @@ async function claimMasterAgentTask(config, runtime) {
}
async function completeMasterAgentTask(config, runtime, payload) {
const response = await fetch(
`${config.controlPlaneUrl.replace(/\/$/, "")}/api/v1/master-agent/tasks/${payload.taskId}/complete`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
...deviceTokenHeaders(config, runtime),
},
body: JSON.stringify(buildMasterAgentTaskCompletionRequestBody(config, payload)),
const result = await postThroughReliableOutbox(config, {
kind: "task.complete",
url: `${config.controlPlaneUrl.replace(/\/$/, "")}/api/v1/master-agent/tasks/${payload.taskId}/complete`,
headers: {
"Content-Type": "application/json",
...deviceTokenHeaders(config, runtime),
},
);
body: buildMasterAgentTaskCompletionRequestBody(config, payload),
});
return {
ok: response.ok,
status: response.status,
body: await response.text(),
};
return result;
}
async function postMasterAgentTaskProgress(config, runtime, payload) {
const response = await fetch(
`${config.controlPlaneUrl.replace(/\/$/, "")}/api/v1/master-agent/tasks/${payload.taskId}/progress`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
...deviceTokenHeaders(config, runtime),
},
body: JSON.stringify({
deviceId: config.deviceId,
status: payload.status || "running",
requestId: payload.requestId,
executionProgress: payload.executionProgress,
}),
const result = await postThroughReliableOutbox(config, {
kind: "task.progress",
url: `${config.controlPlaneUrl.replace(/\/$/, "")}/api/v1/master-agent/tasks/${payload.taskId}/progress`,
headers: {
"Content-Type": "application/json",
...deviceTokenHeaders(config, runtime),
},
);
body: {
deviceId: config.deviceId,
status: payload.status || "running",
phase: payload.phase,
requestId: payload.requestId,
executionProgress: payload.executionProgress,
},
});
return {
ok: response.ok,
status: response.status,
body: await response.text(),
};
return result;
}
async function fetchMasterAgentTaskControlState(config, runtime, task) {
const response = await fetch(
const response = await fetchWithTimeout(
`${config.controlPlaneUrl.replace(/\/$/, "")}/api/v1/master-agent/tasks/${task.taskId}/control-state`,
{
method: "GET",
@@ -644,6 +763,10 @@ async function fetchMasterAgentTaskControlState(config, runtime, task) {
...deviceTokenHeaders(config, runtime),
},
},
{
timeoutMs: config.masterAgentControlStateTimeoutMs ?? 3_000,
timeoutMessage: "LOCAL_AGENT_MASTER_TASK_CONTROL_STATE_TIMEOUT",
},
);
if (!response.ok) {
return {
@@ -685,7 +808,7 @@ function buildCodexRemoteControlMaintenanceReply(task, result) {
}
async function claimSkillLifecycleRequest(config, runtime) {
const response = await fetch(
const response = await fetchWithTimeout(
`${config.controlPlaneUrl.replace(/\/$/, "")}/api/v1/devices/${config.deviceId}/skill-requests/claim`,
{
method: "POST",
@@ -695,6 +818,10 @@ async function claimSkillLifecycleRequest(config, runtime) {
},
body: JSON.stringify({ deviceId: config.deviceId }),
},
{
timeoutMs: config.skillLifecycleClaimTimeoutMs ?? 5_000,
timeoutMessage: "LOCAL_AGENT_SKILL_REQUEST_CLAIM_TIMEOUT",
},
);
return {
@@ -705,7 +832,7 @@ async function claimSkillLifecycleRequest(config, runtime) {
}
async function completeSkillLifecycleRequest(config, runtime, request, result) {
const response = await fetch(
const response = await fetchWithTimeout(
`${config.controlPlaneUrl.replace(/\/$/, "")}/api/v1/devices/${config.deviceId}/skill-requests/${request.requestId}/complete`,
{
method: "POST",
@@ -719,6 +846,10 @@ async function completeSkillLifecycleRequest(config, runtime, request, result) {
error: result.error,
}),
},
{
timeoutMs: config.skillLifecycleCompleteTimeoutMs ?? 5_000,
timeoutMessage: "LOCAL_AGENT_SKILL_REQUEST_COMPLETE_TIMEOUT",
},
);
return {
@@ -897,9 +1028,60 @@ async function runMasterAgentTask(config, runtime, task) {
status: "running",
startedAt: new Date().toISOString(),
};
const emitTaskPhase = async (phase, executionProgress) => {
try {
const result = await postMasterAgentTaskProgress(config, runtime, {
taskId: task.taskId,
status: "running",
phase,
executionProgress: {
...(executionProgress || {}),
phase,
},
});
return result;
} catch (error) {
return {
ok: false,
status: 0,
body: error instanceof Error ? error.message : String(error),
};
}
};
const createLongRunningProgressHeartbeat = ({ phase = "awaiting_reply", getProgress } = {}) => {
const intervalMs = normalizeLongRunningProgressIntervalMs(
config.masterAgentLongTaskProgressIntervalMs ?? config.masterAgentProgressHeartbeatIntervalMs,
);
if (intervalMs <= 0) {
return () => {};
}
const startedAtMs = Date.now();
let heartbeatCount = 0;
const sendHeartbeat = async () => {
heartbeatCount += 1;
await emitTaskPhase(
phase,
buildLongRunningCodexProgressSnapshot({
task,
phase,
startedAtMs,
nowMs: Date.now(),
baseProgress: typeof getProgress === "function" ? getProgress() : undefined,
heartbeatCount,
}),
);
};
const timer = setInterval(() => {
void sendHeartbeat();
}, intervalMs);
return () => {
clearInterval(timer);
};
};
try {
let activeChild = null;
await emitTaskPhase("executor_starting");
const executionResult = await (async () => {
if (canHandleCodexRemoteControlMaintenanceTask(task)) {
const daemonResult = await runCodexRemoteControlDaemonAction(
@@ -966,43 +1148,55 @@ async function runMasterAgentTask(config, runtime, task) {
const codexAppServerRunner = getCodexAppServerRunnerConfig(process.env, config);
if (shouldUseCodexAppServerTaskRunner(codexAppServerRunner, task)) {
const appServerResult = await executeCodexAppServerTask(
{
...codexAppServerRunner,
interruptPollIntervalMs: normalizeInterruptPollIntervalMs(config),
shouldInterruptActiveTurn: async () => {
const controlState = await fetchMasterAgentTaskControlState(config, runtime, task);
if (!controlState.ok) {
let latestCodexExecutionProgress;
const stopLongRunningProgressHeartbeat = createLongRunningProgressHeartbeat({
phase: "awaiting_reply",
getProgress: () => latestCodexExecutionProgress,
});
let appServerResult;
try {
appServerResult = await executeCodexAppServerTask(
{
...codexAppServerRunner,
interruptPollIntervalMs: normalizeInterruptPollIntervalMs(config),
shouldInterruptActiveTurn: async () => {
const controlState = await fetchMasterAgentTaskControlState(config, runtime, task);
if (!controlState.ok) {
return false;
}
if (controlState.body?.canceled === true || controlState.body?.status === "canceled") {
return {
interrupt: true,
reason: controlState.body?.cancelReason || "USER_CANCELED_TASK",
};
}
return false;
}
if (controlState.body?.canceled === true || controlState.body?.status === "canceled") {
return {
interrupt: true,
reason: controlState.body?.cancelReason || "USER_CANCELED_TASK",
};
}
return false;
},
onProgress: async (executionProgress) => {
const progressResult = await postMasterAgentTaskProgress(config, runtime, {
taskId: task.taskId,
status: "running",
executionProgress,
});
if (!progressResult.ok) {
await postAppLog(config, runtime, {
projectId: task.projectId,
level: "warn",
category: "local_agent.codex_app_server_progress_failed",
message: "Codex App Server 进度实时回写失败,完成回写仍会携带最终进度。",
detail: progressResult.body,
mirrorToMaster: false,
},
onProgress: async (executionProgress) => {
latestCodexExecutionProgress = executionProgress;
const progressResult = await postMasterAgentTaskProgress(config, runtime, {
taskId: task.taskId,
status: "running",
phase: "awaiting_reply",
executionProgress,
});
}
if (!progressResult.ok) {
await postAppLog(config, runtime, {
projectId: task.projectId,
level: "warn",
category: "local_agent.codex_app_server_progress_failed",
message: "Codex App Server 进度实时回写失败,完成回写仍会携带最终进度。",
detail: progressResult.body,
mirrorToMaster: false,
});
}
},
},
},
task,
);
task,
);
} finally {
stopLongRunningProgressHeartbeat();
}
if (appServerResult.status === "interrupted") {
return {
interruptedCompletion: {
@@ -1114,6 +1308,7 @@ async function runMasterAgentTask(config, runtime, task) {
},
async () =>
await new Promise((resolveTask, rejectTask) => {
void emitTaskPhase("turn_started");
const child = spawn("codex", codexExecution.args, {
cwd: codexExecution.cwd,
env: process.env,
@@ -1193,6 +1388,7 @@ async function runMasterAgentTask(config, runtime, task) {
return;
}
const { replyBody, dispatchExecutionCompletion, executionProgress } = executionResult;
await emitTaskPhase("completing", executionProgress);
const completion = await completeMasterAgentTask(
config,
@@ -1210,6 +1406,24 @@ async function runMasterAgentTask(config, runtime, task) {
executionProgress,
}),
);
if (!completion.ok) {
await emitTaskPhase("completing", {
...(executionProgress && typeof executionProgress === "object" ? executionProgress : {}),
title: "结果已生成,正在同步",
warnings: [
...(
Array.isArray(executionProgress?.warnings)
? executionProgress.warnings.filter(Boolean).slice(0, 6)
: []
),
{
id: "task-complete-sync-retrying",
severity: "warning",
message: "本机已生成任务结果,正在重试同步到 Boss 对话窗口。",
},
],
});
}
runtime.activeMasterTask = {
taskId: task.taskId,
status: completion.ok ? "completed" : "complete_failed",
@@ -1218,10 +1432,14 @@ async function runMasterAgentTask(config, runtime, task) {
};
await postAppLog(config, runtime, {
projectId: "master-agent",
level: "info",
category: "local_agent.master_agent_task_completed",
message: `Master Codex Node 已完成主 Agent 任务 ${task.taskId}`,
detail: replyBody.slice(0, 280),
level: completion.ok ? "info" : "warn",
category: completion.ok
? "local_agent.master_agent_task_completed"
: "local_agent.master_agent_task_completion_sync_retrying",
message: completion.ok
? `Master Codex Node 已完成主 Agent 任务 ${task.taskId}`
: `Master Codex Node 已生成结果,正在重试同步主 Agent 任务 ${task.taskId}`,
detail: completion.ok ? replyBody.slice(0, 280) : completion.body,
mirrorToMaster: false,
});
} catch (error) {
@@ -1440,10 +1658,124 @@ const runtime = {
lastProjectDiscoveryAt: null,
lastProjectDiscoveryOk: false,
lastProjectDiscoverySummary: null,
lastReliableOutboxReplay: null,
};
function replayReliableOutboxInBackground(config, runtime) {
if (runtime.reliableOutboxReplayBusy) {
return;
}
runtime.reliableOutboxReplayBusy = true;
runtime.lastReliableOutboxReplayStartedAt = new Date().toISOString();
void replayReliableOutbox(config, {
limit: config.heartbeatOutboxReplayLimit ?? 5,
requestTimeoutMs: config.heartbeatOutboxRequestTimeoutMs ?? 1_000,
maxDurationMs: config.heartbeatOutboxReplayBudgetMs ?? 2_500,
})
.then((result) => {
runtime.lastReliableOutboxReplay = result;
runtime.lastReliableOutboxReplayAt = new Date().toISOString();
})
.catch((error) => {
runtime.lastReliableOutboxReplay = {
attempted: 0,
sent: 0,
retained: 0,
stoppedByBudget: false,
error: error instanceof Error ? error.message : String(error),
};
runtime.lastReliableOutboxReplayAt = new Date().toISOString();
})
.finally(() => {
runtime.reliableOutboxReplayBusy = false;
});
}
function syncThreadContextsInBackground(config, runtime, snapshots) {
if (runtime.threadContextSyncBusy || !Array.isArray(snapshots) || snapshots.length === 0) {
return;
}
runtime.threadContextSyncBusy = true;
runtime.lastThreadContextSyncStartedAt = new Date().toISOString();
void (async () => {
const results = [];
for (const snapshot of snapshots) {
let threadResult;
try {
threadResult = await postThreadContext(config, runtime, snapshot);
} catch (error) {
threadResult = {
ok: false,
status: 0,
body: error instanceof Error ? error.message : String(error),
workerId: snapshot.workerId ?? config.workerId ?? `${config.deviceId}-worker`,
threadId: snapshot.threadId,
};
}
results.push(threadResult);
if (!threadResult.ok) {
postAppLog(config, runtime, {
projectId: snapshot.projectId,
level: "error",
category: "local_agent.thread_context_failed",
message: `线程预算上报失败:${snapshot.threadId}`,
detail: threadResult.body,
mirrorToMaster: true,
}).catch(() => null);
}
}
runtime.lastThreadContextResults = results;
runtime.lastThreadContextSyncAt = new Date().toISOString();
})()
.catch((error) => {
runtime.lastThreadContextResults = [{
ok: false,
status: 0,
body: error instanceof Error ? error.message : String(error),
}];
runtime.lastThreadContextSyncAt = new Date().toISOString();
})
.finally(() => {
runtime.threadContextSyncBusy = false;
});
}
function syncSkillsInBackground(config, runtime) {
if (runtime.skillSyncBusy) {
return;
}
runtime.skillSyncBusy = true;
runtime.lastSkillSyncStartedAt = new Date().toISOString();
void (async () => {
const skills = await discoverSkills(config);
runtime.lastSkills = skills;
const skillSyncResult = await postSkills(config, runtime, skills);
runtime.lastSkillSyncAt = new Date().toISOString();
runtime.lastSkillSyncOk = skillSyncResult.ok;
runtime.lastSkillSyncStatus = skillSyncResult.status;
runtime.lastSkillSyncBody = skillSyncResult.body;
})()
.catch((error) => {
runtime.lastSkillSyncAt = new Date().toISOString();
runtime.lastSkillSyncOk = false;
runtime.lastSkillSyncStatus = 0;
runtime.lastSkillSyncBody = error instanceof Error ? error.message : String(error);
postAppLog(config, runtime, {
level: "error",
category: "local_agent.skills_sync_failed",
message: "Skill 扫描或同步失败。",
detail: runtime.lastSkillSyncBody,
mirrorToMaster: true,
}).catch(() => null);
})
.finally(() => {
runtime.skillSyncBusy = false;
});
}
async function performHeartbeat() {
try {
replayReliableOutboxInBackground(config, runtime);
const heartbeatProjects = await resolveHeartbeatProjects(config, runtime);
const result = await postHeartbeat(config, runtime, heartbeatProjects);
runtime.lastHeartbeatAt = new Date().toISOString();
@@ -1464,43 +1796,8 @@ async function performHeartbeat() {
}
const snapshots = Array.isArray(config.threadContexts) ? config.threadContexts : [];
runtime.lastThreadContextResults = [];
for (const snapshot of snapshots) {
const threadResult = await postThreadContext(config, runtime, snapshot);
runtime.lastThreadContextResults.push(threadResult);
if (!threadResult.ok) {
await postAppLog(config, runtime, {
projectId: snapshot.projectId,
level: "error",
category: "local_agent.thread_context_failed",
message: `线程预算上报失败:${snapshot.threadId}`,
detail: threadResult.body,
mirrorToMaster: true,
});
}
}
try {
const skills = await discoverSkills(config);
runtime.lastSkills = skills;
const skillSyncResult = await postSkills(config, runtime, skills);
runtime.lastSkillSyncAt = new Date().toISOString();
runtime.lastSkillSyncOk = skillSyncResult.ok;
runtime.lastSkillSyncStatus = skillSyncResult.status;
runtime.lastSkillSyncBody = skillSyncResult.body;
} catch (error) {
runtime.lastSkillSyncAt = new Date().toISOString();
runtime.lastSkillSyncOk = false;
runtime.lastSkillSyncStatus = 0;
runtime.lastSkillSyncBody = error instanceof Error ? error.message : String(error);
await postAppLog(config, runtime, {
level: "error",
category: "local_agent.skills_sync_failed",
message: "Skill 扫描或同步失败。",
detail: runtime.lastSkillSyncBody,
mirrorToMaster: true,
});
}
syncThreadContextsInBackground(config, runtime, snapshots);
syncSkillsInBackground(config, runtime);
} catch (error) {
runtime.lastHeartbeatAt = new Date().toISOString();
runtime.lastHeartbeatOk = false;
@@ -1516,7 +1813,10 @@ async function performHeartbeat() {
}
}
const heartbeat = createSerializedRunner(performHeartbeat);
const heartbeat = createSerializedRunner(performHeartbeat, {
timeoutMs: config.heartbeatTimeoutMs ?? 12_000,
timeoutErrorMessage: "LOCAL_AGENT_HEARTBEAT_TIMEOUT",
});
const masterTaskPoll = createSerializedRunner(async () => {
await pollMasterAgentTasks(config, runtime);
});
@@ -1646,13 +1946,19 @@ const server = createServer(async (request, response) => {
if (requestUrl.pathname === "/health") {
response.writeHead(200, { "Content-Type": "application/json" });
response.end(
JSON.stringify({
if (requestUrl.searchParams.get("verbose") === "1") {
response.end(
JSON.stringify({
ok: true,
service: "boss-local-agent",
runtime,
}),
);
return;
}
response.end(
JSON.stringify(buildLocalAgentHealthSummary(config, runtime)),
);
return;
}
@@ -1681,9 +1987,11 @@ const server = createServer(async (request, response) => {
}
if (requestUrl.pathname === "/api/v1/heartbeat" && request.method === "POST") {
await heartbeat();
await heartbeat().catch((error) => {
recordHeartbeatRunnerError(runtime, error);
});
response.writeHead(200, { "Content-Type": "application/json" });
response.end(JSON.stringify({ ok: runtime.lastHeartbeatOk, runtime }));
response.end(JSON.stringify(buildLocalAgentHealthSummary(config, runtime)));
return;
}
@@ -1704,14 +2012,18 @@ server.listen(config.port, config.bindHost, () => {
});
void (async () => {
await heartbeat();
await heartbeat().catch((error) => {
recordHeartbeatRunnerError(runtime, error);
});
await masterTaskPoll();
await skillLifecyclePoll();
await bossAgentOtaPoll();
})();
setInterval(() => {
void heartbeat();
void heartbeat().catch((error) => {
recordHeartbeatRunnerError(runtime, error);
});
}, config.heartbeatIntervalMs ?? 15000);
setInterval(() => {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Boss 企业后台</title>
<script type="module" crossorigin src="/admin-web/assets/index-BBKOTElI.js"></script>
<link rel="stylesheet" crossorigin href="/admin-web/assets/index-BVg8rLlq.css">
<script type="module" crossorigin src="/admin-web/assets/index-vP7xEOHK.js"></script>
<link rel="stylesheet" crossorigin href="/admin-web/assets/index-wJJgTNei.css">
</head>
<body>
<div id="app"></div>

View File

@@ -1,10 +1,10 @@
{
"fileName": "boss-android-v2.5.11-release.apk",
"fileName": "boss-android-v2.5.11-debug.apk",
"urlPath": "/api/v1/user/ota/package",
"sizeBytes": 3425840,
"updatedAt": "2026-05-16T06:41:29Z",
"sha256": "354b2c5f62273851abcc00a25719e09aacc31bf10c781d6c3a5e1c57933ea7ba",
"sizeBytes": 5210259,
"updatedAt": "2026-06-07T12:45:03Z",
"sha256": "9811fdd762dced4d5db36aae85d04ad3f9e2a4dbe31f103faeaf8105afe732ed",
"versionName": "2.5.11",
"versionCode": 24,
"buildFlavor": "release"
"buildFlavor": "debug"
}

View File

@@ -5,6 +5,7 @@ import { BOSS_PERMISSION_TEMPLATES } from "@/lib/boss-access-templates";
import { buildAdminOverview } from "@/lib/boss-admin-overview";
import { readState, type BossState } from "@/lib/boss-data";
import { getBossStateBackupStatus, type BossStateBackupStatus } from "@/lib/boss-state-backups";
import { buildMasterAgentTaskSlaRows, type MasterAgentTaskSlaLevel } from "@/lib/master-agent-task-sla";
const PLATFORM_MENU_TREE = [
{ key: "platform-overview", label: "平台总览" },
@@ -286,35 +287,28 @@ function canRecoverActiveTask(task: BossState["masterAgentTasks"][number]) {
function buildTaskRiskSummary(state: BossState) {
const activeStatuses = new Set(["queued", "running", "needs_user_action"]);
const activeTasks = state.masterAgentTasks.filter((task) => activeStatuses.has(task.status));
const rows = activeTasks
.map((task) => {
const activeAt = task.lastProgressAt ?? task.claimedAt ?? task.requestedAt;
const ageMinutes = minutesSince(activeAt);
const stale = ageMinutes !== null && ageMinutes > 10;
const recoverable = canRecoverActiveTask(task);
return {
taskId: task.taskId,
projectId: task.projectId,
deviceId: task.deviceId,
status: task.status,
phase: task.phase ?? task.status,
stale,
recoverable,
lastProgressAt: task.lastProgressAt ?? "",
summary: task.requestText || task.errorMessage || task.taskType,
};
})
.sort((left, right) => Number(right.stale) - Number(left.stale) || right.taskId.localeCompare(left.taskId))
const rows = buildMasterAgentTaskSlaRows(state)
.filter((row) => activeStatuses.has(row.status))
.map((row) => ({
taskId: row.taskId,
projectId: row.projectId,
deviceId: row.deviceId,
status: row.status,
phase: row.phase,
stale: row.stale,
recoverable: row.recoverable || canRecoverActiveTask(state.masterAgentTasks.find((task) => task.taskId === row.taskId)!),
lastProgressAt: row.lastProgressAt,
summary: row.summary,
slaLevel: row.slaLevel,
slaDueAt: row.slaDueAt,
recommendedAction: row.recommendedAction,
}))
.slice(0, 20);
return {
counts: {
active: activeTasks.length,
stale: activeTasks.filter((task) => {
const activeAt = task.lastProgressAt ?? task.claimedAt ?? task.requestedAt;
const ageMinutes = minutesSince(activeAt);
return ageMinutes !== null && ageMinutes > 10;
}).length,
stale: rows.filter((row) => row.stale).length,
recoverable: activeTasks.filter(canRecoverActiveTask).length,
needsUserAction: activeTasks.filter((task) => task.status === "needs_user_action" || task.phase === "needs_user_action").length,
},
@@ -322,6 +316,25 @@ function buildTaskRiskSummary(state: BossState) {
};
}
function buildTaskSlaPanel(state: BossState) {
const rows = buildMasterAgentTaskSlaRows(state).slice(0, 50);
const countByLevel = (level: MasterAgentTaskSlaLevel) => rows.filter((row) => row.slaLevel === level).length;
return {
generatedAt: new Date().toISOString(),
summary: {
total: rows.length,
active: rows.filter((row) => row.status === "queued" || row.status === "running" || row.status === "needs_user_action").length,
ok: countByLevel("ok"),
watch: countByLevel("watch"),
breached: countByLevel("breached"),
recoverable: countByLevel("recoverable"),
terminal: countByLevel("terminal"),
autoRecoverable: rows.filter((row) => row.autoRecoverable).length,
},
rows,
};
}
function buildBackofficeInsights(state: BossState, options: { surface: BackofficeSurface; backupStatus: BossStateBackupStatus }) {
const overview = buildAdminOverview(state);
const devices = state.devices;
@@ -378,6 +391,10 @@ function buildBackofficeInsights(state: BossState, options: { surface: Backoffic
label: "主 Agent 执行失败",
value: riskAggregateValue(overview.risks, (risk) => risk.kind === "master_agent_task_failed"),
},
{
label: "任务 SLA 告警",
value: riskAggregateValue(overview.risks, (risk) => risk.kind === "master_agent_task_sla"),
},
{
label: "Computer Use 权限缺失",
value: riskAggregateValue(overview.risks, (risk) => /Computer Use|权限/.test(risk.title)),
@@ -434,6 +451,7 @@ function buildBackofficeInsights(state: BossState, options: { surface: Backoffic
},
dataSafetySummary: buildDataSafetySummary(options.backupStatus),
taskRiskSummary: buildTaskRiskSummary(state),
taskSlaPanel: buildTaskSlaPanel(state),
capabilitySummary: {
guiReady,
cliReady,

View File

@@ -3,6 +3,7 @@ import { jsonNoStore } from "@/lib/api-response";
import { requireRequestSession } from "@/lib/boss-auth";
import { subscribeBossEvents, type BossEventPayload } from "@/lib/boss-events";
import {
buildProjectMessagesRealtimePatch,
buildProjectMessagesRealtimePayload,
getAuditSummaryView,
getConversationHomeItemForProject,
@@ -14,6 +15,9 @@ import { readState } from "@/lib/boss-data";
export const dynamic = "force-dynamic";
const SHARED_EVENT_PAYLOAD_TTL_MS = 1_000;
const sharedEventPayloads = new Map<string, Promise<BossEventPayload>>();
function sseEvent(event: string, data: unknown) {
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
}
@@ -29,17 +33,32 @@ function shouldEnrichProjectMessagesPatch(event: string, payload: Pick<BossEvent
return event === "project.messages.updated" && Boolean(payload.projectId?.trim());
}
async function buildEventPayload(event: string, payload: BossEventPayload) {
async function buildEventPayload(
event: string,
payload: BossEventPayload,
options: { projectMessagesMode?: "snapshot" | "patch" } = {},
) {
if (!shouldEnrichConversationPatch(event, payload) && !shouldEnrichProjectMessagesPatch(event, payload)) {
return payload;
}
const state = await readState();
if (shouldEnrichProjectMessagesPatch(event, payload)) {
const projectMessagesPatch =
options.projectMessagesMode === "patch"
? buildProjectMessagesRealtimePatch(state, String(payload.projectId ?? ""))
: null;
return {
...payload,
conversationItem: getConversationHomeItemForProject(state, String(payload.projectId ?? "")),
threadConversationItem: getConversationThreadItemForProject(state, String(payload.projectId ?? "")),
projectMessagesPayload: buildProjectMessagesRealtimePayload(state, String(payload.projectId ?? "")),
...(projectMessagesPatch
? { projectMessagesPatch }
: {
projectMessagesPayload: buildProjectMessagesRealtimePayload(
state,
String(payload.projectId ?? ""),
),
}),
};
}
return {
@@ -49,12 +68,55 @@ async function buildEventPayload(event: string, payload: BossEventPayload) {
};
}
function sharedEventPayloadKey(event: string, payload: BossEventPayload) {
return [
event,
payload.at,
payload.projectId ?? "",
payload.deviceId ?? "",
payload.taskId ?? "",
payload.note ?? "",
].join("|");
}
async function getSharedEventPayload(
event: string,
payload: BossEventPayload,
options: { projectMessagesMode?: "snapshot" | "patch" } = {},
) {
if (!shouldEnrichConversationPatch(event, payload) && !shouldEnrichProjectMessagesPatch(event, payload)) {
return payload;
}
const key = `${options.projectMessagesMode ?? "snapshot"}|${sharedEventPayloadKey(event, payload)}`;
const existing = sharedEventPayloads.get(key);
if (existing) {
return existing;
}
const promise = buildEventPayload(event, payload, options);
sharedEventPayloads.set(key, promise);
promise.finally(() => {
setTimeout(() => {
if (sharedEventPayloads.get(key) === promise) {
sharedEventPayloads.delete(key);
}
}, SHARED_EVENT_PAYLOAD_TTL_MS);
});
return promise;
}
export async function GET(request: NextRequest) {
const session = await requireRequestSession(request);
if (!session) {
return jsonNoStore({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
}
const encoder = new TextEncoder();
const realtimeCapabilities = request.headers.get("x-boss-realtime-capabilities") ?? "";
const projectMessagesMode = realtimeCapabilities
.split(",")
.map((item) => item.trim())
.includes("message-patch-v1")
? "patch"
: "snapshot";
let heartbeatTimer: ReturnType<typeof setInterval> | undefined;
let unsubscribe: (() => void) | undefined;
@@ -86,7 +148,7 @@ export async function GET(request: NextRequest) {
unsubscribe = subscribeBossEvents((event, payload) => {
void (async () => {
try {
const eventPayload = await buildEventPayload(event, payload);
const eventPayload = await getSharedEventPayload(event, payload, { projectMessagesMode });
controller.enqueue(encoder.encode(sseEvent(event, eventPayload)));
} catch {
unsubscribe?.();

View File

@@ -83,7 +83,7 @@ export async function POST(
rawThreadReply: normalized.rawThreadReply,
executionProgress: normalized.executionProgress,
});
await deliverTelegramReplyForCompletedTask(task.taskId).catch(() => null);
void deliverTelegramReplyForCompletedTask(task.taskId).catch(() => null);
return NextResponse.json({ ok: true, task });
} catch (error) {
return NextResponse.json(

View File

@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { authorizeDeviceWriteRequest } from "@/lib/boss-device-auth";
import type { ExecutionProgressInput } from "@/lib/boss-data";
import type { ExecutionProgressInput, MasterAgentTaskPhase } from "@/lib/boss-data";
import { updateMasterAgentTaskProgress } from "@/lib/boss-data";
export async function POST(
@@ -10,6 +10,7 @@ export async function POST(
const body = (await request.json().catch(() => ({}))) as {
deviceId?: string;
status?: "queued" | "running";
phase?: MasterAgentTaskPhase;
requestId?: string;
executionProgress?: ExecutionProgressInput;
};
@@ -30,6 +31,7 @@ export async function POST(
taskId,
deviceId,
status: body.status,
phase: body.phase,
requestId: body.requestId,
executionProgress: body.executionProgress,
});

View File

@@ -1,11 +1,12 @@
import type { AuthAccount, BossState, Device, OpsSeverity } from "@/lib/boss-data";
import { buildMasterAgentTaskSlaRows, shouldCreateMasterAgentTaskSlaNotification } from "@/lib/master-agent-task-sla";
export type AdminRiskSeverity = OpsSeverity;
export interface AdminRiskItem {
riskId: string;
severity: AdminRiskSeverity;
kind: "device_offline" | "ops_fault" | "thread_context_alert" | "master_agent_task_failed";
kind: "device_offline" | "ops_fault" | "thread_context_alert" | "master_agent_task_failed" | "master_agent_task_sla";
title: string;
detail: string;
companyId: string;
@@ -134,39 +135,38 @@ function buildRisks(state: BossState): AdminRiskItem[] {
});
}
const failedMasterTaskRisks = new Map<string, { risk: AdminRiskItem; count: number }>();
for (const task of state.masterAgentTasks) {
if (task.status !== "failed") continue;
const device = deviceForRisk(state, task.deviceId, task.projectId);
const masterTaskRisks = new Map<string, { risk: AdminRiskItem; count: number }>();
for (const taskSla of buildMasterAgentTaskSlaRows(state).filter(shouldCreateMasterAgentTaskSlaNotification)) {
const groupKey = [
task.deviceId || "unknown-device",
task.projectId || "unknown-project",
task.taskType,
task.errorMessage || "MASTER_AGENT_TASK_FAILED",
taskSla.deviceId || "unknown-device",
taskSla.projectId || "unknown-project",
taskSla.taskType,
taskSla.slaLevel,
].join(":");
const risk: AdminRiskItem = {
riskId: `master-task:${task.taskId}`,
severity: "warning",
kind: "master_agent_task_failed",
title: "主 Agent 任务失败",
detail: task.errorMessage || task.requestText || "主 Agent 任务执行失败",
companyId: deviceCompanyId(state, device),
deviceId: task.deviceId,
projectId: task.projectId,
lastSeenAt: task.completedAt || task.requestedAt,
riskId: taskSla.riskId,
severity: taskSla.severity,
kind: taskSla.slaLevel === "terminal" ? "master_agent_task_failed" : "master_agent_task_sla",
title: taskSla.slaLevel === "terminal" ? "主 Agent 任务失败" : "任务 SLA 告警",
detail: `${taskSla.summary}${taskSla.recommendedAction}`,
companyId: taskSla.companyId,
deviceId: taskSla.deviceId,
projectId: taskSla.projectId,
slaDueAt: taskSla.slaDueAt,
lastSeenAt: taskSla.lastProgressAt || taskSla.requestedAt,
};
const existing = failedMasterTaskRisks.get(groupKey);
const existing = masterTaskRisks.get(groupKey);
if (!existing) {
failedMasterTaskRisks.set(groupKey, { risk, count: 1 });
masterTaskRisks.set(groupKey, { risk, count: 1 });
continue;
}
failedMasterTaskRisks.set(groupKey, {
masterTaskRisks.set(groupKey, {
risk: newerRisk(existing.risk, risk),
count: existing.count + 1,
});
}
for (const { risk, count } of failedMasterTaskRisks.values()) {
for (const { risk, count } of masterTaskRisks.values()) {
risks.push({
...risk,
detail: count > 1 ? `${risk.detail};已折叠 ${count - 1} 条同类失败。` : risk.detail,

View File

@@ -12,6 +12,10 @@ import { getFixedVerificationCode, getVerificationDeliveryMode } from "@/lib/bos
import { getPublishedOtaAsset } from "@/lib/boss-ota";
import { buildOperationalRiskFaultDrafts, buildRiskSlaNotificationDrafts } from "@/lib/boss-risk-notifications";
import { createBossStateStore } from "@/lib/boss-state-store";
import {
buildMasterAgentTaskSlaRow,
isMasterAgentTaskAutoRecoverable,
} from "@/lib/master-agent-task-sla";
import { BOSS_NATIVE_ORCHESTRATOR } from "@/lib/execution/backends/boss-native-orchestrator";
import {
OMX_TEAM_BACKEND,
@@ -4031,7 +4035,7 @@ export function getMasterIdentitySummaryFromState(state: BossState): MasterIdent
}
function normalizeMessage(raw: Partial<Message>): Message {
const normalizedBody = sanitizeSensitiveUserVisibleText(raw.body);
const normalizedBody = sanitizeUserVisibleMessageText(raw.body);
const normalizedKind =
raw.kind === "text" && raw.sender === "device" && isLikelyThreadProcessBody(normalizedBody)
? "thread_process"
@@ -4058,6 +4062,40 @@ function normalizeMessage(raw: Partial<Message>): Message {
};
}
const USER_VISIBLE_RUNTIME_FAILURE_MARKERS = [
"LOCAL_AGENT_CODEX_THREAD_BINDING_MISSING",
"LOCAL_AGENT_CODEX_THREAD_BINDING_STALE",
"LOCAL_AGENT_CODEX_THREAD_BINDING_MISMATCH",
"LOCAL_AGENT_CODEX_WORKDIR_INVALID",
"THREAD_ENVIRONMENT_INVALID",
"LOCAL_AGENT_CODEX_THREAD_READ_ONLY",
"CODEX_APP_SERVER_TURN_INTERRUPTED",
"CODEX_APP_SERVER_TIMEOUT",
"CODEX_APP_SERVER_STDIN_CLOSED",
"CODEX_APP_SERVER_EXITED",
MASTER_CODEX_NODE_OUTPUT_LEAKED,
];
function sanitizeUserVisibleMessageText(value: string | undefined | null) {
const text = sanitizeSensitiveUserVisibleText(value);
if (!text) {
return undefined;
}
return replaceUserVisibleRuntimeFailureCodes(text);
}
function replaceUserVisibleRuntimeFailureCodes(value: string) {
let next = value;
for (const marker of USER_VISIBLE_RUNTIME_FAILURE_MARKERS) {
if (!next.includes(marker)) continue;
next = next.split(marker).join(buildFriendlyThreadExecutionError(marker));
}
if (/执行失败[:]\s*fetch failed/i.test(next)) {
next = next.replace(/fetch failed/gi, buildFriendlyThreadExecutionError("fetch failed"));
}
return next;
}
function normalizeExecutionProgressSnapshot(raw: Partial<ExecutionProgressSnapshot>): ExecutionProgressSnapshot {
const status = normalizeExecutionProgressStatus(raw.status);
const runtimeKind =
@@ -4344,7 +4382,7 @@ function normalizeProject(raw: Partial<Project>, fallback?: Project): Project {
normalizeGroupMember(member, projectId, project.threadMeta),
);
normalizeProjectConversationShape(project);
project.preview = sanitizeSensitiveUserVisibleText(project.preview) ?? project.preview;
project.preview = sanitizeUserVisibleMessageText(project.preview) ?? project.preview;
project.updatedAt = resolveProjectUpdatedAt(project, project.threadMeta.updatedAt);
return project;
}
@@ -5396,7 +5434,11 @@ function findLatestPreviewableMessage(project: Project) {
return sortedMessages.find((message) => isPreviewableMessageKind(message.kind)) ?? null;
}
function deriveProjectPreview(state: BossState, project: Project) {
function deriveProjectPreview(
state: BossState,
project: Project,
options?: { emptyMessageFallback?: "preserve" | "clear" },
) {
if (project.id === "master-agent") {
return project.preview;
}
@@ -5439,6 +5481,10 @@ function deriveProjectPreview(state: BossState, project: Project) {
return "";
}
if (options?.emptyMessageFallback === "clear") {
return "";
}
return project.preview;
}
@@ -5581,7 +5627,7 @@ function pushProjectLedgerMessage(
id: randomToken("msg"),
sentAt: message.sentAt ?? nowIso(),
...message,
body: sanitizeSensitiveUserVisibleText(message.body) ?? "已拦截内部执行日志,原始内容已隐藏。",
body: sanitizeUserVisibleMessageText(message.body) ?? "已拦截内部执行日志,原始内容已隐藏。",
};
project.messages.push(entry);
if (shouldCountAsUnreadMessage(entry)) {
@@ -5599,7 +5645,7 @@ function safeExecutionProgressText(value: unknown, fallback = "") {
if (!text) {
return fallback;
}
const sanitized = sanitizeSensitiveUserVisibleText(text)
const sanitized = sanitizeUserVisibleMessageText(text)
?.replace(/sk-[A-Za-z0-9_-]{8,}/g, "[redacted]")
.replace(/(api[_-]?key|token|secret|password)\s*[=:]\s*[^ \n\r\t]+/gi, "$1=[redacted]")
.trim();
@@ -6346,31 +6392,6 @@ function upsertTaskExecutionProgressMessageInState(
const OBSERVED_ASSISTANT_DUPLICATE_WINDOW_MS = 5_000;
function isEquivalentObservedAssistantLedgerMessage(
message: Message,
observed: NonNullable<DeviceImportCandidate["recentAssistantMessages"]>[number],
candidate: DeviceImportCandidate,
) {
if (message.externalMessageId === observed.messageId.trim()) {
return true;
}
if (message.sender !== "device" && message.sender !== "master") {
return false;
}
if (message.sender === "device" && message.senderLabel !== candidate.threadDisplayName) {
return false;
}
if (message.body !== observed.body) {
return false;
}
const messageTime = messageTimeValue(message.sentAt);
const observedTime = messageTimeValue(observed.sentAt);
if (!messageTime || !observedTime) {
return false;
}
return Math.abs(messageTime - observedTime) <= OBSERVED_ASSISTANT_DUPLICATE_WINDOW_MS;
}
function convertRecentMirroredThreadReplyToMaster(
project: Project,
input: {
@@ -6414,46 +6435,6 @@ function convertRecentMirroredThreadReplyToMaster(
return mirrored;
}
function mirrorObservedAssistantMessagesToProject(
state: BossState,
project: Project,
candidate: DeviceImportCandidate,
) {
const observedMessages = [...(candidate.recentAssistantMessages ?? [])].sort((left, right) =>
left.sentAt.localeCompare(right.sentAt),
);
const historyClearedAt = trimToDefined(state.conversationHistoryClearedAt);
const historyClearedAtMs = historyClearedAt ? messageTimeValue(historyClearedAt) : 0;
let mirroredCount = 0;
for (const observed of observedMessages) {
const externalMessageId = observed.messageId.trim();
if (!externalMessageId) {
continue;
}
if (historyClearedAtMs > 0 && messageTimeValue(observed.sentAt) <= historyClearedAtMs) {
continue;
}
const alreadyMirrored = project.messages.some((message) =>
isEquivalentObservedAssistantLedgerMessage(message, observed, candidate),
);
if (alreadyMirrored) {
continue;
}
const mirrored = pushProjectLedgerMessage(state, project.id, {
sender: "device",
senderLabel: candidate.threadDisplayName,
body: observed.body,
sentAt: observed.sentAt,
kind: resolveObservedAssistantMessageKind(observed),
externalMessageId,
});
if (mirrored) {
mirroredCount += 1;
}
}
return mirroredCount;
}
function shouldAutoReplyToMirroredLog(entry: AppLogEntry) {
if (entry.level !== "info") return true;
return (
@@ -8178,14 +8159,8 @@ function activeAuthSession(state: BossState, token?: string | null) {
const session = state.authSessions.find((item) => item.sessionToken === token);
if (!session || session.revokedAt) return null;
const account = state.authAccounts.find((item) => item.account === session.account);
if (account?.status === "disabled") {
session.revokedAt = nowIso();
return null;
}
if (new Date(session.expiresAt).getTime() <= Date.now()) {
session.revokedAt = nowIso();
return null;
}
if (account?.status === "disabled") return null;
if (new Date(session.expiresAt).getTime() <= Date.now()) return null;
return session;
}
@@ -8296,12 +8271,9 @@ export async function getVerificationDeliveryTarget(account: string) {
}
export async function getAuthSession(token?: string | null) {
return mutateState((state) => {
const session = activeAuthSession(state, token);
if (!session) return null;
session.lastSeenAt = nowIso();
return { ...session };
});
const state = await readState();
const session = activeAuthSession(state, token);
return session ? { ...session } : null;
}
export async function restoreAuthSession(restoreToken?: string | null) {
@@ -10300,6 +10272,57 @@ function requeueRecoverableMasterAgentTaskInState(task: MasterAgentTask, now: st
task.errorMessage = reason;
}
function isRecoverableRuntimeFailureMessage(message?: string) {
const normalized = message?.trim().toUpperCase() ?? "";
if (!normalized) {
return false;
}
return (
normalized.includes("CODEX_APP_SERVER_TURN_INTERRUPTED") ||
normalized.includes("CODEX_APP_SERVER_TIMEOUT") ||
normalized.includes("CODEX_APP_SERVER_STDIN_CLOSED") ||
normalized.includes("CODEX_APP_SERVER_EXITED") ||
normalized.includes("UND_ERR_SOCKET") ||
normalized.includes("ECONNRESET") ||
normalized.includes("ETIMEDOUT") ||
normalized.includes("FETCH FAILED")
);
}
function canAutoRetryRecoverableRuntimeFailure(task: MasterAgentTask, errorMessage?: string) {
if (task.taskType !== "conversation_reply") {
return false;
}
if (!isRecoverableRuntimeFailureMessage(errorMessage)) {
return false;
}
const maxAttempts = task.maxAttempts ?? defaultMasterAgentTaskMaxAttempts(task.taskType);
return (task.attemptCount ?? 0) < maxAttempts;
}
function requeueRecoverableRuntimeFailureInState(
task: MasterAgentTask,
now: string,
errorMessage?: string,
) {
task.status = "queued";
task.phase = "recoverable_failed";
task.leaseExpiresAt = undefined;
task.claimedAt = undefined;
task.lastProgressAt = now;
task.completedAt = undefined;
task.canceledAt = undefined;
task.canceledBy = undefined;
task.cancelReason = undefined;
task.lastErrorKind = "recoverable_runtime_failure";
task.lastErrorCode = "RECOVERABLE_RUNTIME_FAILURE";
task.recoverable = true;
task.nextRetryAt = now;
task.errorMessage = errorMessage?.trim() || "RECOVERABLE_RUNTIME_FAILURE";
task.replyBody = undefined;
task.requestId = undefined;
}
function expireMasterAgentTaskInState(task: MasterAgentTask, now: string, reason: string) {
task.status = "timed_out";
task.phase = "timed_out";
@@ -10376,6 +10399,80 @@ function hasRecentProjectReplyDuplicate(
});
}
function conversationReplyDuplicateWindowMs(task: Pick<MasterAgentTask, "requestedAt" | "completedAt">) {
const requestedAtMs = messageTimeValue(task.requestedAt);
const completedAtMs = messageTimeValue(task.completedAt);
if (!requestedAtMs || !completedAtMs) {
return 5 * 60 * 1000;
}
return Math.max(5 * 60 * 1000, Math.abs(completedAtMs - requestedAtMs) + 60 * 1000);
}
function stripThreadProcessPrefix(body: string, prefix: string) {
const candidate = body.trimStart();
const trimmedPrefix = prefix.trim();
if (!candidate || !trimmedPrefix) {
return body;
}
if (!candidate.startsWith(trimmedPrefix)) {
return body;
}
return candidate.slice(trimmedPrefix.length).trimStart();
}
function stripAlreadyMirroredThreadProcessPrefix(
project: Project | undefined,
input: {
body?: string;
senderLabel?: string;
requestedAt?: string;
completedAt?: string;
},
) {
const originalBody = input.body?.trim() ?? "";
if (!project || !originalBody) {
return originalBody;
}
const senderLabel = input.senderLabel?.trim();
const requestedAtMs = messageTimeValue(input.requestedAt);
const completedAtMs = messageTimeValue(input.completedAt);
const lowerBoundMs = requestedAtMs
? requestedAtMs - 30 * 1000
: completedAtMs
? completedAtMs - 30 * 60 * 1000
: 0;
const upperBoundMs = completedAtMs ? completedAtMs + 60 * 1000 : Date.now() + 60 * 1000;
const processMessages = project.messages
.filter((message) => {
if (!isThreadProcessMessageKind(message.kind)) {
return false;
}
if (senderLabel && message.senderLabel.trim() !== senderLabel) {
return false;
}
const sentAtMs = messageTimeValue(message.sentAt);
if (!sentAtMs) {
return false;
}
return sentAtMs >= lowerBoundMs && sentAtMs <= upperBoundMs;
})
.sort((a, b) => messageTimeValue(a.sentAt) - messageTimeValue(b.sentAt));
let remainingBody = originalBody;
let strippedAny = false;
for (const message of processMessages) {
const nextBody = stripThreadProcessPrefix(remainingBody, message.body);
if (nextBody !== remainingBody) {
remainingBody = nextBody;
strippedAny = true;
}
}
return strippedAny ? remainingBody.trim() : originalBody;
}
async function sweepExpiredMasterAgentTasksForDevice(deviceId: string, nowMs = Date.now()) {
const expired: MasterAgentTask[] = [];
await mutateStateIfChanged(async (state) => {
@@ -10946,6 +11043,23 @@ export async function completeMasterAgentTask(payload: {
};
}
}
if (
payload.status === "failed" &&
canAutoRetryRecoverableRuntimeFailure(task, payload.errorMessage)
) {
const retryAt = nowIso();
requeueRecoverableRuntimeFailureInState(task, retryAt, payload.errorMessage);
upsertTaskExecutionProgressMessageInState(state, task, "failed", {
...payload.executionProgress,
phase: task.phase,
});
return {
...task,
dispatchPlan: undefined,
dispatchExecution: undefined,
dialogGuardIntervention: undefined,
};
}
task.status = payload.status;
task.phase =
payload.status === "completed"
@@ -11191,14 +11305,6 @@ export async function completeMasterAgentTask(payload: {
(item) => item.id === (task.targetProjectId ?? task.projectId),
);
const device = state.devices.find((item) => item.id === payload.deviceId);
const replyKind = resolveConversationReplyMessageKind(task, {
body: task.replyBody,
phase: undefined,
});
const shouldKeepExistingPreview =
replyKind === "thread_process" &&
Boolean(threadProject?.messages.some((message) => !isThreadProcessMessageKind(message.kind)));
const previousPreview = threadProject?.preview;
const replySender = task.relayViaMasterAgent ? "master" : "device";
const replySenderLabel = task.relayViaMasterAgent
? task.accountLabel
@@ -11208,25 +11314,40 @@ export async function completeMasterAgentTask(payload: {
threadProject?.threadMeta.threadDisplayName ||
device?.name ||
"线程";
const displayReplyBody = stripAlreadyMirroredThreadProcessPrefix(threadProject, {
body: task.replyBody,
senderLabel: replySenderLabel,
requestedAt: task.requestedAt,
completedAt: task.completedAt,
});
const replyKind = resolveConversationReplyMessageKind(task, {
body: displayReplyBody,
phase: undefined,
});
const shouldKeepExistingPreview =
replyKind === "thread_process" &&
Boolean(threadProject?.messages.some((message) => !isThreadProcessMessageKind(message.kind)));
const previousPreview = threadProject?.preview;
const convertedMirroredReply =
task.relayViaMasterAgent && threadProject
displayReplyBody && task.relayViaMasterAgent && threadProject
? convertRecentMirroredThreadReplyToMaster(threadProject, {
body: task.replyBody,
body: displayReplyBody,
sentAt: task.completedAt ?? nowIso(),
senderLabel: replySenderLabel,
kind: replyKind,
})
: null;
const duplicateRecentReply = hasRecentProjectReplyDuplicate(threadProject, {
body: task.replyBody,
body: displayReplyBody,
senderLabel: replySenderLabel,
at: task.completedAt,
windowMs: conversationReplyDuplicateWindowMs(task),
});
if (!convertedMirroredReply && !duplicateRecentReply) {
if (displayReplyBody && !convertedMirroredReply && !duplicateRecentReply) {
pushProjectLedgerMessage(state, threadProject?.id ?? task.projectId, {
sender: replySender,
senderLabel: replySenderLabel,
body: task.replyBody,
body: displayReplyBody,
kind: replyKind,
});
}
@@ -12728,6 +12849,7 @@ export async function upsertDeviceHeartbeat(payload: {
const result = await mutateState((state) => {
let conversationRefreshRequired = false;
const messageRefreshProjectIds = new Set<string>();
const conversationRefreshProjectIds = new Set<string>();
const projectUnderstandingSyncRequests: Array<{
projectId: string;
observedActivityAt: string;
@@ -12866,9 +12988,6 @@ export async function upsertDeviceHeartbeat(payload: {
if (!matchingProject) {
continue;
}
if (mirrorObservedAssistantMessagesToProject(state, matchingProject, candidate) > 0) {
messageRefreshProjectIds.add(matchingProject.id);
}
const previousObservedAt = matchingProject.threadMeta.lastObservedCodexActivityAt;
matchingProject.threadMeta.lastObservedCodexActivityAt = latestIsoTimestamp(
previousObservedAt,
@@ -12879,7 +12998,17 @@ export async function upsertDeviceHeartbeat(payload: {
const hasNewObservedActivity =
Number.isFinite(nextObservedTs) &&
(!Number.isFinite(previousObservedTs) || nextObservedTs > previousObservedTs);
if ((candidate.recentAssistantMessages ?? []).length > 0) {
const nextPreview = deriveProjectPreview(state, matchingProject, {
emptyMessageFallback: "clear",
});
if (matchingProject.preview !== nextPreview) {
matchingProject.preview = nextPreview;
conversationRefreshProjectIds.add(matchingProject.id);
}
}
if (hasNewObservedActivity) {
conversationRefreshProjectIds.add(matchingProject.id);
appendThreadProgressEventInState(state, {
projectId: matchingProject.id,
threadId: matchingProject.threadMeta.threadId,
@@ -12996,6 +13125,7 @@ export async function upsertDeviceHeartbeat(payload: {
projectUnderstandingSyncRequests,
conversationRefreshRequired,
messageRefreshProjectIds: [...messageRefreshProjectIds],
conversationRefreshProjectIds: [...conversationRefreshProjectIds],
};
});
for (const request of result.projectUnderstandingSyncRequests ?? []) {
@@ -13006,6 +13136,12 @@ export async function upsertDeviceHeartbeat(payload: {
publishBossEvent("project.messages.updated", { projectId });
publishBossEvent("conversation.updated", { projectId });
}
for (const projectId of result.conversationRefreshProjectIds ?? []) {
if (result.messageRefreshProjectIds?.includes(projectId)) {
continue;
}
publishBossEvent("conversation.updated", { projectId });
}
if (result.conversationRefreshRequired) {
publishBossEvent("conversation.updated", { deviceId: payload.deviceId, note: "device_import.updated" });
}
@@ -13514,6 +13650,22 @@ function buildFriendlyThreadExecutionError(errorMessage?: string) {
if (message.includes("LOCAL_AGENT_CODEX_THREAD_READ_ONLY")) {
return "线程当前处于只读环境,无法继续执行,请切换到可写线程后再试。";
}
if (message.includes("CODEX_APP_SERVER_TURN_INTERRUPTED")) {
return "Codex 桌面线程本轮被中断,系统会优先自动重试;如果多次失败,请确认 Codex 桌面端没有手动停止或切换线程。";
}
if (message.includes("CODEX_APP_SERVER_TIMEOUT")) {
return "Codex 桌面线程响应超时,系统会优先自动重试;如果多次失败,请确认 Codex 桌面端在线且网络稳定。";
}
if (message.includes("CODEX_APP_SERVER_STDIN_CLOSED") || message.includes("CODEX_APP_SERVER_EXITED")) {
return "Codex App Server 连接中断,系统会优先自动重连并重试;如果多次失败,请重启 Codex 或 boss-agent。";
}
if (
message.toLowerCase().includes("fetch failed") ||
message.toLowerCase().includes("econnreset") ||
message.toLowerCase().includes("etimedout")
) {
return "执行链路网络连接不稳定,系统会优先自动重试;如果多次失败,请检查电脑和服务器网络。";
}
return message;
}
@@ -14607,36 +14759,37 @@ function appendProjectMessageInState(
}
const firstAttachment = payload.attachments?.[0];
const fallbackBody =
payload.kind === "attachment"
? buildAttachmentMessageBody(
firstAttachment ?? {
attachmentId: randomToken("att"),
fileName: "附件",
mimeType: "application/octet-stream",
fileSizeBytes: 0,
attachmentKind: "binary",
storageBackend: "server_file",
storagePath: "",
previewAvailable: false,
uploadedAt: nowIso(),
uploadedBy: payload.senderLabel ?? "你",
analysisState: "not_applicable",
},
)
: payload.kind === "voice_intent"
? "已提交语音转文字请求,等待主 Agent 记录语音摘要。"
: payload.kind === "image_intent"
? "已登记图片证据上传请求,等待对象存储通道接入。"
: payload.kind === "video_intent"
? "已登记视频证据上传请求,等待对象存储通道接入。"
: "已提交消息。";
const normalizedBody = sanitizeUserVisibleMessageText(body ?? fallbackBody) ?? fallbackBody;
const message: Message = {
id: randomToken("msg"),
sender: payload.sender ?? "user",
senderLabel: payload.senderLabel ?? "你",
account: payload.account?.trim() || undefined,
body:
body ??
(payload.kind === "attachment"
? buildAttachmentMessageBody(
firstAttachment ?? {
attachmentId: randomToken("att"),
fileName: "附件",
mimeType: "application/octet-stream",
fileSizeBytes: 0,
attachmentKind: "binary",
storageBackend: "server_file",
storagePath: "",
previewAvailable: false,
uploadedAt: nowIso(),
uploadedBy: payload.senderLabel ?? "你",
analysisState: "not_applicable",
},
)
: payload.kind === "voice_intent"
? "已提交语音转文字请求,等待主 Agent 记录语音摘要。"
: payload.kind === "image_intent"
? "已登记图片证据上传请求,等待对象存储通道接入。"
: payload.kind === "video_intent"
? "已登记视频证据上传请求,等待对象存储通道接入。"
: "已提交消息。"),
body: normalizedBody,
sentAt: nowIso(),
kind: payload.kind ?? "text",
attachments: payload.attachments?.map((attachment) => normalizeMessageAttachment(attachment)),
@@ -15336,6 +15489,75 @@ export async function verifyRepairTicket(ticketId: string) {
export type AdminRiskAction = "ack" | "resolve" | "create_repair_ticket" | "assign_owner" | "set_sla";
function autoRecoverMasterAgentTasksInState(
state: BossState,
input: { actorAccount: string; recoveredAt: string },
) {
const recovered: MasterAgentTask[] = [];
const recoveredAtMs = Date.parse(input.recoveredAt);
const nowMs = Number.isFinite(recoveredAtMs) ? recoveredAtMs : Date.now();
for (const task of state.masterAgentTasks) {
if (!isMasterAgentTaskAutoRecoverable(task, nowMs)) {
continue;
}
const slaRow = buildMasterAgentTaskSlaRow(state, task, new Date(nowMs));
task.status = "queued";
task.phase = "queued";
task.claimedAt = undefined;
task.lastClaimedAt = undefined;
task.leaseExpiresAt = undefined;
task.lastProgressAt = input.recoveredAt;
task.completedAt = undefined;
task.canceledAt = undefined;
task.canceledBy = undefined;
task.cancelReason = undefined;
task.lastErrorKind = undefined;
task.lastErrorCode = undefined;
task.errorMessage = undefined;
task.recoverable = false;
task.nextRetryAt = undefined;
upsertTaskExecutionProgressMessageInState(state, task, "queued", { phase: "queued" });
recovered.push({ ...task });
state.adminRiskTimeline.unshift(
normalizeAdminRiskTimelineEvent({
riskId: slaRow.riskId,
notificationId: slaRow.notificationId,
companyId: slaRow.companyId,
action: "task.auto_recovery_requeued",
actorAccount: input.actorAccount,
note: `自动恢复任务:${task.taskId}`,
createdAt: input.recoveredAt,
}),
);
state.permissionAuditLogs.unshift(
normalizePermissionAuditLog({
auditId: randomToken("audit"),
actorAccount: input.actorAccount,
action: "master_agent.task_retried",
projectId: task.projectId,
deviceId: task.deviceId,
detail: `后台 SLA 扫描自动重排队:${task.taskId}`,
requestId: task.taskId,
createdAt: input.recoveredAt,
afterJson: {
taskId: task.taskId,
phase: task.phase,
status: task.status,
source: "admin_risk_scan",
},
}),
);
}
if (recovered.length > 0) {
state.adminRiskTimeline = state.adminRiskTimeline.slice(0, 1000);
state.permissionAuditLogs = state.permissionAuditLogs.slice(0, 500);
}
return recovered;
}
export async function scanAdminRiskNotifications(input: {
actorAccount: string;
now?: string;
@@ -15374,6 +15596,10 @@ export async function scanAdminRiskNotifications(input: {
const created = drafts
.filter((draft) => !existingIds.has(draft.notificationId))
.map(normalizeAdminNotification);
const autoRecovered = autoRecoverMasterAgentTasksInState(state, {
actorAccount: input.actorAccount,
recoveredAt: scannedAt.toISOString(),
});
if (created.length > 0) {
state.adminNotifications = [...created, ...state.adminNotifications].slice(0, 500);
@@ -15406,18 +15632,31 @@ export async function scanAdminRiskNotifications(input: {
return {
createdFaults,
created,
autoRecovered,
notifications: state.adminNotifications
.filter((notification) => notification.status === "open")
.sort((left, right) => right.createdAt.localeCompare(left.createdAt)),
};
});
if (result.created.length > 0 || result.createdFaults.length > 0) {
if (result.created.length > 0 || result.createdFaults.length > 0 || result.autoRecovered.length > 0) {
publishBossEvent("project.context_risk.updated", {
status: "risk_notification_created",
note: `notifications:${result.created.length};faults:${result.createdFaults.length}`,
note: `notifications:${result.created.length};faults:${result.createdFaults.length};autoRecovered:${result.autoRecovered.length}`,
});
}
for (const task of result.autoRecovered) {
publishBossEvent("master_agent.task.updated", {
taskId: task.taskId,
deviceId: task.deviceId,
status: task.status,
});
const progressProjectId = resolveTaskExecutionProgressProjectId(task);
if (progressProjectId) {
publishBossEvent("project.messages.updated", { projectId: progressProjectId });
publishBossEvent("conversation.updated", { projectId: progressProjectId });
}
}
return result;
}

View File

@@ -41,6 +41,7 @@ export interface BossEventPayload {
type BossEventListener = (event: BossEventName, payload: BossEventPayload) => void;
const eventBus = new EventEmitter();
eventBus.setMaxListeners(100);
export function publishBossEvent(event: BossEventName, payload: Omit<BossEventPayload, "at"> = {}) {
eventBus.emit("boss-event", event, {

View File

@@ -18,6 +18,7 @@ import type {
MasterIdentitySummary,
MasterAgentMemory,
MasterAgentPromptPolicy,
Message,
OpsFault,
OpsRepairTicket,
OpsRepairVerification,
@@ -969,6 +970,30 @@ export interface ProjectMessagesRealtimePayload {
devices: Device[];
}
export interface ProjectMessagesRealtimePatch {
ok: true;
patchKind: "message_window";
projectId: string;
completeMessages: false;
messageCount: number;
latestMessageId?: string;
project: Pick<
Project,
| "id"
| "name"
| "threadMeta"
| "unreadCount"
| "isGroup"
| "collaborationMode"
| "approvalState"
| "lightDispatchReminderEnabled"
| "lastMessageAt"
| "updatedAt"
>;
messages: Message[];
devices: Device[];
}
export function getConversationHomeItems(state: BossState): ConversationItem[] {
const flatItems = getConversationItems(state);
const projectMap = new Map(state.projects.map((project) => [project.id, project]));
@@ -1168,6 +1193,47 @@ export function buildProjectMessagesRealtimePayload(
};
}
export function buildProjectMessagesRealtimePatch(
state: BossState,
projectId: string,
options: { messageWindowSize?: number } = {},
): ProjectMessagesRealtimePatch | null {
const normalizedProjectId = projectId.trim();
if (!normalizedProjectId) {
return null;
}
const project = state.projects.find((item) => item.id === normalizedProjectId);
if (!project) {
return null;
}
const displayProject = cloneProjectWithDisplayTitles(project);
const messageWindowSize = Math.max(1, Math.min(Math.floor(options.messageWindowSize ?? 8), 30));
const messages = displayProject.messages.slice(-messageWindowSize);
const latestMessage = displayProject.messages.at(-1);
return {
ok: true,
patchKind: "message_window",
projectId: displayProject.id,
completeMessages: false,
messageCount: displayProject.messages.length,
latestMessageId: latestMessage?.id,
project: {
id: displayProject.id,
name: displayProject.name,
threadMeta: displayProject.threadMeta,
unreadCount: displayProject.unreadCount,
isGroup: displayProject.isGroup,
collaborationMode: displayProject.collaborationMode,
approvalState: displayProject.approvalState,
lightDispatchReminderEnabled: displayProject.lightDispatchReminderEnabled,
lastMessageAt: displayProject.lastMessageAt,
updatedAt: displayProject.updatedAt,
},
messages,
devices: state.devices.filter((device) => displayProject.deviceIds.includes(device.id)),
};
}
export function buildProjectMessagesRealtimePayloadForSession(
state: BossState,
session: Pick<AuthSession, "account" | "role" | "displayName">,

View File

@@ -8,6 +8,10 @@ import type {
OpsSeverity,
ThreadContextAlert,
} from "@/lib/boss-data";
import {
buildMasterAgentTaskSlaRows,
shouldCreateMasterAgentTaskSlaNotification,
} from "@/lib/master-agent-task-sla";
function fallbackCompanyIdForAccount(account?: string) {
const normalized = account?.trim().toLowerCase() ?? "";
@@ -196,6 +200,26 @@ export function buildRiskSlaNotificationDrafts(
});
}
for (const row of buildMasterAgentTaskSlaRows(state, now)) {
if (!shouldCreateMasterAgentTaskSlaNotification(row)) {
continue;
}
if (existingIds.has(row.notificationId)) {
continue;
}
drafts.push({
notificationId: row.notificationId,
kind: "risk_sla_overdue",
severity: row.severity,
companyId: row.companyId,
riskId: row.riskId,
title: `任务 SLA 告警:${row.taskType}`,
body: `${row.summary};当前阶段 ${row.phase}SLA 截止 ${row.slaDueAt || "未设置"}${row.recommendedAction}`,
status: "open",
createdAt,
});
}
return drafts.sort((left, right) => {
const severityRank = { critical: 3, warning: 2, info: 1 };
return severityRank[right.severity] - severityRank[left.severity] || right.createdAt.localeCompare(left.createdAt);

View File

@@ -0,0 +1,247 @@
import type { BossState, Device, MasterAgentTask, MasterAgentTaskPhase, OpsSeverity } from "@/lib/boss-data";
export type MasterAgentTaskSlaLevel = "ok" | "watch" | "breached" | "recoverable" | "terminal";
export interface MasterAgentTaskSlaRow {
taskId: string;
riskId: string;
notificationId: string;
projectId: string;
deviceId: string;
companyId: string;
taskType: string;
status: string;
phase: string;
summary: string;
requestedAt: string;
claimedAt: string;
lastProgressAt: string;
leaseExpiresAt: string;
slaDueAt: string;
elapsedMs: number | null;
idleMs: number | null;
attemptCount: number;
maxAttempts: number;
attemptLabel: string;
stale: boolean;
recoverable: boolean;
autoRecoverable: boolean;
slaLevel: MasterAgentTaskSlaLevel;
severity: OpsSeverity;
recommendedAction: string;
}
const CONVERSATION_RUNNING_SLA_MS = 15 * 60 * 1000;
const CONVERSATION_QUEUED_SLA_MS = 60 * 60 * 1000;
const DEFAULT_TASK_SLA_MS = 30 * 60 * 1000;
const WATCH_WINDOW_MS = 5 * 60 * 1000;
const STALE_IDLE_MS = 10 * 60 * 1000;
const safeAutoRetryPhases = new Set<MasterAgentTaskPhase>([
"queued",
"claimed",
"executor_starting",
"recoverable_failed",
]);
function fallbackCompanyIdForAccount(account?: string) {
const normalized = account?.trim().toLowerCase() ?? "";
const domain = normalized.includes("@") ? normalized.split("@").at(-1)?.trim() : "";
return domain || "default";
}
function accountCompanyId(state: BossState, account?: string) {
const owner = state.authAccounts.find((item) => item.account === account);
return owner?.companyId || fallbackCompanyIdForAccount(owner?.account ?? account);
}
function deviceCompanyId(state: BossState, device?: Pick<Device, "account" | "companyId"> | null) {
if (device?.companyId) return device.companyId;
return accountCompanyId(state, device?.account);
}
function projectPrimaryDevice(state: BossState, projectId?: string) {
if (!projectId) return null;
const project = state.projects.find((item) => item.id === projectId);
const deviceId = project?.deviceIds[0];
return state.devices.find((device) => device.id === deviceId) ?? null;
}
function deviceForTask(state: BossState, task: MasterAgentTask) {
return state.devices.find((device) => device.id === task.deviceId) ?? projectPrimaryDevice(state, task.projectId);
}
function parseTime(value?: string) {
if (!value) return null;
const ms = Date.parse(value);
return Number.isFinite(ms) ? ms : null;
}
function dateFromMs(value: number | null) {
return value === null ? "" : new Date(value).toISOString();
}
function defaultMaxAttempts(task: MasterAgentTask) {
return task.maxAttempts ?? (task.taskType === "conversation_reply" ? 2 : 3);
}
function taskPhase(task: MasterAgentTask) {
return (task.phase ?? task.status) as MasterAgentTaskPhase;
}
function taskSlaBaseMs(task: MasterAgentTask) {
if (task.status === "queued") {
return parseTime(task.requestedAt);
}
return parseTime(task.lastProgressAt) ?? parseTime(task.claimedAt) ?? parseTime(task.requestedAt);
}
function taskSlaDurationMs(task: MasterAgentTask) {
if (task.taskType !== "conversation_reply") return DEFAULT_TASK_SLA_MS;
return task.status === "queued" ? CONVERSATION_QUEUED_SLA_MS : CONVERSATION_RUNNING_SLA_MS;
}
function taskSlaDueMs(task: MasterAgentTask) {
const explicitLeaseMs = parseTime(task.leaseExpiresAt);
if (explicitLeaseMs !== null) return explicitLeaseMs;
const baseMs = taskSlaBaseMs(task);
return baseMs === null ? null : baseMs + taskSlaDurationMs(task);
}
function elapsedMs(task: MasterAgentTask, nowMs: number) {
const requestedMs = parseTime(task.requestedAt);
return requestedMs === null ? null : Math.max(0, nowMs - requestedMs);
}
function idleMs(task: MasterAgentTask, nowMs: number) {
const activeMs = parseTime(task.lastProgressAt) ?? parseTime(task.claimedAt) ?? parseTime(task.requestedAt);
return activeMs === null ? null : Math.max(0, nowMs - activeMs);
}
export function isMasterAgentTaskAutoRecoverable(task: MasterAgentTask, nowMs = Date.now()) {
if (task.recoverable !== true) return false;
if (task.status === "completed" || task.status === "canceled" || task.status === "timed_out") return false;
if (!safeAutoRetryPhases.has(taskPhase(task))) return false;
const maxAttempts = defaultMaxAttempts(task);
if ((task.attemptCount ?? 0) >= maxAttempts) return false;
const nextRetryMs = parseTime(task.nextRetryAt);
return nextRetryMs === null || nextRetryMs <= nowMs;
}
export function isMasterAgentTaskSlaVisible(task: MasterAgentTask) {
return task.status !== "completed" && task.status !== "canceled";
}
function taskSlaLevel(task: MasterAgentTask, nowMs: number): MasterAgentTaskSlaLevel {
const phase = taskPhase(task);
if (task.status === "failed" || task.status === "timed_out" || phase === "terminal_failed" || phase === "timed_out") {
return "terminal";
}
if (task.recoverable === true || phase === "recoverable_failed") {
return "recoverable";
}
const dueMs = taskSlaDueMs(task);
if (dueMs !== null && dueMs <= nowMs) {
return "breached";
}
if (task.status === "needs_user_action" || phase === "needs_user_action") {
return "watch";
}
const remainingMs = dueMs === null ? null : dueMs - nowMs;
const currentIdleMs = idleMs(task, nowMs);
if ((remainingMs !== null && remainingMs <= WATCH_WINDOW_MS) || (currentIdleMs !== null && currentIdleMs >= STALE_IDLE_MS)) {
return "watch";
}
return "ok";
}
function severityForSlaLevel(level: MasterAgentTaskSlaLevel): OpsSeverity {
if (level === "breached") return "critical";
if (level === "terminal" || level === "recoverable" || level === "watch") return "warning";
return "info";
}
function recommendedActionForTask(task: MasterAgentTask, level: MasterAgentTaskSlaLevel, autoRecoverable: boolean) {
if (autoRecoverable) return "安全阶段失败,后台扫描会自动重排队并等待本机 agent 重新领取。";
if (level === "recoverable") return "等待下一次安全重试;如持续失败,进入任务恢复页人工重试。";
if (level === "terminal") return "查看执行器错误和设备日志,必要时创建修复工单。";
if (level === "breached") return "核查目标线程或本机执行器是否仍在工作,避免用户误以为任务丢失。";
if (level === "watch") return "继续观察;若超过 SLA自动升为后台告警。";
return "任务在 SLA 内,无需处理。";
}
export function buildMasterAgentTaskSlaRow(
state: BossState,
task: MasterAgentTask,
now: Date = new Date(),
): MasterAgentTaskSlaRow {
const nowMs = now.getTime();
const dueMs = taskSlaDueMs(task);
const level = taskSlaLevel(task, nowMs);
const autoRecoverable = isMasterAgentTaskAutoRecoverable(task, nowMs);
const maxAttempts = defaultMaxAttempts(task);
const attemptCount = task.attemptCount ?? 0;
const currentIdleMs = idleMs(task, nowMs);
const device = deviceForTask(state, task);
const riskId = `master-task:${task.taskId}`;
const summary = level === "terminal"
? task.errorMessage || task.requestText || task.taskType
: task.requestText || task.errorMessage || task.taskType;
return {
taskId: task.taskId,
riskId,
notificationId: `risk-sla-overdue:${riskId}`,
projectId: task.projectId,
deviceId: task.deviceId,
companyId: deviceCompanyId(state, device) || accountCompanyId(state, task.requestedByAccount),
taskType: task.taskType,
status: task.status,
phase: taskPhase(task),
summary,
requestedAt: task.requestedAt,
claimedAt: task.claimedAt ?? "",
lastProgressAt: task.lastProgressAt ?? "",
leaseExpiresAt: task.leaseExpiresAt ?? "",
slaDueAt: dateFromMs(dueMs),
elapsedMs: elapsedMs(task, nowMs),
idleMs: currentIdleMs,
attemptCount,
maxAttempts,
attemptLabel: `${attemptCount}/${maxAttempts}`,
stale: level === "breached" || level === "terminal" || (currentIdleMs !== null && currentIdleMs >= STALE_IDLE_MS),
recoverable: task.recoverable === true || level === "recoverable",
autoRecoverable,
slaLevel: level,
severity: severityForSlaLevel(level),
recommendedAction: recommendedActionForTask(task, level, autoRecoverable),
};
}
export function buildMasterAgentTaskSlaRows(
state: BossState,
now: Date = new Date(),
): MasterAgentTaskSlaRow[] {
return state.masterAgentTasks
.filter(isMasterAgentTaskSlaVisible)
.map((task) => buildMasterAgentTaskSlaRow(state, task, now))
.sort((left, right) => {
const severityRank = { critical: 3, warning: 2, info: 1 };
const levelRank: Record<MasterAgentTaskSlaLevel, number> = {
terminal: 5,
breached: 4,
recoverable: 3,
watch: 2,
ok: 1,
};
return (
severityRank[right.severity] - severityRank[left.severity] ||
levelRank[right.slaLevel] - levelRank[left.slaLevel] ||
right.requestedAt.localeCompare(left.requestedAt)
);
});
}
export function shouldCreateMasterAgentTaskSlaNotification(row: MasterAgentTaskSlaRow) {
return row.slaLevel === "breached" || row.slaLevel === "recoverable" || row.slaLevel === "terminal";
}

View File

@@ -398,6 +398,12 @@ test("backoffice bff exposes yudao style management contract without secrets", a
const staleTask = payload.insights.taskRiskSummary.rows.find((row: { taskId: string }) => row.taskId === "task-stale");
assert.equal(staleTask?.stale, true);
assert.equal(staleTask?.phase, "awaiting_reply");
assert.equal(Array.isArray(payload.insights.taskSlaPanel.rows), true);
assert.equal(payload.insights.taskSlaPanel.summary.breached >= 1, true);
const breachedTask = payload.insights.taskSlaPanel.rows.find((row: { taskId: string }) => row.taskId === "task-stale");
assert.equal(breachedTask?.slaLevel, "breached");
assert.equal(breachedTask?.riskId, "master-task:task-stale");
assert.equal(typeof breachedTask?.recommendedAction, "string");
assert.equal(payload.yudaoMapping.tenant, "adminCompanies");
assert.equal(payload.yudaoMapping.user, "authAccounts");
assert.equal(payload.yudaoMapping.role, "BOSS_PERMISSION_TEMPLATES");

View File

@@ -39,6 +39,7 @@ test("Caddy serves the platform admin subdomain", async () => {
const source = await readSource(caddyfilePath);
assert.match(source, /admin\.boss\.hyzq\.net/);
assert.match(source, /handle \/admin-web\/\* \{\s*root \* \/opt\/boss\/public\s*file_server\s*\}/s);
assert.match(source, /@adminRoot path \//);
assert.match(source, /rewrite \* \/admin-web\/index\.html/);
assert.doesNotMatch(source, /redir \/ \/enterprise-admin/);

View File

@@ -208,3 +208,97 @@ test("risk scan creates operational faults for computer use and boss-agent OTA f
const secondPayload = await second.json();
assert.equal(secondPayload.createdFaults.length, 0);
});
test("risk scan creates SLA notifications for stuck master agent tasks", async () => {
const state = await data.readState();
await data.writeState({
...state,
adminNotifications: [],
masterAgentTasks: [
{
taskId: "task-stuck",
projectId: "project-a",
taskType: "conversation_reply",
requestMessageId: "msg-stuck",
requestText: "让线程继续执行",
executionPrompt: "继续执行并回写结果",
requestedBy: "客户负责人",
requestedByAccount: "customer@example.com",
deviceId: "mac-a",
status: "running",
phase: "awaiting_reply",
requestedAt: "2026-04-27T13:00:00+08:00",
claimedAt: "2026-04-27T13:01:00+08:00",
lastProgressAt: "2026-04-27T13:01:00+08:00",
leaseExpiresAt: "2026-04-27T13:16:00+08:00",
attemptCount: 1,
maxAttempts: 2,
},
],
});
const response = await postScan(await adminRequest("http://127.0.0.1:3000/api/v1/admin/risks/scan", {
method: "POST",
}));
assert.equal(response.status, 200);
const payload = await response.json();
assert.equal(
payload.created.some((notification: { riskId: string }) => notification.riskId === "master-task:task-stuck"),
true,
);
assert.equal(
payload.notifications.some((notification: { title: string }) => notification.title.includes("任务 SLA")),
true,
);
});
test("risk scan automatically requeues safely recoverable master agent tasks", async () => {
const state = await data.readState();
await data.writeState({
...state,
adminNotifications: [],
permissionAuditLogs: [],
adminRiskTimeline: [],
masterAgentTasks: [
{
taskId: "task-recoverable",
projectId: "project-a",
taskType: "conversation_reply",
requestMessageId: "msg-recoverable",
requestText: "继续处理",
executionPrompt: "继续处理并回写结果",
requestedBy: "客户负责人",
requestedByAccount: "customer@example.com",
deviceId: "mac-a",
status: "running",
phase: "recoverable_failed",
requestedAt: "2026-04-27T13:00:00+08:00",
claimedAt: "2026-04-27T13:01:00+08:00",
lastProgressAt: "2026-04-27T13:02:00+08:00",
leaseExpiresAt: "2026-04-27T13:16:00+08:00",
attemptCount: 1,
maxAttempts: 2,
recoverable: true,
nextRetryAt: "2026-04-27T13:03:00+08:00",
lastErrorCode: "RECOVERABLE_RUNTIME_FAILURE",
errorMessage: "CODEX_APP_SERVER_TIMEOUT",
},
],
});
const response = await postScan(await adminRequest("http://127.0.0.1:3000/api/v1/admin/risks/scan", {
method: "POST",
}));
assert.equal(response.status, 200);
const payload = await response.json();
assert.equal(payload.autoRecovered.length, 1);
assert.equal(payload.autoRecovered[0].taskId, "task-recoverable");
const nextState = await data.readState();
const task = nextState.masterAgentTasks.find((item) => item.taskId === "task-recoverable");
assert.equal(task?.status, "queued");
assert.equal(task?.phase, "queued");
assert.equal(task?.recoverable, false);
assert.equal(nextState.permissionAuditLogs.some((log) => log.action === "master_agent.task_retried"), true);
assert.equal(nextState.adminRiskTimeline.some((event) => event.action === "task.auto_recovery_requeued"), true);
});

View File

@@ -16,7 +16,7 @@ test("BossApiClient exposes a lightweight project messages endpoint", async () =
);
assert.match(
source,
/return requestWithRestore\("GET", "\/api\/v1\/projects\/" \+ encode\(projectId\) \+ "\/messages", null\);/,
/return requestWithRestoreRaw\(\s*"GET",\s*"\/api\/v1\/projects\/" \+ encode\(projectId\) \+ "\/messages",\s*null,\s*DEFAULT_CONNECT_TIMEOUT_MS,\s*CONVERSATIONS_READ_TIMEOUT_MS\s*\);/s,
"expected lightweight message refreshes to reuse the dedicated messages route",
);
});
@@ -36,7 +36,7 @@ test("ProjectDetailActivity reserves full realtime reloads for non-message event
);
assert.match(
source,
/void triggerRealtimeReload\(boolean requireFullSnapshot\) \{\s*if \(requireFullSnapshot\) \{\s*reload\(\);\s*return;\s*\}\s*reloadMessagesOnly\(\);\s*\}/s,
/void triggerRealtimeReload\(boolean requireFullSnapshot\) \{\s*if \(requireFullSnapshot\) \{\s*reloadInBackground\(false\);\s*return;\s*\}\s*reloadMessagesOnly\(\);\s*\}/s,
"expected debounced realtime reloads to choose between full and lightweight refresh paths",
);
});

View File

@@ -6,13 +6,23 @@ async function readSource(path: string) {
return readFile(new URL(path, import.meta.url), "utf8");
}
test("events route enriches message events with a lightweight project chat payload", async () => {
test("events route supports message patch v1 while retaining snapshot fallback", async () => {
const source = await readSource("../src/app/api/v1/events/route.ts");
assert.match(
source,
/projectMessagesPayload:\s*buildProjectMessagesRealtimePayload\(state,\s*String\(payload\.projectId \?\? ""\)\)/,
"expected realtime event route to include a lightweight project chat payload for message events",
/x-boss-realtime-capabilities/,
"expected realtime event route to inspect native app capability headers",
);
assert.match(
source,
/projectMessagesPatch/,
"expected realtime event route to emit a message patch for capable clients",
);
assert.match(
source,
/projectMessagesPayload:\s*buildProjectMessagesRealtimePayload/,
"expected realtime event route to retain full snapshot fallback for older clients",
);
});
@@ -29,6 +39,16 @@ test("ProjectDetailActivity applies lightweight realtime chat payloads before sc
/JSONObject projectMessagesPayload = event\.payload\.optJSONObject\("projectMessagesPayload"\);/,
"expected chat page to read the lightweight message payload from realtime events",
);
assert.match(
source,
/JSONObject projectMessagesPatch = event\.payload\.optJSONObject\("projectMessagesPatch"\);/,
"expected chat page to read message-patch-v1 realtime payloads first",
);
assert.match(
source,
/scheduleRealtimeReload\(false\);/,
"expected chat page to fall back to a debounced message reload when a patch has a gap",
);
assert.match(
source,
/renderLoadedProjectSnapshot\(new ProjectSnapshot\(projectMessagesPayload,\s*null,\s*null\)\);/,

View File

@@ -142,6 +142,30 @@ test("highest admin can inspect and revoke all active sessions", async () => {
assert.equal(await data.getAuthSession(worker.sessionToken), null);
});
test("getAuthSession validates a session without touching lastSeenAt", async () => {
const session = await data.createAuthSession({
account: "krisolo",
role: "highest_admin",
displayName: "Boss",
loginMethod: "password",
});
const stableLastSeenAt = "2026-04-26T12:00:00+08:00";
const state = await data.readState();
const storedSession = state.authSessions.find((item) => item.sessionId === session.sessionId);
assert.ok(storedSession);
storedSession.lastSeenAt = stableLastSeenAt;
await data.writeState(state);
const resolvedSession = await data.getAuthSession(session.sessionToken);
assert.equal(resolvedSession?.lastSeenAt, stableLastSeenAt);
const after = await data.readState();
assert.equal(
after.authSessions.find((item) => item.sessionId === session.sessionId)?.lastSeenAt,
stableLastSeenAt,
);
});
test("primary admin session uses the current production admin account", async () => {
const session = await data.createPrimaryAdminSession();
assert.equal(session.account, "krisolo");

View File

@@ -134,6 +134,52 @@ test("independent Boss admin web app exposes management actions instead of read
assert.match(appSource, /handleCodexRemoteControl/);
});
test("independent Boss admin web app exposes skill management dispatch workspace", async () => {
const [appSource, apiSource] = await Promise.all([
readSource("../apps/boss-admin-web/src/App.vue"),
readSource("../apps/boss-admin-web/src/api/bossAdmin.ts"),
]);
assert.match(apiSource, /fetchSkillLifecycleRequests/);
assert.match(apiSource, /\/api\/v1\/admin\/skills\/requests/);
assert.match(apiSource, /method:\s*["']GET["']/);
for (const label of [
"Skill 管理分发",
"快捷下发",
"Skill 请求队列",
"待执行",
"执行中",
"最近请求",
"安装远端 Skill",
"更新下发",
"回滚",
"版本锁定",
]) {
assert.match(appSource, new RegExp(label));
}
assert.match(appSource, /skillLifecycleRequests/);
assert.match(appSource, /loadSkillLifecycleRequests/);
assert.match(appSource, /quickSkillRequest/);
});
test("independent Boss admin web app keeps backup tables inside their cards", async () => {
const [appSource, cssSource] = await Promise.all([
readSource("../apps/boss-admin-web/src/App.vue"),
readSource("../apps/boss-admin-web/src/styles.css"),
]);
assert.match(appSource, /boss-admin-wide-card/);
assert.match(cssSource, /\.ant-card\s*\{/);
assert.match(cssSource, /\.boss-admin-wide-card/);
assert.match(cssSource, /grid-column:\s*1\s*\/\s*-1/);
assert.match(cssSource, /min-width:\s*0/);
assert.match(cssSource, /\.ant-table-wrapper\s*\{/);
assert.match(cssSource, /overflow-x:\s*auto/);
assert.match(cssSource, /word-break:\s*break-word/);
assert.match(cssSource, /white-space:\s*normal/);
});
test("root Next project isolates the independent Vue admin workspace", async () => {
const [tsconfigSource, eslintSource, rootPkgSource] = await Promise.all([
readSource("../tsconfig.json"),

View File

@@ -26,6 +26,21 @@ test("events route enriches project conversation events with a visible home item
);
});
test("events route coalesces enriched payload building across realtime clients", async () => {
const source = await readSource("../src/app/api/v1/events/route.ts");
assert.match(
source,
/getSharedEventPayload/,
"expected realtime event route to share one enriched payload build across concurrent SSE clients",
);
assert.match(
source,
/sharedEventPayloads/,
"expected realtime event route to keep a short-lived shared payload cache",
);
});
test("MainActivity applies realtime conversation patches without forcing a network refresh", async () => {
const [mainActivity, mapper] = await Promise.all([
readSource("../android/app/src/main/java/com/hyzq/boss/MainActivity.java"),

View File

@@ -180,7 +180,7 @@ test("allow_always applies only to the active folder and does not unlock other f
assert.equal(blocked.policy.allowPolicy, "forbid");
});
test("claimNextMasterAgentTask keeps conversation replies queued when the device prefers gui mode", async () => {
test("claimNextMasterAgentTask lets conversation replies run through gui mode when a gui execution channel is available", async () => {
await setup();
const project = await getCliProject();
@@ -205,6 +205,49 @@ test("claimNextMasterAgentTask keeps conversation replies queued when the device
const claimed = await claimNextMasterAgentTask("mac-studio");
assert.equal(claimed?.taskId, task.taskId);
const state = await readState();
const running = state.masterAgentTasks.find((item) => item.taskId === task.taskId);
assert.equal(running?.status, "running");
});
test("claimNextMasterAgentTask keeps conversation replies queued when preferred gui mode has no gui channel", async () => {
await setup();
const project = await getCliProject();
await updateDevice("mac-studio", {
preferredExecutionMode: "gui",
capabilities: {
gui: {
connected: false,
lastSeenAt: "2026-04-06T10:00:00.000Z",
lastActiveProjectId: "",
},
codexAppServer: {
connected: false,
lastSeenAt: "2026-04-06T10:00:00.000Z",
lastActiveProjectId: "",
},
},
});
const task = await queueMasterAgentTask({
projectId: project.id,
requestMessageId: "msg-preferred-gui-no-channel",
requestText: "继续推进当前线程任务",
executionPrompt: "请继续推进当前线程任务",
requestedBy: "Boss 超级管理员",
requestedByAccount: "krisolo",
deviceId: "mac-studio",
taskType: "conversation_reply",
targetProjectId: project.id,
targetThreadId: project.threadMeta.threadId,
targetThreadDisplayName: project.threadMeta.threadDisplayName,
targetCodexThreadRef: project.threadMeta.codexThreadRef,
targetCodexFolderRef: project.threadMeta.codexFolderRef,
});
const claimed = await claimNextMasterAgentTask("mac-studio");
assert.equal(claimed, null);
const state = await readState();
const queued = state.masterAgentTasks.find((item) => item.taskId === task.taskId);
@@ -389,6 +432,7 @@ test("claimNextMasterAgentTask reclaims stale running conversation replies for t
const runningTask = state.masterAgentTasks.find((item) => item.taskId === task.taskId);
assert.equal(runningTask?.status, "running");
runningTask!.claimedAt = "2026-04-01T00:00:00.000Z";
runningTask!.lastProgressAt = "2026-04-01T00:00:00.000Z";
await writeState(state);
const reclaimed = await claimNextMasterAgentTask("mac-studio");

View File

@@ -28,7 +28,7 @@ test.after(async () => {
}
});
test("device heartbeat mirrors recent codex desktop replies into the matching thread conversation once", async () => {
test("device heartbeat records recent codex desktop activity without mirroring reply text", async () => {
await setup();
const seedHeartbeat = {
@@ -70,6 +70,7 @@ test("device heartbeat mirrors recent codex desktop replies into the matching th
projectCandidates: [
{
...seedHeartbeat.projectCandidates[0],
lastActiveAt: "2026-04-20T10:02:10.000Z",
recentAssistantMessages: [
{
messageId: "codex-thread:thread-boss-main:2026-04-20T10:02:10.000Z:reply-1",
@@ -87,13 +88,10 @@ test("device heartbeat mirrors recent codex desktop replies into the matching th
(message) => message.externalMessageId === "codex-thread:thread-boss-main:2026-04-20T10:02:10.000Z:reply-1",
);
assert.ok(mirroredMessage);
assert.equal(mirroredMessage?.sender, "device");
assert.equal(mirroredMessage?.senderLabel, "Boss开发主线程");
assert.equal(mirroredMessage?.body, "桌面 Codex 已经把会话实时同步链路修好了。");
assert.equal(nextProject?.lastMessageAt, "2026-04-20T10:02:10.000Z");
assert.equal(nextProject?.preview, "桌面 Codex 已经把会话实时同步链路修好了。");
assert.equal(nextProject?.unreadCount, 1);
assert.equal(mirroredMessage, undefined);
assert.equal(nextProject?.messages.some((message) => message.externalMessageId), false);
assert.equal(nextProject?.threadMeta.lastObservedCodexActivityAt, "2026-04-20T10:02:10.000Z");
assert.equal(nextProject?.unreadCount, 0);
await upsertDeviceHeartbeat({
...seedHeartbeat,
@@ -116,8 +114,86 @@ test("device heartbeat mirrors recent codex desktop replies into the matching th
const mirroredCopies = nextProject?.messages.filter(
(message) => message.externalMessageId === "codex-thread:thread-boss-main:2026-04-20T10:02:10.000Z:reply-1",
);
assert.equal(mirroredCopies?.length, 1);
assert.equal(nextProject?.unreadCount, 1);
assert.equal(mirroredCopies?.length, 0);
assert.equal(nextProject?.unreadCount, 0);
});
test("device heartbeat records codex activity without appending uncorrelated desktop replies", async () => {
await setup();
const seedHeartbeat = {
deviceId: "device-message-activity-only",
token: "device-message-activity-only-token",
name: "Mac Studio",
avatar: "M",
account: "krisolo",
status: "online" as const,
quota5h: 76,
quota7d: 85,
projects: [],
endpoint: "mac://kris.local",
projectCandidates: [
{
folderName: "juyuwan",
folderRef: "/Users/kris/Documents/juyuwan",
threadId: "thread-juyuwan-activity-only",
threadDisplayName: "juyuwan",
codexFolderRef: "/Users/kris/Documents/juyuwan",
codexThreadRef: "thread-juyuwan-activity-only",
lastActiveAt: "2026-06-07T16:30:00.000Z",
suggestedImport: true,
},
],
};
await upsertDeviceHeartbeat(seedHeartbeat);
await upsertDeviceHeartbeat(seedHeartbeat);
const initialState = await readState();
const importedProject = initialState.projects.find(
(project) => project.threadMeta.codexThreadRef === "thread-juyuwan-activity-only",
);
assert.ok(importedProject, "expected heartbeat auto-import to create the thread conversation");
importedProject!.messages = [];
importedProject!.preview = "";
importedProject!.unreadCount = 0;
await writeState(initialState);
await upsertDeviceHeartbeat({
...seedHeartbeat,
projectCandidates: [
{
...seedHeartbeat.projectCandidates[0],
lastActiveAt: "2026-06-07T16:34:15.000Z",
recentAssistantMessages: [
{
messageId: "codex-thread:thread-juyuwan-activity-only:2026-06-07T16:34:15.000Z:final-1",
body: "已完成下一步并实机验证了。APK 已安装到 K30 Ultra。",
sentAt: "2026-06-07T16:34:15.000Z",
phase: "final_answer",
},
],
},
],
});
const nextState = await readState();
const nextProject = nextState.projects.find((project) => project.id === importedProject?.id);
const mirroredMessage = nextProject?.messages.find(
(message) =>
message.externalMessageId ===
"codex-thread:thread-juyuwan-activity-only:2026-06-07T16:34:15.000Z:final-1",
);
const progressEvent = nextState.threadProgressEvents.find(
(event) => event.projectId === importedProject?.id && event.createdAt === "2026-06-07T16:34:15.000Z",
);
assert.equal(mirroredMessage, undefined);
assert.equal(nextProject?.messages.length, 0);
assert.equal(nextProject?.preview, "");
assert.equal(nextProject?.unreadCount, 0);
assert.equal(nextProject?.threadMeta.lastObservedCodexActivityAt, "2026-06-07T16:34:15.000Z");
assert.ok(progressEvent, "expected heartbeat activity to remain visible as a thread progress event");
});
test("device heartbeat does not duplicate a reply already written by task completion", async () => {
@@ -283,7 +359,7 @@ test("device heartbeat does not duplicate a takeover reply already written by ma
assert.equal(nextProject?.preview, replyBody);
});
test("device heartbeat does not count commentary replies as unread and keeps only the final result unread", async () => {
test("device heartbeat ignores commentary and final assistant text as chat messages", async () => {
await setup();
const seedHeartbeat = {
@@ -314,11 +390,22 @@ test("device heartbeat does not count commentary replies as unread and keeps onl
await upsertDeviceHeartbeat(seedHeartbeat);
await upsertDeviceHeartbeat(seedHeartbeat);
const initialState = await readState();
const importedProject = initialState.projects.find(
(project) => project.threadMeta.codexThreadRef === "thread-boss-phase",
);
assert.ok(importedProject);
importedProject!.messages = [];
importedProject!.preview = "";
importedProject!.unreadCount = 0;
await writeState(initialState);
await upsertDeviceHeartbeat({
...seedHeartbeat,
projectCandidates: [
{
...seedHeartbeat.projectCandidates[0],
lastActiveAt: "2026-04-20T10:05:00.000Z",
recentAssistantMessages: [
{
messageId: "codex-thread:thread-boss-phase:2026-04-20T10:03:00.000Z:commentary-1",
@@ -351,10 +438,12 @@ test("device heartbeat does not count commentary replies as unread and keeps onl
);
assert.ok(nextProject);
assert.equal(processMessage?.kind, "thread_process");
assert.equal(finalMessage?.kind, "text");
assert.equal(nextProject?.preview, "这轮已经完成折叠修复,未读现在只会算最终结果。");
assert.equal(nextProject?.unreadCount, 1);
assert.equal(processMessage, undefined);
assert.equal(finalMessage, undefined);
assert.equal(nextProject?.messages.length, 0);
assert.equal(nextProject?.preview, "");
assert.equal(nextProject?.unreadCount, 0);
assert.equal(nextProject?.threadMeta.lastObservedCodexActivityAt, "2026-04-20T10:05:00.000Z");
});
test("device heartbeat does not replay old desktop replies after conversation history is cleared", async () => {
@@ -441,13 +530,14 @@ test("device heartbeat does not replay old desktop replies after conversation hi
(message) =>
message.externalMessageId === "codex-thread:thread-boss-reset:2026-04-20T10:11:00.000Z:new-final",
),
true,
false,
);
assert.equal(nextProject?.preview, "这条新回复应该继续同步回来。");
assert.equal(nextProject?.unreadCount, 1);
assert.equal(nextProject?.preview, "");
assert.equal(nextProject?.unreadCount, 0);
assert.equal(nextProject?.threadMeta.lastObservedCodexActivityAt, "2026-04-20T10:12:00.000Z");
});
test("device heartbeat legacy process text is normalized to thread_process and does not become preview", async () => {
test("device heartbeat process text is kept out of the chat transcript", async () => {
await setup();
const seedHeartbeat = {
@@ -480,6 +570,13 @@ test("device heartbeat legacy process text is normalized to thread_process and d
const resetState = await readState();
resetState.conversationHistoryClearedAt = undefined;
const importedProject = resetState.projects.find(
(project) => project.threadMeta.codexThreadRef === "thread-boss-legacy-process",
);
assert.ok(importedProject);
importedProject!.messages = [];
importedProject!.preview = "";
importedProject!.unreadCount = 0;
await writeState(resetState);
await upsertDeviceHeartbeat({
@@ -487,6 +584,7 @@ test("device heartbeat legacy process text is normalized to thread_process and d
projectCandidates: [
{
...seedHeartbeat.projectCandidates[0],
lastActiveAt: "2026-04-20T10:05:00.000Z",
recentAssistantMessages: [
{
messageId: "codex-thread:thread-boss-legacy-process:2026-04-20T10:03:00.000Z:commentary-legacy",
@@ -516,7 +614,9 @@ test("device heartbeat legacy process text is normalized to thread_process and d
);
assert.ok(nextProject);
assert.equal(legacyProcessMessage?.kind, "thread_process");
assert.equal(nextProject?.preview, "这轮已经完成折叠修复,未读现在只会算最终结果。");
assert.equal(nextProject?.unreadCount, 1);
assert.equal(legacyProcessMessage, undefined);
assert.equal(nextProject?.messages.length, 0);
assert.equal(nextProject?.preview, "");
assert.equal(nextProject?.unreadCount, 0);
assert.equal(nextProject?.threadMeta.lastObservedCodexActivityAt, "2026-04-20T10:05:00.000Z");
});

View File

@@ -38,6 +38,12 @@ function buildHeartbeatPayload(deviceId: string, projectCandidates: Array<{
codexFolderRef: string;
codexThreadRef: string;
lastActiveAt: string;
recentAssistantMessages?: Array<{
messageId: string;
body: string;
sentAt: string;
phase?: "commentary" | "final_answer";
}>;
}>) {
return {
deviceId,
@@ -84,6 +90,52 @@ test("unchanged device heartbeats do not publish conversation refresh events", a
assert.deepEqual(events.map((event) => event.event), ["devices.updated"]);
});
test("assistant observations refresh conversation metadata without publishing message refresh events", async () => {
await setup();
const deviceId = "noise-device-assistant-observation";
const heartbeat = buildHeartbeatPayload(deviceId, [bossThreadCandidate]);
await upsertDeviceHeartbeat(heartbeat);
await upsertDeviceHeartbeat(heartbeat);
const observedHeartbeat = buildHeartbeatPayload(deviceId, [
{
...bossThreadCandidate,
lastActiveAt: "2026-04-10T10:02:00.000Z",
recentAssistantMessages: [
{
messageId: "codex-thread:thread-boss-main:2026-04-10T10:02:00.000Z:final",
body: "桌面线程的最终回复不应作为手机聊天消息刷新。",
sentAt: "2026-04-10T10:02:00.000Z",
phase: "final_answer",
},
],
},
]);
const firstEvents: Array<{ event: string; payload: { projectId?: string } }> = [];
const unsubscribeFirst = subscribeBossEvents((event, payload) => {
firstEvents.push({ event, payload });
});
await upsertDeviceHeartbeat(observedHeartbeat);
unsubscribeFirst();
assert.equal(firstEvents.some((event) => event.event === "project.messages.updated"), false);
assert.deepEqual(
firstEvents.map((event) => event.event),
["devices.updated", "conversation.updated"],
);
const repeatedEvents: Array<{ event: string; payload: { projectId?: string } }> = [];
const unsubscribeRepeated = subscribeBossEvents((event, payload) => {
repeatedEvents.push({ event, payload });
});
await upsertDeviceHeartbeat(observedHeartbeat);
unsubscribeRepeated();
assert.deepEqual(repeatedEvents.map((event) => event.event), ["devices.updated"]);
});
test("device heartbeats publish one conversation refresh when import candidates change", async () => {
await setup();

View File

@@ -575,6 +575,27 @@ rl.on("line", (line) => {
}
if (message.method === "thread/resume") {
if (process.env.BOSS_CODEX_APP_SERVER_FIXTURE_ACTIVE_TURN_ON_RESUME === "1") {
send({
id: message.id,
result: {
thread: {
id: message.params?.threadId ?? "thread-fixture",
name: "fixture thread",
turns: [
{
id: "active-turn-from-resume",
status: "inProgress",
startedAt: 1780852200,
completedAt: null,
items: [],
},
],
},
},
});
return;
}
send({
id: message.id,
result: {

View File

@@ -1355,6 +1355,49 @@ test("codex app-server runner steers an active turn when a target turn id is pre
}
});
test("codex app-server runner steers an active resumed turn instead of starting a competing turn", async () => {
const previousActiveTurn = process.env.BOSS_CODEX_APP_SERVER_FIXTURE_ACTIVE_TURN_ON_RESUME;
const previousSteer = process.env.BOSS_CODEX_APP_SERVER_FIXTURE_STEER;
process.env.BOSS_CODEX_APP_SERVER_FIXTURE_ACTIVE_TURN_ON_RESUME = "1";
process.env.BOSS_CODEX_APP_SERVER_FIXTURE_STEER = "1";
try {
const runnerConfig = getCodexAppServerRunnerConfig(process.env, {
codexAppServerEnabled: true,
codexAppServerCommand: process.execPath,
codexAppServerArgs: ["tests/fixtures/codex-app-server-runtime.mjs"],
codexAppServerWorkdir: repoRoot,
codexAppServerTimeoutMs: 5000,
});
const result = await executeCodexAppServerTask(runnerConfig, {
taskId: "task-auto-steer-active-turn",
taskType: "conversation_reply",
targetCodexThreadRef: "active-thread-from-resume",
targetCodexFolderRef: repoRoot,
mirrorBossUserMessageToCodexDesktop: true,
executionPrompt: "手机端补充:继续下一步",
});
assert.equal(result.status, "completed");
assert.equal(result.threadId, "active-thread-from-resume");
assert.equal(result.turnId, "active-turn-from-resume");
assert.equal(result.turnControl, "steer");
assert.equal(result.replyBody, "STEERED:手机端补充:继续下一步");
assert.equal(result.threadHistorySync, undefined);
} finally {
if (previousActiveTurn === undefined) {
delete process.env.BOSS_CODEX_APP_SERVER_FIXTURE_ACTIVE_TURN_ON_RESUME;
} else {
process.env.BOSS_CODEX_APP_SERVER_FIXTURE_ACTIVE_TURN_ON_RESUME = previousActiveTurn;
}
if (previousSteer === undefined) {
delete process.env.BOSS_CODEX_APP_SERVER_FIXTURE_STEER;
} else {
process.env.BOSS_CODEX_APP_SERVER_FIXTURE_STEER = previousSteer;
}
}
});
test("codex app-server runner interrupts the active turn when the task is canceled while running", async () => {
const previous = process.env.BOSS_CODEX_APP_SERVER_FIXTURE_WAIT_FOR_INTERRUPT;
process.env.BOSS_CODEX_APP_SERVER_FIXTURE_WAIT_FOR_INTERRUPT = "1";

View File

@@ -0,0 +1,21 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
test("master agent complete route does not block completion response on Telegram delivery", async () => {
const source = await readFile(
new URL("../src/app/api/v1/master-agent/tasks/[taskId]/complete/route.ts", import.meta.url),
"utf8",
);
assert.doesNotMatch(
source,
/await\s+deliverTelegramReplyForCompletedTask/,
"task completion must not wait for Telegram delivery before returning to local-agent",
);
assert.match(
source,
/void\s+deliverTelegramReplyForCompletedTask/,
"expected Telegram delivery to be scheduled in the background after the task is persisted",
);
});

View File

@@ -120,6 +120,79 @@ test("expired task after turn start is timed out instead of duplicated", async (
assert.equal(task?.recoverable, false);
});
test("recoverable codex app-server runtime failure requeues conversation reply", async () => {
await setup();
const state = await data.readState();
state.masterAgentTasks.unshift(
makeQueuedTask("task-runtime-retry", {
projectId: "project-juyuwan",
targetProjectId: "project-juyuwan",
targetThreadId: "thread-juyuwan",
status: "running",
phase: "awaiting_reply",
claimedAt: "2026-06-07T06:05:16.000Z",
lastProgressAt: "2026-06-07T06:10:00.000Z",
leaseExpiresAt: "2026-06-07T06:20:16.000Z",
attemptCount: 1,
maxAttempts: 2,
}),
);
await data.writeState(state);
const completed = await data.completeMasterAgentTask({
taskId: "task-runtime-retry",
deviceId: "mac-studio",
status: "failed",
errorMessage: "CODEX_APP_SERVER_TURN_INTERRUPTED",
});
assert.equal(completed.status, "queued");
assert.equal(completed.phase, "recoverable_failed");
assert.equal(completed.recoverable, true);
assert.equal(completed.errorMessage, "CODEX_APP_SERVER_TURN_INTERRUPTED");
assert.equal(completed.attemptCount, 1);
assert.equal(completed.completedAt, undefined);
assert.equal(completed.claimedAt, undefined);
assert.ok(completed.nextRetryAt);
const claimed = await data.claimNextMasterAgentTask("mac-studio");
assert.equal(claimed?.taskId, "task-runtime-retry");
assert.equal(claimed?.attemptCount, 2);
});
test("recoverable codex app-server runtime failure becomes terminal after max attempts", async () => {
await setup();
const state = await data.readState();
state.masterAgentTasks.unshift(
makeQueuedTask("task-runtime-terminal", {
projectId: "project-juyuwan",
targetProjectId: "project-juyuwan",
targetThreadId: "thread-juyuwan",
status: "running",
phase: "awaiting_reply",
claimedAt: "2026-06-07T06:05:16.000Z",
lastProgressAt: "2026-06-07T06:10:00.000Z",
leaseExpiresAt: "2026-06-07T06:20:16.000Z",
attemptCount: 2,
maxAttempts: 2,
}),
);
await data.writeState(state);
const completed = await data.completeMasterAgentTask({
taskId: "task-runtime-terminal",
deviceId: "mac-studio",
status: "failed",
errorMessage: "CODEX_APP_SERVER_TIMEOUT",
});
assert.equal(completed.status, "failed");
assert.equal(completed.phase, "terminal_failed");
assert.equal(completed.recoverable, false);
assert.equal(completed.errorMessage, "CODEX_APP_SERVER_TIMEOUT");
assert.equal(completed.completedAt !== undefined, true);
});
test("codex app server health distinguishes available, degraded, and unavailable", async () => {
await setup();
assert.equal(data.resolveCodexAppServerHealth(undefined), "unavailable");

View File

@@ -156,3 +156,59 @@ test("local agent infrastructure failures stay out of master agent chat", async
"expected the operational log to remain available outside chat",
);
});
test("读取已有状态时会把历史 Codex App Server 错误码转成人类可读说明", async () => {
await setup();
const state = await readState();
state.projects.push({
id: "project-runtime-error-redaction",
name: "juyuwan",
pinned: false,
systemPinned: false,
deviceIds: ["mac-studio"],
preview: "juyuwan 执行失败CODEX_APP_SERVER_TURN_INTERRUPTED",
updatedAt: "2026-06-07T14:20:00+08:00",
lastMessageAt: "2026-06-07T14:20:00+08:00",
isGroup: false,
threadMeta: {
projectId: "project-runtime-error-redaction",
threadId: "thread-runtime-error-redaction",
threadDisplayName: "juyuwan",
folderName: "juyuwan",
activityIconCount: 0,
updatedAt: "2026-06-07T14:20:00+08:00",
codexThreadRef: "019e9b84-decc-7510-b84f-57c5a27de0e3",
codexFolderRef: "juyuwan",
},
groupMembers: [],
createdByAgent: true,
collaborationMode: "development",
approvalState: "not_required",
unreadCount: 1,
riskLevel: "low",
messages: [
{
id: "msg-runtime-error-redaction",
sender: "ops",
senderLabel: "juyuwan",
body: "juyuwan 执行失败CODEX_APP_SERVER_TURN_INTERRUPTED",
sentAt: "2026-06-07T14:20:00+08:00",
kind: "text",
},
],
goals: [],
versions: [],
});
await writeState(state);
const nextState = await readState();
const project = nextState.projects.find((item) => item.id === "project-runtime-error-redaction");
assert.ok(project, "expected a reloaded project");
const message = project.messages.find((item) => item.id === "msg-runtime-error-redaction");
assert.ok(message, "expected the historical message to remain");
assert.equal(message.body.includes("CODEX_APP_SERVER_TURN_INTERRUPTED"), false);
assert.equal(project.preview.includes("CODEX_APP_SERVER_TURN_INTERRUPTED"), false);
assert.match(message.body, /Codex 桌面线程本轮被中断/);
assert.match(project.preview, /Codex 桌面线程本轮被中断/);
});

View File

@@ -1592,6 +1592,123 @@ test("POST /api/v1/master-agent/tasks/[taskId]/complete folds thread commentary
assert.equal(updatedProject?.unreadCount, 1);
});
test("POST /api/v1/master-agent/tasks/[taskId]/complete strips already mirrored process text from aggregate thread replies", async () => {
await setup();
const singleProject = await ensureSingleThreadProject();
assert.ok(singleProject, "expected a seeded single-thread project");
await resetThreadExecutionState(singleProject.id);
await setProjectTakeover(singleProject.id, false);
const sendResponse = await postMessageRoute(
await createAuthedRequest(
`http://127.0.0.1:3000/api/v1/projects/${singleProject.id}/messages`,
"POST",
{ body: "只要不对原有项目有任何影响。你按最推荐的方式做。" },
),
{ params: Promise.resolve({ projectId: singleProject.id }) },
);
const sendPayload = (await sendResponse.json()) as { task?: { taskId: string } };
const queuedState = await readState();
const task = queuedState.masterAgentTasks.find(
(item) => item.taskId === sendPayload.task?.taskId,
);
assert.ok(task, "expected a queued conversation_reply task");
const processOne = "我先按非侵入方式收口:不碰原项目代码,只把这次 APP 端问题整理成可复现、可提交的诊断文档。";
const processTwo = "我在按调试流程收证据,但会收在文档里,不会动任何现有项目文件。";
const processThree = "文档已经落下来了,我再做一次范围确认,确保只有独立报告被新增。";
const finalText = "我按非侵入方式处理了,没有碰任何原有项目代码,只新增了一份独立排查文档。";
const aggregateReply = `${processOne}${processTwo}${processThree}${finalText}`;
const requestedAtMs = Date.parse(task.requestedAt);
assert.ok(Number.isFinite(requestedAtMs), "expected task requestedAt to be parseable");
const taskRelativeTime = (offsetMs: number) => new Date(requestedAtMs + offsetMs).toISOString();
const mirroredState = await readState();
const project = mirroredState.projects.find((item) => item.id === singleProject.id);
assert.ok(project, "expected the single-thread project to exist");
project.messages = project.messages.filter(
(message) =>
message.id === task.requestMessageId ||
message.executionProgress?.taskId === task.taskId,
);
project.messages.push(
{
id: "msg-process-one",
sender: "device",
senderLabel: project.threadMeta.threadDisplayName,
body: processOne,
sentAt: taskRelativeTime(1_000),
kind: "thread_process",
},
{
id: "msg-process-two",
sender: "device",
senderLabel: project.threadMeta.threadDisplayName,
body: processTwo,
sentAt: taskRelativeTime(2_000),
kind: "thread_process",
},
{
id: "msg-process-three",
sender: "device",
senderLabel: project.threadMeta.threadDisplayName,
body: processThree,
sentAt: taskRelativeTime(3_000),
kind: "thread_process",
},
{
id: "msg-final-mirrored",
sender: "device",
senderLabel: project.threadMeta.threadDisplayName,
body: finalText,
sentAt: taskRelativeTime(4_000),
kind: "text",
},
);
project.preview = finalText;
project.lastMessageAt = taskRelativeTime(4_000);
project.unreadCount = 1;
await writeState(mirroredState);
const response = await completeMasterTaskRoute(
await createAuthedRequest(
`http://127.0.0.1:3000/api/v1/master-agent/tasks/${task.taskId}/complete`,
"POST",
{
deviceId: task.deviceId,
status: "completed",
targetProjectId: singleProject.id,
targetThreadId: singleProject.threadMeta.threadId,
replyBody: aggregateReply,
},
),
{ params: Promise.resolve({ taskId: task.taskId }) },
);
assert.equal(response.status, 200);
const nextState = await readState();
const updatedProject = nextState.projects.find((item) => item.id === singleProject.id);
const aggregateMessages =
updatedProject?.messages.filter((message) => message.body === aggregateReply) ?? [];
const finalMessages = updatedProject?.messages.filter((message) => message.body === finalText) ?? [];
assert.equal(aggregateMessages.length, 0, "aggregate process+final reply should not be displayed");
assert.equal(finalMessages.length, 1, "already mirrored final result should not be duplicated");
assert.equal(updatedProject?.preview, finalText);
assert.equal(updatedProject?.unreadCount, 1);
const cleanupState = await readState();
const cleanupProject = cleanupState.projects.find((item) => item.id === singleProject.id);
if (cleanupProject) {
cleanupProject.messages = [];
cleanupProject.preview = "测试线程等待继续处理。";
cleanupProject.lastMessageAt = "2026-04-04T11:30:00+08:00";
cleanupProject.unreadCount = 0;
}
await writeState(cleanupState);
});
test("POST /api/v1/master-agent/tasks/[taskId]/complete keeps compact numbered progress updates folded", async () => {
await setup();
const singleProject = await ensureSingleThreadProject();
@@ -1649,7 +1766,7 @@ test("POST /api/v1/master-agent/tasks/[taskId]/complete keeps compact numbered p
assert.equal(updatedProject?.unreadCount, 0);
});
test("device heartbeat keeps conversation preview on the latest non-process message", async () => {
test("device heartbeat activity does not overwrite conversation preview with desktop process text", async () => {
await setup();
const singleProject = await ensureSingleThreadProject();
assert.ok(singleProject, "expected a seeded single-thread project");
@@ -1713,12 +1830,14 @@ test("device heartbeat keeps conversation preview on the latest non-process mess
(message) => message.externalMessageId === "codex-thread:preview-keep:2026-04-24T05:41:14.246Z:p1",
);
assert.equal(processMessage?.kind, "thread_process");
assert.equal(processMessage, undefined);
assert.equal(updatedProject?.messages.length, 1);
assert.equal(updatedProject?.preview, "这是上一轮最终结果。");
assert.equal(updatedProject?.unreadCount, 0);
assert.equal(updatedProject?.threadMeta.lastObservedCodexActivityAt, "2026-04-24T05:41:14.246Z");
});
test("device heartbeat keeps conversation preview blank when only process messages are mirrored", async () => {
test("device heartbeat activity clears stale process preview without appending desktop process text", async () => {
await setup();
const singleProject = await ensureSingleThreadProject();
assert.ok(singleProject, "expected a seeded single-thread project");
@@ -1773,9 +1892,11 @@ test("device heartbeat keeps conversation preview blank when only process messag
(message) => message.externalMessageId === "codex-thread:preview-empty:2026-04-24T05:41:14.246Z:p1",
);
assert.equal(processMessage?.kind, "thread_process");
assert.equal(processMessage, undefined);
assert.equal(updatedProject?.messages.length, 0);
assert.equal(updatedProject?.preview, "");
assert.equal(updatedProject?.unreadCount, 0);
assert.equal(updatedProject?.threadMeta.lastObservedCodexActivityAt, "2026-04-24T05:41:14.246Z");
});
test("legacy device process text is reclassified and no longer pollutes preview or unread", async () => {

View File

@@ -12,6 +12,14 @@ let handleTelegramWebhookRequest: (typeof import("../src/lib/telegram-gateway"))
let completeTaskRoute: (typeof import("../src/app/api/v1/master-agent/tasks/[taskId]/complete/route"))["POST"];
let baseState: Awaited<ReturnType<typeof import("../src/lib/boss-data")["readState"]>>;
async function waitForCondition(predicate: () => boolean | Promise<boolean>, timeoutMs = 1000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await predicate()) return;
await new Promise((resolve) => setTimeout(resolve, 20));
}
}
async function setup() {
if (runtimeRoot) return;
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-telegram-gateway-"));
@@ -216,11 +224,17 @@ test("Telegram webhook 对需要排队的消息会记录 externalReplyTarget
);
assert.equal(completeResponse.status, 200);
await waitForCondition(() => outboundCalls.length >= 2);
assert.equal(outboundCalls.length, 2);
assert.match(String((outboundCalls[1]?.body as { text: string }).text), /已经整理好迁移方案/);
const completedState = await readState();
const completedTask = completedState.masterAgentTasks.find((item) => item.taskId === task?.taskId);
let completedState = await readState();
let completedTask = completedState.masterAgentTasks.find((item) => item.taskId === task?.taskId);
await waitForCondition(async () => {
completedState = await readState();
completedTask = completedState.masterAgentTasks.find((item) => item.taskId === task?.taskId);
return completedTask?.externalReplyTarget?.deliveredAt?.includes("T") === true;
});
assert.equal(completedTask?.externalReplyTarget?.deliveredAt?.includes("T"), true);
} finally {
globalThis.fetch = originalFetch;