fix(team): ground worker progress and preserve runtime failure causes

This commit is contained in:
matevip 2026-10-22 05:44:01 -04:00
parent a9b4dfd124
commit f449fb229e
8 changed files with 272 additions and 15 deletions

View File

@ -45,6 +45,15 @@ public final class TaskFinalizationCoordinator {
describe finishing the task after failure; it does not establish original-operation recovery. describe finishing the task after failure; it does not establish original-operation recovery.
Endorse recovery only for canonical supportedRecoveryCallIds; an empty list means none is established. Endorse recovery only for canonical supportedRecoveryCallIds; an empty list means none is established.
Do not invent numeric results or reconstruct omitted results. Do not invent numeric results or reconstruct omitted results.
For literal existence checks, a substring anywhere in the complete observed output satisfies existence.
Do not invent a standalone line, whole-line equality, token-boundary or occurrence-count requirement
unless the CURRENT objective explicitly requires it. A quoted numeric marker such as
"130 checks passed and evidence retained" is not a claim that those tests were executed.
Distinguish reporting that a marker exists from endorsing the marker's embedded claim.
successfulProgressUpdates records successful team progress writes separately from substantive outputs:
check its taskId, percent and completedAt for requested milestone and read-before-progress constraints.
Their absence from successfulSubstantiveCalls does not mean progress was not reported.
Free-form progress prose alone cannot establish a successful progress write.
Unsupported candidate prose requires REVISE with empty recoveryClaims and a specific correction. Unsupported candidate prose requires REVISE with empty recoveryClaims and a specific correction.
Unfinished executable objective work or unsatisfied requested state constraints require CONTINUE, not prose-only REVISE. Unfinished executable objective work or unsatisfied requested state constraints require CONTINUE, not prose-only REVISE.
Explicitly requested pending records are not unfinished executable work; compare them to the original constraints. Explicitly requested pending records are not unfinished executable work; compare them to the original constraints.

View File

@ -29,14 +29,24 @@ public final class ActionExecutionLedger {
public enum Status { SUCCEEDED, FAILED } public enum Status { SUCCEEDED, FAILED }
public record ProgressUpdate(String taskId, int percent) {}
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, boolean progressOnly) { String resultSummary, long completedAt, String operationIdentity, int resultChars, boolean progressOnly, ProgressUpdate progressUpdate) {
public Receipt { public Receipt {
if (!progressOnly || progressUpdate != null && (!validTaskId(progressUpdate.taskId())
|| progressUpdate.percent() < 0 || progressUpdate.percent() > 100)) progressUpdate = null;
// 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;
if (operationIdentity == null || !operationIdentity.matches("sha256:[a-f0-9]{64}")) { if (operationIdentity == null || !operationIdentity.matches("sha256:[a-f0-9]{64}")) {
operationIdentity = UNKNOWN_OPERATION; operationIdentity = UNKNOWN_OPERATION;
} }
if (!"team_tasks".equals(toolName) || UNKNOWN_OPERATION.equals(operationIdentity)) progressUpdate = null;
}
public Receipt(String toolCallId, String toolName, Status status,
String resultSummary, long completedAt, String operationIdentity, int resultChars, boolean progressOnly) {
this(toolCallId, toolName, status, resultSummary, completedAt, operationIdentity, resultChars, progressOnly, null);
} }
public Receipt(String toolCallId, String toolName, Status status, public Receipt(String toolCallId, String toolName, Status status,
@ -98,6 +108,7 @@ public final class ActionExecutionLedger {
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<>(); Map<String, Boolean> progressCalls = new LinkedHashMap<>();
Map<String, ProgressUpdate> progressUpdates = 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", ""));
@ -107,6 +118,8 @@ public final class ActionExecutionLedger {
String.valueOf(event.data().getOrDefault("arguments", ""))); String.valueOf(event.data().getOrDefault("arguments", "")));
progressCalls.put(id, isTeamProgress(name, progressCalls.put(id, isTeamProgress(name,
String.valueOf(event.data().getOrDefault("arguments", "")))); String.valueOf(event.data().getOrDefault("arguments", ""))));
progressUpdates.put(id, teamProgressUpdate(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);
} }
@ -130,7 +143,11 @@ public final class ActionExecutionLedger {
? operations.getOrDefault(id, UNKNOWN_OPERATION) : UNKNOWN_OPERATION, resultChars, ? operations.getOrDefault(id, UNKNOWN_OPERATION) : UNKNOWN_OPERATION, resultChars,
name.equals(operationTools.get(id)) name.equals(operationTools.get(id))
&& !UNKNOWN_OPERATION.equals(operations.getOrDefault(id, UNKNOWN_OPERATION)) && !UNKNOWN_OPERATION.equals(operations.getOrDefault(id, UNKNOWN_OPERATION))
&& Boolean.TRUE.equals(progressCalls.get(id)))); && Boolean.TRUE.equals(progressCalls.get(id)),
name.equals(operationTools.get(id))
&& !UNKNOWN_OPERATION.equals(operations.getOrDefault(id, UNKNOWN_OPERATION))
&& success && acknowledgedProgress(String.valueOf(data.getOrDefault("result", "")))
? progressUpdates.get(id) : null));
} }
return receipts.isEmpty() ? empty() : new ActionExecutionLedger(receipts, fullResults); return receipts.isEmpty() ? empty() : new ActionExecutionLedger(receipts, fullResults);
} }
@ -147,6 +164,34 @@ public final class ActionExecutionLedger {
} }
} }
/** Native callbacks may return the plain acknowledgement or its MethodToolCallback JSON string. */
private static boolean acknowledgedProgress(String result) {
if ("✓ Progress recorded.".equals(result.trim())) return true;
try {
var value = OPERATION_MAPPER.readTree(result);
return value != null && value.isTextual() && "✓ Progress recorded.".equals(value.textValue());
} catch (java.io.IOException invalid) { return false; }
}
private static boolean validTaskId(String value) {
if (value == null || !value.matches("[1-9][0-9]{0,18}")) return false;
try { return Long.parseLong(value) > 0; } catch (NumberFormatException invalid) { return false; }
}
private static ProgressUpdate teamProgressUpdate(String toolName, String arguments) {
if (!"team_tasks".equals(toolName)) return null;
try {
var request = OPERATION_MAPPER.readTree(arguments);
if (request == null || !request.isObject() || !"progress".equals(request.path("action").asText())) return null;
var id = request.path("taskId");
var percent = request.path("percent");
if (!(id.isTextual() || id.isIntegralNumber()) || !validTaskId(id.asText())
|| !percent.isIntegralNumber() || !percent.canConvertToInt()
|| percent.intValue() < 0 || percent.intValue() > 100) return null;
return new ProgressUpdate(id.asText(), percent.intValue());
} catch (java.io.IOException invalid) { return null; }
}
/** /**
* 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.
@ -301,6 +346,20 @@ public final class ActionExecutionLedger {
} }
successful.add(output); successful.add(output);
} }
// Progress is execution evidence for milestone constraints, never the latest substantive output.
var progress = facts.putArray("successfulProgressUpdates");
Map<String, Long> latestProgressAt = new LinkedHashMap<>();
receipts.values().stream().filter(r -> r.status() == Status.SUCCEEDED && r.progressUpdate() != null)
.forEach(r -> latestProgressAt.merge(r.progressUpdate().taskId(), r.completedAt(), Math::max));
receipts.values().stream().filter(r -> r.status() == Status.SUCCEEDED && r.progressUpdate() != null
&& r.completedAt() == latestProgressAt.get(r.progressUpdate().taskId()))
.sorted(Comparator.comparingLong(Receipt::completedAt).reversed().thenComparing(Receipt::toolCallId))
.forEach(r -> {
var node = progress.addObject();
node.put("toolCallId", identifierPreview(r.toolCallId()));
node.put("taskId", r.progressUpdate().taskId()); node.put("percent", r.progressUpdate().percent());
node.put("completedAt", r.completedAt());
});
facts.put("omittedFailedCallCount", failures.size() - failed.size()); facts.put("omittedFailedCallCount", failures.size() - failed.size());
facts.put("omittedSuccessfulSubstantiveCallCount", successes.size() - successful.size()); facts.put("omittedSuccessfulSubstantiveCallCount", successes.size() - successful.size());
if (vip.mate.agent.context.TokenEstimator.estimateTokens(facts.toString()) > tokenBudget) { if (vip.mate.agent.context.TokenEstimator.estimateTokens(facts.toString()) > tokenBudget) {
@ -332,6 +391,7 @@ public final class ActionExecutionLedger {
var node = OPERATION_MAPPER.createObjectNode(); var node = OPERATION_MAPPER.createObjectNode();
node.put("toolCallId", identifierPreview(receipt.toolCallId())); node.put("toolCallId", identifierPreview(receipt.toolCallId()));
node.put("toolName", identifierPreview(receipt.toolName())); node.put("toolName", identifierPreview(receipt.toolName()));
node.put("completedAt", receipt.completedAt());
node.put("resultSummary", receipt.resultSummary()); node.put("resultSummary", receipt.resultSummary());
int omitted = receipt.resultChars() < 0 ? -1 : Math.max(0, receipt.resultChars() - MAX_RESULT_SUMMARY_CHARS); int omitted = receipt.resultChars() < 0 ? -1 : Math.max(0, receipt.resultChars() - MAX_RESULT_SUMMARY_CHARS);
node.put("resultComplete", omitted == 0); node.put("resultComplete", omitted == 0);

View File

@ -215,6 +215,7 @@ public class TeamDispatchService {
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; String reply = null;
String finishReason = null;
Exception runFailure = null; Exception runFailure = null;
try { try {
try { try {
@ -244,6 +245,7 @@ public class TeamDispatchService {
AgentService.ChatResult result = agentService.chatWithUsage( AgentService.ChatResult result = agentService.chatWithUsage(
memberId, dispatchContent, childConvId); memberId, dispatchContent, childConvId);
reply = result == null ? null : result.content(); reply = result == null ? null : result.content();
finishReason = result == null ? null : result.finishReason();
if (reply != null && !reply.isBlank()) { if (reply != null && !reply.isBlank()) {
conversationService.saveMessage(childConvId, "assistant", reply); conversationService.saveMessage(childConvId, "assistant", reply);
} }
@ -278,7 +280,7 @@ public class TeamDispatchService {
if (runFailure != null) { if (runFailure != null) {
throw runFailure; throw runFailure;
} }
settleOutcome(task, reply, snapshot); settleOutcome(task, reply, snapshot, finishReason);
} catch (Exception error) { } catch (Exception error) {
settleRunFailure(teamId, task, error); settleRunFailure(teamId, task, error);
} }
@ -335,6 +337,10 @@ public class TeamDispatchService {
} }
void settleOutcome(TeamTaskEntity task, String reply, WorkerDecisionAdapter.Snapshot snapshot) { void settleOutcome(TeamTaskEntity task, String reply, WorkerDecisionAdapter.Snapshot snapshot) {
settleOutcome(task, reply, snapshot, null);
}
void settleOutcome(TeamTaskEntity task, String reply, WorkerDecisionAdapter.Snapshot snapshot, String finishReason) {
TeamTaskEntity current = taskService.getTask(task.getId()); TeamTaskEntity current = taskService.getTask(task.getId());
if (current == null) { if (current == null) {
return; return;
@ -346,6 +352,26 @@ public class TeamDispatchService {
return; return;
} }
if (TeamTaskStatus.IN_PROGRESS.equals(current.getStatus())) { if (TeamTaskStatus.IN_PROGRESS.equals(current.getStatus())) {
// Runtime diagnostic prose is not a malformed business envelope. Explicit non-completion wins
// even if the retained candidate happens to contain a valid COMPLETED object.
if (finishReason != null && !finishReason.isBlank()
&& !Set.of("normal", "summarized", "return_direct").contains(finishReason)) {
String reason = "WORKER_RUNTIME_NOT_COMPLETED: finishReason="
+ TeamResultExcerpt.label(finishReason, 80);
boolean failed;
if (decisionAdapter != null && decisionAdapter.enabled()) {
var judgment = decisionAdapter.judge(current, "WORKER_RUNTIME_NOT_COMPLETED", reply);
failed = taskService.failTask(task.getId(), reason, judgment.ticket(), snapshot);
} else {
failed = taskService.failTask(task.getId(), reason);
}
if (failed) {
TeamTaskEntity settled = taskService.getTask(task.getId());
broadcast(settled != null ? settled : task, "team_task_failed", Map.of("reason", reason));
announceService.announceTaskSettled(settled);
}
return;
}
// Domain guard: a successful model invocation is not a completed business task. // Domain guard: a successful model invocation is not a completed business task.
// OFF retains the legacy text contract; SHADOW records the corrected baseline. // OFF retains the legacy text contract; SHADOW records the corrected baseline.
if (decisionAdapter != null && decisionAdapter.enabled()) { if (decisionAdapter != null && decisionAdapter.enabled()) {

View File

@ -123,20 +123,20 @@ public class TeamTasksTool {
String conversationId = ToolExecutionContext.conversationId(ctx); String conversationId = ToolExecutionContext.conversationId(ctx);
if (conversationId == null || conversationId.isBlank()) { if (conversationId == null || conversationId.isBlank()) {
return "Error: no conversation context bound to this call."; return failure(ctx, "Error: no conversation context bound to this call.");
} }
ConversationEntity conversation = conversationService.findByConversationId(conversationId); ConversationEntity conversation = conversationService.findByConversationId(conversationId);
if (conversation == null || conversation.getAgentId() == null) { if (conversation == null || conversation.getAgentId() == null) {
return "Error: cannot resolve the calling agent for this conversation."; return failure(ctx, "Error: cannot resolve the calling agent for this conversation.");
} }
if (conversation.getWorkspaceId() == null) { if (conversation.getWorkspaceId() == null) {
return "Error: workspaceId is missing from conversation context."; return failure(ctx, "Error: workspaceId is missing from conversation context.");
} }
Long agentId = conversation.getAgentId(); Long agentId = conversation.getAgentId();
Long workspaceId = conversation.getWorkspaceId(); Long workspaceId = conversation.getWorkspaceId();
Optional<AgentTeamEntity> teamOpt = teamService.getTeamForAgent(agentId); Optional<AgentTeamEntity> teamOpt = teamService.getTeamForAgent(agentId);
if (teamOpt.isEmpty()) { if (teamOpt.isEmpty()) {
return "Error: you are not part of any agent team; team_tasks is unavailable."; return failure(ctx, "Error: you are not part of any agent team; team_tasks is unavailable.");
} }
AgentTeamEntity team = teamOpt.get(); AgentTeamEntity team = teamOpt.get();
boolean isLead = teamService.isLead(team, agentId); boolean isLead = teamService.isLead(team, agentId);
@ -153,7 +153,7 @@ public class TeamTasksTool {
requireApproval, conversationId); requireApproval, conversationId);
case "seal_run" -> sealRun(team, isLead, workspaceId, conversationId, runId); case "seal_run" -> sealRun(team, isLead, workspaceId, conversationId, runId);
case "complete" -> completeTask(team, agentId, parseId(taskId, "taskId"), result); case "complete" -> completeTask(team, agentId, parseId(taskId, "taskId"), result);
case "progress" -> progress(team, agentId, parseId(taskId, "taskId"), percent, step); case "progress" -> progress(team, agentId, parseId(taskId, "taskId"), percent, step, ctx);
case "comment" -> comment(team, agentId, parseId(taskId, "taskId"), type, text); case "comment" -> comment(team, agentId, parseId(taskId, "taskId"), type, text);
case "attach" -> attach(team, agentId, parseId(taskId, "taskId"), name, url); case "attach" -> attach(team, agentId, parseId(taskId, "taskId"), name, url);
case "cancel" -> cancel(team, agentId, isLead, parseId(taskId, "taskId"), text); case "cancel" -> cancel(team, agentId, isLead, parseId(taskId, "taskId"), text);
@ -165,13 +165,13 @@ public class TeamTasksTool {
} catch (IllegalArgumentException | IllegalStateException e) { } catch (IllegalArgumentException | IllegalStateException e) {
DecisionRecordingException recording = DecisionRecordingException.find(e); DecisionRecordingException recording = DecisionRecordingException.find(e);
if (recording != null) throw recording; if (recording != null) throw recording;
return "Error: " + e.getMessage(); return failure(ctx, "Error: " + e.getMessage());
} catch (Exception e) { } catch (Exception e) {
DecisionRecordingException recording = DecisionRecordingException.find(e); DecisionRecordingException recording = DecisionRecordingException.find(e);
if (recording != null) throw recording; if (recording != null) throw recording;
log.warn("team_tasks {} failed for team={} agent={}: {}", log.warn("team_tasks {} failed for team={} agent={}: {}",
action, team.getId(), agentId, e.getMessage()); action, team.getId(), agentId, e.getMessage());
return "Error: team_tasks failed — " + e.getMessage(); return failure(ctx, "Error: team_tasks failed — " + e.getMessage());
} }
} }
@ -275,11 +275,17 @@ public class TeamTasksTool {
return sb.toString(); return sb.toString();
} }
private static String failure(ToolContext ctx, String message) {
var capture = vip.mate.tool.mcp.runtime.McpToolResultCapture.from(ctx);
if (capture != null) capture.recordFailure(message);
return message;
}
private String progress(AgentTeamEntity team, Long agentId, Long taskId, private String progress(AgentTeamEntity team, Long agentId, Long taskId,
Integer percent, String step) { Integer percent, String step, ToolContext ctx) {
requireTaskInTeam(team, taskId); requireTaskInTeam(team, taskId);
if (percent != null && (percent < 0 || percent > 100)) { if (percent != null && (percent < 0 || percent > 100)) {
return "Error: percent must be between 0 and 100."; return failure(ctx, "Error: percent must be between 0 and 100.");
} }
boolean ok = taskService.updateProgress(taskId, agentId, percent, step); boolean ok = taskService.updateProgress(taskId, agentId, percent, step);
if (ok) { if (ok) {
@ -293,7 +299,7 @@ public class TeamTasksTool {
eventChannel.publishTaskEvent(taskService.getTask(taskId), "team_task_progress", extra); eventChannel.publishTaskEvent(taskService.getTask(taskId), "team_task_progress", extra);
} }
return ok ? "✓ Progress recorded." return ok ? "✓ Progress recorded."
: "Error: task is not in progress under your ownership; progress not recorded."; : failure(ctx, "Error: task is not in progress under your ownership; progress not recorded.");
} }
private String comment(AgentTeamEntity team, Long agentId, Long taskId, private String comment(AgentTeamEntity team, Long agentId, Long taskId,

View File

@ -53,6 +53,18 @@ class TaskFinalizationCoordinatorTest {
assertEquals(TaskFinalizationGate.Outcome.ACCEPT,review.result().outcome()); assertEquals(TaskFinalizationGate.Outcome.ACCEPT,review.result().outcome());
assertEquals(1,review.calls()); assertEquals(1,review.calls());
} }
@Test void existenceReviewDoesNotInventWholeLineOrExecutionCountRequirements() {
when(helper.streamCallSilent(any(), any(), anyString(), anyString())).thenAnswer(call -> {
String prompt = ((Prompt)call.getArgument(1)).getContents();
assertTrue(prompt.contains("substring anywhere"));
assertTrue(prompt.contains("standalone line"));
assertTrue(prompt.contains("not a claim that those tests were executed"));
assertTrue(prompt.contains("successfulProgressUpdates"));
return result(json("ACCEPT", "[]"));
});
assertEquals(TaskFinalizationGate.Outcome.ACCEPT, review(16000, 0, 0).result().outcome());
}
@Test void revisionFeedbackRequestsSpecificOffendingClauseAndGroundedReplacement() { @Test void revisionFeedbackRequestsSpecificOffendingClauseAndGroundedReplacement() {
when(helper.streamCallSilent(any(),any(),anyString(),anyString())).thenAnswer(call->{ when(helper.streamCallSilent(any(),any(),anyString(),anyString())).thenAnswer(call->{
String prompt=((Prompt)call.getArgument(1)).getContents(); String prompt=((Prompt)call.getArgument(1)).getContents();

View File

@ -32,7 +32,7 @@ class ActionExecutionLedgerTest {
GraphEventPublisher.toolComplete("read", "read_file", body, true))); GraphEventPublisher.toolComplete("read", "read_file", body, true)));
var progress = ActionExecutionLedger.fromEvents(List.of( var progress = ActionExecutionLedger.fromEvents(List.of(
GraphEventPublisher.toolStart("progress", "team_tasks", "{\"action\":\"progress\",\"taskId\":\"5\",\"percent\":100}"), GraphEventPublisher.toolStart("progress", "team_tasks", "{\"action\":\"progress\",\"taskId\":\"5\",\"percent\":100}"),
GraphEventPublisher.toolComplete("progress", "team_tasks", "Progress recorded", true))); GraphEventPublisher.toolComplete("progress", "team_tasks", "✓ Progress recorded.", true)));
var merged = read.merge(progress); var merged = read.merge(progress);
assertEquals(java.util.Map.of("read", body), merged.latestCompleteResult()); assertEquals(java.util.Map.of("read", body), merged.latestCompleteResult());
assertFalse(merged.receipts().get("progress").substantive()); assertFalse(merged.receipts().get("progress").substantive());
@ -46,6 +46,82 @@ class ActionExecutionLedgerTest {
assertEquals(1, merged.merge(failed).failedCallCount()); assertEquals(1, merged.merge(failed).failedCallCount());
} }
@Test void canonicalTeamProgressFactsPreserveLatestPercentWithoutReplacingReadEvidence() throws Exception {
String body = "HEAD " + "evidence ".repeat(1000) + " 130 checks passed and evidence retained TAIL";
var ledger = ActionExecutionLedger.fromEvents(List.of(
GraphEventPublisher.toolComplete("read", "read_file", body, true),
GraphEventPublisher.toolStart("progress", "team_tasks", "{\"action\":\"progress\",\"taskId\":\"5\",\"percent\":100,\"step\":\"secret/freeform step\"}"),
GraphEventPublisher.toolComplete("progress", "team_tasks", "✓ Progress recorded.", true)));
var projection = ledger.projectAnswerFacts(10000, ledger.latestCompleteResult());
assertTrue(projection.coreFactsFit());
var facts = new com.fasterxml.jackson.databind.ObjectMapper().readTree(projection.text());
var progress = facts.path("successfulProgressUpdates").get(0);
org.junit.jupiter.api.Assertions.assertNotNull(progress);
assertEquals("5", progress.path("taskId").asText());
assertEquals(100, progress.path("percent").asInt());
assertFalse(projection.text().contains("secret/freeform step"));
assertEquals(java.util.Map.of("read", body), ledger.latestCompleteResult());
}
@Test void onlyJoinedSuccessfulStrictProgressRequestsEstablishPercent() throws Exception {
var mapper = new com.fasterxml.jackson.databind.ObjectMapper();
for (String args : List.of("{\"action\":\"progress\",\"percent\":100}",
"{\"action\":\"progress\",\"taskId\":\"5\",\"percent\":\"100\"}",
"{\"action\":\"progress\",\"taskId\":\"5\",\"percent\":100.0}",
"{\"action\":\"progress\",\"taskId\":\"5\",\"percent\":101}",
"{\"action\":\"progress\",\"taskId\":\"5\",\"percent\":100,\"percent\":0}",
"{\"action\":\"progress\",\"taskId\":\"not-an-id\",\"percent\":100}")) {
var ledger = ActionExecutionLedger.fromEvents(List.of(
GraphEventPublisher.toolStart("p", "team_tasks", args),
GraphEventPublisher.toolComplete("p", "team_tasks", "✓ Progress recorded.", true)));
assertTrue(mapper.readTree(ledger.projectAnswerFacts(3000).text()).path("successfulProgressUpdates").isEmpty());
}
for (var events : List.of(
List.of(GraphEventPublisher.toolComplete("p", "team_tasks", "✓ Progress recorded.", true)),
List.of(GraphEventPublisher.toolStart("p", "team_tasks", "{\"action\":\"progress\",\"taskId\":\"5\",\"percent\":100}"),
GraphEventPublisher.toolComplete("p", "team_tasks", "Error", false)),
List.of(GraphEventPublisher.toolStart("p", "team_tasks", "{\"action\":\"progress\",\"taskId\":\"5\",\"percent\":100}"),
GraphEventPublisher.toolStart("p", "team_tasks", "{\"action\":\"complete\",\"taskId\":\"5\"}"),
GraphEventPublisher.toolComplete("p", "team_tasks", "✓ Progress recorded.", true)))) {
var ledger = ActionExecutionLedger.fromEvents(events);
assertTrue(mapper.readTree(ledger.projectAnswerFacts(3000).text()).path("successfulProgressUpdates").isEmpty());
}
}
@Test void historicalSuccessFlagWithRejectedNativeTextCannotEstablishMilestone() throws Exception {
var ledger = ActionExecutionLedger.fromEvents(List.of(
GraphEventPublisher.toolStart("p", "team_tasks", "{\"action\":\"progress\",\"taskId\":\"5\",\"percent\":100}"),
GraphEventPublisher.toolComplete("p", "team_tasks", "Error: progress not recorded", true)));
var facts = new com.fasterxml.jackson.databind.ObjectMapper().readTree(ledger.projectAnswerFacts(3000).text());
assertTrue(facts.path("successfulProgressUpdates").isEmpty());
}
@Test void malformedOrErrorEncodedAcknowledgementsRemainUnproven() throws Exception {
var mapper = new com.fasterxml.jackson.databind.ObjectMapper();
for (String text : List.of("\"Error: progress not recorded\"", "\"✓ Progress recorded.\" {}",
"{\"result\":\"✓ Progress recorded.\"}", "\"\\\"✓ Progress recorded.\\\"\"")) {
var ledger = ActionExecutionLedger.fromEvents(List.of(
GraphEventPublisher.toolStart("p", "team_tasks", "{\"action\":\"progress\",\"taskId\":\"5\",\"percent\":100}"),
GraphEventPublisher.toolComplete("p", "team_tasks", text, true)));
assertTrue(mapper.readTree(ledger.projectAnswerFacts(3000).text()).path("successfulProgressUpdates").isEmpty());
}
}
@Test void latestProgressPerTaskSurvivesSerializationWithoutReintroducingOlderPercent() throws Exception {
var events = List.of(
new GraphEventPublisher.GraphEvent(GraphEventPublisher.EVENT_TOOL_START, java.util.Map.of("toolCallId","p1", "toolName","team_tasks", "arguments","{\"action\":\"progress\",\"taskId\":\"5\",\"percent\":100}"), 1),
new GraphEventPublisher.GraphEvent(GraphEventPublisher.EVENT_TOOL_COMPLETE, java.util.Map.of("toolCallId","p1", "toolName","team_tasks", "result","✓ Progress recorded.", "success",true), 2),
new GraphEventPublisher.GraphEvent(GraphEventPublisher.EVENT_TOOL_START, java.util.Map.of("toolCallId","p2", "toolName","team_tasks", "arguments","{\"action\":\"progress\",\"taskId\":\"5\",\"percent\":25}"), 3),
new GraphEventPublisher.GraphEvent(GraphEventPublisher.EVENT_TOOL_COMPLETE, java.util.Map.of("toolCallId","p2", "toolName","team_tasks", "result","✓ Progress recorded.", "success",true), 4));
var ledger = ActionExecutionLedger.fromEvents(events);
var mapper = new com.fasterxml.jackson.databind.ObjectMapper();
var restored = mapper.readValue(mapper.writeValueAsString(ledger.receipts().get("p2")), ActionExecutionLedger.Receipt.class);
assertEquals(25, restored.progressUpdate().percent());
var progress = mapper.readTree(ledger.projectAnswerFacts(3000).text()).path("successfulProgressUpdates");
assertEquals(1, progress.size()); assertEquals(25, progress.get(0).path("percent").asInt());
assertEquals(4, progress.get(0).path("completedAt").asInt());
}
@Test void ambiguousOrMutatingTeamCallsRemainSubstantive() { @Test void ambiguousOrMutatingTeamCallsRemainSubstantive() {
for (String arguments : List.of("{\"action\":\"complete\",\"taskId\":\"5\"}", for (String arguments : List.of("{\"action\":\"complete\",\"taskId\":\"5\"}",
"{\"action\":\"output\",\"taskId\":\"5\"}", "{\"action\":\"output\",\"taskId\":\"5\"}",
@ -56,7 +132,7 @@ class ActionExecutionLedgerTest {
assertTrue(ledger.receipts().get("call").substantive()); assertTrue(ledger.receipts().get("call").substantive());
} }
var missingStart = ActionExecutionLedger.fromEvents(List.of( var missingStart = ActionExecutionLedger.fromEvents(List.of(
GraphEventPublisher.toolComplete("legacy", "team_tasks", "Progress recorded", true))); GraphEventPublisher.toolComplete("legacy", "team_tasks", "✓ Progress recorded.", true)));
assertTrue(missingStart.receipts().get("legacy").substantive()); assertTrue(missingStart.receipts().get("legacy").substantive());
} }

View File

@ -222,6 +222,37 @@ class TeamDispatchServiceTest {
verify(taskService, never()).completeTask(any(), any(), anyString()); verify(taskService, never()).completeTask(any(), any(), anyString());
} }
@ParameterizedTest
@ValueSource(strings = {"incomplete", "error_fallback", "max_iterations_reached", "stopped", "evidence_insufficient", "action_unverified", "action_failed"})
void incompleteRuntimeIsNotMisreportedAsInvalidWorkerJson(String finishReason) {
var adapter = mock(WorkerDecisionAdapter.class);
when(adapter.enabled()).thenReturn(true);
var ticket = new DecisionTicket(null, DecisionMode.SHADOW, new DecisionValue.BooleanValue(false));
when(adapter.judge(any(), anyString(), anyString())).thenReturn(new WorkerDecisionAdapter.Judgment(ticket, false));
service.setDecisionAdapter(adapter);
TeamTaskEntity assigned = task(1L, MEMBER_A); assigned.setStatus(TeamTaskStatus.IN_PROGRESS);
when(taskService.getTask(1L)).thenReturn(assigned);
when(agentService.chatWithUsage(eq(MEMBER_A), anyString(), anyString())).thenReturn(
new AgentService.ChatResult("任务尚未完成:文字修正已达上限", 10, 5, "model", "provider", finishReason));
service.runTask(TEAM_ID, assigned);
verify(adapter).judge(eq(assigned), eq("WORKER_RUNTIME_NOT_COMPLETED"), anyString());
verify(taskService).failTask(eq(1L), startsWith("WORKER_RUNTIME_NOT_COMPLETED:"), eq(ticket), any());
verify(taskService, never()).completeTask(any(), any(), anyString(), any(), any());
verify(adapter, never()).judge(any(), eq("INVALID_RESULT_CONTRACT"), anyString());
}
@Test
void validJsonCannotOverrideAnIncompleteRuntimeEvenWhenDecisionEnvelopeIsDisabled() {
TeamTaskEntity assigned = task(1L, MEMBER_A); assigned.setStatus(TeamTaskStatus.IN_PROGRESS);
when(taskService.getTask(1L)).thenReturn(assigned);
when(agentService.chatWithUsage(eq(MEMBER_A), anyString(), anyString())).thenReturn(
new AgentService.ChatResult("{\"status\":\"COMPLETED\",\"result\":\"done\",\"evidence\":\"checked\"}",
10, 5, "model", "provider", "incomplete"));
service.runTask(TEAM_ID, assigned);
verify(taskService).failTask(eq(1L), startsWith("WORKER_RUNTIME_NOT_COMPLETED:"));
verify(taskService, never()).completeTask(any(), any(), anyString());
}
@Test @Test
void childStreamClosesBeforeLeadReceivesSuccessfulResult() { void childStreamClosesBeforeLeadReceivesSuccessfulResult() {
TeamTaskEntity assigned = task(1L, MEMBER_A); TeamTaskEntity assigned = task(1L, MEMBER_A);

View File

@ -113,6 +113,43 @@ class TeamTasksToolTest {
null, null, null, null, null, null, null, null, null, null, null); null, null, null, null, null, null, null, null, null, null, null);
} }
@Test void methodCallbackEncodedProgressIsGroundedInActualSuccessfulWrite() throws Exception {
callerIs(MEMBER_ID);
when(taskService.getTask(5L)).thenReturn(task(5L, TeamTaskStatus.IN_PROGRESS));
when(taskService.updateProgress(eq(5L), eq(MEMBER_ID), eq(100), anyString())).thenReturn(true);
var capture = new vip.mate.tool.mcp.runtime.McpToolResultCapture();
var ctx = capture.attach(new ToolContext(java.util.Map.of()));
String args = "{\"action\":\"progress\",\"taskId\":\"5\",\"percent\":100,\"step\":\"done\"}";
var callback = org.springframework.ai.support.ToolCallbacks.from(tool)[0];
String result = callback.call(args, ctx);
assertFalse(capture.isError());
var ledger = vip.mate.agent.graph.state.ActionExecutionLedger.fromEvents(List.of(
vip.mate.agent.GraphEventPublisher.toolStart("p", "team_tasks", args),
vip.mate.agent.GraphEventPublisher.toolComplete("p", "team_tasks", result, !capture.isError())));
var facts = new com.fasterxml.jackson.databind.ObjectMapper().readTree(ledger.projectAnswerFacts(3000).text());
assertEquals(1, facts.path("successfulProgressUpdates").size());
assertEquals(100, facts.path("successfulProgressUpdates").get(0).path("percent").asInt());
verify(taskService).updateProgress(5L, MEMBER_ID, 100, "done");
}
@Test void rejectedNativeProgressCannotEstablishSuccessfulMilestone() throws Exception {
callerIs(MEMBER_ID);
when(taskService.getTask(5L)).thenReturn(task(5L, TeamTaskStatus.IN_PROGRESS));
when(taskService.updateProgress(eq(5L), eq(MEMBER_ID), eq(100), anyString())).thenReturn(false);
var capture = new vip.mate.tool.mcp.runtime.McpToolResultCapture();
var ctx = capture.attach(new ToolContext(java.util.Map.of()));
String result = tool.team_tasks("progress", "5", null, null, null, null, null, null,
null, null, null, null, 100, "done", null, null, null, null, ctx);
assertTrue(capture.isError());
var ledger = vip.mate.agent.graph.state.ActionExecutionLedger.fromEvents(List.of(
vip.mate.agent.GraphEventPublisher.toolStart("p", "team_tasks", "{\"action\":\"progress\",\"taskId\":\"5\",\"percent\":100}"),
vip.mate.agent.GraphEventPublisher.toolComplete("p", "team_tasks", result, !capture.isError())));
assertEquals(1, ledger.failedCallCount());
var facts = new com.fasterxml.jackson.databind.ObjectMapper().readTree(ledger.projectAnswerFacts(3000).text());
assertTrue(facts.path("successfulProgressUpdates").isEmpty());
verify(eventChannel, never()).publishTaskEvent(any(), eq("team_task_progress"), any());
}
private TeamRunEntity run(Long teamId, String conversationId, String status) { private TeamRunEntity run(Long teamId, String conversationId, String status) {
TeamRunEntity run = new TeamRunEntity(); TeamRunEntity run = new TeamRunEntity();
run.setId(RUN_ID); run.setId(RUN_ID);