chore: checkpoint Boss app v2.5.11
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
BIN
design/image2/boss-developer-roadmap-20260606.png
Normal file
BIN
design/image2/boss-developer-roadmap-20260606.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
@@ -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 / 大文件默认手动触发
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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=...¤tVersion=...` 与 `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`
|
||||
|
||||
259
docs/architecture/task_sla_auto_recovery_admin_alerts_cn.md
Normal file
259
docs/architecture/task_sla_auto_recovery_admin_alerts_cn.md
Normal 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` 配置;如果客户需要企业微信 / 飞书 / 短信,需要另开通知渠道配置。
|
||||
67
docs/superpowers/plans/2026-06-06-boss-edge-reliability.md
Normal file
67
docs/superpowers/plans/2026-06-06-boss-edge-reliability.md
Normal 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.
|
||||
@@ -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。
|
||||
27
local-agent/codex-app-server-discovery-guard.mjs
Normal file
27
local-agent/codex-app-server-discovery-guard.mjs
Normal 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),
|
||||
};
|
||||
}
|
||||
53
local-agent/codex-app-server-discovery-guard.test.mjs
Normal file
53
local-agent/codex-app-server-discovery-guard.test.mjs
Normal 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 });
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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": [
|
||||
|
||||
30
local-agent/fetch-timeout.mjs
Normal file
30
local-agent/fetch-timeout.mjs
Normal 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);
|
||||
}
|
||||
}
|
||||
41
local-agent/fetch-timeout.test.mjs
Normal file
41
local-agent/fetch-timeout.test.mjs
Normal 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");
|
||||
});
|
||||
});
|
||||
97
local-agent/health-summary.mjs
Normal file
97
local-agent/health-summary.mjs
Normal 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),
|
||||
};
|
||||
}
|
||||
57
local-agent/health-summary.test.mjs
Normal file
57
local-agent/health-summary.test.mjs
Normal 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);
|
||||
});
|
||||
15
local-agent/heartbeat-error-state.mjs
Normal file
15
local-agent/heartbeat-error-state.mjs
Normal 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;
|
||||
}
|
||||
17
local-agent/heartbeat-error-state.test.mjs
Normal file
17
local-agent/heartbeat-error-state.test.mjs
Normal 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/);
|
||||
});
|
||||
117
local-agent/heartbeat-project-snapshot.mjs
Normal file
117
local-agent/heartbeat-project-snapshot.mjs
Normal 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(),
|
||||
};
|
||||
}
|
||||
104
local-agent/heartbeat-project-snapshot.test.mjs
Normal file
104
local-agent/heartbeat-project-snapshot.test.mjs
Normal 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,
|
||||
});
|
||||
});
|
||||
102
local-agent/master-task-progress-heartbeat.mjs
Normal file
102
local-agent/master-task-progress-heartbeat.mjs
Normal 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))),
|
||||
},
|
||||
};
|
||||
}
|
||||
70
local-agent/master-task-progress-heartbeat.test.mjs
Normal file
70
local-agent/master-task-progress-heartbeat.test.mjs
Normal 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);
|
||||
});
|
||||
378
local-agent/reliable-outbox.mjs
Normal file
378
local-agent/reliable-outbox.mjs
Normal 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 };
|
||||
}
|
||||
330
local-agent/reliable-outbox.test.mjs
Normal file
330
local-agent/reliable-outbox.test.mjs
Normal 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 });
|
||||
});
|
||||
19
local-agent/serialized-runner-timeout.test.mjs
Normal file
19
local-agent/serialized-runner-timeout.test.mjs
Normal 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);
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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
493
public/admin-web/assets/index-vP7xEOHK.js
Normal file
493
public/admin-web/assets/index-vP7xEOHK.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
|
||||
Binary file not shown.
@@ -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"
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -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,
|
||||
|
||||
@@ -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?.();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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">,
|
||||
|
||||
@@ -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);
|
||||
|
||||
247
src/lib/master-agent-task-sla.ts
Normal file
247
src/lib/master-agent-task-sla.ts
Normal 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";
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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\)\);/,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
21
tests/fixtures/codex-app-server-runtime.mjs
vendored
21
tests/fixtures/codex-app-server-runtime.mjs
vendored
@@ -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: {
|
||||
|
||||
@@ -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";
|
||||
|
||||
21
tests/master-agent-complete-route-nonblocking.test.ts
Normal file
21
tests/master-agent-complete-route-nonblocking.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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 桌面线程本轮被中断/);
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user