fix(team): improve worker lifecycle and full-result handoff

This commit is contained in:
matevip 2026-10-22 03:51:49 -04:00
parent 3eb488fa8a
commit 945ce60998
19 changed files with 1168 additions and 119 deletions

View File

@ -139,6 +139,18 @@ public class ReasoningNode implements NodeAction {
"(\\d{1,3}(?:[,,]\\d{3})+|\\d+(?:\\.\\d+)?)\\s*(万|千|k|K)?\\s*(字|字符|中文字|汉字|word|words)"); "(\\d{1,3}(?:[,,]\\d{3})+|\\d+(?:\\.\\d+)?)\\s*(万|千|k|K)?\\s*(字|字符|中文字|汉字|word|words)");
private static final Pattern CHINESE_TEN_THOUSAND_CHARS_PATTERN = Pattern.compile( private static final Pattern CHINESE_TEN_THOUSAND_CHARS_PATTERN = Pattern.compile(
"(一万|1万|十千)\\s*(字|字符|中文字|汉字)"); "(一万|1万|十千)\\s*(字|字符|中文字|汉字)");
private static final Pattern OUTPUT_LENGTH_INTENT_PATTERN = Pattern.compile(
"(?i)(?:写|撰写|创作|扩写|续写)[^\\n]{0,48}$|"
+ "(?:^|[::\\s])(?:请帮我|请为我|请|帮我|为我|给我|麻烦|需要|务必|必须|希望)?"
+ "(?:写|撰写|创作|生成|输出|扩写|续写|回答|回复|write\\b|compose\\b|produce\\b|generate\\b|respond\\b|answer\\b)"
+ "[^\\n]{0,48}$");
private static final Pattern DIRECT_LENGTH_TARGET_PATTERN = Pattern.compile(
"(?i)^(?:约|大约|一篇|一部|一份|请用|用|以|about|around|至少|不少于|不低于|at least|no less than)?\\s*$");
private static final Pattern CLAUSE_LENGTH_TARGET_PATTERN = Pattern.compile(
"(?i)^(?:要求|全文(?:字数|长度)?|字数|长度|篇幅|length|word count)\\s*[::]?\\s*"
+ "(?:约|大约|为|是|至少|不少于|不低于|about|around|at least)?\\s*$");
private static final Pattern LENGTH_CEILING_PATTERN = Pattern.compile(
"(?i)(?:不超过|不能超过|不要超过|最多|至多|少于|小于|\\bno more than|\\bat most|\\bless than|\\bunder|\\bup to|\\bmaximum)\\s*$");
private static final Pattern EXPLICIT_ARTIFACT_REQUEST_PATTERN = Pattern.compile( private static final Pattern EXPLICIT_ARTIFACT_REQUEST_PATTERN = Pattern.compile(
"(?i)(word|docx|pdf|pptx|xlsx|markdown|\\bmd\\b|下载|附件|文档|文件|保存|落盘|导出)"); "(?i)(word|docx|pdf|pptx|xlsx|markdown|\\bmd\\b|下载|附件|文档|文件|保存|落盘|导出)");
private static final List<String> ARTIFACT_DELIVERY_TOOL_PREFIXES = List.of( private static final List<String> ARTIFACT_DELIVERY_TOOL_PREFIXES = List.of(
@ -247,13 +259,21 @@ public class ReasoningNode implements NodeAction {
if (userMessage == null || userMessage.isBlank()) { if (userMessage == null || userMessage.isBlank()) {
return OptionalInt.empty(); return OptionalInt.empty();
} }
// Structured workers deliver a bounded JSON summary, even when their
// task describes a much longer source. Let the worker result contract
// validate completion instead of forcing prose continuation by source size.
if (userMessage.contains("[Final result contract]\n"
+ vip.mate.agent.runtime.WorkerResultContract.INSTRUCTIONS)) {
return OptionalInt.empty();
}
Matcher tenThousand = CHINESE_TEN_THOUSAND_CHARS_PATTERN.matcher(userMessage); Matcher tenThousand = CHINESE_TEN_THOUSAND_CHARS_PATTERN.matcher(userMessage);
if (tenThousand.find()) { while (tenThousand.find()) {
return OptionalInt.of(10_000); if (isOutputLengthTarget(userMessage, tenThousand.start())) return OptionalInt.of(10_000);
} }
Matcher matcher = ARABIC_CHAR_COUNT_PATTERN.matcher(userMessage); Matcher matcher = ARABIC_CHAR_COUNT_PATTERN.matcher(userMessage);
int best = 0; int best = 0;
while (matcher.find()) { while (matcher.find()) {
if (!isOutputLengthTarget(userMessage, matcher.start())) continue;
String rawNumber = matcher.group(1).replace(",", "").replace(",", ""); String rawNumber = matcher.group(1).replace(",", "").replace(",", "");
double value; double value;
try { try {
@ -272,6 +292,18 @@ public class ReasoningNode implements NodeAction {
return best >= LONG_FORM_MIN_REQUEST_CHARS ? OptionalInt.of(best) : OptionalInt.empty(); return best >= LONG_FORM_MIN_REQUEST_CHARS ? OptionalInt.of(best) : OptionalInt.empty();
} }
/** Counts describing source material and output ceilings are not minimum writing targets. */
private static boolean isOutputLengthTarget(String request, int numberStart) {
int clauseStart = numberStart;
while (clauseStart > 0 && "\n。!?!?;;,,".indexOf(request.charAt(clauseStart - 1)) < 0) clauseStart--;
String prefix = request.substring(clauseStart, numberStart).strip();
return !LENGTH_CEILING_PATTERN.matcher(prefix).find()
&& (DIRECT_LENGTH_TARGET_PATTERN.matcher(prefix).matches()
|| OUTPUT_LENGTH_INTENT_PATTERN.matcher(prefix).find()
|| (CLAUSE_LENGTH_TARGET_PATTERN.matcher(prefix).matches()
&& OUTPUT_LENGTH_INTENT_PATTERN.matcher(request.substring(0, clauseStart)).find()));
}
static List<ToolCallback> filterLongFormArtifactTools(String userMessage, static List<ToolCallback> filterLongFormArtifactTools(String userMessage,
List<ToolCallback> callbacks) { List<ToolCallback> callbacks) {
String currentRequest = currentUserRequest(userMessage); String currentRequest = currentUserRequest(userMessage);

View File

@ -30,7 +30,7 @@ public final class ActionExecutionLedger {
public enum Status { SUCCEEDED, FAILED } public enum Status { SUCCEEDED, FAILED }
public record Receipt(String toolCallId, String toolName, Status status, public record Receipt(String toolCallId, String toolName, Status status,
String resultSummary, long completedAt, String operationIdentity, int resultChars) { String resultSummary, long completedAt, String operationIdentity, int resultChars, boolean progressOnly) {
public Receipt { public Receipt {
// Older serialized receipts have no original output length (primitive defaults to zero). // Older serialized receipts have no original output length (primitive defaults to zero).
if (resultSummary != null && !resultSummary.isEmpty() && resultChars == 0) resultChars = -1; if (resultSummary != null && !resultSummary.isEmpty() && resultChars == 0) resultChars = -1;
@ -39,6 +39,11 @@ public final class ActionExecutionLedger {
} }
} }
public Receipt(String toolCallId, String toolName, Status status,
String resultSummary, long completedAt, String operationIdentity, int resultChars) {
this(toolCallId, toolName, status, resultSummary, completedAt, operationIdentity, resultChars, false);
}
public Receipt(String toolCallId, String toolName, Status status, public Receipt(String toolCallId, String toolName, Status status,
String resultSummary, long completedAt, String operationIdentity) { String resultSummary, long completedAt, String operationIdentity) {
this(toolCallId, toolName, status, resultSummary, completedAt, operationIdentity, this(toolCallId, toolName, status, resultSummary, completedAt, operationIdentity,
@ -51,7 +56,7 @@ public final class ActionExecutionLedger {
this(toolCallId, toolName, status, resultSummary, completedAt, UNKNOWN_OPERATION); this(toolCallId, toolName, status, resultSummary, completedAt, UNKNOWN_OPERATION);
} }
public boolean substantive() { public boolean substantive() {
return toolName != null && !NON_SUBSTANTIVE_TOOLS.contains(toolName); return !progressOnly && toolName != null && !NON_SUBSTANTIVE_TOOLS.contains(toolName);
} }
} }
@ -92,6 +97,7 @@ public final class ActionExecutionLedger {
// which request completed. Conflicting duplicate starts stay unknown. // which request completed. Conflicting duplicate starts stay unknown.
Map<String, String> operations = new LinkedHashMap<>(); Map<String, String> operations = new LinkedHashMap<>();
Map<String, String> operationTools = new LinkedHashMap<>(); Map<String, String> operationTools = new LinkedHashMap<>();
Map<String, Boolean> progressCalls = new LinkedHashMap<>();
for (GraphEventPublisher.GraphEvent event : events) { for (GraphEventPublisher.GraphEvent event : events) {
if (event == null || !GraphEventPublisher.EVENT_TOOL_START.equals(event.type())) continue; if (event == null || !GraphEventPublisher.EVENT_TOOL_START.equals(event.type())) continue;
String id = String.valueOf(event.data().getOrDefault("toolCallId", "")); String id = String.valueOf(event.data().getOrDefault("toolCallId", ""));
@ -99,6 +105,8 @@ public final class ActionExecutionLedger {
String name = String.valueOf(event.data().getOrDefault("toolName", "")); String name = String.valueOf(event.data().getOrDefault("toolName", ""));
String identity = operationIdentity(name, String identity = operationIdentity(name,
String.valueOf(event.data().getOrDefault("arguments", ""))); String.valueOf(event.data().getOrDefault("arguments", "")));
progressCalls.put(id, isTeamProgress(name,
String.valueOf(event.data().getOrDefault("arguments", ""))));
operationTools.put(id, name); operationTools.put(id, name);
operations.merge(id, identity, (first, next) -> first.equals(next) ? first : UNKNOWN_OPERATION); operations.merge(id, identity, (first, next) -> first.equals(next) ? first : UNKNOWN_OPERATION);
} }
@ -119,11 +127,26 @@ public final class ActionExecutionLedger {
receipts.put(id, new Receipt(id, name, receipts.put(id, new Receipt(id, name,
success ? Status.SUCCEEDED : Status.FAILED, result, event.timestamp(), success ? Status.SUCCEEDED : Status.FAILED, result, event.timestamp(),
name.equals(operationTools.get(id)) name.equals(operationTools.get(id))
? operations.getOrDefault(id, UNKNOWN_OPERATION) : UNKNOWN_OPERATION, resultChars)); ? operations.getOrDefault(id, UNKNOWN_OPERATION) : UNKNOWN_OPERATION, resultChars,
name.equals(operationTools.get(id))
&& !UNKNOWN_OPERATION.equals(operations.getOrDefault(id, UNKNOWN_OPERATION))
&& Boolean.TRUE.equals(progressCalls.get(id))));
} }
return receipts.isEmpty() ? empty() : new ActionExecutionLedger(receipts, fullResults); return receipts.isEmpty() ? empty() : new ActionExecutionLedger(receipts, fullResults);
} }
/** Progress changes metadata, not the substantive result used to verify the answer. */
private static boolean isTeamProgress(String toolName, String arguments) {
if (!"team_tasks".equals(toolName)) return false;
try {
var request = OPERATION_MAPPER.readTree(arguments);
return request != null && request.isObject()
&& "progress".equals(request.path("action").asText());
} catch (java.io.IOException invalid) {
return false;
}
}
/** /**
* Per-run receipts survive model-history compaction. Inject these before generation * Per-run receipts survive model-history compaction. Inject these before generation
* so the streamed answer and its persisted copy share the same factual context. * so the streamed answer and its persisted copy share the same factual context.

View File

@ -52,6 +52,7 @@ public class TeamAnnounceService {
/** A batch drains immediately once it reaches this size. */ /** A batch drains immediately once it reaches this size. */
static final int MAX_BATCH = 20; static final int MAX_BATCH = 20;
static final int MAX_ANNOUNCEMENT_CHARS = 16000;
/** Re-check interval while waiting for a busy lead to go idle. */ /** Re-check interval while waiting for a busy lead to go idle. */
static final long BUSY_RETRY_MILLIS = 2000; static final long BUSY_RETRY_MILLIS = 2000;
@ -304,14 +305,22 @@ public class TeamAnnounceService {
} }
sb.append(".\n"); sb.append(".\n");
} }
for (AnnounceItem item : items) { int visibleCount = Math.min(items.size(), MAX_BATCH);
sb.append("\n--- Task #").append(item.taskNumber()) int itemBudget = visibleCount == 0 ? 0 : (MAX_ANNOUNCEMENT_CHARS - 1000) / visibleCount;
.append(" \"").append(item.subject()).append("\" — ") for (AnnounceItem item : items.subList(0, visibleCount)) {
.append(item.status()) String heading = "\n--- Task #" + item.taskNumber() + " \""
.append(" (member: ").append(item.memberName()).append(") ---\n"); + TeamResultExcerpt.label(item.subject(), 160) + "\" — " + item.status()
if (!item.detail().isBlank()) { + " (member: " + TeamResultExcerpt.label(item.memberName(), 80) + ") ---\n";
sb.append(item.detail()).append('\n'); String reference = "Full worker output: " + TeamResultExcerpt.outputReference(item.taskId()) + "\n";
} sb.append(heading);
sb.append(TeamResultExcerpt.render(item.detail(),
itemBudget - heading.length() - reference.length() - 1, item.taskId())).append('\n');
sb.append(reference);
}
if (items.size() > visibleCount) {
sb.append("\n").append(items.size() - visibleCount)
.append(" additional task details omitted to protect context. ")
.append("Use team_tasks(action=\"list\") and retrieve their task outputs.\n");
} }
sb.append(""" sb.append("""

View File

@ -63,7 +63,7 @@ public class TeamDispatchService {
public void setDecisionAdapter(WorkerDecisionAdapter adapter) { this.decisionAdapter = adapter; } public void setDecisionAdapter(WorkerDecisionAdapter adapter) { this.decisionAdapter = adapter; }
/** Result summaries are capped before persisting to keep the board readable. */ /** Result summaries are capped before persisting to keep the board readable. */
static final int MAX_RESULT_CHARS = 8000; static final int MAX_RESULT_CHARS = TeamTaskService.MAX_RESULT_CHARS;
/** Empty/fallback member runs get one recovery attempt, not three long identical runs. */ /** Empty/fallback member runs get one recovery attempt, not three long identical runs. */
static final int MAX_RESPONSE_FAILURE_DISPATCHES = 2; static final int MAX_RESPONSE_FAILURE_DISPATCHES = 2;
@ -214,79 +214,95 @@ public class TeamDispatchService {
String childConvId = "team-task-" + IdUtil.fastSimpleUUID(); String childConvId = "team-task-" + IdUtil.fastSimpleUUID();
WorkerDecisionAdapter.Snapshot snapshot = WorkerDecisionAdapter.Snapshot.capture(task).withConversation(childConvId); WorkerDecisionAdapter.Snapshot snapshot = WorkerDecisionAdapter.Snapshot.capture(task).withConversation(childConvId);
ScheduledFuture<?> heartbeat = null; ScheduledFuture<?> heartbeat = null;
String reply = null;
Exception runFailure = null;
try { try {
AgentTeamEntity team = teamService.getTeam(teamId); try {
conversationService.createChildConversation(childConvId, memberId, "system", AgentTeamEntity team = teamService.getTeam(teamId);
team == null ? null : team.getWorkspaceId(), task.getLeadConversationId(), conversationService.createChildConversation(childConvId, memberId, "system",
"team_worker"); team == null ? null : team.getWorkspaceId(), task.getLeadConversationId(),
taskService.attachConversation(task.getId(), childConvId); "team_worker");
// Track the child run so graph nodes honor requestStop() — without a taskService.attachConversation(task.getId(), childConvId);
// registered RunState, cancelling the task could never interrupt the // Track the child run so graph nodes honor requestStop() — without a
// member mid-run. // registered RunState, cancelling the task could never interrupt the
streamTracker.register(childConvId); // member mid-run.
streamTracker.incrementFlux(childConvId); streamTracker.register(childConvId);
// Renew the execution lease while the member works; the conditional streamTracker.incrementFlux(childConvId);
// UPDATE inside renewLock makes this a no-op once the task settles. // Renew the execution lease while the member works; the conditional
heartbeat = startLeaseHeartbeat(task.getId()); // UPDATE inside renewLock makes this a no-op once the task settles.
broadcast(task, "team_task_dispatched", Map.of()); heartbeat = startLeaseHeartbeat(task.getId());
log.info("Team {} task #{} dispatched to agent {} (conv {})", broadcast(task, "team_task_dispatched", Map.of());
teamId, task.getTaskNumber(), memberId, childConvId); log.info("Team {} task #{} dispatched to agent {} (conv {})",
teamId, task.getTaskNumber(), memberId, childConvId);
// Message persistence is the caller's contract (the graph expects the // Message persistence is the caller's contract (the graph expects the
// current user message to already be the conversation's last row), // current user message to already be the conversation's last row),
// and the persisted pair is what makes the run's transcript // and the persisted pair is what makes the run's transcript
// reviewable from the task card. // reviewable from the task card.
String dispatchContent = buildDispatchContent(task); String dispatchContent = buildDispatchContent(task);
conversationService.saveMessage(childConvId, "user", dispatchContent); conversationService.saveMessage(childConvId, "user", dispatchContent);
AgentService.ChatResult result = agentService.chatWithUsage( AgentService.ChatResult result = agentService.chatWithUsage(
memberId, dispatchContent, childConvId); memberId, dispatchContent, childConvId);
String reply = result == null ? null : result.content(); reply = result == null ? null : result.content();
if (reply != null && !reply.isBlank()) { if (reply != null && !reply.isBlank()) {
conversationService.saveMessage(childConvId, "assistant", reply); conversationService.saveMessage(childConvId, "assistant", reply);
}
PendingApproval pending = approvalService.findPendingByConversation(childConvId);
if (pending != null) {
String summary = pending.getSummary() == null || pending.getSummary().isBlank()
? pending.getReason() : pending.getSummary();
if (taskService.parkForToolApproval(task.getId(), pending.getPendingId(), summary)) {
TeamTaskEntity parked = taskService.getTask(task.getId());
broadcast(parked != null ? parked : task, "team_task_awaiting_approval",
Map.of("pendingId", pending.getPendingId(),
"toolName", pending.getToolName() == null ? "" : pending.getToolName(),
"summary", summary == null ? "Tool approval required" : summary));
} }
return;
}
settleOutcome(task, reply, snapshot); PendingApproval pending = approvalService.findPendingByConversation(childConvId);
} catch (Exception e) { if (pending != null) {
if (DecisionRecordingException.find(e) != null) { String summary = pending.getSummary() == null || pending.getSummary().isBlank()
log.warn("Team task #{} decision recording failed; retaining current state", task.getTaskNumber()); ? pending.getReason() : pending.getSummary();
return; if (taskService.parkForToolApproval(task.getId(), pending.getPendingId(), summary)) {
TeamTaskEntity parked = taskService.getTask(task.getId());
broadcast(parked != null ? parked : task, "team_task_awaiting_approval",
Map.of("pendingId", pending.getPendingId(),
"toolName", pending.getToolName() == null ? "" : pending.getToolName(),
"summary", summary == null ? "Tool approval required" : summary));
}
return;
}
} catch (Exception error) {
runFailure = error;
} finally {
if (heartbeat != null) {
heartbeat.cancel(false);
}
streamTracker.complete(childConvId);
runningMembers.remove(memberId);
} }
log.warn("Team {} task #{} member run ended exceptionally: {}", teamId, // Close the child before settlement can wake the parent or dispatch
task.getTaskNumber(), e.getMessage()); // follow-up work. A lead must never observe a finished task with its
// Only report a failure the guarded transition actually applied — an // worker still registered as running.
// interrupted run whose task is already cancelled must not produce a try {
// misleading failed event on top of the terminal state. if (runFailure != null) {
boolean failed = taskService.failTask(task.getId(), throw runFailure;
truncate("member run error: " + e.getMessage(), 1000)); }
if (failed) { settleOutcome(task, reply, snapshot);
broadcast(task, "team_task_failed", Map.of("reason", String.valueOf(e.getMessage()))); } catch (Exception error) {
announceService.announceTaskSettled(taskService.getTask(task.getId())); settleRunFailure(teamId, task, error);
} }
} finally { } finally {
if (heartbeat != null) {
heartbeat.cancel(false);
}
streamTracker.complete(childConvId);
runningMembers.remove(memberId);
// Chain: dispatch released dependents and the member's next task.
requestDispatch(teamId); requestDispatch(teamId);
} }
} }
private void settleRunFailure(Long teamId, TeamTaskEntity task, Exception error) {
if (DecisionRecordingException.find(error) != null) {
log.warn("Team task #{} decision recording failed; retaining current state", task.getTaskNumber());
return;
}
log.warn("Team {} task #{} member run ended exceptionally: {}", teamId,
task.getTaskNumber(), error.getMessage());
// A cancelled worker must not publish a failure over its terminal state.
boolean failed = taskService.failTask(task.getId(),
truncate("member run error: " + error.getMessage(), 1000));
if (failed) {
broadcast(task, "team_task_failed", Map.of("reason", String.valueOf(error.getMessage())));
announceService.announceTaskSettled(taskService.getTask(task.getId()));
}
}
/** Share the same DB-backed lease heartbeat with controlled worker replays. */ /** Share the same DB-backed lease heartbeat with controlled worker replays. */
ScheduledFuture<?> startLeaseHeartbeat(Long taskId) { ScheduledFuture<?> startLeaseHeartbeat(Long taskId) {
return HEARTBEAT_SCHEDULER.scheduleAtFixedRate( return HEARTBEAT_SCHEDULER.scheduleAtFixedRate(
@ -398,7 +414,8 @@ public class TeamDispatchService {
if (terminalAlreadyAcknowledged) { if (terminalAlreadyAcknowledged) {
DecisionTicket ticket = (decisionAdapter == null || !decisionAdapter.enabled()) ? null DecisionTicket ticket = (decisionAdapter == null || !decisionAdapter.enabled()) ? null
: decisionAdapter.judge(current, "CHECKPOINT_ACK", reply).ticket(); : decisionAdapter.judge(current, "CHECKPOINT_ACK", reply).ticket();
List<Long> released = completeWorker(task.getId(), truncate(reply, MAX_RESULT_CHARS), ticket, snapshot); List<Long> released = completeWorker(task.getId(),
TeamResultExcerpt.render(reply, MAX_RESULT_CHARS, task.getId()), ticket, snapshot);
if (released == null) return; if (released == null) return;
TeamTaskEntity completed = taskService.getTask(task.getId()); TeamTaskEntity completed = taskService.getTask(task.getId());
log.info("Team task #{} completed after deferred {} acknowledgement " log.info("Team task #{} completed after deferred {} acknowledgement "
@ -441,7 +458,8 @@ public class TeamDispatchService {
} }
} }
List<Long> released = completeWorker(task.getId(), List<Long> released = completeWorker(task.getId(),
truncate(reply == null || reply.isBlank() ? "(no output)" : reply, MAX_RESULT_CHARS), ticket, snapshot); TeamResultExcerpt.render(reply == null || reply.isBlank() ? "(no output)" : reply,
MAX_RESULT_CHARS, task.getId()), ticket, snapshot);
if (released == null) return; if (released == null) return;
current = taskService.getTask(task.getId()); current = taskService.getTask(task.getId());
log.info("Team task #{} auto-completed ({} dependents released)", log.info("Team task #{} auto-completed ({} dependents released)",
@ -450,8 +468,12 @@ public class TeamDispatchService {
String event = switch (current.getStatus()) { String event = switch (current.getStatus()) {
case TeamTaskStatus.FAILED -> "team_task_failed"; case TeamTaskStatus.FAILED -> "team_task_failed";
case TeamTaskStatus.IN_REVIEW -> "team_task_in_review"; case TeamTaskStatus.IN_REVIEW -> "team_task_in_review";
default -> "team_task_completed"; case TeamTaskStatus.COMPLETED -> "team_task_completed";
default -> null;
}; };
if (event == null) {
return;
}
Map<String, Object> payload = new HashMap<>(); Map<String, Object> payload = new HashMap<>();
payload.put("status", current.getStatus()); payload.put("status", current.getStatus());
if (current.getResult() != null) { if (current.getResult() != null) {
@ -674,36 +696,52 @@ public class TeamDispatchService {
if (blockerIds.isEmpty()) { if (blockerIds.isEmpty()) {
return; return;
} }
StringBuilder section = new StringBuilder(); int visibleCount = Math.min(blockerIds.size(), 8);
for (Long blockerId : blockerIds) { int itemBudget = (MAX_PREREQ_SECTION_CHARS - 256) / visibleCount;
StringBuilder section = new StringBuilder("\n[Prerequisite results]\n");
for (Long blockerId : blockerIds.subList(0, visibleCount)) {
TeamTaskEntity blocker = taskService.getTask(blockerId); TeamTaskEntity blocker = taskService.getTask(blockerId);
if (blocker == null) { if (blocker == null) {
continue; continue;
} }
section.append("- #").append(blocker.getTaskNumber()) String heading = "- #" + blocker.getTaskNumber() + " \""
.append(" \"").append(blocker.getSubject()).append("\" (") + TeamResultExcerpt.label(blocker.getSubject(), 120) + "\" ("
.append(blocker.getStatus()).append(')'); + blocker.getStatus() + ")\n";
String reference = " Full worker output: " + TeamResultExcerpt.outputReference(blockerId) + "\n";
StringBuilder row = new StringBuilder(heading);
int resultBudget = Math.min(MAX_PREREQ_RESULT_CHARS,
(itemBudget - heading.length() - reference.length() - 120) * 2 / 3);
if (blocker.getResult() != null && !blocker.getResult().isBlank()) { if (blocker.getResult() != null && !blocker.getResult().isBlank()) {
section.append(": ").append(truncate(blocker.getResult().strip(), row.append(TeamResultExcerpt.render(blocker.getResult().strip(), resultBudget, blockerId))
MAX_PREREQ_RESULT_CHARS)); .append('\n');
} }
section.append('\n'); row.append(reference);
int omittedFiles = 0;
for (TeamTaskService.Deliverable file : taskService.listDeliverables(blocker)) { for (TeamTaskService.Deliverable file : taskService.listDeliverables(blocker)) {
section.append(" File: ").append(file.name()).append(" → ") String fileLine = " File: " + file.name() + " → " + file.url() + "\n";
.append(file.url()).append('\n');
String inspectionPath = generatedFileInspectionPath(file.url()); String inspectionPath = generatedFileInspectionPath(file.url());
if (inspectionPath != null) { if (inspectionPath != null) {
section.append(" Inspect locally: ").append(inspectionPath) fileLine += " Inspect locally: " + inspectionPath + " (do not guess an HTTP port)\n";
.append(" (do not guess an HTTP port)\n"); }
if (row.length() + fileLine.length() + 120 <= itemBudget) {
row.append(fileLine);
} else {
omittedFiles++;
} }
} }
if (omittedFiles > 0) {
row.append(" ").append(omittedFiles).append(" deliverable(s) omitted; use team_tasks(action=\"get\", taskId=\"")
.append(blockerId).append("\").\n");
}
section.append(row);
} }
if (section.isEmpty()) { if (blockerIds.size() > visibleCount) {
return; section.append(blockerIds.size() - visibleCount)
.append(" additional prerequisite(s) omitted; use team_tasks(action=\"get\", taskId=\"")
.append(task.getId()).append("\") to inspect dependencies.\n");
} }
sb.append("\n[Prerequisite results]\n") section.append("Use team_tasks(action=\"get\", taskId=...) for task records and deliverable links.\n");
.append(truncate(section.toString(), MAX_PREREQ_SECTION_CHARS)) sb.append(section);
.append("Use team_tasks(action=\"get\", taskId=...) for any full record.\n");
} }
static String generatedFileInspectionPath(String url) { static String generatedFileInspectionPath(String url) {

View File

@ -0,0 +1,51 @@
package vip.mate.team.service;
/** Bounded model-facing excerpts; persisted worker messages remain authoritative. */
public final class TeamResultExcerpt {
private TeamResultExcerpt() {
}
public static String outputReference(Long taskId) {
return "team_tasks(action=\"output\", taskId=\"" + taskId + "\")";
}
/** Bound descriptive labels separately so names cannot consume an evidence budget. */
public static String label(String text, int maxChars) {
if (text == null) return "";
if (maxChars < 1) return "";
int end = Math.min(text.length(), maxChars);
if (end < text.length() && end > 0 && Character.isHighSurrogate(text.charAt(end - 1))
&& Character.isLowSurrogate(text.charAt(end))) {
end--;
}
return text.substring(0, end).replace('\n', ' ').replace('\r', ' ');
}
/** Keeps both the opening context and the final outcome, including the marker in the cap. */
public static String render(String text, int maxChars, Long taskId) {
if (text == null || text.isEmpty()) {
return "";
}
if (text.length() <= maxChars) {
return text;
}
String marker = "\n[... omitted; full output: " + outputReference(taskId)
+ ". Do not infer omitted content.]\n";
int available = maxChars - marker.length();
if (available < 2) {
throw new IllegalArgumentException("Excerpt budget cannot hold the output reference");
}
int headEnd = available * 3 / 4;
int tailStart = text.length() - (available - headEnd);
if (headEnd > 0 && Character.isHighSurrogate(text.charAt(headEnd - 1))
&& Character.isLowSurrogate(text.charAt(headEnd))) {
headEnd--;
}
if (tailStart > 0 && Character.isLowSurrogate(text.charAt(tailStart))
&& Character.isHighSurrogate(text.charAt(tailStart - 1))) {
tailStart++;
}
return text.substring(0, headEnd) + marker + text.substring(tailStart);
}
}

View File

@ -48,6 +48,8 @@ import java.util.regex.Pattern;
@RequiredArgsConstructor @RequiredArgsConstructor
public class TeamTaskService { public class TeamTaskService {
public static final int MAX_RESULT_CHARS = 8000;
private static final int MAX_STAGED_REPLAY_RESULT_CHARS = 8000; private static final int MAX_STAGED_REPLAY_RESULT_CHARS = 8000;
private static final Pattern CHECKPOINT_RANGE = Pattern.compile( private static final Pattern CHECKPOINT_RANGE = Pattern.compile(
@ -303,6 +305,11 @@ public class TeamTaskService {
} }
private Completion completeTaskInternal(Long taskId, Long agentId, String result, boolean observe, TeamTaskEntity locked) { private Completion completeTaskInternal(Long taskId, Long agentId, String result, boolean observe, TeamTaskEntity locked) {
if (result != null && result.length() > MAX_RESULT_CHARS) {
throw new IllegalArgumentException("result summary must not exceed " + MAX_RESULT_CHARS
+ " characters; keep a concise summary here and put full content in your final reply"
+ " or attach a deliverable");
}
TeamTaskEntity task = locked == null ? requireTask(taskId) : locked; TeamTaskEntity task = locked == null ? requireTask(taskId) : locked;
if (TeamTaskStatus.PENDING.equals(task.getStatus()) && agentId != null) { if (TeamTaskStatus.PENDING.equals(task.getStatus()) && agentId != null) {
if (task.getAssigneeAgentId() != null && !agentId.equals(task.getAssigneeAgentId())) { if (task.getAssigneeAgentId() != null && !agentId.equals(task.getAssigneeAgentId())) {
@ -1297,7 +1304,7 @@ public class TeamTaskService {
return JSONUtil.toJsonStr(ids.stream().map(String::valueOf).toList()); return JSONUtil.toJsonStr(ids.stream().map(String::valueOf).toList());
} }
static List<Long> parseIdArray(String json) { public static List<Long> parseIdArray(String json) {
if (json == null || json.isBlank()) { if (json == null || json.isBlank()) {
return Collections.emptyList(); return Collections.emptyList();
} }

View File

@ -48,15 +48,11 @@ public class TeamWorkerConversationGovernanceService {
return Optional.empty(); return Optional.empty();
} }
TeamRunEntity run = runMapper.selectById(task.getRunId()); TeamRunEntity run = runMapper.selectById(task.getRunId());
boolean legacyMissingParent = conversation.getParentConversationId() == null
&& !"team_worker".equals(conversation.getConversationKind())
&& conversationId.startsWith("team-task-");
if (run == null if (run == null
|| !Objects.equals(run.getTeamId(), task.getTeamId()) || !Objects.equals(run.getTeamId(), task.getTeamId())
|| !Objects.equals(conversation.getWorkspaceId(), run.getWorkspaceId()) || !Objects.equals(conversation.getWorkspaceId(), run.getWorkspaceId())
|| !Objects.equals(conversation.getAgentId(), task.getAssigneeAgentId()) || !Objects.equals(conversation.getAgentId(), task.getAssigneeAgentId())
|| !legacyMissingParent || !matchesParent(conversation, run.getLeadConversationId())) {
&& !Objects.equals(conversation.getParentConversationId(), run.getLeadConversationId())) {
return Optional.empty(); return Optional.empty();
} }
return Optional.of(new TeamWorkerConversationContext( return Optional.of(new TeamWorkerConversationContext(
@ -64,6 +60,17 @@ public class TeamWorkerConversationGovernanceService {
run.getLeadConversationId(), task.getAssigneeAgentId())); run.getLeadConversationId(), task.getAssigneeAgentId()));
} }
/** Shared canonical parent rule, including the established pre-kind legacy fallback. */
public static boolean matchesParent(ConversationEntity conversation, String leadConversationId) {
if (conversation == null) return false;
boolean legacyMissingParent = conversation.getParentConversationId() == null
&& !"team_worker".equals(conversation.getConversationKind())
&& conversation.getConversationId() != null
&& conversation.getConversationId().startsWith("team-task-");
return legacyMissingParent
|| Objects.equals(conversation.getParentConversationId(), leadConversationId);
}
/** /**
* Team worker transcripts are read-only evidence for a team run. The worker * Team worker transcripts are read-only evidence for a team run. The worker
* conversation is owned by the executing agent/user, so workspace admins and * conversation is owned by the executing agent/user, so workspace admins and

View File

@ -24,15 +24,18 @@ import vip.mate.team.service.TeamEventChannel;
import vip.mate.team.service.TeamService; import vip.mate.team.service.TeamService;
import vip.mate.team.service.TeamRunService; import vip.mate.team.service.TeamRunService;
import vip.mate.team.service.TeamTaskService; import vip.mate.team.service.TeamTaskService;
import vip.mate.team.service.TeamWorkerConversationGovernanceService;
import vip.mate.tool.builtin.ToolExecutionContext; import vip.mate.tool.builtin.ToolExecutionContext;
import vip.mate.workspace.conversation.ConversationService; import vip.mate.workspace.conversation.ConversationService;
import vip.mate.workspace.conversation.model.ConversationEntity; import vip.mate.workspace.conversation.model.ConversationEntity;
import vip.mate.workspace.conversation.model.MessageEntity;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.Objects;
import java.util.regex.Pattern; import java.util.regex.Pattern;
/** /**
@ -65,6 +68,7 @@ public class TeamTasksTool {
@Tool(description = "Operate your team's shared task board. Actions: " @Tool(description = "Operate your team's shared task board. Actions: "
+ "'list' all tasks; 'get' one task with comments (taskId); " + "'list' all tasks; 'get' one task with comments (taskId); "
+ "'output' reads the complete latest saved worker reply (taskId), useful when a result summary was shortened; "
+ "'start_run' (lead only; objective required, optional title) returns a runId; " + "'start_run' (lead only; objective required, optional title) returns a runId; "
+ "'create' stages a task (lead only; runId, subject, description, assigneeAgentId required, " + "'create' stages a task (lead only; runId, subject, description, assigneeAgentId required, "
+ "optional blockedBy comma-separated prerequisite task ids, priority, higher first, " + "optional blockedBy comma-separated prerequisite task ids, priority, higher first, "
@ -79,9 +83,9 @@ public class TeamTasksTool {
+ "'retry' a failed/stale task back to pending (lead only; taskId). " + "'retry' a failed/stale task back to pending (lead only; taskId). "
+ "Only usable when you belong to an agent team.") + "Only usable when you belong to an agent team.")
public String team_tasks( public String team_tasks(
@ToolParam(description = "One of: start_run, create, seal_run, list, get, complete, progress, comment, attach, cancel, retry") @ToolParam(description = "One of: start_run, create, seal_run, list, get, output, complete, progress, comment, attach, cancel, retry")
String action, String action,
@ToolParam(description = "Task id (string form is fine) — required by get/complete/progress/comment/attach/cancel/retry", required = false) @ToolParam(description = "Task id (string form is fine) — required by get/output/complete/progress/comment/attach/cancel/retry", required = false)
String taskId, String taskId,
@ToolParam(description = "create/seal_run: explicit team run id", required = false) @ToolParam(description = "create/seal_run: explicit team run id", required = false)
String runId, String runId,
@ -101,7 +105,7 @@ public class TeamTasksTool {
Integer priority, Integer priority,
@ToolParam(description = "create: true to require human approval before the finished task counts as done", required = false) @ToolParam(description = "create: true to require human approval before the finished task counts as done", required = false)
Boolean requireApproval, Boolean requireApproval,
@ToolParam(description = "complete: result summary reported back to the lead", required = false) @ToolParam(description = "complete: result summary reported back to the lead (maximum 8000 characters; put full content in the final reply or attach a deliverable)", required = false)
String result, String result,
@ToolParam(description = "progress: completion percent 0-100", required = false) @ToolParam(description = "progress: completion percent 0-100", required = false)
Integer percent, Integer percent,
@ -143,6 +147,7 @@ public class TeamTasksTool {
conversationId, title, objective, ctx); conversationId, title, objective, ctx);
case "list" -> renderBoard(team); case "list" -> renderBoard(team);
case "get" -> renderDetail(team, parseId(taskId, "taskId")); case "get" -> renderDetail(team, parseId(taskId, "taskId"));
case "output" -> renderOutput(team, workspaceId, parseId(taskId, "taskId"));
case "create" -> createTask(team, agentId, isLead, workspaceId, runId, case "create" -> createTask(team, agentId, isLead, workspaceId, runId,
subject, description, assigneeAgentId, blockedBy, priority, subject, description, assigneeAgentId, blockedBy, priority,
requireApproval, conversationId); requireApproval, conversationId);
@ -154,7 +159,7 @@ public class TeamTasksTool {
case "cancel" -> cancel(team, agentId, isLead, parseId(taskId, "taskId"), text); case "cancel" -> cancel(team, agentId, isLead, parseId(taskId, "taskId"), text);
case "retry" -> retry(team, agentId, isLead, parseId(taskId, "taskId")); case "retry" -> retry(team, agentId, isLead, parseId(taskId, "taskId"));
default -> "Error: unknown action '" + action default -> "Error: unknown action '" + action
+ "'. Use one of: start_run, create, seal_run, list, get, complete, progress, " + "'. Use one of: start_run, create, seal_run, list, get, output, complete, progress, "
+ "comment, attach, cancel, retry."; + "comment, attach, cancel, retry.";
}; };
} catch (IllegalArgumentException | IllegalStateException e) { } catch (IllegalArgumentException | IllegalStateException e) {
@ -368,6 +373,47 @@ public class TeamTasksTool {
return sb.toString(); return sb.toString();
} }
private String renderOutput(AgentTeamEntity team, Long workspaceId, Long taskId) {
TeamTaskEntity task = requireTaskInTeam(team, taskId);
if (!Objects.equals(workspaceId, team.getWorkspaceId())) {
throw new IllegalArgumentException("team output is outside the calling workspace");
}
String header = "Worker output: taskId=" + taskId + ", status=" + task.getStatus() + "\n";
if (task.getConversationId() == null || task.getConversationId().isBlank()) {
return unavailableOutput(header, task);
}
if (task.getRunId() == null) {
throw new IllegalArgumentException("worker output has no canonical team run");
}
TeamRunEntity run = runService.requireRun(task.getRunId(), workspaceId);
ConversationEntity worker = conversationService.findByConversationId(task.getConversationId());
if (run == null || !Objects.equals(run.getTeamId(), team.getId())
|| !Objects.equals(run.getLeadConversationId(), task.getLeadConversationId())
|| !ConversationService.isTeamWorkerConversation(worker)
|| !Objects.equals(worker.getWorkspaceId(), workspaceId)
|| !Objects.equals(worker.getAgentId(), task.getAssigneeAgentId())
|| !TeamWorkerConversationGovernanceService.matchesParent(worker, run.getLeadConversationId())) {
throw new IllegalArgumentException("worker output linkage does not match this team's run");
}
// Read only the last saved turn, never an earlier attempt or a reply
// preceding a new feedback/approval message that has not finished yet.
List<MessageEntity> recent = conversationService.listRecentMessages(task.getConversationId(), 1);
if (recent.isEmpty()) {
return unavailableOutput(header, task);
}
MessageEntity message = recent.getLast();
if (!"assistant".equals(message.getRole()) || message.getContent() == null
|| message.getContent().isBlank()) {
return unavailableOutput(header, task);
}
return header + "Latest saved worker reply (verbatim):\n" + message.getContent();
}
private static String unavailableOutput(String header, TeamTaskEntity task) {
return header + "Full worker output unavailable; stored task summary only:\n"
+ (task.getResult() == null ? "(no result)" : task.getResult());
}
private String renderDetail(AgentTeamEntity team, Long taskId) { private String renderDetail(AgentTeamEntity team, Long taskId) {
TeamTaskEntity task = requireTaskInTeam(team, taskId); TeamTaskEntity task = requireTaskInTeam(team, taskId);
StringBuilder sb = new StringBuilder(512); StringBuilder sb = new StringBuilder(512);
@ -379,6 +425,10 @@ public class TeamTasksTool {
if (task.getDescription() != null && !task.getDescription().isBlank()) { if (task.getDescription() != null && !task.getDescription().isBlank()) {
sb.append("Description: ").append(task.getDescription()).append('\n'); sb.append("Description: ").append(task.getDescription()).append('\n');
} }
List<Long> dependencies = TeamTaskService.parseIdArray(task.getBlockedBy());
if (!dependencies.isEmpty()) {
sb.append("Dependencies (task ids): ").append(dependencies).append('\n');
}
if (task.getProgressStep() != null) { if (task.getProgressStep() != null) {
sb.append("Progress: ").append(task.getProgressPercent() == null ? "?" sb.append("Progress: ").append(task.getProgressPercent() == null ? "?"
: task.getProgressPercent()).append("% — ").append(task.getProgressStep()).append('\n'); : task.getProgressPercent()).append("% — ").append(task.getProgressStep()).append('\n');
@ -389,6 +439,13 @@ public class TeamTasksTool {
if (task.getReason() != null && !task.getReason().isBlank()) { if (task.getReason() != null && !task.getReason().isBlank()) {
sb.append("Reason: ").append(task.getReason()).append('\n'); sb.append("Reason: ").append(task.getReason()).append('\n');
} }
List<TeamTaskService.Deliverable> deliverables = taskService.listDeliverables(task);
if (!deliverables.isEmpty()) {
sb.append("Deliverables:\n");
for (TeamTaskService.Deliverable deliverable : deliverables) {
sb.append("- ").append(deliverable.name()).append(" → ").append(deliverable.url()).append('\n');
}
}
List<TeamTaskCommentEntity> comments = taskService.listComments(taskId); List<TeamTaskCommentEntity> comments = taskService.listComments(taskId);
if (!comments.isEmpty()) { if (!comments.isEmpty()) {
sb.append("Comments:\n"); sb.append("Comments:\n");

View File

@ -59,11 +59,12 @@ The run protocol is `start_run → create* → seal_run`: the lead creates a run
| **Task** | One work item on the board: subject, description, assignee, dependencies (`blockedBy`), progress, result, deliverables, comments, timeline. | | **Task** | One work item on the board: subject, description, assignee, dependencies (`blockedBy`), progress, result, deliverables, comments, timeline. |
| **Board** | A kanban grouped by status. The state machine is guarded by conditional database updates — under concurrency the first successful writer wins, so a task can never hold two states. | | **Board** | A kanban grouped by status. The state machine is guarded by conditional database updates — under concurrency the first successful writer wins, so a task can never hold two states. |
Eight task statuses: Nine task statuses:
``` ```
pending → in_progress → completed / failed / cancelled pending → in_progress → completed / failed / cancelled
↘ in_review (tasks that require human approval) ↘ in_review (tasks that require human approval)
↘ awaiting_approval (a worker tool call needs approval)
blocked (waiting on prerequisites) stale (lease expired, retryable) blocked (waiting on prerequisites) stale (lease expired, retryable)
``` ```
@ -74,7 +75,7 @@ blocked (waiting on prerequisites) stale (lease expired, retryable)
## What one collaboration run looks like ## What one collaboration run looks like
1. **You give the lead a goal**: "Competitive analysis: research vendors A and B separately, then merge into one report." 1. **You give the lead a goal**: "Competitive analysis: research vendors A and B separately, then merge into one report."
2. **The lead puts tasks on the board**: three tasks via the `team_tasks` tool — "research A" and "research B" run in parallel, "merge report" declares `blockedBy` on both and enters `blocked`. 2. **The lead puts tasks on the board**: call `start_run`, create all three tasks with its `runId`, then call `seal_run` to start dispatch. "Research A" and "research B" run in parallel; "merge report" declares `blockedBy` on both and enters `blocked`.
3. **The dispatch engine takes over**: a resident sweep (every 30 s, plus an immediate pass after tool/REST actions) assigns pending tasks to their members — each task gets its own child conversation where the member runs the full agent graph. Each member executes one task at a time; the rest queue. 3. **The dispatch engine takes over**: a resident sweep (every 30 s, plus an immediate pass after tool/REST actions) assigns pending tasks to their members — each task gets its own child conversation where the member runs the full agent graph. Each member executes one task at a time; the rest queue.
4. **Prerequisite results are handed off automatically**: once "research A/B" complete, "merge report" is released and its dispatch envelope **automatically carries the results and deliverable links of both prerequisites** — member C doesn't need the lead to retell what member A did. 4. **Prerequisite results are handed off automatically**: once "research A/B" complete, "merge report" is released and its dispatch envelope **automatically carries the results and deliverable links of both prerequisites** — member C doesn't need the lead to retell what member A did.
5. **Settled results wake the lead**: completions and failures are announced to the lead in debounced batches; the lead wakes up in a real new turn — reviews results, dispatches follow-ups, or declares the job done. 5. **Settled results wake the lead**: completions and failures are announced to the lead in debounced batches; the lead wakes up in a real new turn — reviews results, dispatches follow-ups, or declares the job done.
@ -89,8 +90,11 @@ The lead (and members) operate the board through the `team_tasks` tool, with rol
| Action | Who | What | | Action | Who | What |
|------|--------|--------| |------|--------|--------|
| `list` | any member | Render the current board (the lead also gets a live board snapshot injected every turn — see below) | | `list` | any member | Render the current board (the lead also gets a live board snapshot injected every turn — see below) |
| `get` | any member | Task detail | | `get` | any member | Task detail, prerequisite task IDs, comments and registered deliverables |
| `create` | lead | Create a task: subject, description, assignee, `blockedBy` dependencies, `requireApproval` for a human gate | | `output` | any member | Read the latest complete saved worker reply by `taskId`; verifies the task/run/workspace/worker-conversation linkage |
| `start_run` | lead | Start a delegation batch with an objective; returns its `runId` |
| `create` | lead | Stage a task in that `runId`: subject, description, assignee, `blockedBy` dependencies, `requireApproval` for a human gate |
| `seal_run` | lead | Seal the completed task batch and start dispatch |
| `complete` | assignee | Submit the result (approval-gated tasks move to `in_review`) | | `complete` | assignee | Submit the result (approval-gated tasks move to `in_review`) |
| `progress` | assignee | Report percent and current step (also renews the execution lease and broadcasts to the board) | | `progress` | assignee | Report percent and current step (also renews the execution lease and broadcasts to the board) |
| `comment` | any member | Leave a comment | | `comment` | any member | Leave a comment |
@ -108,6 +112,7 @@ Teams target deep research and long document work — tasks that run for tens of
- **Execution lease + runtime heartbeat.** A dispatched task holds a 60-minute lease that is renewed automatically while the member runs; only genuinely lost tasks (process crash, restart) expire to `stale` and can be safely re-dispatched — no more "task re-dispatched while still running, two instances overwriting each other". - **Execution lease + runtime heartbeat.** A dispatched task holds a 60-minute lease that is renewed automatically while the member runs; only genuinely lost tasks (process crash, restart) expire to `stale` and can be safely re-dispatched — no more "task re-dispatched while still running, two instances overwriting each other".
- **Cancel means interrupt.** Member child conversations register with the stream tracker; graph nodes check the stop flag each round, so a cancelled member stops at the next node boundary — no more burning tokens to natural completion after a cancel. - **Cancel means interrupt.** Member child conversations register with the stream tracker; graph nodes check the stop flag each round, so a cancelled member stops at the next node boundary — no more burning tokens to natural completion after a cancel.
- **Settlement follows actual task state.** A worker returning normally does not turn a cancelled, stale, blocked or approval-waiting task into a completed task. The worker stream closes before completion/failure notifications can wake the lead; tasks requiring approval still report `in_review`.
- **Human approval gates.** Declare `requireApproval` at creation; the submitted task parks at `in_review` until you approve / reject on the Teams page — sensitive output passes a human before it leaves the team. - **Human approval gates.** Declare `requireApproval` at creation; the submitted task parks at `in_review` until you approve / reject on the Teams page — sensitive output passes a human before it leaves the team.
- **Manual task creation.** Tasks don't have to come from the lead: create one directly on the Teams page, assigned to any member; the result lands on the board for you (with no lead conversation to wake, announcement gracefully no-ops). - **Manual task creation.** Tasks don't have to come from the lead: create one directly on the Teams page, assigned to any member; the result lands on the board for you (with no lead conversation to wake, announcement gracefully no-ops).
@ -121,6 +126,8 @@ The right output shape for a complex task is **files + summary**, not one trunca
- Every task detail has a **"view run"** entry: the member child conversation's full transcript — dispatch envelope, round-by-round thinking, tool calls, intermediate output. Opened mid-run it's a live typewriter (reconnects replay the buffer). - Every task detail has a **"view run"** entry: the member child conversation's full transcript — dispatch envelope, round-by-round thinking, tool calls, intermediate output. Opened mid-run it's a live typewriter (reconnects replay the buffer).
- **Task timeline**: who created / dispatched / reported / attached / approved / cancelled, and when — each event lands in the `mate_team_task_event` audit table and renders as a timeline on the task detail. The collaboration has a historian. - **Task timeline**: who created / dispatched / reported / attached / approved / cancelled, and when — each event lands in the `mate_team_task_event` audit table and renders as a timeline on the task detail. The collaboration has a historian.
Long worker replies are saved in full in the worker conversation. Automatic settlement keeps a head/tail task-result excerpt within 8,000 characters, preserving the opening findings and final outcome. An explicit `complete` submission must also fit 8,000 characters; a longer summary is rejected before claiming or changing the task, with guidance to put full text in the final assistant reply or a deliverable. Prerequisite context has a 6,000-character total budget; a merged lead announcement has a 16,000-character total budget. Omitted content is marked and points to `team_tasks(action="output", taskId="...")`; use `get` for prerequisite IDs and all registered deliverable links. These are excerpts, so retrieve missing evidence before relying on it. When no complete saved worker reply is available, `output` explicitly identifies its fallback as the stored task summary. Large retrieved outputs use the existing tool-result spill mechanism when enabled.
--- ---
## Plan-Execute leads: the plan becomes the board ## Plan-Execute leads: the plan becomes the board

View File

@ -59,11 +59,12 @@ planning / running / awaiting_review → cancelled
| **任务(Task)** | 板上的一条工作项:标题、描述、指派人、依赖(`blockedBy`)、进度、结果、交付物、评论、时间线。 | | **任务(Task)** | 板上的一条工作项:标题、描述、指派人、依赖(`blockedBy`)、进度、结果、交付物、评论、时间线。 |
| **任务板(Board)** | 按状态分列的看板。状态机由数据库条件更新守卫——并发场景下谁先改成功谁算数,不会出现双重状态。 | | **任务板(Board)** | 按状态分列的看板。状态机由数据库条件更新守卫——并发场景下谁先改成功谁算数,不会出现双重状态。 |
任务状态一共八种: 任务状态一共九种:
``` ```
pending → in_progress → completed / failed / cancelled pending → in_progress → completed / failed / cancelled
↘ in_review(要求人工审批的任务) ↘ in_review(要求人工审批的任务)
↘ awaiting_approval(worker 工具调用需要审批)
blocked(等前置任务) stale(租约过期,可重试) blocked(等前置任务) stale(租约过期,可重试)
``` ```
@ -74,7 +75,7 @@ blocked(等前置任务) stale(租约过期,可重试)
## 一次典型协作长什么样 ## 一次典型协作长什么样
1. **你对 Lead 说目标**:“做一份竞品分析:先各自调研 A、B 两家,再汇总成一份报告。” 1. **你对 Lead 说目标**:“做一份竞品分析:先各自调研 A、B 两家,再汇总成一份报告。”
2. **Lead 拆任务上板**:调用 `team_tasks` 工具建三条任务——“调研 A”“调研 B”并行,“汇总报告”声明 `blockedBy` 前两条,自动进入 `blocked`。 2. **Lead 拆任务上板**:先调用 `start_run`,用返回的 `runId` 建好全部三条任务,再调用 `seal_run` 开始派发。“调研 A”“调研 B”并行,“汇总报告”声明 `blockedBy` 前两条,自动进入 `blocked`。
3. **派发引擎接手**:常驻扫描(30 秒一轮,工具/REST 动作后立即加扫)把 pending 任务派给指派成员——每个任务开一个独立子会话,成员在里面跑完整的 Agent 图。每个成员同时只吃一个任务,其余排队。 3. **派发引擎接手**:常驻扫描(30 秒一轮,工具/REST 动作后立即加扫)把 pending 任务派给指派成员——每个任务开一个独立子会话,成员在里面跑完整的 Agent 图。每个成员同时只吃一个任务,其余排队。
4. **前置结果自动传递**:“调研 A/B”完成后,“汇总报告”被放行,派发信封里**自动带上两条前置任务的结果与交付物链接**——成员 C 不需要 Lead 人肉转述成员 A 干了什么。 4. **前置结果自动传递**:“调研 A/B”完成后,“汇总报告”被放行,派发信封里**自动带上两条前置任务的结果与交付物链接**——成员 C 不需要 Lead 人肉转述成员 A 干了什么。
5. **结果通报唤醒 Lead**:任务落定(完成/失败)后合批通报给 Lead,Lead 被唤醒发起真实新一轮——检查结果、补派任务或宣布收工。 5. **结果通报唤醒 Lead**:任务落定(完成/失败)后合批通报给 Lead,Lead 被唤醒发起真实新一轮——检查结果、补派任务或宣布收工。
@ -89,8 +90,11 @@ Lead(和成员)通过 `team_tasks` 工具操作任务板,动作按角色
| 动作 | 谁能用 | 干什么 | | 动作 | 谁能用 | 干什么 |
|------|--------|--------| |------|--------|--------|
| `list` | 所有成员 | 渲染当前任务板(Lead 每轮还会自动收到实时看板快照,见下) | | `list` | 所有成员 | 渲染当前任务板(Lead 每轮还会自动收到实时看板快照,见下) |
| `get` | 所有成员 | 查看单个任务详情 | | `get` | 所有成员 | 查看任务详情、前置任务 ID、评论和已登记的交付物 |
| `create` | Lead | 建任务:标题、描述、指派成员、`blockedBy` 依赖、`requireApproval` 是否需人工审批 | | `output` | 所有成员 | 按 `taskId` 读取最近一条完整保存的 worker 回复,校验任务、run、工作区与 worker 会话关联 |
| `start_run` | Lead | 按目标开启一批委派任务,返回 `runId` |
| `create` | Lead | 在该 `runId` 内准备任务:标题、描述、指派成员、`blockedBy` 依赖、`requireApproval` 是否需人工审批 |
| `seal_run` | Lead | 封存已准备好的整批任务,开始派发 |
| `complete` | 执行成员 | 提交结果完成任务(要求审批的任务转入 `in_review` 等人批) | | `complete` | 执行成员 | 提交结果完成任务(要求审批的任务转入 `in_review` 等人批) |
| `progress` | 执行成员 | 上报进度百分比与当前步骤(同时给执行租约续期并广播到看板) | | `progress` | 执行成员 | 上报进度百分比与当前步骤(同时给执行租约续期并广播到看板) |
| `comment` | 所有成员 | 在任务下留言 | | `comment` | 所有成员 | 在任务下留言 |
@ -108,6 +112,7 @@ Lead(和成员)通过 `team_tasks` 工具操作任务板,动作按角色
- **执行租约 + 运行期心跳**。任务派发即持有 60 分钟执行租约,成员运行期间后台自动续期;真正失联的任务(进程崩溃、重启)租约到期被判 `stale`,可安全重派——**不会**出现"任务还在跑就被重派、两个实例互相覆盖"的双重执行。 - **执行租约 + 运行期心跳**。任务派发即持有 60 分钟执行租约,成员运行期间后台自动续期;真正失联的任务(进程崩溃、重启)租约到期被判 `stale`,可安全重派——**不会**出现"任务还在跑就被重派、两个实例互相覆盖"的双重执行。
- **取消即中断**。`cancel` 不只是状态转移:成员子会话注册进流跟踪器,图节点每轮检查停止位,取消后运行中的成员会话在下一个节点边界停下——不再有"取消了还在烧 token 到自然结束"。 - **取消即中断**。`cancel` 不只是状态转移:成员子会话注册进流跟踪器,图节点每轮检查停止位,取消后运行中的成员会话在下一个节点边界停下——不再有"取消了还在烧 token 到自然结束"。
- **结算通知服从真实任务状态**。worker 正常返回不会把已取消、过期、阻塞或等待工具审批的任务误报为完成。先关闭 worker 流,再让完成或失败通知唤醒 Lead;需要人工审批的产出仍报告 `in_review`。
- **人工审批卡点**。建任务时声明 `requireApproval`,成员提交后任务停在 `in_review`,由你在 Teams 页 approve / reject——敏感产出离开团队前先过人。 - **人工审批卡点**。建任务时声明 `requireApproval`,成员提交后任务停在 `in_review`,由你在 Teams 页 approve / reject——敏感产出离开团队前先过人。
- **手动投任务**。任务不非得 Lead 建:Teams 页可以直接建任务指派给某个成员,结果落板上由你查看(没有 Lead 会话要唤醒时,通报自动降级为 no-op)。 - **手动投任务**。任务不非得 Lead 建:Teams 页可以直接建任务指派给某个成员,结果落板上由你查看(没有 Lead 会话要唤醒时,通报自动降级为 no-op)。
@ -121,6 +126,8 @@ Lead(和成员)通过 `team_tasks` 工具操作任务板,动作按角色
- 每个任务详情都有**"查看执行过程"**入口:跳进成员子会话的完整转写——派发信封、逐轮思考、工具调用、中间产物一览无余。任务运行中打开,就是实时打字机直播(断线重连自动回放缓冲)。 - 每个任务详情都有**"查看执行过程"**入口:跳进成员子会话的完整转写——派发信封、逐轮思考、工具调用、中间产物一览无余。任务运行中打开,就是实时打字机直播(断线重连自动回放缓冲)。
- **任务时间线**:谁在什么时候创建/派发/上报进度/挂附件/审批/取消,逐条落 `mate_team_task_event` 审计表,任务详情按时间线渲染——协同过程有史可查。 - **任务时间线**:谁在什么时候创建/派发/上报进度/挂附件/审批/取消,逐条落 `mate_team_task_event` 审计表,任务详情按时间线渲染——协同过程有史可查。
较长的 worker 回复先完整保存到 worker 会话,自动结算再生成不超过 8,000 字符的任务首尾摘要,保留开头发现与结尾结论。主动调用 `complete` 提交的摘要也需在 8,000 字符内;超限会在认领或修改任务前明确拒绝,并提示将完整内容放入最终 assistant 回复或附件交付物。前置任务上下文总预算为 6,000 字符,Lead 批量汇总通知总预算为 16,000 字符。省略内容会明确标记,并指向 `team_tasks(action="output", taskId="...")`;前置任务 ID 与全部交付物链接可用 `get` 查看。摘要不代表省略部分已经验证,需要时应检索原文。没有完整保存的 worker 回复时,`output` 会明确说明只能返回任务板摘要。较大的检索结果在启用工具结果落盘机制时复用现有完整落盘与读取路径。
--- ---
## Plan-Execute Lead:计划直接变任务板 ## Plan-Execute Lead:计划直接变任务板

View File

@ -305,6 +305,50 @@ class ReasoningNodeOutputTest {
assertTrue(filtered.isEmpty()); assertTrue(filtered.isEmpty());
} }
@Test
void structuredWorkerSourceLengthDoesNotTriggerLongFormContinuation() throws Exception {
String answer = "{\"status\":\"COMPLETED\",\"result\":\"HEAD/TAIL/130 verified\",\"evidence\":\"team_tasks output and read_file\"}";
when(streamingHelper.streamCall(any(), any(), anyString(), anyString())).thenReturn(
new NodeStreamingChatHelper.StreamResult(answer, "", new AssistantMessage(answer),
List.of(), false, 100, 100));
Map<String, Object> state = baseStateMap();
String request = "[Team task]\n源任务完整长回复(约 10731 字符)已持久化。请检索并核验,最终 JSON 小于800字符。\n"
+ "[Final result contract]\n"
+ vip.mate.agent.runtime.WorkerResultContract.INSTRUCTIONS
+ "Keep the entire JSON under 8000 characters.\n";
state.put(USER_MESSAGE, request);
state.put(MAX_ITERATIONS, 30);
Map<String, Object> output = createNode().apply(new RunState(state));
assertEquals(false, output.get(CONTINUE_REASONING));
assertEquals(answer, output.get(FINAL_ANSWER));
assertTrue(ReasoningNode.requestedLongFormChars(request).isEmpty());
}
@Test
void sourceSizeAndUpperBoundsDoNotRequestLongFormProse() {
for (String request : List.of("源任务完整长回复(约 10731 字符)已保存。请核验并简短回复。",
"完整回复10975字符,与前置约10731字符一致(含封装头尾)。",
"完整输出约10000字,请核验首尾。", "读取一万字报告,仅回复结论。",
"Read the 10,000 words source report; answer briefly.",
"请输出不超过 10000 字的报告", "Write at most 10,000 words.",
"Write no more than 10000 words", "请写一篇不要超过10000字的报告")) {
assertTrue(ReasoningNode.requestedLongFormChars(request).isEmpty(), request);
}
assertEquals(3000, ReasoningNode.requestedLongFormChars(
"读取 10731 字符原文,写一篇 3000 字摘要。").orElseThrow());
assertEquals(10_000, ReasoningNode.requestedLongFormChars("帮我写一万字小说。").orElseThrow());
assertEquals(10_000, ReasoningNode.requestedLongFormChars("Write 10,000 words of fiction.").orElseThrow());
}
@Test
void explicitChineseWritingIntentsStillEnforceTheirRequestedLength() {
for (String request : List.of("请你写一篇10000字小说", "请把这个故事扩写到10000字",
"请根据工具结果写一篇10000字技术报告", "请写一篇小说,要求10000字",
"Write a report; length: 10000 words")) {
assertEquals(10_000, ReasoningNode.requestedLongFormChars(request).orElseThrow(), request);
}
}
private static ToolCallback mockTool(String name) { private static ToolCallback mockTool(String name) {
ToolCallback callback = mock(ToolCallback.class); ToolCallback callback = mock(ToolCallback.class);
org.springframework.ai.tool.definition.ToolDefinition definition = org.springframework.ai.tool.definition.ToolDefinition definition =

View File

@ -26,6 +26,40 @@ class ActionExecutionLedgerTest {
assertTrue(merged.merge(failure).latestCompleteResult().isEmpty()); assertTrue(merged.merge(failure).latestCompleteResult().isEmpty());
assertThrows(UnsupportedOperationException.class,()->merged.latestCompleteResult().put("forged","body")); assertThrows(UnsupportedOperationException.class,()->merged.latestCompleteResult().put("forged","body"));
} }
@Test void teamProgressPreservesFullReadEvidenceAndSurvivesReceiptSerialization() throws Exception {
String body = "HEAD " + "evidence ".repeat(1000) + " TAIL";
var read = ActionExecutionLedger.fromEvents(List.of(
GraphEventPublisher.toolComplete("read", "read_file", body, true)));
var progress = ActionExecutionLedger.fromEvents(List.of(
GraphEventPublisher.toolStart("progress", "team_tasks", "{\"action\":\"progress\",\"taskId\":\"5\",\"percent\":100}"),
GraphEventPublisher.toolComplete("progress", "team_tasks", "Progress recorded", true)));
var merged = read.merge(progress);
assertEquals(java.util.Map.of("read", body), merged.latestCompleteResult());
assertFalse(merged.receipts().get("progress").substantive());
var mapper = new com.fasterxml.jackson.databind.ObjectMapper();
var restored = mapper.readValue(mapper.writeValueAsString(merged.receipts().get("progress")),
ActionExecutionLedger.Receipt.class);
assertFalse(restored.substantive());
var failed = ActionExecutionLedger.fromEvents(List.of(
GraphEventPublisher.toolStart("failed-progress", "team_tasks", "{\"action\":\"progress\",\"taskId\":\"5\"}"),
GraphEventPublisher.toolComplete("failed-progress", "team_tasks", "Error", false)));
assertEquals(1, merged.merge(failed).failedCallCount());
}
@Test void ambiguousOrMutatingTeamCallsRemainSubstantive() {
for (String arguments : List.of("{\"action\":\"complete\",\"taskId\":\"5\"}",
"{\"action\":\"output\",\"taskId\":\"5\"}",
"{\"action\":\"progress\",\"action\":\"complete\"}", "not-json")) {
var ledger = ActionExecutionLedger.fromEvents(List.of(
GraphEventPublisher.toolStart("call", "team_tasks", arguments),
GraphEventPublisher.toolComplete("call", "team_tasks", "receipt", true)));
assertTrue(ledger.receipts().get("call").substantive());
}
var missingStart = ActionExecutionLedger.fromEvents(List.of(
GraphEventPublisher.toolComplete("legacy", "team_tasks", "Progress recorded", true)));
assertTrue(missingStart.receipts().get("legacy").substantive());
}
@Test void legacyReceiptLengthIsUnknownAndCannotGroundLatestOutput() throws Exception { @Test void legacyReceiptLengthIsUnknownAndCannotGroundLatestOutput() throws Exception {
var receipt=new ActionExecutionLedger.Receipt("legacy","calc",ActionExecutionLedger.Status.SUCCEEDED,"x".repeat(512)+"...",1); var receipt=new ActionExecutionLedger.Receipt("legacy","calc",ActionExecutionLedger.Status.SUCCEEDED,"x".repeat(512)+"...",1);
assertEquals(-1,receipt.resultChars()); assertEquals(-1,receipt.resultChars());

View File

@ -40,6 +40,54 @@ import static org.mockito.Mockito.*;
*/ */
class TeamAnnounceServiceTest { class TeamAnnounceServiceTest {
@Test
void twentyLargeResultsHaveOneAggregateBudgetAndKeepEveryTaskOutcome() {
List<TeamAnnounceService.AnnounceItem> items = java.util.stream.LongStream.rangeClosed(1, 20)
.mapToObj(id -> new TeamAnnounceService.AnnounceItem(id, 10L, (int) id,
"task " + id, TeamTaskStatus.COMPLETED, "member " + id,
"opening " + id + "\n" + "body\n".repeat(2000) + "\nverified " + id))
.toList();
String result = TeamAnnounceService.buildAnnouncement(items);
assertTrue(result.length() <= 16000, "Announcement exceeds aggregate budget: " + result.length());
for (long id = 1; id <= 20; id++) {
assertTrue(result.contains("Task #" + id + " "));
assertTrue(result.contains("taskId=\"" + id + "\""));
assertTrue(result.contains("verified " + id));
}
}
@Test
void longTaskTitlesAndMemberNamesCannotBypassAnnouncementBudget() {
var item = new TeamAnnounceService.AnnounceItem(42L, 10L, 42,
"title".repeat(10000), TeamTaskStatus.IN_REVIEW, "member".repeat(10000),
"approval still required");
String result = TeamAnnounceService.buildAnnouncement(List.of(item));
assertTrue(result.length() <= 16000);
assertTrue(result.contains("in_review"));
assertTrue(result.contains("approval still required"));
assertTrue(result.contains("taskId=\"42\""));
}
@Test
void retryMergedBatchBeyondTwentyExplicitlyIdentifiesOmittedDetails() {
List<TeamAnnounceService.AnnounceItem> items = java.util.stream.LongStream.rangeClosed(1, 25)
.mapToObj(id -> new TeamAnnounceService.AnnounceItem(id, 10L, (int) id,
"task " + id, TeamTaskStatus.COMPLETED, "member",
"Opening\n" + "evidence\n".repeat(2000) + "Final verified"))
.toList();
String result = TeamAnnounceService.buildAnnouncement(items);
assertTrue(result.length() <= 16000);
assertTrue(result.contains("25 delegated team tasks have settled"));
assertTrue(result.contains("5 additional task details omitted"));
assertTrue(result.contains("team_tasks(action=\"list\")"));
}
private static final Long TEAM_ID = 10L; private static final Long TEAM_ID = 10L;
private static final Long LEAD_ID = 1L; private static final Long LEAD_ID = 1L;
private static final Long RUN_ID = 90L; private static final Long RUN_ID = 90L;

View File

@ -5,6 +5,8 @@ import vip.mate.decision.api.*;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import vip.mate.agent.AgentService; import vip.mate.agent.AgentService;
import vip.mate.approval.ApprovalWorkflowService; import vip.mate.approval.ApprovalWorkflowService;
import vip.mate.approval.PendingApproval; import vip.mate.approval.PendingApproval;
@ -207,6 +209,58 @@ class TeamDispatchServiceTest {
// ==================== outcome settling ==================== // ==================== outcome settling ====================
@ParameterizedTest
@ValueSource(strings = {"cancelled", "stale", "pending", "blocked", "awaiting_approval"})
void normalWorkerReturnDoesNotAnnounceUnsettledOrCancelledTask(String status) {
TeamTaskEntity current = task(1L, MEMBER_A);
current.setStatus(status);
when(taskService.getTask(1L)).thenReturn(current);
service.settleOutcome(current, "late worker reply");
verifyNoInteractions(eventChannel, announceService);
verify(taskService, never()).completeTask(any(), any(), anyString());
}
@Test
void childStreamClosesBeforeLeadReceivesSuccessfulResult() {
TeamTaskEntity assigned = task(1L, MEMBER_A);
assigned.setStatus(TeamTaskStatus.IN_PROGRESS);
TeamTaskEntity done = task(1L, MEMBER_A);
done.setStatus(TeamTaskStatus.COMPLETED);
when(taskService.getTask(1L)).thenReturn(assigned, done, done);
when(taskService.completeTask(eq(1L), isNull(), anyString())).thenReturn(List.of());
when(agentService.chatWithUsage(eq(MEMBER_A), anyString(), anyString()))
.thenReturn(AgentService.ChatResult.contentOnly("all done"));
service.runTask(TEAM_ID, assigned);
var order = inOrder(conversationService, streamTracker, eventChannel, announceService);
order.verify(conversationService).saveMessage(anyString(), eq("assistant"), eq("all done"));
order.verify(streamTracker).complete(startsWith("team-task-"));
order.verify(eventChannel).publishTaskEvent(any(), eq("team_task_completed"), any());
order.verify(announceService).announceTaskSettled(done);
}
@Test
void childStreamClosesBeforeLeadReceivesFailedResult() {
TeamTaskEntity assigned = task(1L, MEMBER_A);
assigned.setStatus(TeamTaskStatus.IN_PROGRESS);
TeamTaskEntity failed = task(1L, MEMBER_A);
failed.setStatus(TeamTaskStatus.FAILED);
when(agentService.chatWithUsage(eq(MEMBER_A), anyString(), anyString()))
.thenThrow(new IllegalStateException("model unavailable"));
when(taskService.failTask(eq(1L), anyString())).thenReturn(true);
when(taskService.getTask(1L)).thenReturn(failed);
service.runTask(TEAM_ID, assigned);
var order = inOrder(streamTracker, eventChannel, announceService);
order.verify(streamTracker).complete(startsWith("team-task-"));
order.verify(eventChannel).publishTaskEvent(any(), eq("team_task_failed"), any());
order.verify(announceService).announceTaskSettled(failed);
}
@Test @Test
@DisplayName("an in_progress task is auto-completed with the member's final reply") @DisplayName("an in_progress task is auto-completed with the member's final reply")
void settleAutoCompletes() { void settleAutoCompletes() {
@ -603,17 +657,94 @@ class TeamDispatchServiceTest {
} }
@Test @Test
@DisplayName("an oversized member reply is truncated before persisting") @DisplayName("a bounded task summary preserves the final outcome and a complete-output reference")
void settleTruncatesLongReply() { void settleSummarizesLongReplyWithoutLosingFinalOutcome() {
TeamTaskEntity running = task(1L, MEMBER_A); TeamTaskEntity running = task(1L, MEMBER_A);
running.setStatus(TeamTaskStatus.IN_PROGRESS); running.setStatus(TeamTaskStatus.IN_PROGRESS);
when(taskService.getTask(1L)).thenReturn(running, running); when(taskService.getTask(1L)).thenReturn(running, running);
when(taskService.completeTask(eq(1L), isNull(), anyString())).thenReturn(List.of()); when(taskService.completeTask(eq(1L), isNull(), anyString())).thenReturn(List.of());
service.settleOutcome(running, "x".repeat(TeamDispatchService.MAX_RESULT_CHARS + 500)); service.settleOutcome(running, "Opening findings\n"
+ "x".repeat(TeamDispatchService.MAX_RESULT_CHARS + 500) + "\nFinal verification passed");
verify(taskService).completeTask(eq(1L), isNull(), argThat(r -> verify(taskService).completeTask(eq(1L), isNull(), argThat(r ->
r.length() <= TeamDispatchService.MAX_RESULT_CHARS + 20 r.length() <= TeamDispatchService.MAX_RESULT_CHARS
&& r.endsWith("...(truncated)"))); && r.startsWith("Opening findings")
&& r.endsWith("Final verification passed")
&& r.contains("team_tasks(action=\"output\", taskId=\"1\")")));
}
@Test
void completeWorkerReplyIsSavedBeforeTaskSummaryIsDerived() {
TeamTaskEntity assigned = task(1L, MEMBER_A);
assigned.setStatus(TeamTaskStatus.IN_PROGRESS);
TeamTaskEntity done = task(1L, MEMBER_A);
done.setStatus(TeamTaskStatus.COMPLETED);
when(taskService.getTask(1L)).thenReturn(assigned, done, done);
when(taskService.completeTask(eq(1L), isNull(), anyString())).thenReturn(List.of());
String full = "Opening\n" + "x".repeat(10000) + "\nFinal verification";
when(agentService.chatWithUsage(eq(MEMBER_A), anyString(), anyString()))
.thenReturn(AgentService.ChatResult.contentOnly(full));
service.runTask(TEAM_ID, assigned);
var order = inOrder(conversationService, taskService);
order.verify(conversationService).saveMessage(anyString(), eq("assistant"), eq(full));
order.verify(taskService).completeTask(eq(1L), isNull(), argThat(result ->
result.length() <= TeamDispatchService.MAX_RESULT_CHARS
&& result.endsWith("Final verification")
&& result.contains("action=\"output\"")));
}
@Test
void prerequisiteContextKeepsFinalVerificationAndEveryIncludedTaskReference() {
TeamTaskEntity dependent = task(20L, MEMBER_B);
dependent.setBlockedBy("[1,2,3,4,5,6]");
for (long id = 1; id <= 6; id++) {
TeamTaskEntity prerequisite = task(id, MEMBER_A);
prerequisite.setStatus(TeamTaskStatus.COMPLETED);
prerequisite.setResult("opening " + id + "\n" + "analysis\n".repeat(2000)
+ "\nverification " + id + " passed");
when(taskService.getTask(id)).thenReturn(prerequisite);
}
StringBuilder context = new StringBuilder();
service.appendPrerequisiteResults(context, dependent);
String result = context.toString();
assertTrue(result.length() <= TeamDispatchService.MAX_PREREQ_SECTION_CHARS);
for (long id = 1; id <= 6; id++) {
assertTrue(result.contains("taskId=\"" + id + "\""), "Missing task " + id);
assertTrue(result.contains("verification " + id + " passed"), "Lost tail for " + id);
}
}
@Test
void manyPrerequisitesAndDeliverablesHaveExplicitRetrievalPathsInsideTotalBudget() {
TeamTaskEntity dependent = task(50L, MEMBER_B);
dependent.setBlockedBy("[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20]");
for (long id = 1; id <= 20; id++) {
TeamTaskEntity prerequisite = task(id, MEMBER_A);
prerequisite.setStatus(TeamTaskStatus.COMPLETED);
prerequisite.setSubject("Long subject ".repeat(50));
prerequisite.setResult("Opening\n" + "evidence\n".repeat(2000) + "Final verified");
when(taskService.getTask(id)).thenReturn(prerequisite);
when(taskService.listDeliverables(prerequisite)).thenReturn(java.util.stream.IntStream.range(0, 10)
.mapToObj(index -> new TeamTaskService.Deliverable("report-" + index + ".docx",
"/api/v1/files/generated/complete-generated-id-" + index, null)).toList());
}
StringBuilder context = new StringBuilder();
service.appendPrerequisiteResults(context, dependent);
String result = context.toString();
assertTrue(result.length() <= TeamDispatchService.MAX_PREREQ_SECTION_CHARS, result);
assertTrue(result.contains("12 additional prerequisite(s) omitted"));
assertTrue(result.contains("taskId=\"50\""));
assertTrue(result.contains("deliverable(s) omitted"));
for (long id = 1; id <= 8; id++) {
assertTrue(result.contains("taskId=\"" + id + "\""));
assertTrue(result.contains("Final verified"));
}
} }
} }

View File

@ -0,0 +1,49 @@
package vip.mate.team.service;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.*;
class TeamResultExcerptTest {
@Test
void shortResultRemainsVerbatim() {
String json = "{\"path\":\"report.docx\",\"verified\":true}";
assertEquals(json, TeamResultExcerpt.render(json, 300, 42L));
assertEquals("", TeamResultExcerpt.render(null, 300, 42L));
}
@Test
void longResultPreservesOpeningAndFinalVerificationWithinStrictBudget() {
String full = "Produced report.docx\n" + "body\n".repeat(2000)
+ "\nVerification: 18 tests passed; remaining issue: missing owner approval.";
String excerpt = TeamResultExcerpt.render(full, 1500, 42L);
assertTrue(excerpt.length() <= 1500);
assertTrue(excerpt.startsWith("Produced report.docx"));
assertTrue(excerpt.endsWith("remaining issue: missing owner approval."));
assertTrue(excerpt.contains("team_tasks(action=\"output\", taskId=\"42\")"));
assertTrue(excerpt.contains("Do not infer omitted content"));
}
@ParameterizedTest
@ValueSource(ints = {256, 257, 300, 999, 1500, 8000})
void excerptsNeverSplitUnicodeSurrogatePairs(int budget) {
String full = "🙂".repeat(10000);
String excerpt = TeamResultExcerpt.render(full, budget, Long.MAX_VALUE);
assertTrue(excerpt.length() <= budget);
for (int index = 0; index < excerpt.length(); index++) {
char ch = excerpt.charAt(index);
if (Character.isHighSurrogate(ch)) {
assertTrue(index + 1 < excerpt.length());
assertTrue(Character.isLowSurrogate(excerpt.charAt(++index)));
} else {
assertFalse(Character.isLowSurrogate(ch));
}
}
}
}

View File

@ -9,6 +9,8 @@ import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.InOrder; import org.mockito.InOrder;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
import vip.mate.team.model.AgentTeamEntity; import vip.mate.team.model.AgentTeamEntity;
@ -53,6 +55,19 @@ class TeamTaskServiceTest {
private TeamRunService runService; private TeamRunService runService;
private TeamTaskService service; private TeamTaskService service;
@ParameterizedTest
@ValueSource(strings = {"pending", "in_progress"})
void explicitCompletionRejectsOversizedSummaryBeforeAnyTaskMutation(String status) {
when(taskMapper.selectById(5L)).thenReturn(task(5L, status));
when(taskMapper.update(isNull(), any())).thenReturn(1);
var error = assertThrows(IllegalArgumentException.class,
() -> service.completeTask(5L, MEMBER_ID, "x".repeat(8001)));
assertTrue(error.getMessage().contains("8000"));
assertTrue(error.getMessage().contains("final reply"));
verifyNoInteractions(taskMapper, commentMapper, eventMapper, projectionScheduler);
}
@Test @Test
void staleActiveCompletionCannotMutateOrProduceSuccessfulOutcome() { void staleActiveCompletionCannotMutateOrProduceSuccessfulOutcome() {
var adapter = mock(WorkerDecisionAdapter.class); var adapter = mock(WorkerDecisionAdapter.class);

View File

@ -0,0 +1,354 @@
package vip.mate.team.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.toolkit.GlobalConfigUtils;
import com.baomidou.mybatisplus.spring.MybatisSqlSessionFactoryBean;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.core.io.ClassPathResource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator;
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
import org.springframework.transaction.interceptor.TransactionInterceptor;
import vip.mate.agent.AgentService;
import vip.mate.agent.repository.AgentMapper;
import vip.mate.agent.graph.executor.ToolResultProperties;
import vip.mate.agent.graph.executor.ToolResultStorage;
import vip.mate.approval.ApprovalWorkflowService;
import vip.mate.approval.repository.ToolApprovalMapper;
import vip.mate.auth.service.AuthService;
import vip.mate.channel.repository.ChannelSessionMapper;
import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.config.MybatisPlusConfig;
import vip.mate.i18n.I18nService;
import vip.mate.task.repository.AsyncTaskMapper;
import vip.mate.team.model.AgentTeamEntity;
import vip.mate.team.model.TeamTaskStatus;
import vip.mate.team.repository.TeamRunMapper;
import vip.mate.team.repository.TeamTaskCommentMapper;
import vip.mate.team.repository.TeamTaskEventMapper;
import vip.mate.team.repository.TeamTaskMapper;
import vip.mate.team.tool.TeamTasksTool;
import vip.mate.tool.builtin.ToolExecutionContext;
import vip.mate.tool.builtin.ReadFileTool;
import vip.mate.workspace.conversation.ConversationService;
import vip.mate.workspace.conversation.repository.ConversationMapper;
import vip.mate.workspace.conversation.repository.MessageMapper;
import vip.mate.workspace.core.service.ChatUploadLocationResolver;
import vip.mate.workspace.core.service.WorkspaceService;
import java.util.Optional;
import java.util.UUID;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/** Real MyBatis/H2 persistence and service gates; only the model and external scheduling are stubbed. */
class TeamWorkerOutputPersistenceTest {
private JdbcTemplate jdbc;
private ConversationService conversations;
private TeamTaskService tasks;
private TeamTasksTool tool;
private TeamDispatchService dispatch;
private AgentService agents;
private TeamEventChannel events;
private TeamAnnounceService announcements;
@BeforeEach
void setup() throws Exception {
var source = new JdbcDataSource();
source.setURL("jdbc:h2:mem:team_output_" + UUID.randomUUID()
+ ";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1");
String[] migrations = {"V1__baseline_schema.sql", "V5__conversation_parent.sql",
"V114__conversation_pinned.sql", "V116__conversation_model.sql",
"V123__conversation_progress_ledger.sql", "V151__webchat_session_id.sql",
"V152__webchat_archive_and_revocation.sql", "V166__message_usage_detail.sql",
"V172__agent_team_foundation.sql", "V174__team_task_event_timeline.sql",
"V181__team_run_foundation.sql", "V183__conversation_kind.sql"};
var populator = new ResourceDatabasePopulator();
for (String migration : migrations) {
populator.addScript(new ClassPathResource("db/migration/h2/" + migration));
}
populator.execute(source);
jdbc = new JdbcTemplate(source);
var configuration = new MybatisConfiguration();
var global = GlobalConfigUtils.defaults().setMetaObjectHandler(new MybatisPlusConfig());
GlobalConfigUtils.setGlobalConfig(configuration, global);
configuration.addMapper(TeamTaskMapper.class);
configuration.addMapper(TeamTaskCommentMapper.class);
configuration.addMapper(TeamTaskEventMapper.class);
configuration.addMapper(TeamRunMapper.class);
configuration.addMapper(ConversationMapper.class);
configuration.addMapper(MessageMapper.class);
var factory = new MybatisSqlSessionFactoryBean();
factory.setDataSource(source);
factory.setConfiguration(configuration);
factory.setGlobalConfig(global);
var session = new SqlSessionTemplate(factory.getObject());
var teamService = mock(TeamService.class);
var team = new AgentTeamEntity();
team.setId(10L);
team.setWorkspaceId(30L);
team.setLeadAgentId(1L);
when(teamService.getTeam(10L)).thenReturn(team);
when(teamService.getTeamForAgent(1L)).thenReturn(Optional.of(team));
when(teamService.getTeamForAgent(2L)).thenReturn(Optional.of(team));
when(teamService.isLead(team, 1L)).thenReturn(true);
var runService = new TeamRunService(session.getMapper(TeamRunMapper.class),
session.getMapper(TeamTaskMapper.class), teamService);
var taskTarget = new TeamTaskService(session.getMapper(TeamTaskMapper.class),
session.getMapper(TeamTaskCommentMapper.class), session.getMapper(TeamTaskEventMapper.class),
teamService, mock(TeamRunProjectionScheduler.class), runService);
var proxy = new ProxyFactory(taskTarget);
proxy.setProxyTargetClass(true);
proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(source),
new AnnotationTransactionAttributeSource()));
tasks = (TeamTaskService) proxy.getProxy();
var agentMapper = mock(AgentMapper.class);
conversations = new ConversationService(session.getMapper(ConversationMapper.class),
session.getMapper(MessageMapper.class), agentMapper, new ObjectMapper(),
mock(ToolApprovalMapper.class), mock(AsyncTaskMapper.class), mock(ChannelSessionMapper.class),
mock(ApplicationEventPublisher.class), mock(AuthService.class), mock(WorkspaceService.class),
mock(ChatUploadLocationResolver.class));
conversations.getOrCreateConversation("lead-conv", 1L, "system", 30L);
jdbc.update("""
INSERT INTO mate_team_run(id,team_id,workspace_id,lead_agent_id,lead_conversation_id,
title,objective,status) VALUES(20,10,30,1,'lead-conv','Run','Analyze results','running')
""");
jdbc.update("""
INSERT INTO mate_team_task(id,team_id,run_id,task_number,subject,status,assignee_agent_id,
owner_agent_id,dispatch_count,lead_conversation_id)
VALUES(5,10,20,5,'Analyze results','in_progress',2,2,1,'lead-conv')
""");
agents = mock(AgentService.class);
events = mock(TeamEventChannel.class);
announcements = mock(TeamAnnounceService.class);
dispatch = spy(new TeamDispatchService(teamService, tasks, agents, conversations,
mock(ChatStreamTracker.class), announcements, events, mock(ApprovalWorkflowService.class)));
doNothing().when(dispatch).requestDispatch(anyLong());
tool = new TeamTasksTool(teamService, tasks, runService, dispatch, events, conversations, agentMapper);
ToolExecutionContext.set("lead-conv", "system");
}
@AfterEach
void cleanup() {
ToolExecutionContext.clear();
}
private String output() {
return tool.team_tasks("output", "5", null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, null);
}
private void runWorker(String reply) {
when(agents.chatWithUsage(eq(2L), anyString(), anyString()))
.thenReturn(AgentService.ChatResult.contentOnly(reply));
dispatch.runTask(10L, tasks.getTask(5L));
}
@Test
void completeReplySurvivesPersistenceWhileBoardSummaryIsStrictlyBounded() {
String full = "Opening findings\n" + "Evidence line\n".repeat(2000) + "Final verification: passed";
runWorker(full);
var task = tasks.getTask(5L);
assertEquals(TeamTaskStatus.COMPLETED, task.getStatus());
assertTrue(task.getResult().length() <= 8000);
assertTrue(task.getResult().endsWith("Final verification: passed"));
assertTrue(task.getResult().contains("action=\"output\""));
assertEquals(full, jdbc.queryForObject(
"SELECT content FROM mate_message WHERE conversation_id=? AND role='assistant'",
String.class, task.getConversationId()));
assertTrue(output().endsWith(full));
assertEquals(1, jdbc.queryForObject(
"SELECT COUNT(*) FROM mate_team_task_event WHERE task_id=5 AND event_type='completed'", Integer.class));
}
@ParameterizedTest
@ValueSource(strings = {"worker_workspace", "run_workspace", "run_team", "task_parent", "task_assignee"})
void persistedLinkageMismatchCannotDiscloseWorkerText(String mismatch) {
runWorker("CONFIDENTIAL-WORKER-RESULT");
switch (mismatch) {
case "worker_workspace" -> jdbc.update(
"UPDATE mate_conversation SET workspace_id=999 WHERE conversation_id=?",
tasks.getTask(5L).getConversationId());
case "run_workspace" -> jdbc.update("UPDATE mate_team_run SET workspace_id=999 WHERE id=20");
case "run_team" -> jdbc.update("UPDATE mate_team_run SET team_id=999 WHERE id=20");
case "task_parent" -> jdbc.update("UPDATE mate_team_task SET lead_conversation_id='unrelated' WHERE id=5");
case "task_assignee" -> jdbc.update("UPDATE mate_team_task SET assignee_agent_id=999 WHERE id=5");
default -> throw new AssertionError(mismatch);
}
String result = output();
assertTrue(result.startsWith("Error:"));
assertFalse(result.contains("CONFIDENTIAL"));
}
@Test
void retryReadsReplacementConversationEvenWhenDispatchCounterRepeats() {
runWorker("OLD-ATTEMPT-RESULT");
String originalConversation = tasks.getTask(5L).getConversationId();
assertEquals(1, tasks.getTask(5L).getDispatchCount());
assertTrue(tasks.resumeForWorkerFeedback(5L));
assertTrue(tasks.failTask(5L, "follow-up failed"));
assertTrue(tasks.retryTask(5L));
assertTrue(tasks.assignTask(5L, 2L));
assertTrue(tasks.tryAcquireDispatch(5L));
runWorker("NEW-ATTEMPT-RESULT");
assertEquals(1, tasks.getTask(5L).getDispatchCount());
assertNotEquals(originalConversation, tasks.getTask(5L).getConversationId());
String result = output();
assertTrue(result.endsWith("NEW-ATTEMPT-RESULT"));
assertFalse(result.contains("OLD-ATTEMPT-RESULT"));
assertEquals("OLD-ATTEMPT-RESULT", jdbc.queryForObject(
"SELECT content FROM mate_message WHERE conversation_id=? AND role='assistant'",
String.class, originalConversation));
}
@Test
void normalReturnAfterDurableCancellationDoesNotAnnounceCompletion() {
when(agents.chatWithUsage(eq(2L), anyString(), anyString())).thenAnswer(invocation -> {
tasks.cancelTask(5L, "cancelled during model invocation");
return AgentService.ChatResult.contentOnly("late normal return");
});
dispatch.runTask(10L, tasks.getTask(5L));
assertEquals(TeamTaskStatus.CANCELLED, tasks.getTask(5L).getStatus());
assertNull(tasks.getTask(5L).getResult());
verify(events, never()).publishTaskEvent(any(), eq("team_task_completed"), any());
verifyNoInteractions(announcements);
}
@Test
void approvalTaskRemainsInReviewWithCompleteOutputAvailable() {
jdbc.update("UPDATE mate_team_task SET require_approval=TRUE WHERE id=5");
runWorker("Produced draft; human approval required");
assertEquals(TeamTaskStatus.IN_REVIEW, tasks.getTask(5L).getStatus());
assertTrue(output().contains("Produced draft; human approval required"));
verify(events).publishTaskEvent(any(), eq("team_task_in_review"), any());
verify(events, never()).publishTaskEvent(any(), eq("team_task_completed"), any());
}
@Test
void explicitOversizedCompletionIsRejectedAndFullFinalReplyRemainsRetrievable() {
String full = "Full analysis\n" + "evidence\n".repeat(2000) + "Final verification";
when(agents.chatWithUsage(eq(2L), anyString(), anyString())).thenAnswer(invocation -> {
ToolExecutionContext.set(invocation.getArgument(2), "system");
try {
String response = tool.team_tasks("complete", "5", null, null, null, null, null,
null, null, null, null, full, null, null, null, null, null, null, null);
assertTrue(response.startsWith("Error:"));
assertTrue(response.contains("8000"));
assertEquals(TeamTaskStatus.IN_PROGRESS, tasks.getTask(5L).getStatus());
return AgentService.ChatResult.contentOnly(full);
} finally {
ToolExecutionContext.set("lead-conv", "system");
}
});
dispatch.runTask(10L, tasks.getTask(5L));
assertEquals(TeamTaskStatus.COMPLETED, tasks.getTask(5L).getStatus());
assertTrue(tasks.getTask(5L).getResult().length() <= 8000);
assertTrue(output().endsWith(full));
}
@Test
void largeRetrievedOutputSpillsLosslesslyAndItsTailCanBeRead(@TempDir Path workspace) throws Exception {
String full = "Opening findings\n" + "evidence line\n".repeat(4000) + "Final verification: passed";
runWorker(full);
String retrieved = output();
var properties = new ToolResultProperties();
properties.setStorageBaseDir(workspace.resolve("tool-results").toString());
var storage = new ToolResultStorage(properties);
String preview = storage.persistIfOversized(retrieved, "team_tasks", "output-5",
"lead-conv", workspace.toString());
assertTrue(preview.startsWith(ToolResultStorage.SPILL_MARKER_PREFIX));
assertTrue(preview.contains("read_file"));
var pathMatcher = java.util.regex.Pattern.compile("path=(\\S+)").matcher(preview);
assertTrue(pathMatcher.find());
Path spill = Path.of(pathMatcher.group(1));
assertEquals(retrieved, Files.readString(spill));
ToolExecutionContext.set("lead-conv", "system", workspace.toString());
try {
var reader = new ReadFileTool(mock(I18nService.class));
String tail = reader.read_file(spill.toString(), 4000, null, null, null);
assertTrue(tail.contains("Final verification: passed"), tail);
} finally {
ToolExecutionContext.set("lead-conv", "system");
}
}
/** Opt-in verification across the real 60-minute lease and 20-minute heartbeat cadence. */
@Test
@Timeout(value = 105, unit = TimeUnit.MINUTES)
@EnabledIfSystemProperty(named = "mateclaw.team.lease-soak", matches = "true")
void longWorkerRetainsLeaseAcrossOriginalExpiryAndSettlesAfterCleanup() throws Exception {
long minutes = Long.getLong("mateclaw.team.lease-soak.minutes", 95L);
assertTrue(minutes >= 65 && minutes <= 100, "Soak must cross the actual initial lease expiry");
var entered = new CountDownLatch(1);
var release = new CountDownLatch(1);
when(agents.chatWithUsage(eq(2L), anyString(), anyString())).thenAnswer(invocation -> {
entered.countDown();
assertTrue(release.await(102, TimeUnit.MINUTES));
return AgentService.ChatResult.contentOnly("Long-running worker verification complete");
});
jdbc.update("UPDATE mate_team_task SET lock_expires_at=? WHERE id=5",
LocalDateTime.now().plusMinutes(TeamTaskService.LOCK_MINUTES));
LocalDateTime originalExpiry = tasks.getTask(5L).getLockExpiresAt();
long started = System.nanoTime();
long duration = Duration.ofMinutes(minutes).toNanos();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var worker = executor.submit(() -> dispatch.runTask(10L, tasks.getTask(5L)));
try {
assertTrue(entered.await(10, TimeUnit.SECONDS));
while (System.nanoTime() - started < duration) {
Thread.sleep(Duration.ofSeconds(30));
assertTrue(tasks.recoverStaleTasks().isEmpty(), "Live worker was reclaimed as stale");
var current = tasks.getTask(5L);
assertEquals(TeamTaskStatus.IN_PROGRESS, current.getStatus());
assertTrue(current.getLockExpiresAt().isAfter(LocalDateTime.now()));
System.out.printf("TEAM_LEASE_SOAK elapsedSeconds=%d originalExpiry=%s renewedExpiry=%s status=%s%n",
TimeUnit.NANOSECONDS.toSeconds(System.nanoTime() - started), originalExpiry,
current.getLockExpiresAt(), current.getStatus());
}
assertTrue(LocalDateTime.now().isAfter(originalExpiry));
assertTrue(tasks.getTask(5L).getLockExpiresAt().isAfter(originalExpiry));
} finally {
release.countDown();
}
worker.get(10, TimeUnit.SECONDS);
}
assertEquals(TeamTaskStatus.COMPLETED, tasks.getTask(5L).getStatus());
assertNull(tasks.getTask(5L).getLockExpiresAt());
assertTrue(output().contains("Long-running worker verification complete"));
}
}

View File

@ -26,6 +26,7 @@ import vip.mate.team.service.TeamTaskService;
import vip.mate.tool.builtin.ToolExecutionContext; import vip.mate.tool.builtin.ToolExecutionContext;
import vip.mate.workspace.conversation.ConversationService; import vip.mate.workspace.conversation.ConversationService;
import vip.mate.workspace.conversation.model.ConversationEntity; import vip.mate.workspace.conversation.model.ConversationEntity;
import vip.mate.workspace.conversation.model.MessageEntity;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@ -122,6 +123,136 @@ class TeamTasksToolTest {
return run; return run;
} }
private TeamTaskEntity savedWorkerTask() {
TeamTaskEntity task = task(5L, TeamTaskStatus.COMPLETED);
task.setConversationId("team-task-worker");
task.setLeadConversationId(CONV);
task.setRunId(RUN_ID);
task.setResult("short board summary");
when(taskService.getTask(5L)).thenReturn(task);
when(runService.requireRun(RUN_ID, WORKSPACE_ID))
.thenReturn(run(TEAM_ID, CONV, TeamRunStatus.COMPLETED));
ConversationEntity worker = new ConversationEntity();
worker.setConversationId(task.getConversationId());
worker.setWorkspaceId(WORKSPACE_ID);
worker.setAgentId(MEMBER_ID);
worker.setConversationKind("team_worker");
worker.setParentConversationId(CONV);
when(conversationService.findByConversationId(task.getConversationId())).thenReturn(worker);
return task;
}
@Test
void outputReturnsCompleteSavedWorkerReplyInsteadOfBoardSummary() {
callerIs(LEAD_ID);
TeamTaskEntity task = savedWorkerTask();
MessageEntity message = new MessageEntity();
message.setRole("assistant");
message.setStatus("completed");
String full = "HEAD\n" + "analysis\n".repeat(2000) + "FINAL VERIFICATION";
message.setContent(full);
when(conversationService.listRecentMessages(task.getConversationId(), 1)).thenReturn(List.of(message));
String output = invoke("output", "5");
assertTrue(output.contains(full));
assertTrue(output.contains("taskId=5"));
assertFalse(output.contains("short board summary"));
}
@Test
void outputRejectsForeignTeamBeforeReadingAnyWorkerMessages() {
callerIs(LEAD_ID);
TeamTaskEntity foreign = task(5L, TeamTaskStatus.COMPLETED);
foreign.setTeamId(999L);
when(taskService.getTask(5L)).thenReturn(foreign);
assertTrue(invoke("output", "5").contains("not found on this team's board"));
verify(conversationService, never()).listRecentMessages(anyString(), anyInt());
}
@Test
void outputRejectsWorkerConversationOutsideTeamWorkspace() {
callerIs(LEAD_ID);
TeamTaskEntity task = savedWorkerTask();
conversationService.findByConversationId(task.getConversationId()).setWorkspaceId(999L);
assertTrue(invoke("output", "5").startsWith("Error:"));
verify(conversationService, never()).listRecentMessages(anyString(), anyInt());
}
@Test
void outputRejectsWorkerConversationWithDifferentParent() {
callerIs(LEAD_ID);
TeamTaskEntity task = savedWorkerTask();
conversationService.findByConversationId(task.getConversationId()).setParentConversationId("unrelated-lead");
assertTrue(invoke("output", "5").startsWith("Error:"));
verify(conversationService, never()).listRecentMessages(anyString(), anyInt());
}
@Test
void outputRejectsWorkerConversationWithDifferentAgent() {
callerIs(MEMBER_ID);
TeamTaskEntity task = savedWorkerTask();
conversationService.findByConversationId(task.getConversationId()).setAgentId(999L);
assertTrue(invoke("output", "5").startsWith("Error:"));
verify(conversationService, never()).listRecentMessages(anyString(), anyInt());
}
@Test
void outputWithoutSavedWorkerReplyClearlyIdentifiesFallbackSummary() {
callerIs(LEAD_ID);
TeamTaskEntity task = savedWorkerTask();
when(conversationService.listRecentMessages(task.getConversationId(), 1)).thenReturn(List.of());
String output = invoke("output", "5");
assertTrue(output.contains("Full worker output unavailable"));
assertTrue(output.contains("short board summary"));
}
@Test
void fullTaskRecordExposesDependenciesAndDeliverablesForOmittedContext() {
callerIs(MEMBER_ID);
TeamTaskEntity task = task(5L, TeamTaskStatus.COMPLETED);
task.setBlockedBy("[11,12,13,14,15,16,17,18,19]");
when(taskService.getTask(5L)).thenReturn(task);
when(taskService.listDeliverables(task)).thenReturn(List.of(
new TeamTaskService.Deliverable("report.docx", "/api/v1/files/generated/report", null)));
String output = invoke("get", "5");
assertTrue(output.contains("Dependencies (task ids): [11, 12, 13, 14, 15, 16, 17, 18, 19]"));
assertTrue(output.contains("report.docx → /api/v1/files/generated/report"));
}
@Test
void outputAcceptsTheSameBoundedLegacyParentFallbackAsTranscriptGovernance() {
callerIs(LEAD_ID);
TeamTaskEntity task = savedWorkerTask();
ConversationEntity worker = conversationService.findByConversationId(task.getConversationId());
worker.setConversationKind("primary");
worker.setParentConversationId(null);
MessageEntity reply = new MessageEntity();
reply.setRole("assistant");
reply.setContent("legacy saved worker result");
when(conversationService.listRecentMessages(task.getConversationId(), 1)).thenReturn(List.of(reply));
assertTrue(invoke("output", "5").endsWith("legacy saved worker result"));
}
@Test
void modernWorkerWithMissingParentCannotUseLegacyFallback() {
callerIs(LEAD_ID);
TeamTaskEntity task = savedWorkerTask();
conversationService.findByConversationId(task.getConversationId()).setParentConversationId(null);
assertTrue(invoke("output", "5").contains("linkage does not match"));
verify(conversationService, never()).listRecentMessages(anyString(), anyInt());
}
// ==================== context & membership gating ==================== // ==================== context & membership gating ====================
@Test @Test

View File

@ -8,6 +8,7 @@ import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.api.io.TempDir;
import vip.mate.i18n.I18nService; import vip.mate.i18n.I18nService;
import vip.mate.tool.guard.WorkspacePathGuard;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
@ -31,6 +32,7 @@ import static org.mockito.Mockito.mock;
class ReadFileToolLargeLineTest { class ReadFileToolLargeLineTest {
private ReadFileTool tool; private ReadFileTool tool;
private Path previousDefaultRoot;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
@ -40,12 +42,15 @@ class ReadFileToolLargeLineTest {
"msg".equals(inv.getMethod().getName()) ? inv.getArgument(0) : null); "msg".equals(inv.getMethod().getName()) ? inv.getArgument(0) : null);
tool = new ReadFileTool(i18n); tool = new ReadFileTool(i18n);
// Ensure no workspace boundary is active so absolute temp paths validate. // Ensure no workspace boundary is active so absolute temp paths validate.
previousDefaultRoot = WorkspacePathGuard.getDefaultRoot();
WorkspacePathGuard.setDefaultRoot(null);
ToolExecutionContext.clear(); ToolExecutionContext.clear();
} }
@AfterEach @AfterEach
void tearDown() { void tearDown() {
ToolExecutionContext.clear(); ToolExecutionContext.clear();
WorkspacePathGuard.setDefaultRoot(previousDefaultRoot == null ? null : previousDefaultRoot.toString());
} }
/** Build a single-line JSON array of {@code n} string elements. */ /** Build a single-line JSON array of {@code n} string elements. */