Add thread execution conflict guards to chat flows

This commit is contained in:
kris
2026-04-06 12:01:06 +08:00
parent 2c47df702e
commit 9d7d2f4d17
10 changed files with 690 additions and 24 deletions

View File

@@ -81,6 +81,48 @@ public final class ProjectChatUiState {
return nearBottom || forced;
}
public static String threadExecutionConflictTitle(@Nullable JSONObject conflict) {
if (conflict == null) {
return "当前线程命中冲突保护";
}
if ("preferred_gui_mode".equals(conflict.optString("reason", ""))) {
return "当前项目默认先走 GUI";
}
return "当前项目已命中并发保护";
}
public static String threadExecutionConflictSummary(@Nullable JSONObject conflict) {
if (conflict == null) {
return "当前线程命中了 GUI / CLI 冲突保护,请先确认是否继续。";
}
String projectName = conflict.optString("projectName", "当前项目");
String deviceName = conflict.optString("deviceName", "当前设备");
if ("preferred_gui_mode".equals(conflict.optString("reason", ""))) {
return deviceName + " 现在默认优先 GUI。要让主 Agent 继续通过 CLI 推进 " + projectName + ",需要你先对这个项目放行;这个选择只对这个项目生效。";
}
return projectName + " 最近检测到 GUI / CLI 同时活动,当前先按禁止处理。这个提示只影响这个项目;你可以临时放行,或者把这个项目永久放行。";
}
public static String labelForThreadExecutionConflictDecision(@Nullable String decision) {
if ("allow_once".equals(decision)) {
return "允许本次";
}
if ("allow_always".equals(decision)) {
return "永久放行";
}
return "禁止";
}
public static String summarizeThreadExecutionConflictDecisionResult(@Nullable String decision) {
if ("allow_once".equals(decision)) {
return "已允许本次,继续发送中…";
}
if ("allow_always".equals(decision)) {
return "已对当前项目永久放行,继续发送中…";
}
return "已保持禁止,这次消息没有发出。";
}
public static SelectionState emptySelection() {
return new SelectionState(new LinkedHashSet<>());
}

View File

@@ -649,7 +649,20 @@ public class ProjectDetailActivity extends BossScreenActivity {
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.sendProjectMessage(projectId, body, kind);
JSONObject executionConflict = response.json.optJSONObject("executionConflict");
if (!response.ok()) {
if (response.statusCode == 409
&& "THREAD_EXECUTION_CONFLICT".equals(response.json.optString("code", ""))
&& executionConflict != null) {
runOnUiThread(() -> {
composerSending = false;
setRefreshing(false);
removePendingOutgoingBubble();
updateComposerSendButtonState();
showThreadExecutionConflictDialog(executionConflict, body, kind);
});
return;
}
throw new IllegalStateException(response.message());
}
JSONObject dispatchPlan = response.json.optJSONObject("dispatchPlan");
@@ -702,6 +715,85 @@ public class ProjectDetailActivity extends BossScreenActivity {
});
}
private void showThreadExecutionConflictDialog(JSONObject executionConflict, String body, String kind) {
String preferredMode = "gui".equals(executionConflict.optString("preferredExecutionMode", "cli"))
? "GUI"
: "CLI";
String deviceName = executionConflict.optString("deviceName", "当前设备");
String folderKey = executionConflict.optString("folderKey", "").trim();
StringBuilder messageBuilder = new StringBuilder(ProjectChatUiState.threadExecutionConflictSummary(executionConflict))
.append("\n\n设备")
.append(deviceName)
.append(" · 默认模式:")
.append(preferredMode);
if (!folderKey.isEmpty()) {
messageBuilder.append("\n范围").append(folderKey);
}
new AlertDialog.Builder(this)
.setTitle(ProjectChatUiState.threadExecutionConflictTitle(executionConflict))
.setMessage(messageBuilder.toString())
.setNegativeButton(
ProjectChatUiState.labelForThreadExecutionConflictDecision("forbid"),
(dialog, which) -> {
showMessage(ProjectChatUiState.summarizeThreadExecutionConflictDecisionResult("forbid"));
updateComposerSendButtonState();
}
)
.setNeutralButton(
ProjectChatUiState.labelForThreadExecutionConflictDecision("allow_once"),
(dialog, which) -> applyThreadExecutionConflictDecision(executionConflict, "allow_once", body, kind)
)
.setPositiveButton(
ProjectChatUiState.labelForThreadExecutionConflictDecision("allow_always"),
(dialog, which) -> applyThreadExecutionConflictDecision(executionConflict, "allow_always", body, kind)
)
.show();
}
private void applyThreadExecutionConflictDecision(
JSONObject executionConflict,
String decision,
String body,
String kind
) {
composerSending = true;
updateComposerSendButtonState();
setRefreshing(true);
executor.execute(() -> {
try {
String folderKey = executionConflict.optString("folderKey", "").trim();
BossApiClient.ApiResponse response = apiClient.updateProjectConflictDecision(
executionConflict.optString("deviceId", ""),
executionConflict.optString("projectId", ""),
folderKey.isEmpty() ? null : folderKey,
decision
);
if (!response.ok()) {
throw new IllegalStateException(response.message());
}
runOnUiThread(() -> {
showMessage(ProjectChatUiState.summarizeThreadExecutionConflictDecisionResult(decision));
if ("forbid".equals(decision)) {
composerSending = false;
setRefreshing(false);
updateComposerSendButtonState();
return;
}
composerSending = false;
updateComposerSendButtonState();
sendProjectMessage(kind, body);
});
} catch (Exception error) {
runOnUiThread(() -> {
composerSending = false;
setRefreshing(false);
updateComposerSendButtonState();
showMessage("冲突放行设置失败:" + error.getMessage());
});
}
});
}
private void uploadAttachment(AttachmentComposerState.PendingAttachment attachment) {
composerSending = true;
updateComposerSendButtonState();

View File

@@ -249,4 +249,27 @@ public class ProjectChatUiStateTest {
assertFalse(ProjectChatUiState.hasReplyBeyondBaseline(project, "msg-thread-1"));
assertFalse(ProjectChatUiState.hasReplyBeyondBaseline(project, ""));
}
@Test
public void threadExecutionConflictCopyExplainsPreferredGuiModeAsProjectScoped() throws Exception {
JSONObject conflict = new JSONObject()
.put("projectName", "Boss UI 主线程")
.put("deviceName", "Mac Studio")
.put("reason", "preferred_gui_mode");
assertEquals("当前项目默认先走 GUI", ProjectChatUiState.threadExecutionConflictTitle(conflict));
assertTrue(ProjectChatUiState.threadExecutionConflictSummary(conflict).contains("只对这个项目生效"));
}
@Test
public void threadExecutionConflictCopyExplainsForbidAsProjectOnly() throws Exception {
JSONObject conflict = new JSONObject()
.put("projectName", "Boss UI 主线程")
.put("reason", "project_conflict_forbid");
assertEquals("当前项目已命中并发保护", ProjectChatUiState.threadExecutionConflictTitle(conflict));
assertTrue(ProjectChatUiState.threadExecutionConflictSummary(conflict).contains("只影响这个项目"));
assertEquals("允许本次", ProjectChatUiState.labelForThreadExecutionConflictDecision("allow_once"));
assertEquals("永久放行", ProjectChatUiState.labelForThreadExecutionConflictDecision("allow_always"));
}
}